2018-01-31
Virtual-DOM
虚拟 DOM 原理表述
过去的两周里,有空就看了看虚拟 DOM 的各种,勉勉强强算是有了一个简单的实现:
https://github.com/eczn/vdom
本篇着重介绍为什么要有虚拟 DOM ,以及虚拟 DOM 的一般性原理,接下来的几篇我将陆陆续续的实现一个简单的虚拟 DOM

# DOM 的性能问题

观察如下的 HTML 结构
00<div id="app">
01    <p>Apple</p>
02    <p>Bababa</p>
03    <p>Star</p>
04    <p>eczn</p>
05</div>
现在要实现这样的功能:点击 p 标签让其置顶,比如点击了 Star 之后,DOM 变成如下样子:
00<div id="app">
01    <p>Star</p>
02    <p>Apple</p>
03    <p>Bababa</p>
04    <p>eczn</p>
05</div>
一个简单的实现如下:
00window.onload = clickTop; 
01
02function clickTop(data = ['Apple', 'Bababa', 'Star', 'eczn']){
03    // 容器 
04    let $app = document.querySelector('#app'); 
05
06    // 让第 idx 个元素提前 
07    let makeFirst = idx => {
08        let target = data.splice(idx, 1); 
09        data.unshift(target); 
10    }
11
12    // 调用 data2html 将会得到如下结果 
13    // => 
14    // <p data-pos="0">Apple</p>
15    // <p data-pos="1">Bababa</p>
16    // <p data-pos="2">Star</p>
17    // <p data-pos="3">eczn</p>
18    let data2html = () => data.map(
19        (e, idx) => `<p data-pos="${idx}">${e}</p>`
20    ).join(''); 
21
22    // render, 渲染 data 到 $app 里 
23    let render = () => $app.innerHTML = data2html(); 
24
25    // 事件委托处理 p 标签 
26    $app.addEventListener('click', e => {
27        // 被点击的 p 标签以及其下标
28        let $p = e.srcElement; 
29        let pos = parseInt($p.dataset.pos); 
30
31        // 调整并重新皴染
32        makeFirst(pos); 
33        render(); 
34    }); 
35
36    // 初始渲染 
37    render(); 
38}
显然随着 data 的增长,每次 render 的量会越来越大,性能也会越来越差,每次 render 所花费时间也会越来越多。
再来看看另外一个实现:
00window.onload = clickTopV2;
01
02function clickTopV2(data = ['Apple', 'Bababa', 'Star', 'eczn']){
03    // 容器 
04    let $app = document.querySelector('#app'); 
05
06    // 生成 html 
07    let html = data.map(
08        (e, idx) => `<p data-pos="${idx}">${e}</p>`
09    ).join(''); 
10
11    // 初始渲染 
12    $app.innerHTML = html; 
13
14    $app.addEventListener('click', e => {
15        let $p = e.srcElement; 
16        let $nowTop = $app.children[0]; 
17
18        // $app.removeChild($p); 
19        // 下一句 insertBefore 会自动去重
20        // 此步省略 
21        // 感谢 @deswan 纠错 
22
23        $app.insertBefore($p, $nowTop); 
24    }); 
25}
这个实现跟之前的不一样,因为它将元素提升的操作是操作 $app 的子节点,吧 srcElment 取出来插入到最前面,在这种情况下,即便 data 很大,性能也能保持为 O(1) (假设 removeChild 和 insertBefore 的复杂度也是常数)
此处,没有直接操作 innerHTML 进行 DOM 操作,性能比前者好很多,DOM 是很慢的,毫秒级的,很花费时间,要想要更好的性能,最好都要使用第二种方式来操作 DOM,但这种操作比起前者的实现要更难一些,原因在于:需要找到不同的地方以及相关的 DOM 节点进行位置的变换,而前者仅仅只要赋值 innerHTML 就可以了。

# 被忽略的差异、以及不可忽略的差异

实现如上的例子,有两种思路,一种是忽略差异,一种是不忽略差异,分别对应前述的两种实现:
00<div id="app">
01    <p>Apple</p>
02    <p>Bababa</p>
03    <p>Star</p>
04    <p>eczn</p>
05</div>
06
07==> 
08
09<div id="app2">
10    <p>Bababa</p>
11    <p>Apple</p>
12    <p>Star</p>
13    <p>eczn</p>
14</div>
先谈谈不忽略差异的实现吧:
$app 的差异在于 Apple 和 Bababa 的位置互换了。
因此,要让
app2 仅需将 $app 里的 Bababa 和 Apple 互换位置即可,这样就可以实现功能。
而所谓的忽略差异其实就是直接操作 innerHTML 而不管原来的 DOM 树要怎么样才可以变成新的 DOM 树:
观察到多个 p 标签跟数组的结构是一一对应的,因而很容器得到如下的映射关系:
00$app.innerHTML = ['Apple', 'eczn'].map(
01    e => `<p>${e}</p>`
02).join('');
对此进行编程即可实现功能。
很显然,忽略差异会做更多的事,不忽略差异的本质是:让原 DOM 树走一条路径变成新的 DOM 树。

# 虚拟 DOM 应运而生

从前面的讨论可以知道,忽略差异的话, DOM 会做很多多余的事,要让性能最好,必须找出那条使得让原树变成新树的路径,这样才可以让 DOM 做少一些事,从而使性能更好。
那么要怎么样才能知道两颗 DOM 树的差异,以及一条从原树到新树的路径?
答案是使用虚拟 DOM:
虚拟 DOM 技术是指用 JavaScript 来表达 DOM 的数据结构,并由一定的算法来找出虚拟 DOM 变化前后的差异,最后将差异应用到真实的 DOM 上,从而达到性能优化的技术目的。
特别说明一下:找出旧树和新树的差异,叫做 diff 操作;把路径应用到旧树使其变成新树,叫做 patch 操作。
这两个操作是虚拟 DOM 的核心操作,实现了这两个操作,虚拟 DOM 就写的七七八八了。




回到顶部