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

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

译者说:经过一些实际场景和demo,给你们描述React的Virtual Dom Diff一些核心的原理和规则,以及基于这些咱们能够作些什么提升应用的性能,很棒的文章。html


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

React在前端圈内保持领先的缘由之一,由于它的学习曲线很是平易近人:把你的模板包在JSX,了解一下propsstate的概念以后,你就能够轻松写出React代码了。java

若是你已经熟悉React的工做方式,能够直接跳至“优化个人代码”篇。node

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

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

"eBay上的一个巨大的React表格  用于业务。"

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


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


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

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: 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: 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: 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”复选框:

"在Chrome中使用“突出显示更新”复选框选中DevTools"

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

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

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

"React DevTools的“Performance”选项卡"

在输出的结果中,咱们关注“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


笔者 @Yuying Wu,前端爱好者 / 鼓励师 / 新西兰打工度假 / 铲屎官。目前就任于某大型电商的B2B前端团队。

感谢你读到这里。若是你和我同样喜欢前端,喜欢捣腾独立博客或者前沿技术,或者有什么职业疑问,欢迎关注我以及各类交流哈。

独立博客:wuyuying.com
知乎ID:@Yuying Wu
Github:Yuying Wu

相关文章
相关标签/搜索