Skip to main content

深入理解 React 的 Virtual DOM

· 26 分钟阅读

React在前端界一直很流行,而且学起来也不是很难,只需要学会JSX、理解StateProps,然后就可以愉快的玩耍了,但想要成为React的专家你还需要对React有一些更深入的理解,希望本文对你有用。

这是Choerodon的一个前端页面

在复杂的前端项目中一个页面可能包含上百个状态,对React框架理解得更精细一些对前端优化很重要。曾经这个页面点击一条记录展示详情会卡顿数秒,而这仅仅是前端渲染造成的。

为了能够解决这些问题,开发者需要了解React组件从定义到在页面上呈现(然后更新)的整个过程。

React在编写组件时使用混合HTMLJavaScript的一种语法(称为JSX)。 但是,浏览器对JSX及其语法一无所知,浏览器只能理解纯JavaScript,因此必须将JSX转换为HTML。 这是一个div的JSX代码,它有一个类和一些内容:

<div className='cn'>
文本
</div>

在React中将这段jsx变成普通的js之后它就是一个带有许多参数的函数调用:

React.createElement(
'div',
{ className: 'cn' },
'文本'
);
React.createElement(
'div',
{ className: 'cn' },
['Content 1!', React.createElement('br'), 'Content 2!']
)
它的第一个参数是一个字符串,对应html中的标签名,第二个参数是它的所有属性所构成的对象,当然,它也有可能是个空对象,剩下的参数都是这个元素下的子元素,这里的文本也会被当作一个子元素,所以第三个参数是 `“文本”`

到这里你应该就能想象这个元素下有更多`children`的时候会发生什么。
```html
<div className='cn'>
文本1
<br />
文本2
</div>
React.createElement(
'div',
{ className: 'cn' },
'文本1', // 1st child
React.createElement('br'), // 2nd child
'文本1' // 3rd child
)

目前的函数有五个参数:元素的类型,全部属性的对象和三个子元素。 由于一个child也是React已知的HTML标签,因此它也将被解释成函数调用。

到目前为止,本文已经介绍了两种类型的child参数,一种是string纯文本,一种是调用其他的React.createElement函数。其实,其他值也可以作为参数,比如:

  • 基本类型 false,null,undefined和 true
  • 数组
  • React组件

使用数组是因为可以将子组件分组并作为一个参数传递:


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

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

组件允许开发者将模板分解为可重用的块。在上面的“纯函数”组件的示例中,组件接受一个包含表行数据的对象数组,并返回React.createElement对table元素及其行作为子元素的单个调用 。

每当开发者将组件放入JSX布局中时它看上去是这样的:

<Table rows={rows} />

但从浏览器角度,它看到的是这样的:

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

请注意,这次的第一个参数不是以string描述的HTML元素,而是组件的引用(即函数名)。第二个参数是传入该组件的props对象。

将组件放在页面上

现在,浏览器已经将所有JSX组件转换为纯JavaScript,现在浏览器获得了一堆函数调用,其参数是其他函数调用,还有其他函数调用......如何将它们转换为构成网页的DOM元素?

为此,开发者需要使用ReactDOM库及其render方法:

function Table({ rows }) { /* ... */ } // 组件定义

// 渲染一个组件
ReactDOM.render(
React.createElement(Table, { rows: rows }), // "创建" 一个 component
document.getElementById('#root') // 将它放入DOM中
);

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

// 这个对象里还有很多其他的字段,但现在对开发者来说重要的是这些。
{
type: Table,
props: {
rows: rows
},
// ...
}

这些对象构成了React意义上的Virtual DOM

它们将在所有进一步渲染中相互比较,并最终转换为真正的DOM(与Virtual 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属性中,不管传入的是什么函数,他们最终都会作为children传入props中。

而且,开发者可以直接在JSX代码中添加children属性,将子项直接放在children中,结果仍然是相同的:

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

在Virtual DOM对象被建立出来之后ReactDOM.render会尝试按以下规则把它翻译成浏览器能够看得懂的DOM节点:

  • 如果Virtual DOM对象中的type属性是一个string类型的tag名称,创建一个tag,包含props里的全部属性。
  • 如果Virtual DOM对象中的type属性是一个函数或者class,调用它,它返回的可能还是一个Virtual DOM然后将结果继续递归调用此过程。
  • 如果props中有children属性,对children中的每个元素进行以上过程,并将返回的结果放到父DOM节点中。

最后,浏览器获得了以下HTML(对于上述table的例子):

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

重建DOM

接下浏览器要“重建”一个DOM节点,如果浏览器要更新一个页面,显然,开发者并不希望替换页面中的全部元素,这就是React真正的魔法了。如何才能实现它?先从最简单的方法开始,重新调用这个节点的ReactDOM.render方法。

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

这一次,上面的代码执行逻辑将与看到的代码不同。React不是从头开始创建所有DOM节点并将它们放在页面上,React将使用“diff”算法,以确定节点树的哪些部分必须更新,哪些部分可以保持不变。

那么它是怎样工作的?只有少数几个简单的情况,理解它们将对React程序的优化有很大帮助。请记住,接下来看到的对象是用作表示React Virtual DOM中节点的对象。

▌Case 1:type是一个字符串,type在调用之间保持不变,props也没有改变。

// before update
{ type: 'div', props: { className: 'cn' } }

// after update
{ type: 'div', props: { className: 'cn' } }

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

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

// before update:
{ type: 'div', props: { className: 'cn' } }

// after update:
{ type: 'div', props: { className: 'cnn' } }

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

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

// before update:
{ type: 'div', props: { className: 'cn' } }

// after update:
{ type: 'span', props: { className: 'cn' } }

由于React现在看到类型不同,它甚至不会尝试更新DOM节点:旧元素将与其所有子节点一起被删除(unmount)。因此,在DOM树上替换完全不同的元素的代价会非常之高。幸运的是,这在实际情况中很少发生。

重要的是要记住React使用===(三等)来比较type值,因此它们必须是同一个类或相同函数的相同实例。

下一个场景更有趣,因为这是开发者最常使用React的方式。

▌Case 4:type是一个组件。

// before update:
{ type: Table, props: { rows: rows } }

// after update:
{ type: Table, props: { rows: rows } }

你可能会说,“这好像没有任何变化”,但这是不对的。

如果type是对函数或类的引用(即常规React组件),并且启动了树diff比较过程,那么React将始终尝试查看组件内部的所有child以确保render的返回值没有更改。即在树下比较每个组件 - 是的,复杂的渲染也可能变得昂贵!

组件中的children

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

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

开发者开发者想将它重新渲染成这样(spandiv交换了位置):

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

那么会发生什么?

当React看到里面的任何数组类型的props.children,它会开始将它中的元素与之前看到的数组中的元素按顺序进行比较:index 0将与index 0,index 1与index 1进行比较,对于每对子元素,React将应用上述规则集进行比较更新。在以上的例子中,它看到div变成一个span这是一个情景3中的情况。但这有一个问题:假设开发者想要从1000行表中删除第一行。React必须“更新”剩余的999个孩子,因为如果与先前的逐个索引表示相比,他们的内容现在将不相等。

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

// ...
props: {
children: [ // 现在react就是根据key,而不是索引来比较了
{ type: 'div', key: 'div' },
{ type: 'span', key: 'span' },
{ type: 'br', key: 'bt' }
]
},
// ...

当状态改变时

到目前为止,本文只触及了props,React哲学的一部分,但忽略了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>)
}

现在,上述例子中的state对象有一个counter属性。单击按钮会增加其值并更改按钮文本。但是当用户点击时,DOM会发生什么?它的哪一部分将被重新计算和更新?

调用this.setState也会导致重新渲染,但不会导致整个页面重渲染,而只会导致组件本身及其子项。父母和兄弟姐妹都可以幸免于难。

修复问题

本文准备了一个DEMO,这是修复问题前的样子。你可以在这里查看其源代码。不过在此之前,你还需要安装React Developer Tools

打开demo要看的第一件事是哪些元素以及何时导致Virtual DOM更新。导航到浏览器的Dev Tools中的React面板,点击设置然后选择“Highlight Updates”复选框:

现在尝试在表中添加一行。如你所见,页面上的每个元素周围都会出现边框。这意味着每次添加行时,React都会计算并比较整个Virtual DOM树。现在尝试按一行内的计数器按钮。你将看到Virtual DOM如何更新 (state仅相关元素及其子元素更新)。

React DevTools暗示了问题可能出现的地方,但没有告诉开发者任何细节:特别是有问题的更新是指元素“diff”之后有不同,还是组件被unmount/mount了。要了解更多信息,开发者需要使用React的内置分析器(请注意,它不能在生产模式下工作)。

转到Chrome DevTools中的“Performance”标签。点击record按钮,然后点击表格。添加一些行,更改一些计数器,然后点击“Stop”按钮。稍等一会儿之后开发者会看到:

在结果输出中,开发者需要关注“Timing”。缩放时间轴,直到看到“React Tree Reconciliation”组及其子项。这些都是组件的名称,旁边有[update][mount]。可以看到有一个TableRow被mount了,其他所有的TableRow都在update,这并不是开发者想要的。

大多数性能问题都由[update][mount]引起

一个组件(以及组件下的所有东西)由于某种原因在每次更新时重新挂载,开发者不想让它发生(重新挂载很慢),或者在大型分支上执行代价过大的重绘,即使组件似乎没有发生任何改变。

修复mount/unmount

现在,当开发者了解React如何决定更新Virtual DOM并知道幕后发生的事情时,终于准备好解决问题了!修复性能问题首先要解决 mount/unmount。

如果开发者将任何元素/组件的多个子元素在内部表示为数组,那么程序可以获得非常明显的速度提升。

考虑一下:

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

在虚拟DOM中,将表示为:

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

一个简单的Message组件(是一个div带有一些文本,像是猪齿鱼的顶部通知)和一个很长的Table,比方说1000多行。它们都是div元素的child,因此它们被放置在父节点的props.children之下,并且它们没有key。React甚至不会通过控制台警告来提醒开发者分配key,因为子节点React.createElement作为参数列表而不是数组传递给父节点。

现在,用户已经关闭了顶部通知,所以Message从树中删除。TableFooter是剩下的child。

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

React如何看待它?它将它视为一系列改变了type的child:children[0]的type本来是Message,但现在他是Table。因为它们都是对函数(和不同函数)的引用,它会卸载整个Table并再次安装它,渲染它的所有子代:1000多行!

因此,你可以添加唯一键(但在这种特殊情况下使用key不是最佳选择)或者采用更智能的trick:使用 && 的布尔短路运算,这是JavaScript和许多其他现代语言的一个特性。像这样:

<div>
{isShowMessage && <Message />}
<Table />
<Footer />
</div>

即使Message被关闭了(不再显示),props.children父母div仍将拥有三个元素,children[0]具有一个值false(布尔类型)。还记得true/false, null甚至undefined都是Virtual DOM对象type属性的允许值吗?浏览器最终得到类似这样的东西:

// ...
props: {
children: [
false, // isShowMessage && <Message /> 短路成了false
{ type: Table },
{ type: Footer }
]
}
// ...

所以,不管Message是否被显示,索引都不会改变,Table仍然会和Table比较,但仅仅比较Virtual DOM通常比删除DOM节点并从中创建它们要快得多。

现在来看看更高级的东西。开发者喜欢HOC。高阶组件是一个函数,它将一个组件作为一个参数,添加一些行为,并返回一个不同的组件(函数):

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

开发者在父render方法中创建了一个HOC 。当React需要重新渲染树时,React 的Virtual DOM将如下所示:

// On first render:
{
type: ComponentWithName,
props: {},
}

// On second render:
{
type: ComponentWithName, // Same name, but different instance
props: {},
}

现在,React只会在ComponentWithName上运行一个diff算法,但是这次同名引用了一个不同的实例,三等于比较失败,必须进行完全重新挂载。注意它也会导致状态丢失,幸运的是,它很容易修复:只要返回的实例都是同一个就好了:

// 单例
const ComponentWithName = withName(Component);

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

修复update

现在浏览器已经确保不会重新装载东西了,除非必要。但是,对位于DOM树根目录附近的组件所做的任何更改都将导致其所有子项的进行对比重绘。结构复杂,价格昂贵且经常可以避免。

如果有办法告诉React不要查看某个分支,那将是很好的,因为它没有任何变化。

这种方式存在,它涉及一个叫shouldComponentUpdate的组件生命周期函数。React会在每次调用组件之前调用此方法,并接收propsstate的新值。然后开发者可以自由地比较新值和旧值之间的区别,并决定是否应该更新组件(返回truefalse)。如果函数返回false,React将不会重新渲染有问题的组件,也不会查看其子组件。

通常比较两组propsstate一个简单的浅层比较就足够了:如果顶层属性的值相同,浏览器就不必更新了。浅比较不是JavaScript的一个特性,但开发者很多方法来自己实现它,为了不重复造轮子,也可以使用别人写好的方法

在引入浅层比较的npm包后,开发者可以编写如下代码:

class TableRow extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
const { props, state } = this;
return !shallowequal(props, nextProps)
&& !shallowequal(state, nextState);
}
render() { /* ... */ }
}

但是你甚至不必自己编写代码,因为React在一个名为React.PureComponent的类中内置了这个功能,它类似于React.Component,只是shouldComponentUpdate已经为你实现了浅层props/state比较。

或许你会有这样的想法,能替换ComponentPureComponent就去替换。但开发者如果错误地使用PureComponent同样会有重新渲染的问题存在,需要考虑下面三种情况:

<Table
// map每次都会返回一个新的数组实例,所以每次比较都是不同的
rows={rows.map(/* ... */)}
// 每一次传入的对象都是新的对象,引用是不同的。
style={ { color: 'red' } }
// 箭头函数也一样,每次都是不同的引用。
onUpdate:{() => { /* ... */ }}
/>

上面的代码片段演示了三种最常见的反模式,请尽量避免它们!

正确地使用PureComponent,你可以在这里看到所有的TableRow都被“纯化”后渲染的效果。

但是,如果你迫不及待想要全部使用纯函数组件,这样是不对的。比较两组propsstate不是免费的,对于大多数基本组件来说甚至都不值得:运行shallowCompare比diff算法需要更多时间。

可以使用此经验法则:纯组件适用于复杂的表单和表格,但它们通常会使按钮或图标等简单元素变慢。

现在,你已经熟悉了React的渲染模式,接下来就开始前端优化之旅吧。

关于猪齿鱼

Choerodon 猪齿鱼是一个全场景效能平台,基于 Kubernetes 的容器编排和管理能力,整合 DevOps 工具链、微服务和移动应用框架,来帮助企业实现敏捷化的应用交付和自动化的运营管理的平台,同时提供 IoT、支付、数据、智能洞察、企业应用市场等业务组件,致力帮助企业聚焦于业务,加速数字化转型。

大家也可以通过以下社区途径了解猪齿鱼的最新动态、产品特性,以及参与社区贡献: