[长文预警] 一文掌握React 渲染原理及性能优化

如今的前端,框架横行,不掌握点框架的知识,出去面试都虚。

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

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 总体架构

几点要了解的知识

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

1

JSX 如何生成Element

先看一个例子, Counter :

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

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

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

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

这里也简单的举个例子:

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

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

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

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

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

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

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

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

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

2

Element 如何生成DOM

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

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

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

第一步是 instantiateReactComponent。

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

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

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

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

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

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

React生命周期

React 组件基本由三个部分组成,

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

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

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

  1. componentWillMount、
  2. render
  3. componentDidMount

卸载组件时,执行 componentDidUnmount

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

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

更新策略

通过 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 对树进行了分层比较,两棵树只会对同一层次的节点进行比较。

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

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

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

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

2. Element Diff

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

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

原理解析

几个概念

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

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

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

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

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

更新流程:

1

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

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

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

2

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

3

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

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

4

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

5

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

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

另一种情况

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

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

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

1

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

2

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

3

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

4

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

5

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

此时发现了 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算法

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

看个例子

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

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

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

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

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

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

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

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

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

然后我们使用PureComponent :

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

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

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

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

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

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

相似的APi还有React.memo:

回到组件

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

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

原因在于 shallow compare , 浅比较。

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

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

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

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

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

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

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

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

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

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

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

性能分析

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

Hooks 及其后续更新

本文分享自微信公众号 - 程序员成长指北(coder_growth)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-10-11

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券