本文是『horseshoe·React专题』系列文章之一,后续会有更多专题推出
来我的 GitHub repo 阅读完整的专题文章
来我的 个人博客 获得无与伦比的阅读体验
所有的UI都是树形结构,大节点嵌套小节点。于是React开发人员开始想,既然它们有这些共性,能不能把它们抽象出来呢?
于是就有了所谓的Virtual DOM
,但我更愿意称它为抽象UI。抽象比虚拟更加触及精髓,因为DOM是一个只存在于网页中的概念。
对象
一个节点就是一个对象,对象之间的嵌套关系对应于DOM节点之间的嵌套关系。
这个对象要描述哪些关键信息呢?
- 节点类型。
- 节点属性。注意,子节点也是属性之一。
- 服务于React自身的信息。
下面是一个节点的对象表示法。
{
$$typeof: Symbol(react.element),
key: null,
props: {
children: Object || Array,
},
ref: null,
type: 'div',
_owner: {
alternate: {},
child: {},
effectTag: 5,
expirationTime: 0,
firstEffect: null,
index: 0,
key: null,
lastEffect: null,
memoizedProps: null,
memoizedState: null,
mode: 0,
nextEffect: null,
pendingProps: null,
},
}
复制代码
diff
有了抽象UI,那么组件挂载的过程究竟是怎样的呢?
首先呢,初次挂载的时候肯定是把抽象UI编译成DOM标签,然后插入到container
当中。
没错,React再通天,最终也是要使用innerHTML
来挂载DOM 的。
const setInnerHTML = createMicrosoftUnsafeLocalFunction(function(node, html) {
node.innerHTML = html;
});
复制代码
然后呢,每当有state或props更新,再将抽象UI编译一次,插入到container
当中。
当然不是这样的啦!
React怎么可能跟我们写的过家家代码一样呢。
初始挂载的时候,React确实是会使用innerHTML
接口,但是一旦挂载完毕,后续的更新都是打补丁,也就是精准的局部更新。
打补丁就涉及到两个问题:
- 如何知道在哪里打补丁?
- 打补丁的频率。
这就要说到抽象UI的diff算法。
当React决定来一波更新的时候,它会生成一套新的抽象UI树。注意,这时候React手里有两套抽象UI树了,通过对比这两棵树,React就能知道哪里产生了变化。
按照常识,假如某棵树的某个父节点由div换成了ul,但是子节点没有任何变化,那我们只需要修改该父节点的类型即可。因为只有这里变了,这才是打补丁的正确方式。
但是我们要考虑另一个问题:比较的过程所花的时间。
实现上述常识操作的算法复杂度是O(n^3),因为要递归比较。也就是说如果抽象UI树非常庞大,那以JavaScript的尿性是吃不消的,页面会出现卡顿。
所以React不得不做一些妥协。只比较同级的节点可以让算法复杂度降低到O(n),相对来讲,是一个比较好的折中方案。那么上面的例子,一旦父节点由div换成了ul,那么React就会简单粗暴的替换掉所有子节点。
每次setState都会触发diff算法吗?
当然不会。因为不是真正的UI,所以React可以控制它的更新频率,这样也可以提升性能。
React生命周期大致可以分为三个阶段:挂载,更新,卸载。在挂载阶段内和一次更新阶段内,diff算法只会运行一次,这就是打补丁的频率。
这也是人们说setState是异步的原因。
两种变化类型
现在我们知道,React只会对同一层级的节点进行比较。
那么就会产生三种结果:没有变化,节点类型改变(或者同时节点属性),节点类型不变节点属性改变。
没有变化是最好的,早点收工。
节点类型改变,比如说div变成了ul,那么不再检查属性,直接替换。同时所有的子节点也要替换。当然如果新的抽象UI树没有对应的节点,那就意味着删除了。
节点类型不变节点属性改变,那么DOM树结构不变,只是对该节点的属性做一些增删改操作。
列表
上面说到,React会依次对同一层级的节点进行比较,如果类型不同则重新挂载节点,如果类型相同属性不同则更新节点。
我们来看看列表,如果我给一个列表unshift
一项,可以想象,React在比较列表的每一项时,结果都是类型相同属性不同,于是需要更新列表的每一项。但其实我们只是往列表插入一项而已,原生写法只需要insertBefore
就好了。
算法不能太复杂,有没有什么其他的办法呢?
如果给列表的每一项加一个唯一标识符,React在比较的时候就能知道某一项不是被去掉了,而是换了位置。也就能学会插入操作了。
所以开发者在使用map
方法渲染列表时,都会要求给列表每一项加一个唯一的key属性,它就是让React变得更聪明的脑白金。
import React from 'react';
const App = ({ list }) => {
return (
<div>
{list.map(v => <div key={v}>{v}</div>)}
</div>
);
}
export default App;
复制代码
有人问了:我不用map
方法渲染,手动写一个列表,React打算怎么处理?
不处理!
你想,map
方法渲染的列表是数据映射出来的,开发者很容易操作数组。但是在JSX中手动写一个列表,首先不推荐大家这样做(吃力不讨好),其次大家很少会在React中操作DOM,所以你也不能把这个列表玩出花来。最重要的,React没办法识别这是一个列表。
还有一点,React不推荐使用列表的索引来充当key属性值,因为对列表进行插入或者删除操作时,索引也会相应的改变,它是不稳定的,key存在的意义也就没有了。
但是有些时候我们实在找不到唯一的稳定的某个值,那就只能用索引来搪塞了。
那意思React是说:孩子,不为难你了,早点下班吧。
Fiber
一般的显示器刷新频率都是60Hz。这是什么意思呢?意味着屏幕画面每秒刷新60次,或者大约每16.7ms屏幕画面刷新一次。这是人眼比较舒服的频率。
对网页而言,亦是如此。
这意味着如果JavaScript计算任务连续占用浏览器主线程超过16.7ms,网页就没办法做到60Hz的刷新率,结果就是我们常说的页面卡顿甚至白屏。
一般来说,只要不陷入死循环或者太深的调用栈,JavaScript可以从容的执行,甚至大概率还有剩余时间,行到水穷处,坐看云起时。
偏偏哪,React优化性能的机制是依靠大量的JavaScript计算。如果是一个大型的项目,组件可能会有上百层的嵌套,这个调用栈之深可想而知。
所以为了更加平滑的体验,React开发组闭关两年,祭出了Fiber这个大杀器。
可以把Fiber理解为虚拟调用栈。计算机术语中除了进程(Process)、线程(Thread),还有纤程(Fiber),React就是取其更精密的并发控制之意。
React这个开源项目大体上可以分为两部分,一部分是构建抽象UI,并提供变化检测,叫Reconciler。另一部分是将抽象UI渲染到具体的平台上,叫Renderer。
Reconciler的主要工作是计算,Renderer的主要工作是排版和绘制。
首先,要将Reconciler拆分成更小的事务,再将这些事务从用户体验的角度划分优先级,从而可以实现更细微的调度,而不是像之前一口气跑完。至于排版和绘制,那本来就是平滑体验的关键,去打断它没有任何意义。
调度工具
接下来就轮到大佬上场了。
requestAnimationFrame
API:刀耕火种时期的前端想要写动画,得用setTimeout
或者setInterval
来触发一些定时动作。首先,如果定时器执行频率比屏幕刷新率高,有一些动作可能会被忽略,浏览器直接渲染下一个动作去了。这就是定时器与屏幕刷新步调不一致导致的丢帧。还有,定时器是异步任务,如果主线程被占用它就只能眼巴巴的等着。而requestAnimationFrame
从名字就能看出来,它是为动画而生的,杀手锏是浏览器会保证它的执行频率和屏幕刷新率一致(如果有空余时间的话),就是那种被大佬罩着的人。优先级比较高。
requestIdleCallback
API:这个接口更有意思,它会告诉开发者,在16.7ms之内,浏览器把活干完还剩下多少时间,然后把剩下时间的控制权交给开发者。当然如果浏览器自己都不够用,你就喝汤吧。所以它不保证一定会执行,行就行,不行就拉倒。
React就是通过这两个API来实现事务的调度的。
Reconciler从一个风风火火的意气少年变成了一个小心翼翼的中年男人,每执行一小段事务就探出头来看看,浏览器当前有没有高优先级的事务在排队啊?如果有赶紧让开啊,等浏览器处理完咱们再偷偷的进村啊。
对生命周期的影响
我们说生命周期分为挂载、更新和卸载,生命周期从另一个角度又可以分为render前和render后。
之所以这样划分是因为React本身有Reconciler和Renderer两部分,现在的问题在哪呢?
因为Reconciler现在是可以打断的,也就是说render前的生命周期钩子有可能被执行多次,而且它的表现是没办法预测的,因为是否被打断要依据当时的情况。
这也就是componentWillMount、componentWillReceiveProps和componentWillUpdate生命周期钩子要被逐步替换掉的原因。
React专题一览