blog
blog copied to clipboard
从 React 渲染原理看性能优化@黄琼(转载)
学而不思则惘,思而不学则怠
前言
- Video - 从 React 渲染原理看性能优化@黄琼
- Article:首次渲染 | 更新渲染
- PPT:从 React 渲染原理看性能优化
相信很多人都用过 React ,那么大家是否遇到过海量 DOM render 卡顿的问题? React 16 对渲染机制做了大改动,很大的提升了交互体验,背后的原理又是什么? 实践出真知,本次分享深入挖掘 React 的渲染机制,同时结合实例来解决实践中遇到的性能问题,从而写出高性能的 React 应用。
JSX 如何生成 element
当我们写下一段 JSX 代码的时候,react 是如何根据我们的 JSX 代码来生成虚拟 DOM 的组成元素 element 的。
return (
<div className="cn">
<Header> Hello, This is React </Header>
<div>Start to learn right now!</div>
Right Reserve.
</div>
);
中间过程经过 babel 编译, createElement 的参数有三个,其一: type -> 标签类型,其二 :attributes -> 标签属性,没有的话,可以为 null,其三: children -> 标签的子节点
return React.createElement(
'div',
{ className: 'cn' },
React.createElement(Header, null, 'Hello, This is React'),
React.createElement('div', null, 'Start to learn right now!'),
'Right Reserve'
);
简化版本的 dom 树
{
"div": {
"attributes": [{ "name": "classname", "value": "cn" }],
"childNodes": [
{ "nodeName": "#text", "nodeType": 3, "nodeValue": "↵" },
{
"nodeName": "HEADER",
"nodeType": 1,
"nodeValue": null,
"innerText": "Hello, This is React"
},
{
"nodeName": "#text",
"nodeType": 3,
"nodeValue": "↵"
},
{
"nodeName": "DIV",
"nodeType": 1,
"nodeValue": null,
"innerText": "Start to learn right now!"
}
],
"children": [
{ "tagName": "HEADER", "textContent": " Hello, This is React " },
{ "tagName": "DIV", "textContent": "Start to learn right now!" }
],
"tagName": "DIV",
"innerHTML": "↵ <header> Hello, This is React </header>↵ <div>Start to learn right now!</div>↵ Right Reserve.↵ "
}
}
对比 render 函数被调用的时候,会返回的 element 对象
{
type: 'div',
props: {
className: 'cn',
children: [
{
type: function Header,
props: {
children: 'Hello, This is React'
}
},
{
type: 'div',
props: {
children: 'start to learn right now!'
}
},
'Right Reserve'
]
}
}
我们来观察一下这个对象的 children,现在有三种类型:
1、string
2、原生 DOM 节点
3、React Component - 自定义组件
除了这三种,还有两种类型:
4、false ,null, undefined, number
5、数组 - 使用 map 方法的时候
这里需要记住一个点:element 不一定是 Object 类型。
二、element 如何生成真实节点
在生成 elment 之后,react 又如何将其转成浏览器的真实节点。首次渲染以及更新渲染的流程是怎样的 ?
转化的规则如下:
ReactDOMComponent, ReactCompositeComponentWrapper 是 React 自己使用私有类,不会暴露给用户,常用方法:mountComponent, updateComponent 等是私有类的方法由于涉及创建,更新操作生命周期方法被暴露给用户使用
ReactDOMComponent
核心关键是 ReactMComponent 的 mountComponent 方法(直接操作浏览器 DOM 元素),作用是 将 element 转成真实 DOM 节点,并且插入到相应的 container 里 ,然后返回 markup(realDOM),最后暴露生命周期钩子给用户
ReactCompositeComponentWrapper
mountComponent: 实例化自定义组件,不直接生成 DOM 节点,最后是通过递归调用到 ReactDOMComponent 的 mountComponent 方法来得到真实 DOM
假设我们有一个 Example 的组件,它返回
首次渲染
过程如下:
首先从 React.render 开始, render 函数被调用的时候会返回一个 element
{
type: function Example,
props: {
children: null
}
}
由于这个 type 是一个自定义组件类,此时要初始化的类是 ReactCompositeComponentWrapper,接着调用它的 mountComponent 方法。这里面会做四件事情,详情可以看上图。其中,第二步的 render 的得到的 element 为
{
type: 'div',
props: {
children: 'Hello World'
}
}
由图可知,在第一步得到 instance 对象之后,就会去看 instance.componentWillMount 是否有被定义,有的话调用,而在整个渲染过程结束之后调用 componentDidMount。
以上,就是渲染原理的部分,让我们来总结以下:
- JSX 代码经过 babel 编译之后变成 React.createElement 的表达式,这个表达式在 render 函数被调用的时候执行生成一个 element。
- 在首次渲染的时候,先去按照规则初始化 element,接着 ReactCompositeComponentWrapper 通过递归,最终调用 ReactDOMComponent 的 mountComponent 方法来帮助生成真实 DOM 节点。
React v15
渲染更新
触发组件的更新有两种更新方式:props 以及 state 改变带来的更新。本次主要解析 state 改变带来的更新。整个过程流程图如下
1、一般改变 state,都是从 setState 开始,这个函数被调用之后,会将我们传入的 state 放进 pendingState 的数组里存起来,然后判断当前流程是否处于批量更新,如果是,则将当前组件的 instance 放进 dirtyComponent 里,当这个更新流程中所有需要更新的组件收集完毕之后(这里面涉及到事务的概念,感兴趣的可以自己去了解一下)就会遍历 dirtyComponent 这个数组,调用他们的 uptateComponent 对组件进行更新。当然,如果当前不处于批量更新的状态,会直接去遍历 dirtyComponent 进行更新。
2、在我们这个例子中,由于 Example 是自定义组件,所以调用的是 ReactCompositeComponentWrapper 这个类的 updateComponent 方法,这个方法做三件事。
计算出 nextState render()得到 nextRenderElement 与 prevElement 进行 Diff 比较(这个过程后面会介绍),更新节点 最后这个需要去更新节点的时候,跟首次渲染一样,也需要调用 ReactDOMComponent 的 updateComponent 来更新。其中第二步 render 得到的也是自定义组件的话, 会形成递归调用。
shouldComponentUpdate
由图可知,shouldComponentUpdate 在第一步调用得到 nextState 之后调用,因为 nextState 也是它的其中一个参数嘛~这个函数很重要,它是我们性能优化的一个很关键的点:由图可以看到,当 shouldComponentUpdate 返回 false 的时候,下面的一大块都不会被去执行,包括已经被优化的 diff 算法。
当 shouldComponentUpdate 返回 true 的时候,会先调用 componentWillUpdate,在整个更新过程结束之后调用 componentDidUpdate。
以上就是更新渲染的过程。
Diff 算法
React 基于两个假设:
- 两个相同的组件产生类似的 DOM 结构,不同组件产生不同 DOM 结构
- 对于同一层次的一组子节点,它们可以通过唯一的 id 区分
发明了一种叫 Diff 的算法来比较两棵 DOM tree,它极大的优化了这个比较的过程,将算法复杂度从 O(n^3)降低到 O(n)。
同时,基于第一点假设,我们可以推论出,Diff 算法只会对同层的节点进行比较。如图,它只会对颜色相同的节点进行比较
也就是说如果父节点不同,React 将不会在去对比子节点。因为不同的组件 DOM 结构会不相同,所以就没有必要在去对比子节点了。这也提高了对比的效率。
下面,我们具体看下 Diff 算法是怎么做的,这里分为三种情况考虑
- 节点类型不同
- 节点类型相同
- 子节点比较
不同节点类型
对于不同的节点类型,react 会基于第一条假设,直接删去旧的节点,新建一个新的节点。
比如:
<A>
<C/>
</A>
// 由shape1到shape2
<B>
<C/>
</B>
生命周期打印结果:
Shape1 :
A is created
A render
C is created
C render
C componentDidMount
A componentDidMount
Shape2 :
A componentWillUnmount
C componentWillUnmount
B is created
B render
C is created
C render
C componentDidMount
B componentDidMount
Shape2 - 2019/08/14:
B is created
B render
is created
C render
A componentWillUnmount
C componentWillUnmount
C componentDidMount
B componentDidMount
由此可以看出,A 与其子节点 C 会被删除,然后重新建一个 B,C 插入。这样就给我们的性能优化提供了一个思路,就是我们要保持 DOM 标签的稳定性。
打个比方,如果写了一个 <div><List /></div>
(List 是一个有几千个节点的组件),切换的时候变成了<section><List /></section>
,此时即使 List 的内容不变,它也会先被卸载在创建,其实是很浪费的。
相同节点类型
当对比相同的节点类型比较简单,这里分为两种情况,一种是 DOM 元素类型,对应 html 直接支持的元素类型:div,span 和 p,还有一种是自定义组件。
- DOM 元素类型
react 会对比它们的属性,只改变需要改变的属性
- 自定义组件类型
由于 React 此时并不知道如何去更新 DOM 树,因为这些逻辑都在 React 组件里面,所以它能做的就是根据新节点的 props 去更新原来根节点的组件实例,触发一个更新的过程,最后在对所有的 child 节点在进行 diff 的递归比较更新。
-shouldComponentUpdate -
componentWillReceiveProps -
componentWillUpdate -
render -
componentDidUpdate;
子节点比较
div>
<A />
<B />
</div>
// 列表一到列表二
<div>
<A />
<C />
<B />
</div>
因为 React 在没有 key 的情况下对比节点的时候,是一个一个按着顺序对比的。从列表一到列表二,只是在中间插入了一个 C,但是如果没有 key 的时候,react 会把 B 删去,新建一个 C 放在 B 的位置,然后重新建一个节点 B 放在尾部。
生命周期打印结果:
列表一:
A is created
A render
B is created
B render
A componentDidMount
B componentDidMount
列表二:
A render
B componentWillUnmount
C is created
C render
B is created
B render
A componentDidUpdate
C componentDidMount
B componentDidMount
列表二 - 2019/08/14:
A render
C is created
C render
B is created
B render
B componentWillUnmount
A componentDidUpdate
C componentDidMount
B componentDidMount
当节点很多的时候,这样做是非常低效的。有两种方法可以解决这个问题:
1、保持 DOM 结构的稳定性,我们来看这个变化,由两个子节点变成了三个,其实是一个不稳定的 DOM 结构,我们可以通过通过加一个 null,保持 DOM 结构的稳定。这样按照顺序对比的时候,B 就不会被卸载又重建回来。
<div>
<A />
{null}
<B />
</div>
// 列表一到列表二
<div>
<A />
<C />
<B />
</div>
更新时的打印结果:
B is created
C is created
A componentWillUnmount
C componentWillUnmount
C componentDidMount
B componentDidMount
2、key
通过给节点配置 key,让 React 可以识别节点是否存在。
配上 key 之后,再跑一遍的打印结果。
A render
C is created
C render
B render
A componentDidUpdate
C componentDidMount
B componentDidUpdate
果然,配上 key 之后,列表二的生命周期就如我所愿,只在指定的位置创建 C 节点插入。
这里要注意的一点是,key 值必须是稳定(所以我们不能用 Math.random()去创建 key),可预测,并且唯一的。
这里给我们性能优化也提供了两个非常重要的依据:
- 保持 DOM 结构的稳定性
- map 的时候,加 key
性能优化
结合渲染原理,通过实际例子,看看如何优化组件。
1、Mount/Unmount
- Key
- 稳定性
- 保持标签的稳定
<div> -> <section>
- 保持 DOM 结构的稳定
- 保持标签的稳定
2、避免重复渲染
- shouldComponentUpdate
- PureComponent(immutable.js)
- 分离组件,只传入关心的值
3、使用 Pure Functional Component recompose
目前 react 性能优化的点主要集中在防止重复渲染,DOM 稳定性的方面:
但是大家看一个问题,如例子中所展示,点击改变计数按钮后,开始有大量组件重新渲染,但比较阶段不可被打断,input 输入框不可使用。
更新机制:一边对比一边更新,操作 dom 结构同步,从上到下是不间断的,主线程用于大批量更新时会被卡住,导致其他的用户操作无法响应,体验很差
现在 React 16 将异步渲染方案分为了两个阶段,第一阶段专注比较,第二阶段专注更新
工具
React 16 异步渲染方案
到目前为止,这些优化组件的方法还不能解决什么问题,所以我们需要引入异步渲染,以及异步渲染的原理是什么。
React 16 改动
1、比较阶段 – 可被打断
2、commit 阶段 – 不可被打断
主线程不间断使用(同步比较 + 同步更新) =》 自由释放主线程(可打断的比较 + 异步更新)
由于 React 16 异步方案的引入,异步 render 函数之前的函数可能被打断,调用多次,所以 render 之前的函数变得不安全,从而新增了 getDerivedStateFromProps
API 代替 componentWillMount
,componentWillReceiveProps,componentWillUpdate,在此静态方法里面专门做 state 的更新初始化操作
参考链接
- https://facebook.github.io/react/docs/reconciliation.htm
- 渲染更新 - Diff Codesandbox Demo
- 为什么需要 React 16 异步渲染 - Codesandbox Demo
拓展阅读
- function component