如今的前端,框架横行,出去面试问到框架是常有的事。


 我比较常用React, 这里就写了一篇 React 基础原理的内容, 面试基本上也就问这些, 分享给大家。


React 是什么

【React】393 深入了解React 渲染原理及性能优化_生命周期

React是一个专注于构建用户界面的 Javascript Library.

React做了什么?



  • Virtual Dom模型
  • 生命周期管理
  • setState机制
  • Diff算法
  • React patch、事件系统
  • React的 Virtual Dom模型

virtual dom 实际上是对实际Dom的一个抽象,是一个js对象。react所有的表层操作实际上是在操作Virtual dom。

经过 Diff 算法会计算出 Virtual DOM 的差异,然后将这些差异进行实际的DOM操作更新页面。

React  总体架构

【React】393 深入了解React 渲染原理及性能优化_生命周期_02

几点要了解的知识

  • JSX 如何生成Element
  • Element 如何生成DOM

1

JSX 如何生成Element

先看一个例子, Counter :

【React】393 深入了解React 渲染原理及性能优化_生命周期_03

App.js 就做了一件事情,就是把 Counter 组件挂在 #root 上.


【React】393 深入了解React 渲染原理及性能优化_初始化_04



Counter 组件里面定义了自己的 state, 这是个默认的 property ,还有一个 handleclick 事件和 一个 render 函数。

看到 render 这个函数里,竟然在 JS 里面写了 html ! 

这是一种 JSX 语法。React 为了方便 View 层组件化,承载了构建 html 结构化页面的职责。

这里也简单的举个例子:

【React】393 深入了解React 渲染原理及性能优化_初始化_05


将 html 语法直接加入到 javascript 代码中,再通过翻译器转换到纯 javascript 后由浏览器执行。


这里调用了 React 和 createElement 方法,这个方法就是用于创建虚拟元素 Virtual Dom 的。


【React】393 深入了解React 渲染原理及性能优化_Diff_06

React 把真实的 DOM 树转换成 Javascript 对象树,也就是 Virtual Dom


每次数据更新后,重新计算 Virtual Dom ,并和上一次生成的 virtual dom 做对比,对发生变化的部分做批量更新


而 React 是通过创建与更新虚拟元素 Virtual Element 来管理整个Virtual Dom 的


 虚拟元素可以理解为真实元素的对应,它的构建与更新都是在内存中完成的,并不会真正渲染到 dom 中去。

回到我们的计数器 counter 组件:

【React】393 深入了解React 渲染原理及性能优化_生命周期_07

注意下 a 标签 createElement 的返回结果, 这里 CreateElement 只是做了简单的参数修正,返回一个 ReactElemet 实例对象。

Virtual element 彼此嵌套和混合,就得到了一颗 virtual dom 的树:

【React】393 深入了解React 渲染原理及性能优化_生命周期_08


2

Element 如何生成DOM


【React】393 深入了解React 渲染原理及性能优化_初始化_09

现在我们有了由 ReactElement 组成的 Virtual Dom 树,接下来我们要怎么我们构建好的 Virtual dom tree 渲染到真正的 DOM 里面呢?

这时可以利用 ReactDOM.render 方法,传入一个 reactElement 和一个 作为容器的 DOM 节点。

看进去 ReactDOM.render 的源码,里面有两个比较关键的步骤:

第一步是 instantiateReactComponent。

【React】393 深入了解React 渲染原理及性能优化_初始化_10


这个函数创建一个 ReactComponent 的实例并返回,也可以看到 ReactDOM.render 最后返回的也是这个实例。

【React】393 深入了解React 渲染原理及性能优化_Diff_11

instantiateReactComponent 方法是初始化组件的入口函数,它通过判断 node 的类型来区分不同组件的入口。

  1. 当 node 为的时候,初始化空组件。
  2. 当 node 为对象,类型 type 字段标记为是字符串,初始化 DOM 标签。否则初始化自定义组件。
  3. 当 node 为字符串或者数字时,初始化文本组件。

【React】393 深入了解React 渲染原理及性能优化_Diff_12


虽然 Component 有多种类型,但是它们具有基本的数据结构:ReactComponent 类。


注意到这里的 setState, 这也是重点之一。


【React】393 深入了解React 渲染原理及性能优化_Diff_13

创建了 Component 实例后,调用 component 的 mountComponent 方法,注意到这里是会被批量 mount 的,这样组件就开始进入渲染到 DOM 的流程了。


React生命周期

【React】393 深入了解React 渲染原理及性能优化_初始化_14
React 组件基本由三个部分组成,

  1. 属性 props
  2. 状态 state
  3. 生命周期方法

React 组件可以接受参数props, 也有自身状态 state。
一旦接受到的参数 props 或自身状态 state 有所改变,React 组件就会执行相应的生命周期方法。

React 生命周期的全局图


【React】393 深入了解React 渲染原理及性能优化_生命周期_15


首次挂载组件时,按顺序执行

  1. componentWillMount、
  2. render
  3. componentDidMount

卸载组件时,执行 componentDidUnmount

当组件接收到更新状态,重新渲染组件时,执行

  1. componentWillReceiveProps
  2. shouldComponentUpdate
  3. componentWillUpdate
  4. render  
  5. componentDidUpdate



更新策略


【React】393 深入了解React 渲染原理及性能优化_生命周期_16



通过 updateComponent 更新组件,首先判读上下文是否改变,前后元素是否一致,如果不一致且组件的 componentWillReceiveProps 存在,则执行。然后进行 state 的合并。


调用 shouldComponentUpdate 判断是否需要进行组件更新,如果存在 componentWillUpdate 则执行。


后面的流程跟 mountComponent 相似,这里就不赘述了。


setState机制

为避免篇幅过长,这部分可移步我的另一篇文章:

        [第10期] 深入了解 React setState 运行机制

Diff算法

Diff算法用于计算出两个virtual dom的差异,是React中开销最大的地方。

传统diff算法通过循环递归对比差异,算法复杂度为 O(n3)。

React diff算法制定了三条策略,将算法复杂度从 O(n3)降低到O(n)

  • 1. UI中的DOM节点跨节点的操作特别少,可以忽略不计。
  • 2. 拥有相同类的组件会拥有相似的DOM结构。拥有不同类的组件会生成不同的DOM结构。
  • 3. 同一层级的子节点,可以根据唯一的​​ID​​来区分。



   1. Tree Diff



【React】393 深入了解React 渲染原理及性能优化_Diff_17


对于策略一,React 对树进行了分层比较,两棵树只会对同一层次的节点进行比较。


只会对相同层级的 DOM 节点进行比较,当发现节点已经不存在时,则该节点及其子节点会被完全删除,不会用于进一步的比较。


如果出现了 DOM 节点跨层级的移动操作。


如上图这样,A节点就会被直接销毁了。


Diif 的执行情况是:create A -> create C -> create D -> delete A




    2. Element Diff




  1. 当节点处于同一层级时,diff 提供了 3 种节点操作:插入、移动删除
  2. 对于同一层的同组子节点添加唯一 key 进行区分。



【React】393 深入了解React 渲染原理及性能优化_初始化_18


通过 diff 对比后,发现新旧集合的节点都是相同的节点,因此无需进行节点删除和创建,只需要将旧集合中节点的位置更新为新集合中节点的位置.


原理解析



几个概念



  • 集合中的节点进行循环遍历,新旧集合中是否存在相同节点
  • nextIndex: 集合中当前节点的位置
  • lastIndex: 访问过的节点在集合中最的位置(最大位置)
  • If (child._mountIndex < lastIndex)




对新集合中的节点进行循环遍历,通过 key 值判断,新旧集合中是否存在相同节点,如果存在,则进行移动操作。


在移动操作的过程中,有两个指针需要注意,


一个是 nextIndex,表示新集合中当前节点的位置,也就是遍历新集合时当前节点的坐标。


另一个是 lastIndex,表示访问过的节点在旧集合中最右的位置,



更新流程:


1


【React】393 深入了解React 渲染原理及性能优化_初始化_19


( 如果新集合中当前访问的节点比 lastIndex 大,证明当前访问节点在旧集合中比上一个节点的位置靠后,则该节点不会影响其他节点的位置,即不进行移动操作。只有当前访问节点比 lastIndex 小的时候,才需要进行移动操作。)


首先,我们开遍历新集合中的节点, 当前 lastIndex = 0, nextIndex = 0,拿到了 B.



此时在旧集合中也发现了 B,B 在旧集合中的 mountIndex 为 1 , 比当前 lastIndex 0 要大,不满足 child._mountIndex < lastIndex,对 B 不进行移动操作,更新 lastIndex = 1, 访问过的节点在旧集合中最右的位置,也就是 B 在旧集合中的位置,nextIndex++ 进入下一步。



2



【React】393 深入了解React 渲染原理及性能优化_Diff_20


当前 lastIndex = 1, nextIndex = 1,拿到了 A,在旧集合中也发现了 A,A 在旧集合中的 mountIndex 为 0 , 比当前 lastIndex 1 要小,满足 child._mountIndex < lastIndex,对 A 进行移动操作,此时 lastIndex 依然 = 1, A 的 _mountIndex 更新为 nextIndex = 1, nextIndex++, 进入下一步.



3



【React】393 深入了解React 渲染原理及性能优化_Diff_21




这里,A 变成了蓝色,表示对 A 进行了移动操作。


当前 lastIndex = 1, nextIndex = 2,拿到了 D,在旧集合中也发现了 D,D 在旧集合中的 mountIndex 为 3 , 比当前 lastIndex 1 要大,不满足 child._mountIndex < lastIndex,不进行移动操作,此时 lastIndex = 3, D 的 _mountIndex 更新为 nextIndex = 2, nextIndex++, 进入下一步.




4

【React】393 深入了解React 渲染原理及性能优化_生命周期_22



当前 lastIndex = 3, nextIndex = 3,拿到了 C,在旧集合中也发现了 C,C 在旧集合中的 mountIndex 为 2 , 比当前 lastIndex 3 要小,满足 child._mountIndex < lastIndex,要进行移动,此时 lastIndex不变,为 3, C 的 _mountIndex 更新为 nextIndex = 3.





5



【React】393 深入了解React 渲染原理及性能优化_初始化_23


由于 C 已经是最后一个节点,因此 diff 操作完成.


这样最后,要进行移动操作的只有 A C。




【React】393 深入了解React 渲染原理及性能优化_生命周期_24

另一种情况



刚刚说的例子是新旧集合中都是相同节点但是位置不同


那如果新集合中有新加入的节点且旧集合存在需要删除的节点,


那 diff 又是怎么进行的呢?比如:



【React】393 深入了解React 渲染原理及性能优化_生命周期_25




1




【React】393 深入了解React 渲染原理及性能优化_Diff_26


首先,依旧,我们开遍历新集合中的节点, 当前 lastIndex = 0, nextIndex = 0,拿到了 B,此时在旧集合中也发现了 B,B 在旧集合中的 mountIndex 为 1 , 比当前 lastIndex 0 要大,不满足 child._mountIndex < lastIndex,对 B 不进行移动操作,更新 lastIndex = 1, 访问过的节点在旧集合中最右的位置,也就是 B 在旧集合中的位置,nextIndex++ 进入下一步。




2






【React】393 深入了解React 渲染原理及性能优化_生命周期_27


当前 lastIndex = 1, nextIndex = 1,拿到了 E,发现旧集合中并不存在 E,此时创建新节点 E,nextIndex++,进入下一步



3


【React】393 深入了解React 渲染原理及性能优化_生命周期_28


当前 lastIndex = 1, nextIndex = 2,拿到了 C,在旧集合中也发现了 C,C 在旧集合中的 mountIndex 为 2 , 比当前 lastIndex 1 要大,不满足 child._mountIndex < lastIndex,不进行移动,此时 lastIndex 更新为 2, nextIndex++ ,进入下一步



4



【React】393 深入了解React 渲染原理及性能优化_Diff_29


当前 lastIndex = 2, nextIndex = 3,拿到了 A,在旧集合中也发现了 A,A 在旧集合中的 mountIndex 为 0 , 比当前 lastIndex 2 要小,不满足 child._mountIndex < lastIndex,进行移动,此时 lastIndex 不变, nextIndex++ ,进入下一步



5


【React】393 深入了解React 渲染原理及性能优化_初始化_30


当完成新集合中所有节点的差异化对比后,还需要对旧集合进行循环遍历,判断是否勋在新集合中没有但旧集合中存在的节点。

此时发现了 D 满足这样的情况,因此删除 D。

Diff 操作完成。

整个过程还是很繁琐的, 明白过程即可。

二、性能优化

由于react中性能主要耗费在于update阶段的diff算法,因此性能优化也主要针对diff算法。

1

减少diff算法触发次数

减少diff算法触发次数实际上就是减少update流程的次数。

正常进入update流程有三种方式:

1.setState

setState机制在正常运行时,由于批更新策略,已经降低了update过程的触发次数。

因此,setState优化主要在于非批更新阶段中(timeout/Promise回调),减少setState的触发次数。

常见的业务场景即处理接口回调时,无论数据处理多么复杂,保证最后只调用一次setState。

2.父组件render

父组件的render必然会触发子组件进入update阶段(无论props是否更新)。此时最常用的优化方案即为shouldComponentUpdate方法。

最常见的方式为进行this.props和this.state的浅比较来判断组件是否需要更新。或者直接使用PureComponent,原理一致。

需要注意的是,父组件的render函数如果写的不规范,将会导致上述的策略失效。

// Bad case
// 每次父组件触发render 将导致传入的handleClick参数都是一个全新的匿名函数引用。
// 如果this.list 一直都是undefined,每次传入的默认值[]都是一个全新的Array。
// hitSlop的属性值每次render都会生成一个新对象
class Father extends Component {
onClick() {}
render() {
return <Child handleClick={() => this.onClick()} list={this.list || []} hitSlop={{ top: 10, left: 10}}/>
}
}
// Good case
// 在构造函数中绑定函数,给变量赋值
// render中用到的常量提取成模块变量或静态成员
const hitSlop = {top: 10, left: 10};
class Father extends Component {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
this.list = [];
}
onClick() {}
render() {
return <Child handleClick={this.onClick} list={this.list} hitSlop={hitSlop} />
}
}

3. forceUpdate

forceUpdate方法调用后将会直接进入componentWillUpdate阶段,无法拦截,因此在实际项目中应该弃用。

其他优化策略


   1.  shouldComponentUpdate

     使用shouldComponentUpdate钩子,根据具体的业务状态,减少不必要的props变化导致的渲染。如一个不用于渲染的props导致的update。
另外, 也要尽量避免在shouldComponentUpdate 中做一些比较复杂的操作, 比如超大数据的pick操作等。

2. 合理设计state,不需要渲染的state,尽量使用实例成员变量。

     不需要渲染的 props,合理使用 context机制,或公共模块(比如一个单例服务)变量来替换。

2

正确使用 diff算法


  • 不使用跨层级移动节点的操作。
  • 对于条件渲染多个节点时,尽量采用隐藏等方式切换节点,而不是替换节点。
  • 尽量避免将后面的子节点移动到前面的操作,当节点数量较多时,会产生一定的性能问题。



【React】393 深入了解React 渲染原理及性能优化_生命周期_24

看个具体的例子


【React】393 深入了解React 渲染原理及性能优化_Diff_32



这时一个 List 组件,里面有标题,包含 ListItem 子组件的members列表,和一个按钮,绑定了一个 onclick 事件.


然后我加了一个插件,可以显示出各个组件的渲染情况。


现在我们来点击改变标题, 看看会发生些什么。


【React】393 深入了解React 渲染原理及性能优化_Diff_33



奇怪的事情发生了,为什么我只改了标题,  为什么不相关的 ListItem 组件也会重新渲染呢?


我们可以回到组件生命周期看看为什么。




【React】393 深入了解React 渲染原理及性能优化_初始化_34


还记得这个组件更新的生命周期流程图嘛,这里的重点在于这个 shouldComponentUpdate


只有这个方法返回 true 的时候,才会进行更新组件的操作。我们进步一来看看源码。


可以看到这里,原来如果组件没有定义 shouldComponentUpdate 方法,也是默认认为需要更新的。


当然,我们的 ListItem 组件是没有定义这个 shouldComponentUpdate 方法的。


然后我们使用PureComponent :


【React】393 深入了解React 渲染原理及性能优化_初始化_35


【React】393 深入了解React 渲染原理及性能优化_初始化_36

【React】393 深入了解React 渲染原理及性能优化_Diff_37



【React】393 深入了解React 渲染原理及性能优化_Diff_38



其原理为重新实现了 shouldComponentUpdate 生命周期方法,让当前传入的 props 和 state 之前做浅比较,如果返回 false ,那么组件就不会更新了。



这里也放上一张官网的例图:


【React】393 深入了解React 渲染原理及性能优化_生命周期_39


根据渲染流程,首先会判断shouldComponentUpdate(SCU)是否需要更新。


如果需要更新,则调用组件的render生成新的虚拟DOM,然后再与旧的虚拟DOM对比(vDOMEq)。


如果对比一致就不更新,如果对比不同,则根据最小粒度改变去更新DOM;


如果SCU不需要更新,则直接保持不变,同时其子元素也保持不变。


相似的APi还有React.memo:


【React】393 深入了解React 渲染原理及性能优化_初始化_40



回到组件


再次回到我们的组件中, 这次点击按钮, 把第二条数据换掉:


【React】393 深入了解React 渲染原理及性能优化_初始化_41



奇怪的事情发生了,为什么我只改了第二个 listItem, 还是全部 10 个都重新渲染了呢?



原因在于 shallow compare , 浅比较。


前面说到,我们不能直接修改 this.state 的值,所以我们把

this.state.members 拷贝出来再修改第二个人的信息。


很明显,因为对象的比较是引用地址,显然是不相等的。


因此 shoudComponentUpdate 方法都返回了 false, 组件就进行了更新。



那么我们怎么能避免这种情况的发生呢?


其中一个方法是做深比较,但是如果对象或数组层级比较深和复制,那么这个代价就太昂贵了。


我们就可以用到 Immutable.js 来解决这个问题,进一步提高组件的渲染性能。


 Immutable Data 就是一旦被创建,就是不能再更改的数据。


【React】393 深入了解React 渲染原理及性能优化_Diff_42



首先,我们定义了一个 Immutable 的 List 对象,List 对应于原生 JS 的 Array,对 Immutable 对象进行修改、添加或删除操作,都会返回一个新的 Immutable 对象,所以这里 bbb 不等于 aaa。


但是同时为了避免深拷贝吧所有节点都复制一遍带来的性能消耗,Immutable 使用了结构共享,即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其他节点则进行共享。



结果也是我们预期的那样。


【React】393 深入了解React 渲染原理及性能优化_Diff_43



性能分析


【React】393 深入了解React 渲染原理及性能优化_Diff_44


用好火焰图, 该优化的时候再优化。

​​

【React】393 深入了解React 渲染原理及性能优化_生命周期_45