众所周知,操作 DOM 是很耗费性能的一件事情,既然如此,我们可以考虑通过 JS 对象来模拟 DOM 对象。

什么是虚拟DOM

vdom是虚拟DOM(Virtual DOM)的简称,指的是用JS模拟的DOM结构,将DOM变化的对比放在JS层来做。换而言之,vdom就是JS对象

如下DOM结构:

<ul class='list'>
<li>1</li>
</ul>

映射成虚拟DOM就是这样:

const ul = {
tag: 'ul',
props: {
class: 'list'
},
children: {
tag: 'li',
children: '1'
}
}

使用原生DOM操作

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>

<body>
<div id="container"></div>
<button id="btn-change">改变</button>
<button id="btn-change-one">改变单个</button>
<script src="https://cdn.bootcss.com/jquery/3.2.0/jquery.js"></script>
<script>
const data = [{
name: "张三",
age: 20,
address: "北京"
},
{
name: "李四",
age: 21,
address: "武汉"
},
{
name: "王五",
age: 22,
address: "杭州"
},
];
//渲染函数
function render(data) {
const $container = $('#container');
$container.html('');
const $table = $('<table>');
// 重绘一次
$table.append($('<tr><td>name</td><td>age</td><td>address</td></tr>'));
data.forEach(item => {
// 每次进入都重绘
$table.append($(`<tr><td>${item.name}</td><td>${item.age}</td><td>${item.address}</td></tr>`))
})
$container.append($table);
}

render(data)

$('#btn-change').click(function () {
for(let i=0; i < data.length; i++) {
data[i].age = data[i].age + i
}
render(data);
});
$('#btn-change-one').click(function () {
data[1].age = 2000
render(data);
})
</script>
</body>
</html>

看动图:

前端框架通识:Virtual DOM(虚拟 DOM)_javascript

每次改变数据的时候,浏览器都会重绘渲染DOM,无论之前的DOM数据是否一样。

那有什么解决方法?

这时候就有了虚拟DOM了,就是采用javascript对象模拟,将DOM的比对操作放在javascript层,减少浏览器不必要的重绘,提高效率。

当然有人说虚拟DOM并不比真实的DOM快,其实也是有道理的。

当上述table中的每一条数据都改变时,显然真实的DOM操作更快,因为虚拟DOM还存在javascript中diff算法的比对过程。

所以,上述性能优势仅仅适用于大量数据的渲染并且改变的数据只是一小部分的情况。

虚拟DOM更加优秀的地方在于:

  1. 它打开了函数式的UI编程的大门,即UI = f(data)这种构建UI的方式。
  2. 可以将JS对象渲染到浏览器DOM以外的环境中,也就是支持了跨平台开发,比如ReactNative。

​尤大在知乎上的回答​

虚拟DOM

我们上面也简单介绍了虚拟DOM,其实通过javascript来模拟 DOM 并且渲染对应的 DOM 只是第一步,难点在于如何判断新旧两个 javascript对象的最小差异并且实现局部更新 DOM

使用snabbdom实现虚拟DOM

​snabbdom的github地址​

这是一个简易的实现虚拟DOM功能的库,相比vue、react,对于虚拟DOM这块更加简易,适合我们学习虚拟DOM。

虚拟DOM里面有两个核心的api,一个是h函数(用来生成虚拟DOM对象),一个是patch函数(做虚拟DOM的比对 和 将虚拟DOM挂载到真实DOM上)。

简单介绍一下这两个函数的用法:

  • h('标签名', {属性}, [子元素])
  • h('标签名', {属性}, [文本])
  • patch(container, vnode) container为容器DOM元素
  • patch(vnode, newVnode)

现在我们就来用snabbdom重写一下刚才的例子:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<button id="btn-change">改变</button>
<button id="btn-change-one">改变单个</button>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-class.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-props.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-style.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-eventlisteners.min.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/h.js"></script>
<script>
let snabbdom = window.snabbdom;

// 定义patch
let patch = snabbdom.init([
snabbdom_class, // 从script引入
snabbdom_props, // 从script引入
snabbdom_style, // 从script引入
snabbdom_eventlisteners // 从script引入
]);

//定义h
let h = snabbdom.h;

const data = [{
name: "张三",
age: 20,
address: "北京"
},
{
name: "李四",
age: 21,
address: "武汉"
},
{
name: "王五",
age: 22,
address: "杭州"
},
];
data.unshift({name: "姓名", age: "年龄", address: "地址"});

let container = document.getElementById('container');
let vnode;
const render = (data) => {
let newVnode = h('table', {}, data.map(item => {
let tds = [];
for(let i in item) {
if(item.hasOwnProperty(i)) {
tds.push(h('td', {}, item[i] + ''));
}
}
return h('tr', {}, tds);
}));

if(vnode) {
patch(vnode, newVnode);
} else {
patch(container, newVnode);
}
vnode = newVnode;
}

render(data);

let btnChnage = document.getElementById('btn-change');
btnChnage.addEventListener('click', function() {
for(let i=1; i < data.length; i++) {
data[i].age = data[i].age + i
}
render(data);
})
let btnChnageOne = document.getElementById('btn-change-one');
btnChnageOne.addEventListener('click', function() {
data[1].age = 2000
render(data);
})
</script>
</body>
</html>

看动图:

前端框架通识:Virtual DOM(虚拟 DOM)_html_02

 只有改变的栏目才闪烁,也就是浏览器进行重绘,数据没有改变的栏目还是保持原样,这样就大大节省了浏览器重新渲染的开销。

好了,我们来讲讲  上文画出的重点

难点在于如何判断新旧两个 javascript对象的最小差异并且实现局部更新 DOM

 diff算法

所谓diff算法,就是用来找出两段文本之间的差异的一种算法。

作为一个前端,大家经常会听到diff算法这个词,其实diff并不是前端原创的算法,其实这一个算法早已在linux的diff命令中有所体现,并且大家常用的git diff也是运用的diff算法。

看到这里,在对接上文说的 

难点在于如何判断新旧两个 javascript对象的最小差异并且实现局部更新 DOM 

你就应该知道为什么要用diff算法了吧,找出需要更新的节点,找出的过程就是应用了diff算法。

DOM 是一个多叉树的结构,如果需要完整的对比两颗树的差异,那么需要的时间复杂度会是 O(n ^ 3),这个复杂度肯定是不能接受的。

于是 React 团队优化了算法,实现了 O(n) 的复杂度来对比差异。

实现 O(n) 复杂度的关键就是只对比同层的节点,而不是跨层对比,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素。 所以判断差异的算法就分为了两步

  1. 首先从上至下,从左往右遍历对象,也就是树的深度遍历,这一步中会给每个节点添加索引,便于最后渲染差异
  2. 一旦节点有子元素,就去判断子元素是否有不同

虚拟DOM算法实现

树的递归

首先我们来实现树的递归算法,在实现该算法前,先来考虑下两个节点对比会有几种情况

  1. 新的节点的 tagName 或者 key 和旧的不同,这种情况代表需要替换旧的节点,并且也不再需要遍历新旧节点的子元素了,因为整个旧节点都被删掉了
  2. 新的节点的 tagName 和 key(可能都没有)和旧的相同,开始遍历子树
  3. 没有新的节点,那么什么都不用做
import { StateEnums, isString, move } from './util'
import Element from './element'

export default function diff(oldDomTree, newDomTree) {
// 用于记录差异
let pathchs = {}
// 一开始的索引为 0
dfs(oldDomTree, newDomTree, 0, pathchs)
return pathchs
}

function dfs(oldNode, newNode, index, patches) {
// 用于保存子树的更改
let curPatches = []
// 需要判断三种情况
// 1.没有新的节点,那么什么都不用做
// 2.新的节点的 tagName 和 `key` 和旧的不同,就替换
// 3.新的节点的 tagName 和 key(可能都没有) 和旧的相同,开始遍历子树
if (!newNode) {
} else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
// 判断属性是否变更
let props = diffProps(oldNode.props, newNode.props)
if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props })
// 遍历子树
diffChildren(oldNode.children, newNode.children, index, patches)
} else {
// 节点不同,需要替换
curPatches.push({ type: StateEnums.Replace, node: newNode })
}

if (curPatches.length) {
if (patches[index]) {
patches[index] = patches[index].concat(curPatches)
} else {
patches[index] = curPatches
}
}
}

 判断属性的更改

判断属性的更改也分三个步骤

  1. 遍历旧的属性列表,查看每个属性是否还存在于新的属性列表中
  2. 遍历新的属性列表,判断两个列表中都存在的属性的值是否有变化
  3. 在第二步中同时查看是否有属性不存在与旧的属性列列表中
function diffProps(oldProps, newProps) {
// 判断 Props 分以下三步骤
// 先遍历 oldProps 查看是否存在删除的属性
// 然后遍历 newProps 查看是否有属性值被修改
// 最后查看是否有属性新增
let change = []
for (const key in oldProps) {
if (oldProps.hasOwnProperty(key) && !newProps[key]) {
change.push({
prop: key
})
}
}
for (const key in newProps) {
if (newProps.hasOwnProperty(key)) {
const prop = newProps[key]
if (oldProps[key] && oldProps[key] !== newProps[key]) {
change.push({
prop: key,
value: newProps[key]
})
} else if (!oldProps[key]) {
change.push({
prop: key,
value: newProps[key]
})
}
}
}
return change
}

总结

虚拟DOM算法分三步:

  1. 通过 JS 来模拟创建 DOM 对象
  2. 判断两个对象的差异
  3. 渲染差异
let test4 = new Element('div', { class: 'my-div' }, ['test4'])
let test5 = new Element('ul', { class: 'my-div' }, ['test5'])

let test1 = new Element('div', { class: 'my-div' }, [test4])

let test2 = new Element('div', { id: '11' }, [test5, test4])

let root = test1.render()

let pathchs = diff(test1, test2)
console.log(pathchs)

setTimeout(() => {
console.log('开始更新')
patch(root, pathchs)
console.log('结束更新')
}, 1000)

只能用于了解diff算法。