Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从 React 渲染原理看性能优化@黄琼(转载) #50

Open
yanyue404 opened this issue Aug 13, 2019 · 0 comments
Open

从 React 渲染原理看性能优化@黄琼(转载) #50

yanyue404 opened this issue Aug 13, 2019 · 0 comments

Comments

@yanyue404
Copy link
Owner

yanyue404 commented Aug 13, 2019

学而不思则惘,思而不学则怠

前言

相信很多人都用过 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 的组件,它返回

hello world
这样一个标签。

首次渲染

过程如下:

首先从 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。

以上,就是渲染原理的部分,让我们来总结以下:

  1. JSX 代码经过 babel 编译之后变成 React.createElement 的表达式,这个表达式在 render 函数被调用的时候执行生成一个 element。
  2. 在首次渲染的时候,先去按照规则初始化 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 基于两个假设:

  1. 两个相同的组件产生类似的 DOM 结构,不同组件产生不同 DOM 结构
  2. 对于同一层次的一组子节点,它们可以通过唯一的 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 的更新初始化操作

参考链接

拓展阅读

@yanyue404 yanyue404 changed the title 从 React 渲染原理看性能优化@黄琼 从 React 渲染原理看性能优化@黄琼(转载) Mar 30, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant