React专题:抽象UI

本文是『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拆分成更小的事务,再将这些事务从用户体验的角度划分优先级,从而可以实现更细微的调度,而不是像之前一口气跑完。至于排版和绘制,那本来就是平滑体验的关键,去打断它没有任何意义。

调度工具

接下来就轮到大佬上场了。

requestAnimationFrameAPI:刀耕火种时期的前端想要写动画,得用setTimeout或者setInterval来触发一些定时动作。首先,如果定时器执行频率比屏幕刷新率高,有一些动作可能会被忽略,浏览器直接渲染下一个动作去了。这就是定时器与屏幕刷新步调不一致导致的丢帧。还有,定时器是异步任务,如果主线程被占用它就只能眼巴巴的等着。而requestAnimationFrame从名字就能看出来,它是为动画而生的,杀手锏是浏览器会保证它的执行频率和屏幕刷新率一致(如果有空余时间的话),就是那种被大佬罩着的人。优先级比较高。

requestIdleCallbackAPI:这个接口更有意思,它会告诉开发者,在16.7ms之内,浏览器把活干完还剩下多少时间,然后把剩下时间的控制权交给开发者。当然如果浏览器自己都不够用,你就喝汤吧。所以它不保证一定会执行,行就行,不行就拉倒。

React就是通过这两个API来实现事务的调度的。

Reconciler从一个风风火火的意气少年变成了一个小心翼翼的中年男人,每执行一小段事务就探出头来看看,浏览器当前有没有高优先级的事务在排队啊?如果有赶紧让开啊,等浏览器处理完咱们再偷偷的进村啊。

对生命周期的影响

我们说生命周期分为挂载、更新和卸载,生命周期从另一个角度又可以分为render前和render后。

之所以这样划分是因为React本身有Reconciler和Renderer两部分,现在的问题在哪呢?

因为Reconciler现在是可以打断的,也就是说render前的生命周期钩子有可能被执行多次,而且它的表现是没办法预测的,因为是否被打断要依据当时的情况。

这也就是componentWillMount、componentWillReceiveProps和componentWillUpdate生命周期钩子要被逐步替换掉的原因。

React专题一览

什么是UI

JSX

可变状态

不可变属性

生命周期

组件

事件

操作DOM

抽象UI

猜你喜欢

转载自juejin.im/post/5b95c291e51d450e6057f59a
今日推荐