记录、分享、学习

[译] React性能优化-虚拟Dom原理浅析

29 min

本文译自 《Optimizing React: Virtual DOM explained》,作者是 Alexey IvanovAndy Barnov,来自 Evil Martians’team 团队。

译者说:通过一些实际场景和 demo,给大家描述 React 的 Virtual Dom Diff 一些核心的原理和规则,以及基于这些我们可以做些什么提高应用的性能,很棒的文章。


通过学习 React 的 Virtual DOM 的知识,去加速你们的应用吧。对框架内部实现的介绍,比较全面且适合初学者,我们会让 JSX 更加简单易懂,给你展示 React 是如何判断要不要重新 render,解释如何找到应用的性能瓶颈,以及给大家一些小贴士,如何避免常见错误。

React 在前端圈内保持领先的原因之一,因为它的学习曲线非常平易近人:把你的模板包在 JSX,了解一下propsstate 的概念之后,你就可以轻松写出 React 代码了。

如果你已经熟悉 React 的工作方式,可以直接跳至“优化我的代码”篇。

但要真正掌握 React,你需要像 React 一样思考(think in React)。本文也会试图在这个方面帮助你。

下面看看我们其中一个项目中的 React table:

这个表里有数百个动态(表格内容变化)和可过滤的选项,理解这个框架更精细的点,对于保证顺畅的用户体验至关重要。


当事情出错时,你一定能感觉到。输入字段变得迟缓,复选框需要检查一秒钟,弹窗一个世纪后才出现,等等。


为了能够解决这些问题,我们需要完成一个 React 组件的整个生命旅程,从一开始的声明定义到在页面上渲染(再然后可能会更新)。系好安全带,我们要发车了!

JSX 的背后

这个过程一般在前端会称为“转译”,但其实“汇编”将是一个更精确的术语。

React 开发人员敦促你在编写组件时使用一种称为 JSX 的语法,混合了 HTML 和 JavaScript。但浏览器对 JSX 及其语法毫无头绪,浏览器只能理解纯碎的 JavaScript,所以 JSX 必须转换成 JavaScript。这里是一个 div 的 JSX 代码,它有一个 class name 和一些内容:

<div className="cn">

  Content!

</div>

以上的代码,被转换成“正经”的 JavaScript 代码,其实是一个带有一些参数的函数调用:

React.createElement(

  'div',

  { className: 'cn' },

  'Content!'

)

让我们仔细看看这些参数。

  • 第一个是元素的 type。对于 HTML 标签,它将是一个带有标签名称 的字符串。
  • 第二个参数是一个包含所有元素属性(attributes)的对象。如果没有,它也可以是空的对象。
  • 剩下的参数都可以认为是元素的子元素(children)。元素中的文本也算作一个 child,是个字符串’Content!’ 作为函数调用的第三个参数放置。 你应该可以想象,当我们有更多的 children 时会发生什么:
<div className="cn">

  Content 1!

  <br />

  Content 2!

</div>
React.createElement(

  'div',

  { className: 'cn' },

  'Content 1!', // 1st child

  React.createElement('br'), // 2nd child

  'Content 2!' // 3rd child

)

我们的函数现在有五个参数:

  • 一个元素的类型
  • 一个属性对象
  • 三个子元素。 因为其中一个 child 是一个 React 已知的 HTML 标签(<br/>),所以它也会被描述为一个函数调用(React.createElement('br'))。

到目前为止,我们已经涵盖了两种类型的 children:

  • 简单的 String

  • 另一种会调用 React.createElement。 然而,还有其他值可以作为参数:

  • 基本类型 false, null, undefined, true

  • 数组

  • React Components 可以使用数组是因为可以将 children 分组并作为一个参数传递:

React.createElement(

  'div',

  { className: 'cn' },

  ['Content 1!', React.createElement('br'), 'Content 2!']

)

当然了,React 的厉害之处,不仅仅因为我们可以把 HTML 标签直接放在 JSX 中使用,而是我们可以自定义自己的组件,例如:

function Table({ rows }) {
  return (

    <table>

      {rows.map(row => (

        <tr key={row.id}>

          <td>{row.title}</td>

        </tr>

      ))}

    </table>

  )
}

组件可以让我们把模板分解为多个可重用的块。在上面的“函数式”(functional)组件的例子里,我们接收一个包含表格行数据的对象数组,最后返回一个调用 React.createElement方法的<table>元素,rows 则作为 children 传进 table。

无论什么时候,我们这样去声明一个组件时:

<Table rows={rows} />

从浏览器的角度来看,我们是这么写的:

React.createElement(Table, { rows })

注意,这次我们的第一个参数不是 String描述的HTML 标签,而是一个引用,指向我们编写组件时编写的函数。组件的attributes现在是接收的props 参数了。

把组件(components)组合成页面(a page)

所以,我们已经将所有 JSX 组件转换为纯 JavaScript,现在我们有一大堆函数调用,它的参数会被其他函数调用的,或者还有更多的其他函数调用这些参数…这些带参数的函数调用,是怎么转化成组成这个页面的实体 DOM 的呢?

为此,我们有一个 ReactDOM库及其它的render 方法:

function Table({ rows }) { /* ... */ } // defining a component

// rendering a component

ReactDOM.render(

  React.createElement(Table, { rows }), // "creating" a component

  document.getElementById('#root') // inserting it on a page

)

ReactDOM.render被调用时,React.createElement 最终也会被调用,返回以下对象:

// There are more fields, but these are most important to us

{

  type: Table,

  props: {

    rows: rows

  },

  // ...

}

这些对象,在 React 的角度上,构成了虚拟 DOM。


他们将在所有进一步的渲染中相互比较,并最终转化为 真正的 DOM(virtual VS real, 虚拟 DOM VS 真实 DOM)。

下面是另一个例子:这次 div 有一个 class 属性和几个 children:

React.createElement(

  'div',

  { className: 'cn' },

  'Content 1!',

  'Content 2!',

)

变成:

{

  type: 'div',

  props: {

    className: 'cn',

    children: [

      'Content 1!',

      'Content 2!'

    ]

  }

}

需要注意的是,那些除了 typeattribute以外的属性,原本是单独传进来的,转换之后,会作为在props.children 以一个数组的形式打包存在。也就是说,无论 children 是作为数组还是参数列表传递都没关系 —— 在生成的虚拟 DOM 对象的时候,它们最后都会被打包在一起的。

进一步说,我们可以直接在组件中把 children 作为一项属性传进去,结果还是一样的:

<div className="cn" children={['Content 1!', 'Content 2!']} />

在构建虚拟 DOM 对象完成之后,ReactDOM.render 将会按下面的原则,尝试将其转换为浏览器可以识别和展示的 DOM 节点:

  • 如果 type包含一个带有String 类型的标签名称(tag name)—— 创建一个标签,附带上 props 下所有attributes
  • 如果 type 是一个函数(function)或者类(class),调用它,并对结果递归地重复这个过程。
  • 如果 props下有children 属性 —— 在父节点下,针对每个 child 重复以上过程。 最后,得到以下 HTML(对于我们的表格示例):
<table>

  <tr>

    <td>Title</td>

  </tr>

  ...

</table>

重新构建 DOM(Rebuilding the DOM)

在实际应用场景,render通常在根节点调用一次,后续的更新会有 state 来控制和触发调用。

请注意,标题中的“重新”!当我们想更新一个页面而不是全部替换时,React 中的魔法就开始了。我们有一些实现它的方式。我们先从最简单的开始 —— 在同一个 node 节点再次执行 ReactDOM.render

// Second call

ReactDOM.render(

  React.createElement(Table, { rows }),

  document.getElementById('#root')

)

这一次,上面的代码的表现,跟我们已经看到的有所不同。React 将启动其 diff 算法,而不是从头开始创建所有 DOM 节点并将其放在页面上,来确定节点树的哪些部分必须更新,哪些可以保持不变。

那么,它是怎样工作的呢?其实只有少数几个简单的场景,理解它们将对我们的优化帮助很大。请记住,现在我们在看的,是在React Virtual DOM里面用来代表节点的 对象

场景 1:type是一个字符串,type在通话中保持不变,props 也没有改变。

// before update

{ type: 'div', props: { className: 'cn' } }

// after update

{ type: 'div', props: { className: 'cn' } }

这是最简单的情况:DOM 保持不变。

场景 2:type仍然是相同的字符串,props 是不同的。

// before update:

{ type: 'div', props: { className: 'cn' } }

// after update:

{ type: 'div', props: { className: 'cnn' } }

type 仍然代表 HTML 元素,React 知道如何通过标准 DOM API 调用来更改元素的属性,而无需从 DOM 树中删除一个节点。

场景 3:type已更改为不同的 String或从String 组件。

// before update:

{ type: 'div', props: { className: 'cn' } }

// after update:

{ type: 'span', props: { className: 'cn' } }

React 看到的 type 是不同的,它甚至不会尝试更新我们的节点:old 元素将和它的所有子节点一起被删除(unmounted 卸载)。因此,将元素替换为完全不同于 DOM 树的东西代价会非常昂贵。幸运的是,这在现实世界中很少发生。

划重点,记住 React 使用 ===(triple equals)来比较 type 的值,所以这两个值需要是相同类或相同函数的相同实例。

下一个场景更加有趣,通常我们会这么使用 React。

场景 4:type 是一个 component

// before update:

{ type: Table, props: { rows: rows } }

// after update:

{ type: Table, props: { rows: rows } }

你可能会说,“咦,但没有任何变化啊!”,但是你错了。


如果 type 是对函数或类的引用(即常规的 React 组件),并且我们启动了 tree diff 的过程,则 React 每次都会去检查组件的内部逻辑,以确保 render 返回的值不会改变(类似对副作用的预防措施)。对树中的每个组件进行遍历和扫描 —— 是的,在复杂的渲染场景下,成本可能会非常昂贵!

值得注意的是,一个 componentrender(只有类组件在声明时有这个函数)跟ReactDom.render 不是同一个函数。

关注子组件(children)的情况

除了上述四种常见场景之外,当一个元素有多个子元素时,我们还需要考虑 React 的行为。现在假设我们有这么一个元素:

// ...

props: {

  children: [

      { type: 'div' },

      { type: 'span' },

      { type: 'br' }

  ]

},

// ...

我们想要交换一下这些 children 的顺序:

// ...

props: {

  children: [

    { type: 'span' },

    { type: 'div' },

    { type: 'br' }

  ]

},

// ...

之后会发生什么呢?

diffing的时候,如果 React 在检查props.children 下的数组时,按顺序去对比数组内元素的话:index 0 将与 index 0 进行比较,index 1 和 index 1,等等。对于每一次对比,React 会使用之前提过的 diff 规则。在我们的例子里,它认为 div 成为一个span,那么就会运用到情景 3。这样不是很有效率的:想象一下,我们已经从 1000 行中删除了第一行。React 将不得不“更新”剩余的 999 个子项,因为按 index 去对比的话,内容从第一条开始就不相同了。

幸运的是,React 有一个 内置的方法(built-in)来解决这个问题。如果一个元素有一个key属性,那么元素将按key而不是index来比较。只要key 是唯一的,React 就会移动元素,而不是将它们从 DOM 树中移除然后再将它们放回(这个过程在 React 里叫 mounting 和 unmounting)。

// ...

props: {

  children: [ // Now React will look on key, not index

    { type: 'div', key: 'div' },

    { type: 'span', key: 'span' },

    { type: 'br', key: 'bt' }

  ]

},

// ...

当 state 发生了改变

到目前为止,我们只聊了下 React 哲学里面的 props部分,却忽视了另外很重要的一部分state。下面是一个简单的stateful 组件:

class App extends Component {
  state = { counter: 0 }

  increment = () => this.setState({

    counter: this.state.counter + 1,

  })

  render = () => (
    <button onClick={this.increment}>

      {`Counter: ${this.state.counter}`}

    </button>
  )
}

state 对象里,我们有一个 keycounter。点击按钮时,这个值会增加,然后按钮的文本也会发生相应的改变。但是,当我们这样做时,DOM 中发生了什么?哪部分将被重新计算和更新?

调用 this.setState 会导致re-render(重新渲染),但不会影响到整个页面,而只会影响组件本身及其 children 组件。父母和兄弟姐妹都不会受到影响。当我们有一个层级很深的组件链时,这会让状态更新变得非常方便,因为我们只需要重绘 (redraw) 它的一部分。

把问题说清楚

我们准备了一个 小 demo,以便你可以在看到在“野蛮生长”的 React 编码方式下最常见的问题,后续我也告诉大家怎么去解决这些问题。你可以在 这里看看它的源代码。你还需要 React Developer Tools,请确保浏览器安装了它们。

我们首先要看看的是,哪些元素以及什么时候导致 Virtual DOM 的更新。在浏览器的开发工具中,打开 React 面板并选择“Highlight Updates”复选框:

现在尝试在表格中添加一行。如你所见,页面上的每个元素周围都会显示一个边框。这意味着每次添加一行时,React 都在计算和比较整个虚拟 DOM 树。现在尝试点击一行内的 counter 按钮。你将看到 state 更新后虚拟 DOM 如何更新 —— 只有引用了state key的元素及其 children 受到影响。

React DevTools 会提示问题出在哪里,但不会告诉我们有关细节的信息:特别是所涉及的更新,是由 diffing 元素引起的?还是被挂载(mounting)或者被卸载(unmounting)了?要了解更多信息,我们需要使用 React 的内置 分析器(注意它不适用于生产模式)。

添加 ?react_perf 到应用的 URL,然后转到 Chrome DevTools 中的“Performance”标签。点击“录制”(Record)并在表格上点击。添加一些 row,更改一下 counter,然后点击“停止”(Stop)。

在输出的结果中,我们关注“User timing”这项指标。放大时间轴直到看到“React Tree Reconciliation”这个组及其子项。这些就是我们组件的名称,它们旁边都写着 [update] 或 [mount]。


我们的大部分性能问题都属于这两类问题之一。


无论是组件(还是从它分支的其他组件)出于某种原因都会在每次更新时 re-mounted(慢),又或者我们在大型应用上执行对每个分支做 diff,尽管这些组件并没有发生改变,我们不希望这些情况的发生。

优化我们的代码:Mounting / Unmounting

现在,我们已经了解到当需要 update Virtual Dom 时,React 是依据哪些规则去判断要不要更新,以及也知道了我们可以通过什么方式去追踪这些 diff 场景的背后发生了什么,我们终于准备好优化我们的代码了!首先,我们来看看 mounts/unmounts。

如果你能够注意到当一个元素包含的多个 children,他们是由 array 组成的话,你可以实现十分显著的速度优化。

我们来看看这个 case:

<div>

  <Message />

  <Table />

  <Footer />

</div>

在我们的 Virtual DOM 里这么表示:

// ...

props: {
  children: [

    { type: Message },

    { type: Table },

    { type: Footer }

  ]
}

// ...

这里有一个简单的 Message例子,就是一个div写着一些简单的文本,和以及一个巨大的Table,比方说,超过 1000 行。它们(MessageTable)都是顶级div的子组件,所以它们被放置在父节点的props.children下,并且它们key都不会有。React 甚至不会通过控制台警告我们要给每个child分配key,因为 children 正在React.createElement 作为参数列表传递给父元素,而不是直接遍历一个数组。

现在我们的用户已读了一个通知,Message(譬如新通知按钮)从 DOM 上移除。TableFooter 是剩下的全部。

// ...

props: {
  children: [

    { type: Table },

    { type: Footer }

  ]
}

// ...

React 会怎么处理呢?它会看作是一个 array 类型的 children,现在少了第一项,从前第一项是 Message现在是Table了,也没有key作为索引,比较type 的时候又发现它们俩不是同一个 function 或者 class 的同一个实例,于是会把整个Tableunmount,然后在 mount 回去,渲染它的 1000+ 行子数据。

因此,你可以给每个 component 添加唯一的 key(但在目特殊的 case 下,使用 key 并不是最佳选择),或者采用更聪明的小技巧:使用 短路求值(又名“最小化求值”),这是 JavaScript 和许多其他现代语言的特性。看:

// Using a boolean trick

<div>

  {isShown && <Message />}

  <Table />

  <Footer />

</div>

虽然 Message会离开屏幕,父元素divprops.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 开始创建”它们要来得快。

现在我们来看看更多的东西。大家都挺喜欢用 HOC 的,高阶组件是一个将组件作为参数,执行某些操作,最后返回另外一个不同功能的组件:

function withName(SomeComponent) {
  // Computing name, possibly expensive...

  return function (props) {
    return <SomeComponent {...props} name={name} />
  }
}

这是一种常见的模式,但你需要小心。如果我们这么写:

class App extends React.Component() {
  render() {
    // Creates a new instance on each render

    const ComponentWithName = withName(SomeComponent)

    return <SomeComponentWithName />
  }
}

我们在父节点的 render 方法内部创建一个 HOC。当我们重新渲染(re-render)树时,虚拟 DOM 是这样子的:

// On first render:

{

  type: ComponentWithName,

  props: {},

}

// On second render:

{

  type: ComponentWithName, // Same name, but different instance

  props: {},

}

现在,React 会对 ComponentWithName 这个实例做 diff,但由于此时同名引用了不同的实例,因此全等比较(triple equal)失败,一个完整的 re-mount 会发生(整个节点换掉),而不是调整属性值或顺序。注意它也会导致状态丢失,如此处所述。幸运的是,这很容易解决,你需要始终在 render 外面创建一个 HOC:

// Creates a new instance just once

const ComponentWithName = withName(Component)

class App extends React.Component() {
  render() {
    return <ComponentWithName />
  }
}

优化我的代码:Updating

现在我们可以确保在非必要的时候,不做 re-mount 的事情了。然而,对位于 DOM 树根部附近(层级越上面的元素)的组件所做的任何更改都会导致其所有 children 的 diffing 和调整(reconciliation)。在层级很多、结构复杂的应用里,这些成本很昂贵,但经常是可以避免的。


如果有一种方法可以告诉 React 你不用来检查这个分支了,因为我们可以肯定那个分支不会有更新,那就太棒了!


这种方式是真的有的哈,它涉及一个 built-in 方法叫 shouldComponentUpdate,它也是 组件生命周期 的一部分。这个方法的调用时机:组件的render和组件接收到 state 或 props 的值的更新时。然后我们可以自由地将它们与我们当前的值进行比较,并决定是否更新我们的组件(返回truefalse)。如果我们返回false,React 将不会重新渲染组件,也不会检查它的所有子组件。

通常来说,比较两个集合(set)propsstate 一个简单的浅层比较(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 定义之外创建所有对象、数组和函数,并确保它们在各种调用间,不发生更改 —— 你是安全的。


你在 updated demo,所有 table 的 rows 都被“净化”(purified)过,你可以看到 PureComponent 的表现了。如果你在 React DevTools 中打开“Highlight Updates”,你会注意到只有表格本身和新行在插入时会触发 render,其他的行保持不变。

[译者说:为了便于大家理解 purified,译者在下面插入了原文 demo 的一段代码]

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)),)
  }
};

不过,如果你迫不及待地 all in PureComponent,在应用里到处都用的话 —— 控制住你自己!

shallow 比较两组 propsstate不是免费的,对于大多数基本组件来说,甚至都不值得:shallowComparediffing 算法需要耗费更多的时间。

使用这个经验法则:pure component 适用于复杂的表单和表格,但它们通常会减慢简单元素(按钮、图标)的效率。


感谢你的阅读!现在你已准备好将这些见解应用到你的应用程序中。可以使用我们的小 demo(用了没有用PureComponent)的 仓库 作为你的实验的起点。此外,请继续关注本系列的下一部分,我们计划涵盖 Redux 并优化你的数据,目标是提高整个应用的总体性能。

译者说

正如原文末所说,Alex 和 Andy 后续会继续写一个关于整体性能的系列,包括核心 React 和 Redux 等,我也会继续跟踪这个系列的文章,到时 po 到我的 个人博客 和知乎专栏 《集异璧》,感兴趣的同学们可以关注一下哈:)

欢迎对本文的翻译质量、内容的各种讨论。若有表述不当,欢迎斧正。

2018.05.13,晴,杭州滨江

Yuying Wu