Virtual DOM 原理

Virtual DOM是什么?

官方定义:Virtual DOM是一种编程理念(数据驱动视图),将UI虚拟的保持到内存中,并且通过某些库渲染成真实的dom,这个过程又叫做协调。

通俗来讲,就是对HTML节点进行抽象,用js对象的形式表示HTML

Virtual DOM VS 原生DOM

DOM操作是很慢的,其元素非常庞大,页面的性能问题鲜有由JS引起的,大部分都是由DOM操作引起的

原生DOM更新

DOM API 调用更新UI

Virtual DOM更新

以React为例

  1. 每次render都会产生一份新的React DOM
  2. Virtual DOM要对新旧React DOM进行比较,也就是常说的diff算法,从而确定在旧DOM的基础上进行多少变更
  3. 确定最优的变更策略之后调用DOM API更新

Virtual DOM 数据结构

虽然一个DOM节点有N个属性,但是我们平时在操作DOM时,只需要以下3个基本信息:

  • 标签名: tagName
  • HTML属性: attribute
  • 子节点: children

对于其他属性我们并不关心,于是,我们可以对一段DOM进行以下抽象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<li class="item"  
<a>link</a>
</li>

{
tag:'li',
props:{
class:['item']
},
children:{
tag:'a',
props:{},
children:'link'
}
}

可以看到,一段DOM片段被抽象成了JS对象,对应着节点的tagName,attribute和children。也就是说,React组件中,render方法返回的是一个JS对象。

1
2
3
4
5
6
7
8
9
10
11
12
class List extends React.Component {
......

//render方法返回的是JS对象
render() {
return (
<li className="item">
<a>link</a>
</li>
);
}
}

既然React中得到的是JS对象而不是DOM,那么对象是在哪里转换成真实的DOM呢?答案就在ReactDOM.render方法中:

1
2
//将得到的虚拟DOM转化为真实DOM
ReactDOM.render(<List/>, $container);

FaceBook将React拆分成了两个库,一个是React的核心,另一个是针对React运行平台的渲染库,这里运行平台是浏览器,所以渲染库就是ReactDOM。

这样做的好处在哪?

  1. 性能提升:将对DOM的操作提升到了对JS对象的操作,单纯操作JS对象的性能肯定会优于操作庞大的DOM对象的性能。
  2. 抽象扩展性更强,不再仅限于渲染DOM:React的设计思想是UI = F(State,props),在传统的前端开发思想中,这个UI可能就是DOM,现在React在开发者和DOM之间抽象了一层,这一层抽象可以被设计得十分强大:对于开发者可以有着相同的API,但是另一边不仅仅可以对接DOM,还可能时Native或者是服务端渲染(这也是React Native实现的基础),所以这个UI的概念就很自然地被扩大了。核心功能与渲染职责的拆分使得React变成了一个平台无关的UI库。

虚拟DOM中间层

Virtual DOM Diff

如果说虚拟DOM是React的核心,那么diff算法就是虚拟DOM的核心

通常情况下,对DOM结构进行修改,我们可以用jquery直接操作,现在有了虚拟DOM,事情反而变得复杂起来了。因为当你对虚拟DOM进行修改时,按理来说,React需要将你修改的虚拟DOM映射回真实的DOM上面去。但是问题是,React根本不知道你修改了哪个地方,那么现在办法有两个:

  1. 按照新的虚拟DOM结构,重新生成一个整个真实DOM。
  2. 将新、旧两个虚拟DOM进行对比,找出不同的地方,更新到真实DOM。

很显然第一个办法的效率是最低的(但是足够简单粗暴),React采用的是第二种方法,这个方法就是diff算法,顾名思义diff算法就是对比两个虚拟DOM差异的算法

diff算法发生在setState方法里面:

1
2
3
4
5
6
7
8
9
10
/*
* setStete到底做了什么:
* 1. 将新,旧state进行对比合并
* 2. 生成一个新的虚拟DOM
* 3. 将新,旧虚拟DOM进行对比,记录差异
* 4. patch:将差异更新到真实DOM
*/
handler() {
this.setState();
}

也就是说,设计一个diff算法主要有两个要点:

  • 如何比较两个JS对象树
  • 如何记录对象之间的差异

由于diff操作本身也会带来性能损耗,React文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 **O(n^3)**,其中n是树中元素的数量。

而一个文档的DOM结构有上百个节点是很正常的情况,如果在React中使用了该算法,那么展示1000个元素所需要执行的计算量将在十亿的量级范围。所以React团队进行了优化,预设三个限制:

  • 只对同级元素进行diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
  • 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  • 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分(开发者可以通过设置 key 来表示哪些子节点能够保持稳定)
1
2
3
4
5
6
7
8
9
10
11
12
// 更新前
<div>
<p key="p-node">ka</p>
<h3 key="h-node">song</h3>
</div>

// 更新后
<div>
<h3 key="h-node">song</h3>
<p key="p-node">ka</p>
</div>

如果没有key,React会认为div的第一个子节点由p变为h3,第二个子节点由h3变为p。这符合限制2的设定,会销毁并新建

但是当我们用key指明了节点前后对应关系后,React知道key === “p-node”的p在更新后还存在,所以DOM节点可以复用,只是需要交换下顺序。