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

点击上方蓝字关注程序员成长指北,还可加入「技术交流群」共同进步

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

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

React 是什么


640?wx_fmt=png

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

640?wx_fmt=png

几点要了解的知识

  • JSX 如何生成Element

  • Element 如何生成DOM

1
JSX 如何生成Element

先看一个例子, Counter :

640?wx_fmt=png

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

640?wx_fmt=png

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

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

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

这里也简单的举个例子:

640?wx_fmt=png

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

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

640?wx_fmt=png

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

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

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

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

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

640?wx_fmt=png

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

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

640?wx_fmt=png

2
Element 如何生成DOM

640?wx_fmt=png

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

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

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

第一步是 instantiateReactComponent。

640?wx_fmt=png

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

640?wx_fmt=png

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

  1. 当 node 为空的时候,初始化空组件。

  2. 当 node 为对象,类型 type 字段标记为是字符串,初始化 DOM 标签。否则初始化自定义组件。

  3. 当 node 为字符串或者数字时,初始化文本组件。

640?wx_fmt=png

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

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

640?wx_fmt=png

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

React生命周期

640?wx_fmt=pngReact 组件基本由三个部分组成,

  1. 属性 props

  2. 状态 state

  3. 生命周期方法

React 生命周期的全局图640?wx_fmt=png

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

  1. componentWillMount、

  2. render

  3. componentDidMount

卸载组件时,执行 componentDidUnmount

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

  1. componentWillReceiveProps

  2. shouldComponentUpdate

  3. componentWillUpdate

  4. render  

  5. componentDidUpdate

更新策略

640?wx_fmt=png

通过 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

640?wx_fmt=png

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

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

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

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

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

    2.  Element Diff

  1. 当节点处于同一层级时,diff 提供了 3 种节点操作:插入、移动和删除。

  2. 对于同一层的同组子节点添加唯一 key 进行区分。

640?wx_fmt=png

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

原理解析

几个概念

  • 对新集合中的节点进行循环遍历,新旧集合中是否存在相同节点

  • nextIndex: 新集合中当前节点的位置

  • lastIndex: 访问过的节点在旧集合中最右的位置(最大位置)

  • If (child._mountIndex < lastIndex)

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

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

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

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

更新流程:

1

640?wx_fmt=png

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

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

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

2

640?wx_fmt=png

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

3

640?wx_fmt=png

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

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

4

640?wx_fmt=png

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

5

640?wx_fmt=png

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

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

640?
另一种情况

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

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

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

640?wx_fmt=png

1

640?wx_fmt=png

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

2

640?wx_fmt=png

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

3

640?wx_fmt=png

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

4

640?wx_fmt=png

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

5

640?wx_fmt=png

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

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

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

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

2
正确使用 diff算法

  • 不使用跨层级移动节点的操作。

  • 对于条件渲染多个节点时,尽量采用隐藏等方式切换节点,而不是替换节点。

  • 尽量避免将后面的子节点移动到前面的操作,当节点数量较多时,会产生一定的性能问题。

640?
看个例子

640?wx_fmt=png

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

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

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

640?wx_fmt=png

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

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

640?wx_fmt=png

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

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

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

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

然后我们使用PureComponent :

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

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

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

640?wx_fmt=png

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

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

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

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

相似的APi还有React.memo:

640?wx_fmt=png

回到组件

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

640?wx_fmt=png

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

原因在于 shallow compare , 浅比较。

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

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

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

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

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

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

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

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

640?wx_fmt=png

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

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

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

640?wx_fmt=png

640?
性能分析

640?wx_fmt=png

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

Hooks  及其后续更新

请转到 第7期:全面了解 React Suspense 和 Hooks

如果你觉得内容有帮助可以关注下这个公众号 「 前端e进阶 」,一起成长!

交流学习

大家好,我是koala,公众号「程序员成长指北」作者。公众号为您打造优质Node学习路线,并且会推送超级优质文章。加入我们一起学习吧!博客地址:https://github.com/koala-coding/goodBlog

640?wx_fmt=gif

在看你最美smiley_63.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值