从React到React Fiber

本文首发于公众号:前端之巅

前言

浏览器中的渲染引擎是单线程的,几乎所有的操作都在这个单线程中执行——解析渲染DOM Tree和CSS Tree,解析执行JavaScript——除了网络操作。这个线程就是浏览器的主线程。单线程意味着,一段时间只做一件事,所以浏览器在同一时间内,其主线程只能关注于一个任务。

在Web开发中,很多人觉得,不就是写HTML和CSS将数据显示出来么,So Easy!抱着这样想法开发出来的网站,如果比较简单的话,还不能看出差别,但是一旦页面复杂,用户交互变多,弊端就会爆发:卡顿,没反应,容易崩溃……

稍有经验的前端工程师会知道,页面的DOM改变,就会导致页面重新计算DOM,进行重绘或者重排,DOM结构复杂或者频繁操作DOM通常是产生性能瓶颈的原因。而网站从最开始比较简单,开始变的越来越复杂,用户交互也会越来越多,怎么去减轻DOM操作带来的性能损耗就变得重要起来。

(图片来自:http://t.cn/R0wwkPo)

React来了

React是近几年非常火的一个前端框架,它第一次提出了Virtual DOM的概念。

Virtual DOM是一个JavaScript对象。每次,我们只需要告诉React下一个状态是什么,React就会自己构建一个新的Virtual DOM,然后根据新旧Virtual DOM快速计算其差异,找出需要重绘或重排的元素,告诉浏览器。浏览器根据相关的更新,重新计算DOM Tree,重绘页面。

我们下面看一个例子:

这个例子会在页面中创建一个输入框、一个按钮、一个BlockList组件。BlockList组件会根据NUMBER_OF_BLOCK的数值渲染出对应数量的数字显示框,数字显示框显示点击按钮的次数。我们最开始设置据NUMBER_OF_BLOCK为2 ,只渲染2个数字显示框。

首次渲染出页面之后,我们点击按钮,页面中的数字显示框的值由0变为1。如下图所示:

当点击按钮的时候,按钮点击次数从0变为1,我们需要告诉React下面要显示1了,于是,通过setState操作,我们告诉React: 下一个你需要显示的数据是1。然后,React 开始更新组件。对应的Virtual DOM Tree变化如下图所示。黄色表示状态被更新。

我们点击按钮,触发setState之后,React就会创建一个新的Virtual DOM,然后将新旧Virtual DOM进行diff操作,判断哪些元素需要更新,将需要更新的元素放到更新列表中,最后遍历更新列表更新所有的元素,这所有的过程都是React帮我们完成的。

对浏览器而言,这个过程仅仅是编译执行了一段JavaScript代码而已,我们把从setState开始,到页面渲染结束的浏览器主线程工作流程画出来,如下图所示。蓝色粗线表示浏览器主线程。

所以从拿到最新的数据,到将数据在页面中渲染出来,可以分为两个阶段。

  1. 调度阶段。这个阶段React用新数据生成新的Virtual DOM,遍历Virtual DOM,然后通过Diff算法,快速找出需要更新的元素,放到更新队列中去。
  2. 渲染阶段。这个阶段 React 根据所在的渲染环境,遍历更新队列,将对应元素更新。在浏览器中,就是跟新对应的DOM元素。除浏览器外,渲染环境还可以是 Native,硬件,VR 等。

想知道怎么React 怎么进行Diff的,可以看这里

新问题

之前,React在官网中写道:

We built React to solve one problem: building large applications with data that changes over time.

现在更新为:

React is a declarative, efficient, and flexible JavaScript library for building user interfaces.

所以我们看出,React新的定位在于灵活高效的数据。但是在实际的使用中,尤其是遇到页面结构复杂、数据更新频繁的应用时,React的表现不尽如人意。

在上一个例子中,我们可以设置NUMBER_OF_BLOCK的值为100000(实际情况下,可能没有那么多),将其变为一个“复杂”的网页。

点击按钮,触发setState,页面开始更新。

点击输入框,输入一些字符串,比如“hireact”。我们可以看到,页面此时没有任何的响应。

等待7s,输入框中突然出现了之前输入的“hireact”,同时,BlockList组件也更新了。

在这等待的7s中,页面不会给我任何的响应,我会以为网站崩溃了,或者电脑死机了。如果没有让我等待几秒,只是等待了0.5秒,多等待几个0.5秒之后我会在心里默想:这是什么破网站!

显而易见,这样的用户体验并不好。

浏览器主线程在这7s的performance如下图所示:

黄色部分是JavaScript执行时间,也是React占用主线程时间,紫色部分是浏览器重新计算DOM Tree的时间,绿色部分是浏览器绘制页面的时间。

三种任务,占用浏览器主线程7s,此时间内浏览器无法与用户交互。但是DOM改变之后,浏览器重新计算DOM Tree,重绘页面是一个必不可少的阶段(紫色绿色阶段),浏览器一直都是这样执行的。主要是黄色部分执行时间较长,占用了6s,即React较长时间占用主线程,导致主线程无法响应用户输入。

新技能Get

可以确定的是复杂度为常数的diff算法还是很优秀的,主要问题出现在,React的调度策略–Stack reconcile。这个策略像函数调用栈一样,会深度优先遍历所有的Virtual DOM节点,进行Diff。它一定要等整棵Virtual DOM计算完成之后,才将任务出栈释放主线程。所以,在浏览器主线程被React更新状态任务占据的时候,用户与浏览器进行任何的交互都不能得到反馈,只有等到任务结束,才能突然得到浏览器的响应。

React这样的调度策略对动画的支持也不好。如果React更新一次状态,占用浏览器主线程的时间超过16.6ms[1],就会被人眼发现前后两帧不连续,给用户呈现出动画卡顿的效果。

React核心团队很早之前就预知这样的风险的存在,并且持续探索可解决的方式。基于浏览器对 requestIdleCallbackrequestAnimationFrame这两个API的支持,以及其他团队对这两个API的实现,如React Native团队。React团队实现新的调度策略–Fiber reconcile。

Fiber)是一种轻量的执行线程,同线程一样共享定址空间,线程靠系统调度,并且是抢占式多任务处理,Fiber 则是自调用,协作式多任务处理。

Fiber Reconcile与 Stack Reconcile主要有两方面的不同:

首先,使用协作式多任务处理任务。将原来的整个Virtual DOM的更新任务拆分成一个个小的任务。每次做完一个小任务之后,放弃一下自己的执行将主线程空闲出来,看看有没有其他的任务。如果有的话,就暂停本次任务,执行其他的任务,如果没有的话,就继续下一个任务。

整个页面更新并重渲染过程分为两个阶段。

  1. Reconcile阶段。此阶段中,依序遍历组件,通过diff 算法,判断组件是否需要更新,给需要更新的组件加上tag。遍历完之后,将所有带有tag的组件加到一个数组中。这个阶段的任务可以被打断。
  2. Commit阶段。根据在Reconcile阶段生成的数组,遍历更新DOM,这个阶段需要一次性执行完。如果是在其他的渲染环境–Native,硬件,就会更新对应的元素。

所以之前浏览器主线程执行更新任务的执行流程就变成了这样。

其次,对任务进行优先级划分。不是每来一个新任务,就要放弃现执行的任务,转而执行新任务。与我们做事情一样,将任务划分优先级,只有当比现任务优先级高的任务来了,才需要放弃现任务的执行。比如说,屏幕外元素的渲染和更新任务的优先级应该小于响应用户输入任务。若现在进行屏幕外组件状态更新,用户又在输入,浏览器就应该先执行响应用户输入任务。浏览器主线程任务执行流程如下图所示。

我们重写一个组件,跟之前的一样。一个输入框,一个按钮,一个BlockList组件。BlockList组件会根据NUMBER_OF_BLOCK的数值渲染出对应数量的数字显示框,数字显示框显示点击按钮的次数。将NUMBER_OF_BLOCK设置为100000,模拟一个复杂的页面。不同的是,使用Fiber reconcile调度策略,设置任务优先级,让浏览器先响应用户输入再执行组件更新。

在对比代码差异之前,我们先执行同样的操作,对比一下浏览器的行为。

点击button,触发setState,页面开始更新。

点击输入框,输入一些字符串,比如“hireact”。我们可以看到,页面能够响应我们的输入了。

浏览器主线程的performance如下图所示:

可以看到,在黄色JavaScript执行过程中,也就是React占用浏览器主线程期间,浏览器在也在重新计算DOM Tree,并且进行重绘,截图显示,浏览器渲染的就是用户新输入的内容。简单说,在React占用浏览器主线程期间,浏览器也在与用户交互。这个才是我们在网站上面期望获得的体验,浏览器总是对我的输入有反馈。

那我们的代码改变了哪些呢?从下往上看:

首先,从reactDOM.render()变成了ReactDOMFiber.render()。我们使用了ReactFiber去渲染整个页面,ReactFiber会将整个更新任务分成若干个小的更新任务,然后设置一些任务默认的优先级。每执行完一个小任务之后,会释放主线程。

其次,render方法中返回的不再是一个被div元素包一层的组件列表,而是直接返回一个组件列表,这是React在新版中提供的新的写法。除此之外,可以直接返回字符串和数字。像下面:

再次,我们传给setState的不是最新状态,而是一个callback,这个callback返回最新状态。同上,这个也是React新版中提供的新的写法,同时也是推荐的写法。

最后,我们没有直接调用setState,而是将其作为callback传给了unstable_deferredUpdates这个API。从名字就可以看出,deferredUpdates是将更新推迟,unstable表明现在还不稳定,在开发阶段。从源代码上看,unstable_deferredUpdates做了一件事情,就是将传给它的更新任务的优先级设置为lowpriority。所以我们将seState 作为callback传给了unstable_deferredUpdates,就是告诉React,这个setState任务,是一个lowpriority的任务。(需要注意的是,并不确定React团队是否将unstable_deferredUpdates或者deferredUpdates作为一个开放的接口,现在这个版本[2]可以通过这个API去设置优先级。同时,从源代码可以看到,React团队想要实现“给任务设置优先级”的功能,目前只看到一个performWithPriority的接口,也还没有实现。)

我们点击按钮之后,unstable_deferredUpdates将这个更新任务设置为low priority。此时是没有其他任务存在的,React就开始进行状态更新了。更新任务进入了Reconcile阶段,我们点击输入框,此时,用户交互任务来了,此任务优先级高于更新任务,所以浏览器主线程将焦点放在了输入框……。之后更新任务进入了Commit阶段,不能将浏览器主线程放弃,到了最后浏览器渲染完成之后,将用户在更新任务Commit阶段的输入以及最新的状态显示出来。

对比Stack Reconcile和Fiber Reconfile的实现,我们可以看到React新的调度策略让开发者对React应用有了更细节的控制。开发者,可以通过优先级,控制不同类型任务的优先级,提高用户体验,以及整个应用程序的灵活性。

采用新的调度算法之后,会将动画的渲染任务优先级提高,对动画的支持会比较友好,具体例子可以看Lin Clark在React Conf 2017的演讲。

后记

看起来React Fiber很厉害的样子,如果要用的话,还是有一些问题是需要考虑的。

比如说,task按照优先级之后,可能低优先级的任务永远不会执行,称之为starvation;

比如说,task有可能被打断,需要重新执行,那么某些依赖生命周期实现的业务逻辑可能会受到影响。

……

React Fiber也是带来了很多的好处的。

比如说,增强了某些领域的支持,如动画、布局和手势;

比如说,在复杂页面,对用户的反馈会更及时,应用的用户体验会变好,简单页面看不到明显的差异;

比如说,api基本上没有变化,对现有项目很友好。

……

现在,react-fiber已经通过了所有的测试,在网站上面Is Fiber Ready Yet?http://isfiberreadyyet.com/,已经通过了所有的测试,还有4个warning需要fix。它会随着React 16发布,到底效果怎么样,只有用过才知道了。

参考资料:

备注:

[1]: 只有动画或者视频达到 60 fps,人眼看起来才是流畅的,即平均 16.6 ms 就要完成整个页面的重渲染,否则就会让用户觉得卡顿。这里所指的动画,不是在页面播放一个视频或者动画,而是用 React 写动画。

[2]: 本文中 React 的版本是 React@16.0.0-alpha.3

Share

React全家桶与前端单元测试艺术

TL;DR——什么是好的单元测试?

其实我是个标题党,单元测试根本没有“艺术”可言。

好的测试来自于好的代码,如果说有艺术,那也是代码的艺术。

注:以下“测试”一词,如非特指均为单元测试。

 

单元测试的好坏在于“单元”而不在“测试”。如果一个系统毫无单元可言,那就没法进行单元测试,几乎只能用Selenium做大量的E2E测试,其成本和稳定性可想而知。科学的单元划分可以让你摆脱mock,减少依赖,提高并行度,不依赖实现/易重构,提高测试对业务的覆盖率,以及易学易用,大幅减少测试代码。

最好的单元是返回简单数据结构的函数:函数是最基本的抽象,可大可小,不需要mock,只依靠传参。简单数据结构可以判等。 最好的测试工具是Assert.Equal这种的:只是判等。判等容易,判断发生了什么很难。你可以看到后面对于DOM和异步操作这些和副作用相关的例子都靠判等测试。把作用幂等于数据,拿到数据就一定发生作用,然后再测数据,是一个基本思路。

以上是你以前学习测试第一天就会的内容,所以不存在门槛。

为什么不谈TDD?

首先,TDD肯定是有价值的(价值大小不论)。反对TDD的原因一般比较明显,对于TDD是否带来正收益不确定(动机不足)。 某些项目质量要求很高,预算宽绰,TDD势在必行。某些项目比较紧急,或者并非关键或无长期维护计划,TDD理由就不充分。

为什么谈测试?

因为测试难。

第一难学,第二难写。写测试是个挺困难的活,要在测试里正确重演业务要费好大劲,只能靠反复练习。虽然这些测试在某些项目中是值得的,但是可能并不适合其他某些项目的基本情况。

测试难,就代表训练成本高,生产成本也高,收益就下降。要提高采用TDD的动机,与其说服别人,不如从简化测试开始。

(图片来自:http://t.cn/Rpw9WKg

为什么谈前端测试?

一般项目都是后端测试覆盖率高,同时后端套路也比较固定。测RESTful API粒度足够大,可以很好地避开实现并且覆盖业务。同时RESTful API一般也正好对应Web框架的Action handler,在这里同时它粒度也足够小,刚好可以直接调用而不启动真的Web server,使得测试最大程度并行化。所以这样测试收益总是最高的,争议很小。

前端不说套路不固定,测不测都有待商榷。因为前端流派不统一,资源不规则,边界也不清晰,有渲染又有点业务,有导航有请求,很多团队不测试/测Model/测Component/测E2E,五花八门。 但得益于JavaScript本身,前端测试其实是可以非常高效的。

下面你可以看到各种极简极快的测试工具和测试方式,并且它们完全可以贯穿开发始终,而非仅给Hello World体量项目准备的,你可以在很大的全家桶项目中完全机械地套用这些方法。(机械也是极限的一部分,你不应该在使用工具过程中面临太多抉择,而应当专注于将业务翻译成测试)。

为什么谈React全家桶?

前端从每周刷新一个框架,稳定到了Angular, React, Vue3个主流框架并存的阶段。网络中争论这三个框架盖的楼已经可以绕太阳系了。根据盖的各种大楼看来,现在哪个更优秀还没个定论。不过具体到单元测试方面,得益于Virtual DOM本身和模块化设计(不然全家桶白叫了),React全家桶明显更优秀些。

测试工具

我们本篇中的测试有三个目标:学得快,写得快,跑得快

(图片来自:http://t.cn/RpwCke3

平台上Selenium, Phantom, Chrome, 包括Karma都比较重,最好的测试框架就是直接跑在node上的。本着极限编程的原则,我们将测试本身和测试环境尽可能简化,以达到加快测试速度,最终反馈到开发速度的目的。

我们使用AVA进行测试,它非常简洁,速度非常快,和mocha不同,它默认会启动多线程并发测试。因此我们的测试必须减少共享状态来提高并发能力,不然就会出现意想不到的错误。安装和运行:

yarn add ava
ava --watch

这样可以运行并watch测试。改变代码测试结果会立刻改变,你也可以看到友善的错误信息,以及expected和actual之间的diff。写下第一段测试:

import test from 'ava'

test(t => {
  t.is(1 + 1, 2)
})

除了is方法以外,我们还会用到deepEqual和true方法。好,你现在已经完全会用AVA了。其他的功能我们完全不关心。

Redux测试 (Model测试)

Redux就是用一堆Reducer函数来reduce所有事件用来做全局Store的状态机(FSM)。用源码本身介绍它甚至比用上一小段文字介绍还快:

const createStore = reducer => {
  let state, listeners = []

  const dispatch = action => {
    state = reducer(state, action)
    listeners.forEach(listeners => listeners())
  }

  return {
    getState() { return state },
    subscribe(listener) {
      listeners.push(listener)
      return () => { listeners = listeners.filter(l => l !== listener)}
    },
    dispatch,
  }
}

这是一个简化版的代码,去掉了抛错等等细节,但功能是完整的。把你自己写的reducer扔进去,然后可以发事件来使其更新,你还可以订阅它来拿状态。有点像Event Sourcing,以消息而非调用来处理逻辑,更新和订阅的逻辑不在一起(事件是写模型,各种view就是多个读模型)。

reducer几乎包括了我们所有前端业务的核心,测好它就测了大半。它们全都是(State, Action) => nextState形式的纯函数,无异步操作,用swtich case来模拟模式匹配来处理事件。比如用喜闻乐见的简陋版的栈停车场举例:

export const parkingLot = (state = [], action) => {
  switch (action.type) {
    case 'parkingLot/PARK':
      return [action.car, ...state]
    case 'parkingLot/PICK':
      const [_, ...rest] = state
      return rest
    default: return state
  }
}

Reducer是这么用的:

const store = createStore(parkingLot)
store.subscribe(() => renderMyView(store.getState()))
store.dispatch({ type: 'parkingLot/PARK' })

好,现在你又理解了Redux。那我们可以看看怎么测试上面的parkingLot reducer了:

test('parking lot', t => {
  const initial = parkingLot(undefined, {})
  t.deepEqual(initial, [], 'should be empty when init')

  const parked = parkingLot(initial, { type: 'parkingLot/PARK', car: 'Tesla Model S' })
  t.deepEqual(parked, ['Tesla Model S'], 'should park Model S in lot')

  const picked = parkingLot(parked, { type: 'parkingLot/PICK' })
  t.deepEqual(picked, [], 'should remove the car')
})

它就是你第一天学测试就会写的那种测试。这些测试不受任何上下文影响,是幂等的。试着把那几个const声明的state挪到任何地方,你都可以发现测试还是正确的,这和我们平常小心翼翼分离各个测试case,并用beforeEach和afterEach重置截然不同。

(图片来自:http://t.cn/RpwS3AK

测试Reducer是非常机械的,你不需要问自己“我到底应该测哪些东西”,只需要机械地测试初始state和每个switch case就好了。(小秘密:redux-devtools写完实现,在浏览器里打开,反过来还可以自动生成各种框架的测试代码,粘贴回来就行了。推荐不写测试的项目尝试下,反正白送的测试……而且跟你写的没两样)

随着业务变得复杂,当state树变大时,我们可以将reducer结构继续往下抽,并继续传递事件,函数没有this,重构起来比普通OO要简单得多,就不赘述了。这时候测试还是完全一样的,这种树形结构保证了我们能最大限度地覆盖一个bounded context—也就是root reducer。

另外更好的方式是用t.is(断言引用相同)而非t.deepEqual。但是JavaScript对象本身是可变的,引入immutable.js可以让你只用t.is测试,不过immutable的API有点别扭,不展开了。

组件测试 (View测试)

React是一个View library,它干的活就是DOM domain里的两个事:渲染和捕获事件。我们在这里依然从简,只用stateless component这个子集,虽然在用到生命周期方法的时候需要用一下class,但绝大多数时候应该只用stateless component。

它以Virtual DOM的形式封装了恶心的浏览器基础设施,让我们以函数和数据结构来描述组件,所以和大部分框架不同,我们的测试依然可以在node上并行运行。如果用Karma + Chrome真正地渲染测试,你会发现共享一个浏览器实例的测试非常慢,几乎无法watch测试,因此我们的TDD cycle就会变得不那么流畅了。

最基本的就是state => UI这种纯函数组件:

const Greeter = ({ name }) => <p>Greetings {name}!</p>

使用的时候就像HTML一样传递attribute就可以了。

render(<Greeter name="React"/>, document.body)

最简单的测试还是判等,我们用一个叫jsx-test-helpers的库来帮我们渲染:

import { renderJSX, JSX } from 'jsx-test-helpers'

const Paragraph = ({ children }) => <p>{children}</p>
const Greeter = ({ name }) => <Paragraph>Greetings {name}!</Paragraph>

test('Greeter', t => {
  t.is(renderJSX(<Greeter name="React"/>), 
       JSX(<Paragraph>Greetings React!</Paragraph>), 
       'should render greeting text with name')
})

这里我多加了一层叫做Paragraph的组件,它的作用仅仅是传递给p标签,children这个prop表示XML标签传进来的子元素。多加这层Paragraph是为了展示renderJSX只向下渲染了一层,而非最终需要渲染的p标签。这样我们在View上的测试粒度就会变得更小,成本更低,速度更快。

(图片来自:http://t.cn/RpwYskG

View不像业务本身那么稳定,细粒度低成本的快速测试更划算些,这也是为什么我们的View都只是接受参数渲染,这样你只用测很少的case就能保证View可以正确渲染。假如你的FSM Model有M种可能性,View显示的逻辑有N种,如果将两个集成在一起测试可能就需要M×N种Path,如果分开测就有M+N种。View和Model的边界清晰时,你的Model测试不容易被更困难的View测试干扰,View测试也减少了混沌程度,需要测试的情形就减少了。

我们的组件不应该只有渲染,还有事件,比如我们封装个TextField组件:

const TextField = ({ label, onChange }) => <label>
  {label}
  <input type="text" onChange={onChange} />
</label>

当然我们还可以判等,只要onChange函数引用相同就好了。

test('TextField', t => {
  const onChange = () => {}
  const actual = renderJSX(<TextField label="Email" onChange={onChange} />)
  const expected = JSX(<label>
    Email
    <input type="text" onChange={onChange}/>
  </label>)
  t.is(actual, expected)
})

当然有时候你的组件更复杂些,测试时并不关心组件是不是完全按你想要的样子渲染,可能你想像jQuery一样选择什么,触发什么。这样可以用更主流的enzyme来测试:

import {shallow} from 'enzyme'
import sinon from 'sinon'

test('TextField with enzyme', t => {
  const onChange = sinon.spy()
  const wrapper = shallow(<TextField label="Email" onChange={onChange} />)
  t.true(wrapper.contains(<label>Email</label>), 'should render label')

  const event = { target: { value: 'foo@bar.com' } }
  wrapper.find('input').simulate('change', event)
  t.true(onChange.calledWith(event))
})

这里用的shallow顾名思义,也是向下渲染一层。此外我们还用了spy,这样测试就变得有点复杂了,丢掉了我们之前声明式的优雅,所以组件还是小一点、一下测完比较好。

还不够快?Facebook就觉得不够快,他们觉得View测试成本比较浪费,干脆搞了个Snapshot测试——意思就是照个像,只断言它不变。下次谁改了别的地方不小心影响到这里,就会挂掉,如果无意的就修好,如果有意的话和git一样commit一下就修好了:

import render from 'react-test-renderer'

test('Greeter', t => {
  const tree = render.create(<Greeter name="React"/>).toJSON()
  t.snapshot(tree, 'should not change')
})

当你修改Greeter的时候,测试就会挂掉,这时候运行:

ava --update-snapshots

就好了。Facebook自家的Jest对snapshot的支持更好,当snapshot不匹配时按个y/n就完事了,够快了吧。要有更快的可能就是不测了……

小结

这节里我们展示了3种测试View的不同方式,它们都比传统框架更简单更快速。我们的思路还是以判等为主,但不同于Model,粒度越大越好。View测试粒度越小越好,足够小、足够幂等之后,其实不用测试你也可以发现组件总是按照预期工作。相比之下MVVM天然有一种让View和Model粒度拟合的倾向,很容易让测试变得既难测又缺乏价值。

异步Effect测试

这算个续集……异步操作不复杂的项目可以无视这段,可以选择性不测。

React先解决了恶心的DOM问题,把Model的问题留下了。然后Redux把同步逻辑解决了,其实前端还留下异步操作的大问题没有解决。这种类似“Unix只做一件事”的哲学是React全家桶的根基。我们用一个叫做Redux-saga的库来展现全家桶的异步测试怎么写,Redux模仿的目标是Elm architecture,但是简化掉了Elm的作用模型,只保留了同步模型,Redux-saga其实就是把Elm的作用模型又拿回来了。

Saga是一种worker模式,很早之前在Java社区就存在了。Redux-saga抽象出来多种通用的作用比如call / takeEvery等等,然后有了这些作用,我们又可以愉快地判等了。比如:

import { takeEvery, put, call, fork, cancel } from 'redux-saga/effects'

function *account() {
  yield call(takeEvery, 'login/REQUESTED', login)
}
function *login({ name, password }) {
  try {
    const { token } = yield call(fetch, '/login', { method: 'POST', body: { name, password } })
    yield put({ type: 'login/SUCCEEDED', token })
  }
  catch (error) {
    yield put ({ type: 'login/FAILED', error })
  }
}

这段代码乍看起来很丑,这是因为它把程序里所有异步操作全都集中在自己身上了。其他部分都可以开心地发同步事件了,此外有了Saga之后Redux终于有了“用事件触发事件”的机制了,只用redux,应用复杂到一定程度你一定会想这个问题的。

这是个最普通的API处理saga,一个account worker看到每个’login/REQUESTED’就会forward给login worker(takeEvery),让它继续管下面的事。然后login worker拿到消息就会去发请求(call),之后傻傻地等着回复,或者是出错。最后它会发出和结果相关的事件。用这个方式你可以轻松解决疯狂难度的异步问题。

test('account saga', t => {
  const gen = account()
  t.deepEqual(gen.next().value, call(takeEvery, 'login/REQUESTED', login))
})

test('login saga', t => {
  const gen = login({ name: 'John', password: 'super-secret-123'})

  const request = gen.next().value
  t.deepEqual(request, call(fetch, '/login', { method: 'POST', body: { name: 'John', password: 'super-secret-123'} }))
  const response = gen.next({ token: 'non-human-readable-token' }).value
  t.deepEqual(response, put({ type: 'login/SUCCEEDED', token: 'non-human-readable-token' }))
  const failure = gen.throw('You code just exploded!').value
  t.deepEqual(failure, put({ type: 'login/FAILED', error: 'You code just exploded!'}))
})

你看我们的测试连异步操作都还可以无耻地判等。call就是以某些参数调用某个函数,put就是发事件。

可以试着把fetch覆盖成空函数,你可以发现实际上副作用根本没发生,“fetch到底是个啥”对测试一点影响都没有。你可能发现了,其实saga就是用数据结构表示作用,而不着急执行,在这里又走回幂等的老路了。这和React Virtual DOM的思路异曲同工。

结语

首先是文章开头提到的TL;DR的内容。函数是个好东西,测函数不等同“测1+1=2”这种没营养的单元,函数是可以包含很大上下文的。这种输入输出的模型既简单又有效。

我们消灭了mock,减少了依赖,并发了测试,加快了速度,降低了门槛,减少了测试路径等等。如果你的React项目原来在TDD的边缘摇摆不定,现在是时候入一发这种唯快不破了。

全家桶让Model/View/Async这三者之间的边界变得清晰,任由业务变更,它们之间的职责是不会互相替代的,这样你测它们的时候才更容易。后端之所以测试稳定是因为有API。所以想让前端好测也是一样的思路。

文中好多次提到“幂等”这个概念,幂等可以让你减少测试的case,写代码更有底气。抛开测试不谈,代码幂等的地方越多,程序越可控可预期。其实仔细思考一下我们的实际项目,大部分业务都是非常确定的,并没有什么随机因素。为什么最后还是会出现很多随机现象呢?

声明优于命令,描述发生什么、想要什么比亲自指导具体步骤好。

消息机制优于调用机制。Smalltalk > Simula。其实RESTful API一定程度上也是消息。简单的对象直接互相作用是完全没问题的,人作为复杂对象主要通过语言媒介来交流,听到内容思考其中的含义,而不是靠肢体接触,或者像连体婴儿那样共享器官。所以才有一句俗语叫“你的对象都想成长为Actor”。

从View的几种测试里我们也可以看到,测试并不是只有测或者不测这两种选择,我们老提测试金字塔,意思是测试可多可少,不同层级的测试保持正金字塔形状比较健康,像今天我们说的就可以大幅加宽你测试金字塔的底座。所以你的项目有可能测试过少,也可能测试过度,所以时间可以动态调整。

没用全家桶的项目可以把“大Model小View”的思想拿走,这样更容易于专注价值。尽量抽出Model层,不要把逻辑写在VM里,看那样似省事,行数在测试里都还回来了。


更多精彩洞见,请关注微信公众号:思特沃克

Share

React Native性能之谜

在PhoneGap、RubyMotion、Xamarin、Ionic一众跨平台开发工具中,React Native能够杀出一条血路,获得目前这么大的影响力,除了React社区生态圈的加持和Facebook的大力推广以外,另外一个最主要的原因就是其在开发效率和应用性能方面取得了一个比较好的平衡:

  • 开发效率通过JS工程实践,逻辑跨平台复用得到极大提升
  • 性能则通过全Native的UI层得到满足

不过,虽说框架提供了这个平衡能力,平衡点的选择却掌握在开发者手中,本文将从React Native的性能角度来看看应该如何掌握这个平衡点。

React Native的工作原理

在React Native的应用中,存在着两个不同的技术王国:JS王国和Native王国。应用在启动时会先进行双向注册,搭好桥,让两个王国知道彼此的存在,以及定义好彼此合作的方式:

(图片来源:https://tadeuzagallo.com/blog/react-native-bridge/

然后,在应用的实际运行过程中,两个技术王国通过搭好的桥,彼此合作完成用户功能:

(图片来源:http://www.jianshu.com/p/978c4bd3a759)

因此,React Native的本质是在两个技术王国之间搭建双向桥梁,让他们可以相互调用和响应。那么就可以把上图简化一下:

React Native的性能瓶颈

经过上面的分析,我们就可以把一个React Native应用分成三个部分:Native王国、Bridge、JS王国。当应用运行时,Native王国和JS王国各自运行在自己独立的线程中:

Native王国:

  • 运行在主线程上(可能会有些独立的后台线程处理运算,当前讨论中可忽略)
  • iOS平台上运行Object-C/Swift代码,Android平台上运行Java/Kotlin代码
  • 负责处理UI的渲染,事件响应。

JS王国:

  • 运行在JS引擎的JS线程上
  • 运行JS代码
  • 负责处理业务逻辑,还包括了应该显示哪个界面,以及如何给页面加样式。

在Native王国中,经过谷歌、苹果公司多年的优化调整,Native代码能够非常快速的运行在设备上。在JS王国中,JS代码作为脚本语言,也能够很快速的运行在JS引擎上,这两边独立来看都不会有性能问题。性能的瓶颈只会出现在从一个王国转入另一个王国时,尤其是频繁的在两个王国之间切换时,两个王国之间不能直接通信,只能通过Bridge做序列化和反序列化,查找模块,调用模块等各种逻辑,最终反应到应用上,就是UI层用户可感知的卡顿。 因此,对React Native的性能控制就主要集中在如何尽量减少Bridge需要处理的逻辑上。

那么,什么情况下会需要Bridge处理逻辑呢?

  1. UI事件响应: 所有的UI事件都发生在Native侧,会以事件的形式传递到JS侧。这个过程非常简单,也不会涉及大量的数据转移。在React Native应用中,业务逻辑,应用状态,数据都在JS侧,所以UI事件只是一个触发器,不会有性能问题。
  2. UI更新:前面已经说过JS负责决定应该展示哪个界面,以及如何样式化界面,因此UI更新的发起方是JS侧,更新时会向Native侧同步大量的UI结构和数据,这类更新经常出现性能问题,尤其是在界面复杂、变动数据大,或者做动画、变动频繁时。
  3. UI事件响应和UI更新同时出现:在UI更新时,结构变化不大,则性能问题不大;但是如果这时又有UI事件触发JS侧逻辑处理,而该逻辑处理又比较复杂,耗时较长,导致JS侧没有时间片处理与Native侧数据同步时,也会发生性能问题。

React Native的性能优化措施

前面已经解释了React Native的性能瓶颈会在什么地方,React Native官方也知道这些,其在React Native中提供了一些性能优化措施帮助开发者克服这些性能问题:

  1. 框架自带的React基于Virtual Dom的Diff算法保证了UI变动时传递的只是变化的UI部分,尽量减少需要同步的数据。
  2. 通过Direct Manipulation的方式直接在底层更新了Native组件的属性,从而避免渲染组件结构和同步太多视图变化所带来的大量开销。这样的确会带来一定的性能提升,同时也会使代码逻辑难以理清,而且并没有解决从JS侧到Native侧的数据同步开销问题。因此这个方式官方都不再推荐,更推荐的做法是合理使用setState()和shouldComponentUpdate()方法解决这类问题。
  3. 在遇到动画性能问题时,可以使用Annimated类的库,一次性把如何变化的声明发送到Native侧,Native侧根据接收到的声明自己负责接下来的UI更新。不需要每帧的UI变化都同步一次数据。
  4. Native和JS混编,把会大量变化的组件做成Native组件,这样UI的变更数据直接在Native侧自己处理了,无需通过Bridge,而不变的内部组件因为没有数据更新需要同步,所以也不会使用到Bridge。框架提供的NavigatorIOS相对于Navigator的性能提升就是这种做法。
  5. 遇到事件响应和UI更新同时发生导致的性能问题时,可以使用Interaction Manager把那些耗时较长的工作安排到所有互动或动画完成之后再进行。

探求性能和效率平衡的套路

在了解了React Native的性能瓶颈和优化措施之后,就可以大概总结一个探寻React Native开发效率和性能平衡点的套路:

第一步: 全JS实现, 从一开始在技术选型上用React Native就是为了保证开发的效率,在没有遇到性能问题之前,最大化效率是团队的一致追求。

第二步: 从JS侧进行性能优化

  • 对于那些明显会涉及Bridge、需大量处理逻辑的场景,比方说动画,复杂的手势操作响应等,尝试使用经过优化过的库(比方说:Animated),一次传递动画或者数据整个数据的描述给Native,Native侧自己会按照声明执行下去。
  • 使用InteractionManager把耗时操作递延到UI响应之后,处理那些存在因为耗时的JS操作导致的UI响应性能问题。

第三步:在真机上实测,检查性能问题点。不要过早优化,找到问题点再一一处理。

第四步:如果经过JS端的优化策略之后,在设备上还是有性能问题,可以把有问题的部分以Native方式实现,这也是为什么要推荐React Native团队中有10%左右的Native Developer。在这个步骤中,需要注意问题的隔离方式,假设一个场景:在移动一个Container时,Container的UI同时发生变化,但是Container内部的内容并没有发生变化,这种情况下,只需要用Native实现Container,Container内部的组件还是以JS实现。


更多精彩洞见,请关注微信公众号:思特沃克

Share

使用Enzyme测试React(Native)组件

组件化与UI测试

在组件化出现之前,我们不谈UI的单元测试,哪怕是对于UI页面进行测试都是一件非常困难的事情。其实组件化并不完全是为了复用,很多情况下也恰恰是为了分治,使得我们可以分组件对UI页面进行开发,然后分别对其进行单元测试。

特别是当浏览器中的Web应用越来越庞大的时候,与在后端将大型单体应用拆分成微服务架构的最佳实践一样,前端应用也可以被拆分成不同的页面和特性。

(图片来自:http://t.cn/R6UgwrH)

每个特性由一个单独的团队从端到端对其负责,它允许团队规模化地交付那些能够独立部署和维护的服务,在2016年11月期的技术雷达当中这种方式被称之为微前端,微前端的目标就是允许Web应用的特性彼此独立,每个特性可以独立地开发、测试和部署。

React.js作为前端框架的后起之秀,却在2015年携着虚拟DOM、组件化、单向数据流等利器,给前端UI构建掀起了一波声势浩大的函数式新潮流。虽说组件化不是React最先提出来的,但却是被React在前端世界里发扬光大的,而现在几乎所有的所谓现代化UI框架比如Angular或者Vue都已经将组件化作为框架的立足之本。

React已经让UI测试变得容易很多,React组件都可以被简化为这样一个表达式,即UI=f(data),这个纯函数返回的只是一个描述UI组件应该是什么样子的虚拟DOM,本质上就是一个树形的数据结构。给这个纯函数输入一些应用程序的状态,就会得到相应的UI描述的输出,这个过程不会去直接操作实际的UI元素,也不会产生所谓的副作用。

React组件树的测试

按理来说按照纯函数这样的思路,React组件的测试应该很简单。但与此同时,对于(渲染出UI的)组件树进行测试依然存在一个问题,从下图中可以看出,越处于上层的组件,其复杂度越高。对于最底层的子组件来说,我们可以很容易的将其进行渲染并测试其逻辑正确与否,但对于较上层的父组件来说,就需要对其所包含的所有子组件都进行预先渲染,甚至于最上面的组件需要渲染出整个 UI 页面的真实DOM节点才能对其进行测试,这显然是不可取的。

Shallow rendering lets you render a component “one level deep” and assert facts about what its render method returns, without worrying about the behavior of child components, which are not instantiated or rendered. This does not require a DOM.

浅渲染(Shallow Rendering)解决了这个问题,也就是说在我们针对某个上层组件进行测试时,可以不用渲染它的子组件,所以就不用再担心子组件的表现和行为,这样就可以只对特定组件的逻辑及其渲染输出进行测试了。Facebook官方提供了react-addons-test-utils可以让我们使用浅渲染这个特性,用于测试虚拟DOM对象,即React.Component的实例。

使用Enzyme简化测试代码

我们常常会提到,测试代码对于复杂代码库的可维护性至关重要,但是测试代码本身的易于理解和编写,以及可读性和可维护性也同等重要

Enzyme is a JavaScript Testing utility for React that makes it easier to assert, manipulate, and traverse your React Components’ output.

而Enzyme则来自于活跃在JavaScript开源社区的Airbnb公司,是对官方测试工具库(react-addons-test-utils)的封装,它模拟了jQuery的API,非常直观并且易于使用和学习,提供了一些与众不同的接口和方法来减少测试的样板代码,方便你判断、操纵和遍历React Components的输出,并且减少了测试代码和实现代码之间的耦合。Enzyme理论上应该与所有TestRunner和断言库相兼容,已经集成了多种测试类库,比如Jest、Mocha&Chai、Jasmine,不过这些不是我们今天的重点。

对比一下两者facebook/react-addons-test-utils vs airbnb/enzyme的API就一目了然,立见分明:

Enzyme的三种渲染方法

shallow(node[, options]) => ShallowWrapper

shallow方法就是对官方的Shallow Rendering的封装,浅渲染在将一个组件作为一个单元进行测试的时候非常有用,可以确保你的测试不会去间接断言子组件的行为。shallow方法只会渲染出组件的第一层DOM结构,其嵌套的子组件不会被渲染出来,从而使得渲染的效率更高,单元测试的速度也会更快。

import { shallow } from 'enzyme'
describe('Enzyme Shallow', () => {
  it('App should have three <Todo /> components', () => {
   const app = shallow(<App />)
   expect(app.find('Todo')).to.have.length(3)
  })
}

mount(node[, options]) => ReactWrapper

mount方法则会将React组件渲染为真实的DOM节点,特别是在你依赖真实的DOM结构必须存在的情况下,比如说按钮的点击事件。完全的DOM渲染需要在全局范围内提供完整的DOM API, 这也就意味着它必须在至少“看起来像”浏览器环境的环境中运行,如果不想在浏览器中运行测试,推荐使用mount的方法是依赖于一个名为jsdom的库,它本质上是一个完全在JavaScript中实现的headless浏览器。

import { mount } from 'enzyme'
describe('Enzyme Mount', () => {
  it('should delete Todo when click button', () => {
   const app = mount(<App />)
   const todoLength = app.find('li').length
   app.find('button.delete').at(0).simulate('click')
   expect(app.find('li').length).to.equal(todoLength - 1)
  })
})

render(node[, options]) => CheerioWrapper

render方法则会将React组件渲染成静态的HTML字符串,返回的是一个Cheerio实例对象,采用的是一个第三方的HTML解析库Cheerio,官方的解释是「我们相信Cheerio可以非常好地处理HTML的解析和遍历,再重复造轮子只能算是一种损失」。这个CheerioWrapper可以用于分析最终结果的HTML代码结构,它的API跟shallow和mount方法的API都保持基本一致。

import { render } from 'enzyme'
describe('Enzyme Render', () => {
  it('Todo item should not have todo-done class', () => {
   const app = render(<App />)
   expect(app.find('.todo-done').length).to.equal(0)
   expect(app.contains(<div className="todo" />)).to.equal(true)
  })
})

Enzyme 的 API 方法

find() 方法与选择器

从前面的示例代码中可以看到,无论哪种渲染方式所返回的wrapper都有一个.find()方法,它接受一个selector参数,然后返回一个类型相同的wrapper对象,里面包含了所有符合条件的子组件。在这个对象的基础上,at方法则可以返回指定位置的子组件,simulate方法可以在这个组件上模拟触发某种行为。

Enzyme中的Selectors即选择器类似于CSS选择器,但是只支持非常简单的CSS选择器,如果需要支持复杂的CSS选择器,就需要引入react-dom模块的findDOMNode方法,而这是官方的TestUtils都无法提供的方式。

/* CSS Selector */
wrapper.find('.foo') //class syntax
wrapper.find('input') //tag syntax
wrapper.find('#foo') //id syntax
wrapper.find('[htmlFor="foo"]') //prop syntax

Selectors也可以是许多其他的东西,以便于在Enzyme的wrapper中轻松地指定想要查找的节点,在下面的示例中,我们可以通过React组件构造函数的引用找到该组件,也可以基于React的displayName来查找组件,如果一个组件存在于渲染树中,其中设置了displayName并且它的第一个字符为大写字母,就能通过字符串找到它,与此同时也可以基于React组件属性的子集来查找组件和节点。

/* Component Constructor */
wrapper.find(ChildrenComponent)
myComponent.displayName = 'ChildrenComponent'
wrapper.find('ChildrenComponent')
/* Object Property Selector */
const wrapper = mount(
  <div>
   <span foo={3} bar={false} title="baz" />
  </div>
)
wrapper.find({ foo: 3 })
wrapper.find({ bar: false })
wrapper.find({ title: 'baz'})

测试组件的交互行为

我们不但可以通过find方法查找DOM元素,还可以通过simulate方法在组件上模拟触发某个DOM事件,比如Click,Change等等。对于浅渲染来说,事件模拟并不会像真实环境中所预期的那样进行传播,因此我们必须在一个已经设置好了事件处理方法的实际节点上调用,实际上.simulate()方法将会根据模拟的事件触发这个组件的prop。例如,.simulate(‘click’) 实际上会获取onClick prop并调用它。

Sinon则是一个可以用来Mock和Stub数据代码的第三方测试工具库,当我们需要检查一个组件当中某个特定的函数是否被调用时,我们可以使用sinon.spy()方法监视所传入该组件作为prop的onButtonClick方法,然后再通过wrapper的simulate方法模拟一个Click事件,最终验证这个被spy的onButtonClick函数是否被调用。

it('simulates click events', () => { 
  const onButtonClick = sinon.spy()
  const wrapper = shallow(
   <Foo onButtonClick={onButtonClick} />
  )
  wrapper.find('button').simulate('click')
  expect(onButtonClick.calledOnce).to.be.true
})

如何测试 React Native?

前面我们所谈论的都是如何测试使用react-dom所构建的React组件,即最终渲染的结果是浏览器当中的DOM结构,但对于React Native来说,JavaScript代码最终会被编译并用于调用iOS或Android上的Native代码,因此无法再使用基于DOM的测试工具了。

(图片来自:http://t.cn/R6UrTrG)

与此同时,React Native还有特别多的Mobile环境依赖,所以在没有真实设备的情况下很难对其运行环境进行模拟,特别是当你希望在持续集成服务器(如Jenkins、Travis CI)运行单元测试的时候。

事实上,我们可以通过欺骗React Native让它返回常规的React组件而不是Native组件,然后就又能愉快地使用传统的JavaScript测试库来单独测试React Native组件逻辑。最基本的mock示例代码如下:

const mockComponent = (type) => {
  return React.createClass({
   displayName: type,
   propTypes: {
     children: React.PropTypes.node
   },
   render() {
     return <div {...this.props}>{this.props.children}</div>
   }
  })
}
RN.View = mockComponent("View")
RN.Text = mockComponent("Text")
RN.Image = mockComponent("Image")

Enzyme推荐在测试环境中使用react-native-mock这个辅助库,这是一个使用纯JavaScript将全部的React Native组件进行mock的第三方库,只需要导入这个库就可以对React Native组件进行渲染和测试。

总结

上一期技术雷达中指出:我们非常享受Enzyme为React.js应用提供的快速组件级UI测试功能。与许多其他基于快照的测试框架不同,Enzyme允许开发者在不进行设备渲染的情况下做测试,从而实现速度更快、粒度更小的测试。在开发React应用时,我们经常需要做大量的功能测试,而Enzyme可以在大规模地减少功能测试数量上做出贡献。

(图片来自2016年11月期技术雷达)

最新一卷技术雷达将于3月28日全球发布,现在就去订阅,获悉一手技术雷达发布消息。


更多精彩洞见,请关注微信公众号:思特沃克

Share

虚拟DOM已死?

More than React系列文章:

More than React(一)为什么ReactJS不适合复杂的前端项目?

More than React(二)React.Component损害了复用性?

More than React(三)虚拟DOM已死?

More than React(四)HTML也可以静态编译?


本系列的上一篇文章《React.Component损害了复用性?》探讨了如何在前端开发中编写可复用的界面元素。本篇文章将从性能和算法的角度比较 Binding.scala 和其他框架的渲染机制。

Binding.scala 实现了一套精确数据绑定机制,通过在模板中使用 bindfor/yield 来渲染页面。你可能用过一些其他 Web 框架,大多使用脏检查或者虚拟 DOM 机制。和它们相比,Binding.scala 的精确数据绑定机制使用更简单、代码更健壮、性能更高。

ReactJS虚拟DOM的缺点

比如, ReactJS 使用虚拟 DOM 机制,让前端开发者为每个组件提供一个 render 函数。render 函数把 propsstate 转换成 ReactJS 的虚拟 DOM,然后 ReactJS 框架根据 render 返回的虚拟 DOM 创建相同结构的真实 DOM。

每当 state 更改时,ReactJS 框架重新调用 render 函数,获取新的虚拟 DOM 。然后,框架会比较上次生成的虚拟 DOM 和新的虚拟 DOM 有哪些差异,进而把差异应用到真实 DOM 上。

这样做有两大缺点:

  1. 每次 state 更改,render 函数都要生成完整的虚拟 DOM,哪怕 state 改动很小,render函数也会完整计算一遍。如果 render 函数很复杂,这个过程就会白白浪费很多计算资源。
  2. ReactJS 框架比较虚拟 DOM 差异的过程,既慢又容易出错。比如,你想要在某个 <ul> 列表的顶部插入一项 <li> ,那么 ReactJS 框架会误以为你修改了 <ul> 的每一项 <li>,然后在尾部插入了一个 <li>

这是因为 ReactJS 收到的新旧两个虚拟 DOM 之间相互独立,ReactJS 并不知道数据源发生了什么操作,只能根据新旧两个虚拟 DOM 来猜测需要执行的操作。自动的猜测算法既不准又慢,必须要前端开发者手动提供 key 属性、shouldComponentUpdate 方法、componentDidUpdate 方法或者 componentWillUpdate 等方法才能帮助 ReactJS 框架猜对。

AngularJS的脏检查

除了类似 ReactJS 的虚拟 DOM 机制其他流行的框架比如 AngularJS 还会使用基于脏检查的定值算法来渲染页面。

脏检查算法和 ReactJS 有一样的缺点无法得知状态修改的意图这使得 AugularJS 必须反复执行`$digest`轮循、反复检查各个ng-controller中的各个变量。除此之外,AngularJS 更新 DOM 的范围往往会比实际所需大得多所以会比 ReactJS 还要慢。

Binding.scala的精确数据绑定

Binding.scala 使用精确数据绑定算法来渲染 DOM 。

在 Binding.scala 中,你可以用 @dom 注解声明数据绑定表达式。@dom 会自动把 = 之后的代码包装成 Binding 类型。

比如:

@dom val i: Binding[Int] = 1
@dom def f: Binding[Int] = 100
@dom val s: Binding[String] = "content"

@dom 既可用于 val 也可以用于 def ,可以表达包括 IntString 在内的任何数据类型。

除此之外,@dom 方法还可以直接编写 XHTML,比如:

@dom val comment: Binding[Comment] = <!-- This is a HTML Comment -->
@dom val br: Binding[HTMLBRElement] = <br/>
@dom val seq: Binding[BindingSeq[HTMLBRElement]] = <br/><br/>

这些 XHTML 生成的 CommentHTMLBRElement 是 HTML Node 的派生类。而不是 XML Node

每个 @dom 方法都可以依赖其他数据绑定表达式:

val i: Var[Int] = Var(0)
@dom val j: Binding[Int] = 2
@dom val k: Binding[Int] = i.bind * j.bind
@dom val div: Binding[HTMLDivElement] = <div>{ k.bind.toString }</div>

通过这种方式,你可以编写 XHTML 模板把数据源映射为 XHTML 页面。这种精确的映射关系,描述了数据之间的关系,而不是 ReactJS 的 render 函数那样描述运算过程。所以当数据发生改变时,只有受影响的部分代码才会重新计算,而不需要重新计算整个 @dom 方法。

比如:

val count = Var(0)

@dom def status: Binding[String] = {
  val startTime = new Date
  "本页面初始化的时间是" + startTime.toString + "。按钮被按过" + count.bind.toString + "次。按钮最后一次按下的时间是" + (new Date).toString
}

@dom def render = {
  <div>
    { status.bind }
    <button onclick={ event: Event => count := count.get + 1 }>更新状态</button>
  </div>
}

以上代码可以在ScalaFiddle实际运行一下试试。

注意,status 并不是一个普通的函数,而是描述变量之间关系的特殊表达式,每次渲染时只执行其中一部分代码。比如,当 count 改变时,只有位于 count.bind 以后的代码才会重新计算。由于 val startTime = new Date 位于 count.bind 之前,并不会重新计算,所以会一直保持为打开网页首次执行时的初始值。

有些人在学习 ReactJS 或者 AngularJS 时,需要学习 keyshouldComponentUpdate$apply$digest 等复杂概念。这些概念在 Binding.scala 中根本不存在。因为 Binding.scala 的 @dom 方法描述的是变量之间的关系。所以,Binding.scala 框架知道精确数据绑定关系,可以自动检测出需要更新的最小部分。

结论

本文比较了虚拟 DOM 、脏检查和精确数据绑定三种渲染机制。

AngularJS ReactJS Binding.scala
渲染机制 脏检查 虚拟DOM 精确数据绑定
数据变更时的运算步骤
  1. 重复检查数据是否更改
  2. 大范围更新页面,哪怕这部分页面根本没有修改
  1. 重新生成整个虚拟DOM
  2. 比较新旧虚拟DOM的差异
  3. 根据差异更新页面
  1. 直接根据数据映射关系,更新最小范围页面
检测页面更新范围的准确性 不准 默认情况下不准,需要人工提供keyshouldComponentUpdate才能准一点
需要前端工程师理解多少API和概念才能正确更新页面 很多 很多 只有@dombind两个概念
总体性能 非常差

这三种机制中,Binding.scala 的精确数据绑定机制概念更少,功能更强,性能更高。我将在下一篇文章中介绍 Binding.scala 如何在渲染 HTML 时静态检查语法错误和语义错误,从而避免 bug 。

相关链接

 


你想看到的洞见,都在这里

wechat

Share