一、Virtual DOM 是什么
本质上来说,Virtual DOM
只是一个简单的 JS
对象,并且最少包含 tag
、 props
和 children
三个属性。不同的框架对这三个属性的命名会有点差别,但表达的意思是一致的。它们分别是标签名(tag
)、属性(props
)和子元素对象(children
)。下面是一个典型的 Virtual DOM
对象例子:
{
tag: "div",
props: {},
children: [
"Hello World",
{
tag: "ul",
props: {},
children: [{
tag: "li",
props: {
id: 1,
class: "li-1"
},
children: ["第", 1]
}]
}
]
}
Virtual DOM
跟 dom
对象有一一对应的关系,上面的 Virtual DOM
是由以下的 HTML
生成的:
<div>
Hello World
<ul>
<li id="1" class="li-1">
第1
</li>
</ul>
</div>
一个 dom
对象,比如li
,由 tag(li)
, props({id:1,class:“li-1”})
和 children([“第”,1])
三个属性来描述。
二、为什么需要Virtual DOM
Virtual DOM 最大的特点是将页面的状态抽象为 JS 对象的形式,配合不同的渲染工具,使跨平台渲染成为可能。如 React 就借助 Virtual DOM 实现了服务端渲染、浏览器渲染和移动端渲染等功能。
借助 Virtual DOM
,可以达到有效减少页面渲染次数的目的,从而提高渲染效率。我们先来看下页面的更新一般会经过几个阶段:
从上面的例子中,可以看出页面的呈现会分以下 3 个阶段:
- JS 计算
- 生成渲染树
- 绘制页面
这个例子里面,JS 计算用了 691毫秒,生成渲染树 578毫秒,绘制 73毫秒。如果能有效的减少生成渲染树和绘制所花的时间,更新页面的效率也会随之提高。
通过 Virtual DOM
的比较,我们可以将多个操作合并成一个批量的操作,从而减少 dom 重排的次数,进而缩短了生成渲染树和绘制所花的时间。
三、如何实现 Virtual DOM
与 真实 DOM 的映射
我们先从如何生成 Virtual DOM
说起。借助 JSX
编译器,可以将文件中的 HTML
转化成函数的形式,然后再利用这个函数生成 Virtual DOM
。看下面这个例子:
function render() {
return (
<div>
Hello World
<ul>
<li id="1" class="li-1">
第1
</li>
</ul>
</div>
);
}
这个函数经过 JSX 编译后,会输出下面的内容:
function render() {
return h(
'div',
null,
'Hello World',
h(
'ul',
null,
h(
'li',
{ id: '1', 'class': 'li-1' },
'\u7B2C1'
)
)
);
}
这里的 h
是一个函数,可以起任意的名字。这个名字通过 babel
进行配置:
// .babelrc 文件
{
"plugins": [
["transform-react-jsx", {
"pragma": "h" // 这里可配置任意的名称
}]
]
}
babel 会将JSX转化成h函数,状态值会将state当作参数传给h函数
接下来,我们只需要定义 h 函数,就能构造出 VD:
function flatten(arr) {
return [].concat.apply([], arr);
}
function h(tag, props, ...children) {
return {
tag,
props: props || {},
children: flatten(children) || []
};
}
h
函数会传入三个或以上的参数,前两个参数一个是标签名,一个是属性对象,从第三个参数开始的其它参数都是 children。children 元素有可能是数组的形式,需要将数组解构一层。比如:
function render() {
return (
<ul>
<li>0</li>
{
[1, 2, 3].map( i => (
<li>{i}</li>
))
}
</ul>
);
}
// JSX 编译后
function render() {
return h(
'ul',
null,
h(
'li',
null,
'0'
),
/*
* 需要将下面这个数组解构出来再放到 children 数组中
*/
[1, 2, 3].map(i => h(
'li',
null,
i
))
);
}
继续之前的例子。执行 h
函数后,最终会得到如下的 Virtual DOM
对象:
{
tag: "div",
props: {},
children: [
"Hello World",
{
tag: "ul",
props: {},
children: [{
tag: "li",
props: {
id: 1,
class: "li-1"
},
children: ["第", 1]
}]
}
]
}
下一步,通过遍历 Virtual DOM
对象,生成真实的 dom
// 创建 dom 元素
function createElement(vdom) {
// 如果 vdom 是字符串或者数字类型,则创建文本节点,比如“Hello World”
if (typeof vdom === 'string' || typeof vdom === 'number') {
return doc.createTextNode(vdom);
}
const {tag, props, children} = vdom;
// 1. 创建元素
const element = doc.createElement(tag);
// 2. 属性赋值
setProps(element, props);
// 3. 创建子元素
// appendChild 在执行的时候,会检查当前的 this 是不是 dom 对象,因此要 bind 一下
children.map(createElement)
.forEach(element.appendChild.bind(element));
return element;
}
// 属性赋值
function setProps(element, props) {
for (let key in props) {
element.setAttribute(key, props[key]);
}
}
Virtual DOM 如何更新真实的Dom
使用 Virtual DOM
的框架,一般的设计思路都是页面等于页面状态的映射,即UI = render(state)
。当需要更新页面的时候,无需关心 DOM
具体的变换方式,只需要改变state即可,剩下的事情(render
)将由框架代劳。我们考虑最简单的情况,当 state
发生变化时,我们重新生成整个 Virtual DOM
,触发比较的操作。上述过程分为以下四步:
-
state
变化,生成新的Virtual DOM
- 比较
Virtual DOM
与之前Virtual DOM
的异同 - 生成差异对象(
patch
) - 遍历差异对象并更新 DOM
差异对象的数据结构是下面这个样子,与每一个 vdom
元素一一对应:
{
type,
vdom,
props: [{
type,
key,
value
}]
children
}
提高渲染性能
渲染数组给数组增加key
用过React
或者Vue
的朋友都知道在渲染数组元素的时候,编译器会提醒加上 key
这个属性,那么key
是用来做什么的呢?
在渲染数组元素时,它们一般都有相同的结构,只是内容有些不同而已,比如:
<ul>
<li>
<span>商品:苹果</span>
<span>数量:1</span>
</li>
<li>
<span>商品:香蕉</span>
<span>数量:2</span>
</li>
<li>
<span>商品:雪梨</span>
<span>数量:3</span>
</li>
</ul>
可以把这个例子想象成一个购物车。此时如果想往购物车里面添加一件商品,性能不会有任何问题,因为只是简单的在ul的末尾追加元素,前面的元素都不需要更新:
<ul>
<li>
<span>商品:苹果</span>
<span>数量:1</span>
</li>
<li>
<span>商品:香蕉</span>
<span>数量:2</span>
</li>
<li>
<span>商品:雪梨</span>
<span>数量:3</span>
</li>
<li>
<span>商品:橙子</span>
<span>数量:2</span>
</li>
</ul>
但是,如果我要删除第一个元素,根据VD的比较逻辑,后面的元素全部都要进行更新的操作。dom结构简单还好说,如果是一个复杂的结构,那页面渲染的性能将会受到很大的影响。
<ul>
<li>
<span>商品:香蕉</span>
<span>数量:2</span>
</li>
<li>
<span>商品:雪梨</span>
<span>数量:3</span>
</li>
<li>
<span>商品:橙子</span>
<span>数量:2</span>
</li>
</ul>
有什么方式可以降低这种性能的损耗呢?
最直观的方法肯定是直接删除第一个元素然后其它元素保持不变了。但程序没有这么智能,可以像我们一样一眼就看出变化。程序能做到的是尽量少的修改元素,通过移动元素而不是修改元素来达到更新的目的。为了告诉程序要怎么移动元素,我们必须给每个元素加上一个唯一标识,也就是key。
<ul>
<li key="apple">
<span>商品:苹果</span>
<span>数量:1</span>
</li>
<li key="banana">
<span>商品:香蕉</span>
<span>数量:2</span>
</li>
<li key="pear">
<span>商品:雪梨</span>
<span>数量:3</span>
</li>
<li key="orange">
<span>商品:橙子</span>
<span>数量:2</span>
</li>
</ul>
当把苹果删掉的时候,VD里面第一个元素是香蕉,而dom里面第一个元素是苹果。当元素有key属性的时候,框架就会尝试根据这个key去找对应的元素,找到了就将这个元素移动到第一个位置,循环往复。最后VD里面没有第四个元素了,才会把苹果从dom移除。
- 将所有dom子元素分为有key和没key两组
- 遍历VD子元素,如果VD子元素有key,则去查找有key的分组;如果没key,则去没key的分组找一个类型相同的元素出来
- diff一下,得出是否更新元素的类型
- 如果是更新元素且子元素不是原来的,则移动元素
- 最后清理删除没用上的dom子元素
setState异步更新
为了减少不必要的渲染,提高性能,React
并不是在我们每次setState
的时候都进行渲染,而是将一个同步操作里面的多个setState
进行合并后再渲染,给人异步渲染的感觉。
总结
react
和 vue
框架 提升开发效率是因为框架帮我们完成了数据和视图之间的绑定,使得开发者只需要关注数据的变化,减少开发者各种不必要的DOM
操作达到性能提升,而数据到视图的映射利用了Virtual DOM
这一思路来提升性能。
Virtual DOM
只是一种利用数据结构的思想,把复杂的,真实的DOM树转化为轻量的,速度更快的JS Object
,通过优化的diff算法后再把变化应用到真实的DOM树上。