很多人都使用过React,但是很少人能说出它内部的渲染原理。有人会说,会用就行了,知道渲染原理有必要么?其实渲染原理决定着性能优化的方法,只有在了解原理之后,才能完全理解为什么这样做可以优化性能。正所谓:知其然,然后知其所以然。
本篇文章,将会分为四部分介绍:
一、JSX 如何生成 element
当我们写下一段JSX代码的时候,react是如何根据我们的JSX代码来生成虚拟DOM的组成元素element的?
这里是一段写在render里的jsx代码。
return (
<div className="cn">
<Header> Hello, This is React </Header>
<div>Start to learn right now!</div>
Right Reserve.
</div>
)
但浏览器不能解析JSX及其语,浏览器只能理解纯碎的JavaScript,所以JSX必须先转换成JavaScript。
它会经过babel,把JSX编译成React.createElement的表达式。
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'
)
)
这个createElement方法是做什么的呢?
其实从它的名字就可以看出,这是用来生成element的。element在React里,其实就是组成虚拟DOM 树的节点,它用来描述你想要在浏览器上看到什么。
它的参数有三个:
1、type -> 标签
2、attributes -> 标签属性,没有的话,可以为null
3、children -> 标签的子节点
这个React.createElement的表达式会在render函数被调用的时候执行,换句话说,当render函数被调用的时候,会返回一个element(虚拟DOM)。
这个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'
]
}
}
element 对象的 children 一共有5种类型:
1、string
2、原生DOM节点
3、React Component - 自定义组件
4、false ,null, undefined,number
5、数组 - 使用map方法的时候
这里需要记住一个点:element不一定是Object类型。
需要注意的是,那些除了type
和attribute
以外的属性,原本是单独传进来的,转换之后,会作为在props.children
以一个数组的形式打包存在。也就是说,无论children是作为数组还是参数列表传递都没关系 —— 在生成的虚拟DOM对象的时候,它们最后都会被打包在一起的。
进一步说,我们可以直接在组件中把children作为一项属性传进去,结果还是一样的:
<div className='cn' children={['Content 1!', 'Content 2!']} />
二、element 如何生成真实 DOM 节点
在生成elment之后,react又如何将其转成浏览器的真实节点。这里会通过介绍首次渲染以及更新渲染的流程来帮助大家理解这个渲染流程。
首先,需要去初始化element,初始化的规则如下:
先判断是否为Object类型,是的话,看它的type是否是原生DOM标签,是的话,给它创建ReactDOMComponent的实例对象,其他同理。
这时候有的人可能会有所疑问:这些个ReactDOMComponent, ReactCompositeComponentWrapper怎么开发的时候都没有见过?
其实这些都是React的私有类,React自己使用,不会暴露给用户的。它们的常用方法有:mountComponent,updateComponent等。其中mountComponent 用于创建组件,而updateComponent用于用户更新组件。而我们自定义组件的生命周期函数以及render函数都是在这些私有类的方法里被调用的。
既然这些私有类的方法那么重要我们就先来简单了解一下吧~
ReactDOMComponent
首先是ReactMComponent的mountComponent方法,这个方法的作用是:将element转成真实DOM节点,并且插入到相应的container里,然后返回markup(realDOM)。
由此可知ReactDOMComponent的mountComponent是element生成真实节点的关键。
下面看个栗子它是怎么做到的吧。
假设有这样一个type类型是原生DOM的element:
{
type: 'div',
props: {
className: 'cn',
children: 'Hello world',
}
}
简单mountComponent的实现:
mountComponent(container) {
const domElement = document.createElement(this._currentElement.type);
const textNode = document.createTextNode(this._currentElement.props.children);
domElement.appendChild(textNode);
container.appendChild(domElement);
return domElement;
}
其实实现的过程很简单,就是根据type生成domElement,再将子节点append进来返回。当然,真实的mountComponent没有那么简单,感兴趣的可以自己去看源码啦。
这里需要记住的一个点是:这个类的mountComponent方法会自己操作浏览器DOM元素。
讲完ReactDOMComponent,再来看看ReactCompositeComponentWrapper。
ReactCompositeComponentWrapper
这个类的mountComponent方法作用是:实例化自定义组件,最后是通过递归调用到ReactDOMComponent的mountComponent方法来得到真实DOM。
注意:也就是说他自己是不直接生成DOM节点的。
那这个递归是一个怎样的过程呢?我们通过首次渲染来看下。
首次渲染
假设我们有一个Example的组件,它返回<div>hello world</div> 这样一个标签。
首次渲染的过程如下:
首先从React.render开始,由于我们刚刚说,render函数被调用的时候会返回一个element,所以此时返回给我们的element是:
{
type: function Example,
props: {
children: null
}
}
由于这个type是一个自定义组件类,此时要初始化的类是ReactCompositeComponentWrapper,接着调用它的mountComponent方法。这里面会做四件事情,详情可以看上图。其中,第二步的render的得到的element为:
{
type: 'div',
props: {
children: 'Hello World'
}
}
由于这个type是一个原生DOM标签,此时要初始化的类是ReactDOMComponent。接下来它的mountComponent方法就可以帮我们生成对应的DOM节点放在浏览器里啦。
这时候有人可能会有疑问,如果第二步render出来的element 类型也是自定义组件呢?
这时候它就会去调用ReactCompositeComponentWrapper的mountComponent方法,从而形成了一个递归。不管你的自定义组件嵌套多少层,最后总会生成原生dom类型的element,所以最后一定能调用到ReactDOMComponent的mountComponent方法。
那么首次渲染时候生命周期函数 componentWillMount 跟 componentDidMount 在哪被调用呢?
由图可知,在第一步得到instance对象之后,就会去看instance.componentWillMount是否有被定义,有的话调用,而在整个渲染过程结束之后调用componentDidMount。
渲染原理总结
JSX代码经过babel编译之后变成React.createElement的表达式,这个表达式在render函数被调用的时候执行生成一个element。
在首次渲染的时候,先去按照规则初始化element,接着ReactCompositeComponentWrapper通过递归,最终调用ReactDOMComponent的mountComponent方法来帮助生成真实DOM节点。
三、性能优化
上面我们介绍了首次渲染,接下来我们来看更新渲染的过程。
更新渲染
触发组件的更新有两种更新方式: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,componentWillUpdate跟componentDidUpdate在哪被调用呢?
由图可知,shouldComponentUpdate在第一步调用得到nextState之后调用,因为nextState也是它的其中一个参数嘛~这个函数很重要,它是我们性能优化的一个很关键的点:由图可以看到,当shouldComponentUpdate返回false的时候,下面的一大块都不会被去执行,包括已经被优化的diff算法。
当shouldComponentUpdate返回true的时候,会先调用componentWillUpdate,在整个更新过程结束之后调用componentDidUpdate。
以上就是更新渲染的过程。
下面我们重点再来介绍这个过程中的Diff算法。
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>
React会直接删掉A节点(包括它所有的子节点),然后新建一个B节点插入
为了验证这一点,我打印出了从shape1到shape2节点的生命周期
最后终端输出的结果是:
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
由此可以看出,A与其子节点C会被直接删除,然后重新建一个B,C插入。这样就给我们的性能优化提供了一个思路,就是我们要保持DOM标签的稳定性。
打个比方,如果写了一个<div><List /></div>(List 是一个有几千个节点的组件),切换的时候变成了<section><List /></section>,,此时即使List的内容不变,它也会先被卸载在创建,其实是很浪费的。
相同节点类型
当对比相同的节点类型比较简单,这里分为两种情况,一种是DOM元素类型,对应html直接支持的元素类型:div,span和p,还有一种是自定义组件。
DOM元素类型
react会对比它们的属性,只改变需要改变的属性
比如:
<div className="before" title="stuff" />
<div className="after" title="stuff" />
这两个div中,react会只更新className的值
<div style={{color: 'red', fontWeight: 'bold'}} />
<div style={{color: 'green', fontWeight: 'bold'}} />
这两个div中,react只会去更新color的值
自定义组件类型
由于React此时并不知道如何去更新DOM树,因为这些逻辑都在React组件里面,所以它能做的就是根据新节点的props去更新原来根节点的组件实例,触发一个更新的过程,最后在对所有的child节点在进行diff的递归比较更新。
- componentWillReceiveProps
- shouldComponentUpdate
- 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
当节点很多的时候,这样做是非常低效的。有两种方法可以解决这个问题:
1、保持DOM结构的稳定性,我们来看这个变化,由两个子节点变成了三个,其实是一个不稳定的DOM结构,我们可以通过通过加一个null,保持DOM结构的稳定。这样按照顺序对比的时候,B就不会被卸载又重建回来。
<div>
<A />
{null}
<B />
</div>
// 列表一到列表二
<div>
<A />
<C />
<B />
</div>
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),可预测,并且唯一的。
Mounting / Unmounting
我们来看看这个case:
<div>
<Message />
<Table />
<Footer />
</div>
在我们的Virtual DOM里这么表示:
// ...
props: {
children: [
{ type: Message },
{ type: Table },
{ type: Footer }
]
}
// ...
这里有一个简单的Message组件
,就是一个div
写着一些简单的文本,和以及一个巨大的Table
,比方说,超过1000行。它们(Message
和Table
)都是顶级div
的子组件,所以它们被放置在父节点的props.children
下,并且它们key
都不会有。React甚至不会通过控制台警告我们要给每个child
分配key
,因为children正在React.createElement
作为参数列表传递给父元素,而不是直接遍历一个数组。
现在我们的用户已读了一个通知,Message
(譬如新通知按钮)从DOM上移除。Table
和Footer
是剩下的全部。
// ...
props: {
children: [
{ type: Table },
{ type: Footer }
]
}
// ...
React会怎么处理呢?它会看作是一个array类型的children,现在少了第一项,从前第一项是Message
现在是Table
了,也没有key
作为索引,比较type
的时候又发现它们俩不是同一个function或者class的同一个实例,于是会把整个Table
unmount,然后在mount回去,渲染它的1000+行子数据。
因此,你可以给每个component添加唯一的key
(但在特殊的case下,使用key并不是最佳选择),或者采用更聪明的小技巧:使用短路求值(又名“最小化求值”),这是JavaScript和许多其他现代语言的特性。看:
// Using a boolean trick
<div>
{isShown && <Message />}
<Table />
<Footer />
</div>
虽然Message
会离开屏幕,父元素div
的props.children
仍然会拥有三个元素,children[0]
具有一个值false
(一个布尔值)。请记住true, false, null, undefined
是虚拟DOM对象type
属性的允许值,我们最终得到了类似的结果:
// ...
props: {
children: [
false, // isShown && <Message /> evaluates to false
{ type: Table },
{ type: Footer }
]
}
// ...
因此,有没有Message
组件,我们的索引值都不会改变,Table
当然仍然会跟Table
比较(当type
是一个函数或类的引用时,diff比较的成本还是会有的),但仅仅比较虚拟DOM的成本,通常比“删除DOM节点”并“从0开始创建”它们要来得快。
Updating
现在我们可以确保在非必要的时候,不做re-mount的事情了。然而,对位于DOM树根部附近(层级越上面的元素)的组件所做的任何更改都会导致其所有children的diffing和调整(reconciliation
)。在层级很多、结构复杂的应用里,这些成本很昂贵,但经常是可以避免的。
如果有一种方法可以告诉React你不用来检查这个分支了,因为我们可以肯定那个分支不会有更新,那就太棒了!
这种方式是真的有的哈,它涉及一个built-in方法叫shouldComponentUpdate
,它也是组件生命周期的一部分。这个方法的调用时机:组件的render
和组件接收到state或props的值的更新时。然后我们可以自由地将它们与我们当前的值进行比较,并决定是否更新我们的组件(返回true
或false
)。如果我们返回false
,React将不会重新渲染组件,也不会检查它的所有子组件。
通常来说,比较两个集合(set)props
和state
一个简单的浅层比较(shallow comparison)就足够了:如果顶层的值不同,我们不必接着比较了。浅比较不是JavaScript的一个特性,但有很多小而美的库(utilities
)可以让我们用上那么棒的功能。
现在可以像这样编写我们的代码:
class TableRow extends React.Component {
// will return true if new props/state are different from old ones
shouldComponentUpdate(nextProps, nextState) {
const { props, state } = this;
return !shallowequal(props, nextProps)
&& !shallowequal(state, nextState);
}
render() {}
}
但是你甚至都不需要自己写代码,因为React把这个特性内置在一个类React.PureComponent
里面。它类似于 React.Component
,只是shouldComponentUpdate
已经为你实施了一个浅的props
/state
比较。
这听起来很“不动脑”,在声明class继承(extends
)的时候,把Component
换成PureComponent
就可以享受高效率。事实上,并不是这么“傻瓜”,看看这些例子:
<Table
// map returns a new instance of array so shallow comparison will fail
rows={rows.map(/* ... */)}
// object literal is always "different" from predecessor
style={ { color: 'red' } }
// arrow function is a new unnamed thing in the scope, so there will always be a full diffing
onUpdate={() => { /* ... */ }}
/>
上面的代码片段演示了三种最常见的反模式。尽量避免它们!
如果你能注意点,在render定义之外创建所有对象、数组和函数,并确保它们在各种调用间,不发生更改 —— 你是安全的。
class TableRow extends React.PureComponent {
render() {
return React.createElement('tr', { className: 'row' },
React.createElement('td', { className: 'cell' }, this.props.title),
React.createElement('td', { className: 'cell' }, React.createElement(Button)),
);
}
};
如果你在React DevTools中打开“Highlight Updates”调试上面的 PureComponent,你会注意到只有表格本身和新行在插入时会触发render
,其他的行保持不变。
不过,如果你迫不及待地all in PureComponent,在应用里到处都用的话 —— 控制住你自己!
shallow比较两组props
和state
不是免费的,对于大多数基本组件来说,甚至都不值得:shallowCompare
比diffing
算法需要耗费更多的时间。
使用这个经验法则:pure component适用于复杂的表单和表格,但它们通常会减慢简单元素(按钮、图标)的效率。
参考文献: