React 性能优化:虚拟DOM阐述

516 阅读17分钟

学习React的虚拟DOM,并使用这些知识来提升你的应用程序的速度。通过这篇对框架内部实现友好入门的介绍中,我们将揭开JSX的神秘面纱,向您展示React如何做出渲染决策,解释如何查找瓶颈,并分享一些避免常见错误的技巧。

React不断震撼前端世界,而且没有衰退迹象的原因之一是它平滑的学习曲线:在了解了JSX和整个“State”,“Props”概念之后,你就可以开始了。

但要真正掌握React,你需要站在React之上进行思考,这篇文章将如你所愿。看看我们为其中一个项目制作的React表:

有数百个动态的、可过滤的行,理解框架的细微之处就成为保证用户体验顺畅的关键。

当事情出错时,你当然能感觉到。输入字段会变慢,复选框需要一秒钟才能被选中,模态窗口展现缓慢。 为了能够解决这类问题,我们需要走完一个React组件从被你定义到呈现到页面上,然后更新的一整段旅程,那我们就马上开始吧!

JSX的背后

React开发人员建议您在编写组件时混合使用HTML和JavaScript,即JSX。但是,浏览器对JSX及其语法一无所知。浏览器只能理解普通的JavaScript,因此必须将JSX转换成普通的JavaScript。下面是包含一个类和一些内容的div的JSX代码

<div className='cn'>
  Content!
</div>

在“正式”JavaScript中,这段代码等同于一个带有许多参数的函数调用

React.createElement(
  'div',
  { className: 'cn' },
  'Content!'
);

让我们仔细看看这些参数。第一个是元素的类型。对于HTML标记,它是一个带有标记名称的字符串。第二个参数是一个具有所有元素属性的对象。如果没有对象,它也可以是空对象。余下所有参数都是元素的子元素。元素内的文本也算作子元素,因此字符串'Content!'作为函数调用的第三个参数

你可以想象当我们有更多的孩子会发生什么

<div className='cn'>
  Content 1!
  <br />
  Content 2!
</div>
React.createElement(
  'div',
  { className: 'cn' },
  'Content 1!',              // 第一个孩子节点
  React.createElement('br'), // 第二个孩子节点
  'Content 2!'               // 第三个孩子节点
)

我们的函数现在有5个参数:一个元素的类型、一个属性对象和3个子元素。由于我们的一个子标记也是已知的HTML标记,所以这个子标记也将被描述为一个函数调用。

到目前为止,我们已经介绍了两种类型的子元素:纯字符串或另一个对React.createElement的调用。但是,其他值也可以用作参数

  • 基本数据类型false, null, undefined and true
  • 数组
  • React组件 使用数组是因为可以将子元素分组并作为一个参数传递
React.createElement(
  'div',
  { className: 'cn' },
  ['Content 1!', React.createElement('br'), 'Content 2!']
)

React的强大功能当然不是来自HTML规范中描述的标记,而是来自用户创建的组件,比如

function Table({ rows }) {
  return (
    <table>
      {rows.map(row => (
        <tr key={row.id}>
          <td>{row.title}</td>
        </tr>
      ))}
    </table>
  );
}

组件允许我们将模板分解成可重用的块。在上面的组件示例中,我们接受一个包含表行数据的对象数组,并返回单个table元素及其行作为子元素的React.createElement调用。 我们把组件放到页面上时,我们这样写

<Table rows={rows} />

从浏览器的角度来看,我们都会这样写

React.createElement(Table, { rows: rows });

注意,这一次我们的第一个参数不是描述HTML元素标记,而是在编写组件时定义的函数的引用,我们的属性现在成了props

将组件放在页面上

因此,我们已经将所有的JSX组件转换为纯JavaScript,现在我们有了一堆函数调用,它们的参数是其他函数调用的参数……它是如何全部转换成DOM元素来形成web页面的? 为此,我们有一个ReactDOM库及其render方法

function Table({ rows }) { /* ... */ } // 定义一个组件
// 渲染一个组件
ReactDOM.render(
  React.createElement(Table, { rows: rows }), // "创建" 一个组件
  document.getElementById('#root') // 插入到页面
);

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

// 还有更多字段,但这些字段对我们是最重要的
{
  type: Table,
  props: {
    rows: rows
  },
  // ...
}

这些对象构成React意义上的虚拟DOM 它们将在所有进一步的渲染中相互比较,并最终转换为一个真正的DOM(而不是虚拟的)。

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

React.createElement(
  'div',
  { className: 'cn' },
  'Content 1!',
  'Content 2!',
);

转换成

{
  type: 'div',
  props: {
    className: 'cn',
    children: [
      'Content 1!',
      'Content 2!'
    ]
  }
}

注意到,在React.createElement函数中,子元素被分开成独立的参数,现在作为内部的props的children键出现,因此,不管子元素是以数组还是参数列表的形式传递,在最终的虚拟DOM对象中,它们最终都会在一起,更重要的是,我们可以在JSX代码中直接将孩子添加到props中,结果仍然是相同的。

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

在构建一个虚拟DOM对象之后,根据以下规则,ReactDOM.render试图将虚拟DOM对象转换成浏览器可以呈现的DOM节点

  • 如果type属性是一个标签字符串--->创建一个包含所有props属性的标签
  • 如果type属性是个函数或者是一个类--->调用这个函数或者类,并对结果递归地重复该过程
  • 如果props下有任何的children-→对每个子节点逐个的调用该过程并将结果放在父节点的DOM节点中。

结果,我们得到以下html(对于上面表格的例子)

<table>
  <tr>
    <td>Title</td>
  </tr>
  ...
</table>

重建DOM

注意到标题中的“重建”,当我们想要更新一个页面而不替换所有内容时,React中真正的魔力就开始了。我们有很多途径来实现这一目标,让我们从最简单的开始--->对相同的节点再次调用React.render

// 第二次调用
ReactDOM.render(
  React.createElement(Table, { rows: rows }),
  document.getElementById('#root')
);

这一次,上面的代码段的行为将与我们已经看到的不同。而不是从头创建所有DOM节点并将它们放在页面上。React将启动调和算法,以确定哪些节点需要更新,哪些可以保持不变。 那么,它是如何工作的呢?只有少数几个简单的场景,理解它们对我们的优化有很大的帮助。请记住,我们现在看到的对象是React Virtual DOM中节点的表示形式

  • 场景1:type是字符串,type在调用期间保持不变,props也没有改变
// 更新前
{ type: 'div', props: { className: 'cn' } }
 
// 更新后
{ type: 'div', props: { className: 'cn' } }

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

  • 场景2:type仍然是字符串,props改变了
// 更新前
{ type: 'div', props: { className: 'cn' } }
 
// 更新后
{ type: 'div', props: { className: 'cnn' } }

由于我们的类型仍然表示HTML元素,React知道如何通过标准的DOM API调用来更改其属性,而无需从DOM树中删除节点

  • 场景3:type变成了不同的字符串或者从一个字符串变成了组件
// 更新前
{ type: 'div', props: { className: 'cn' } }
 
// 更新后
{ type: 'span', props: { className: 'cn' } }

当React现在看到类型不同时,它甚至不会尝试更新我们的节点:旧元素将与其所有子元素一起被删除(卸载)。因此,将一个元素替换为DOM树中完全不同的元素可能非常昂贵。幸运的是,这种情况在现实世界中很少发生。 务必记住,React使用===(三重等于)来比较类型值,因此它们必须是相同类或相同函数的相同实例。

下一个场景更有趣,因为这是我们最常用的React方式

  • 场景4:type是一个组件
// 更新前
{ type: Table, props: { rows: rows } }
 
// 更新后
{ type: Table, props: { rows: rows } }

你可能会说“但是什么也没有改变!”那你就错了。 如果类型是一个函数或一个类(即常规React组件),然后我们开始树的调和过程,React总是尽量的深入到组件内部确保render返回的值没有变化。对树下的每个组件进行同样的过程——是的,复杂的渲染也可能变得昂贵!

注意一下children

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

// ...
props: {
  children: [
      { type: 'div' },
      { type: 'span' },
      { type: 'br' }
  ]
},
// ...

我们想把这些孩子们拖来拖去

// ...
props: {
  children: [
    { type: 'span' },
    { type: 'div' },
    { type: 'br' }
  ]
},
// ...

然后会发生什么呢?

如果,在调和的过程中,React遇到props.children数组,它开始比较其中的元素和它之前看到的数组中的元素,通过查看它们的顺序:索引0将与索引0进行比较,索引1与索引1进行比较,等等。对于每一对,React将应用上述规则集。在我们的例子中,它看到div变成了一个span,因此将应用场景3。这不是很有效:假设我们从一个1000行的表中删除了第一行。React将不得不“更新”剩下的999个子元素,因为它们的内容与之前对于索引表示的内容将不相等。

幸运的是,React有一个内置的方法来解决这个问题。如果一个元素有一个key属性,那么元素将通过key的值进行比较,而不是通过索引。只要key是惟一的,React就会移动元素,而不需要从DOM树中删除它们,然后将它们放回去(在React中称为挂载/卸载)。

// ...
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。下面是一个简单的“有状态”组件:

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

状态对象中有一个counter键。单击按钮将增加其值并更改按钮文本。但在DOM中会发生什么呢?哪一部分需要重新计算和更新? 调用this.setState也会导致重新渲染,但不是整个页面,而是组件本身及其子组件。父节点和兄弟节点幸免于难。当我们有一棵很大的树时,这是很方便的,我们只想重画它的一部分。

理清问题

我们准备了一个小的演示应用程序,所以你可以看到最常见的问题,在我们开始修复它们之前。您可以在这里查看它的源代码。您还需要React Developer工具,因此请确保为您的浏览器安装了这些工具。

我们首先要看的是哪些元素以及什么时候更新虚拟DOM。导航到浏览器开发工具中的React面板,并选择“高亮显示更新”复选框

现在尝试向表中添加一行。可以看到,页面上的每个元素周围都有一个边框。这意味着React会在每次添加一行时计算并比较整个虚拟DOM树。现在试着点击一行中的计数器按钮。您将看到有关元素及其子元素的状态更改对虚拟DOM更新的影响

React DevTools暗示了问题可能在哪里,但没有告诉我们任何细节:特别是所涉及的更新是否意味着“调和”元素或挂载/卸载它们。要了解更多信息,我们需要使用React的内置分析器(注意,它在生产模式下无法工作)

将?react_perf添加到应用程序的任何URL中,并在Chrome DevTools中打开“Performance”选项卡。点击录制按钮,然后点击表格。添加一些行,改变一些计数器,然后点击“停止”

在结果输出中,我们感兴趣的是“用户计时”。放大到时间轴,直到看到“React Tree Reconciliation”组及其子组。这些都是我们组件的名称,旁边有[更新]或[挂载]

我们的大多数性能问题都属于这两类

要么某个组件(以及从它派生出来的所有组件)由于某种原因在每次更新时都要重新挂载,我们不想要重新挂载(重新挂载很慢),要么我们在大型分支上执行代价高昂的协调,即使没有任何更改。

解决问题:挂载/卸载

现在,当我们了解了有关React如何决定更新虚拟DOM的一些理论,并了解了如何查看幕后发生的事情时,我们终于准备好解决问题了!首先,让我们处理挂载/卸载。 如果你简单的意识到任务元素/组件的多个子元素在内部被当做一个数组的事实,那么您可以获得非常显著的速度提升

考虑一下这个

<div>
  <Message />
  <Table />
  <Footer />
</div>

在我们的虚拟DOM中,它将被表示为

// ...
props: {
  children: [
    { type: Message },
    { type: Table },
    { type: Footer }
  ]
}
// ...

我们有一个简单的消息,它是一个包含一些文本的div和一个巨大的表,比方说,跨越1000多行。它们都是封闭的div的子元素,因此它们被放置在父节点的props.children之下,它们没有key。而React甚至不会提醒我们通过控制台警告来分配key,因为子节点会作为参数列表而不是数组被传递到父节点的React.createElement ,现在我们的用户已经取消了一个通知,消息也从树中删除了。只剩下Table和Footer

// ...
props: {
  children: [
    { type: Table },
    { type: Footer }
  ]
}
// ...

React会如何看待这种情况?它将被视为children数组改变了形状:children[0]包含的是Message,现在children[0]包含的是Tabel,因为没有可比较的key,所以它比较类型,由于它们都是对函数的引用(以及不同的函数),所以它卸载整个表并再次挂载它,呈现它的所有子表:1000+行!

因此,您可以添加唯一的键(但在这种情况下,使用键并不是最佳选择),或者使用更聪明的方法:使用短路布尔求值,这是JavaScript和许多其他现代语言的一个特性。看下面的

// Using a boolean trick
<div>
  {isShown && <Message />}
  <Table />
  <Footer />
</div>

即使Message从屏幕中移除,父div的props.children仍然包含三个元素,children[0]的值为false(一个布尔原始值)。还记得true/false、null和undefined都是虚拟DOM对象的type属性的允许值吗?最终得到这样的结果

// ...
props: {
  children: [
    false, //  isShown && <Message /> evaluates to false
    { type: Table },
    { type: Footer }
  ]
}
// ...

因此,无论有没有Message,我们的索引都不会改变,当然,Tabel仍然会与Tabel进行比较(无论如何,在类型开始协调时引用组件),但是仅仅比较虚拟DOM通常比删除DOM节点并再次从头创建它们要快得多

现在让我们看看更先进的东西。我知道你喜欢高阶组件。高阶组件是一个函数,它接受一个组件作为参数,执行一些操作,然后返回一个不同的函数

function withName(SomeComponent) {
  // Computing name, possibly expensive...
  return function(props) {
    return <SomeComponent {...props} name={name} />;
  }
}

这是一个非常常见的模式,但是您需要小心使用它

考虑如下:

class App extends React.Component() {
  render() {
    // 在每一次渲染的时候都生成一个实例
    const ComponentWithName = withName(SomeComponent);
    return <ComponentWithName />;
  }
}

我们在父组件的render方法中创建了一个高阶组件,当重新渲染的时候,我们的虚拟dom看起来像下面一样

// 第一次渲染:
{
  type: ComponentWithName,
  props: {},
}
 
// 第二次渲染:
{
  type: ComponentWithName, // 相同的名字,不同的实例
  props: {},
}

现在,React希望在ComponentWithName上运行一个扩展算法,但是由于这次相同的名称引用了不同的实例,所以三重等于比较失败,而且必须进行完整的重新挂载,而不是调和。注意,它还会导致状态丢失,如这里所述。幸运的是,它很容易修复:你需要把高阶组件创建在render方法之外

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

解决更新问题

所以,现在我们确保不重新挂载,除非有必要。但是,对位于DOM树根附近的组件的任何更改都将导致其所有子组件的差异和协调。复杂的结构是昂贵的,通常可以避免。 如果有一种方法可以告诉React不去查看某个分支,那就太好了,因为我们确信其中没有任何变化。

这种方法是存在的,它涉及到一个名为shouldComponentUpdate的方法,该方法是组件生命周期的一部分。此方法在每次调用组件的render方法之前调用,并接收新的props和state值。然后我们可以自由地将它们与当前值进行比较,并决定是否应该更新组件(返回true或false)。如果返回false, React将不会重新渲染有问题的组件,也不会查看它的子组件。

通常情况下,对props和state做一个浅层的比较就已经足够了,如果顶层的值相同,则不要需要更新。浅层比较不是JavaScript的一个特性,但是有许多实用程序可以实现这一点。

在他们的帮助下,我们可以像这样编写代码


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,只是通过浅层比较props和state帮你实现了shouldComponentUpdate 。这听起来很简单,只需将类定义的extends部分中的组件替换为PureComponent,就可以享受效率。不过没那么快!考虑这些例子:

<Table
    // map返回的是数组的一个新实例,所以浅层比较会失败
    rows={rows.map(/* ... */)}
    // 字符串字面量始终不会等于前一个
    style={ { color: 'red' } }
    // 在作用域里箭头函数是一个新的未命名的方法,所以它总是触发全局 diffing
    onUpdate={() => { /* ... */ }}
/>

上面的代码片段演示了三种最常见的反模式。尽量避开他们! 如果你在render方法定义之外,创建所以对象、数组、函数,并确保它们不会在调用之间更改,那么您就是安全的。

您可以在更新的演示中观察PureComponent的效果,其中所有表的行都被“净化”了。如果您在React DevTools中打开“Highlight Updates”,您将注意到只有表本身和新行在行插入时呈现,其他所有行都保持不变。

但是,如果您迫不及待地要全部使用纯组件并在您的应用程序中到处实现它们,赶紧暂停。比较两组props和state并不是免费的,对于大多数基本组件来说甚至不值得:运行shallowCompare要比使用diffing算法花费更多的时间。

经验法则:纯组件适用于复杂的表单和表,但是对于简单组件例如button、icon,纯组件会降低速度

原文链接:Optimizing React: Virtual DOM explained