前言
DOM是很慢的。真正的 DOM 元素非常龐大,這是因?yàn)闃?biāo)準(zhǔn)就是這么設(shè)計(jì)的。而且操作它們的時(shí)候你要小心翼翼,輕微的觸碰可能就會(huì)導(dǎo)致頁(yè)面重排產(chǎn)生回流重繪,這可是殺死性能的罪魁禍?zhǔn)住?/p>
Virtual Dom的原理是用 JavaScript 對(duì)象表示 DOM 信息和結(jié)構(gòu),當(dāng)狀態(tài)變更的時(shí)候,重新渲染這個(gè) JavaScript 的對(duì)象結(jié)構(gòu)。然后通過(guò)新渲染的對(duì)象樹(shù)去和舊的樹(shù)進(jìn)行對(duì)比使用一個(gè)diff算法計(jì)算差異,記錄下來(lái)的不同就是我們需要對(duì)頁(yè)面真正的 DOM 操作,然后把它們應(yīng)用在真正的 DOM 樹(shù)上,從而減少了頁(yè)面的回流和重繪次數(shù)。
Vue選擇的virtual dom庫(kù)是snabbdom,本文是對(duì)這個(gè)庫(kù)的源代碼進(jìn)行解析,核心會(huì)放在diff算法上。
代碼
項(xiàng)目地址:snabbdom
代碼是typescript,不過(guò)我解析的時(shí)候會(huì)說(shuō)一些補(bǔ)充的東西讓讀者不會(huì)出現(xiàn)因?yàn)槭莟ypescript所以看不懂的情況。
解析
解析我會(huì)從三個(gè)大方向上來(lái)說(shuō),第一個(gè)是js模擬的dom節(jié)點(diǎn)vnode的結(jié)構(gòu),第二個(gè)是diff算法,第三個(gè)是有了diff如何打patch的。
vnode結(jié)構(gòu)(用JS對(duì)象模擬DOM樹(shù))
vnode的定義在項(xiàng)目中src文件夾的vnode.ts上
import {Hooks} from './hooks';
import {AttachData} from './helpers/attachto'
import {VNodeStyle} from './modules/style'
import {On} from './modules/eventlisteners'
import {Attrs} from './modules/attributes'
import {Classes} from './modules/class'
import {Props} from './modules/props'
import {Dataset} from './modules/dataset'
import {Hero} from './modules/hero'
export type Key = string | number;
export interface VNode {
sel: string | undefined;
data: VNodeData | undefined;
children: Array<VNode | string> | undefined;
elm: Node | undefined;
text: string | undefined;
key: Key | undefined;
}
export interface VNodeData {
props?: Props;
attrs?: Attrs;
class?: Classes;
style?: VNodeStyle;
dataset?: Dataset;
on?: On;
hero?: Hero;
attachData?: AttachData;
hook?: Hooks;
key?: Key;
ns?: string; // for SVGs
fn?: () => VNode; // for thunks
args?: Array<any>; // for thunks
[key: string]: any; // for any other 3rd party module
}
export function vnode(sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
let key = data === undefined ? undefined : data.key;
return {sel: sel, data: data, children: children,
text: text, elm: elm, key: key};
}
export default vnode;
代碼中定義了兩個(gè)interface,VNode和VNodeData,暴露了一個(gè)vnode的構(gòu)造函數(shù)
vnode對(duì)象屬性
vnode對(duì)象有6個(gè)屬性
sel
可以是custom tag,可以是'div','span',etc,代表這個(gè)virtual dom的tag name
data
, virtual dom數(shù)據(jù),它們與dom element的prop、attr的語(yǔ)義類(lèi)似。但是virtual dom包含的數(shù)據(jù)可以更靈活。比如利用./modules/class.js插件,我們?cè)赿ata里面輕松toggle一個(gè)類(lèi)名h('p', {class: {'hide': hideIntro}})
children
, 對(duì)應(yīng)element的children,但是這是vdom的children。vdom的實(shí)現(xiàn)重點(diǎn)就在對(duì)children的patch上
text, 對(duì)應(yīng)element.textContent,在children里定義一個(gè)string,那么我們會(huì)為這個(gè)string創(chuàng)建一個(gè)textNode
elm
, 對(duì)dom element的引用
key
用于提示children patch過(guò)程,隨后面詳細(xì)說(shuō)明
vnode的創(chuàng)建與渲染
在接下來(lái)的說(shuō)明之前先介紹一個(gè)豆知識(shí),在vue文檔中,同時(shí)在這個(gè)snabbdom中我們都會(huì)看到有個(gè)h函數(shù),這個(gè)函數(shù)我之前一直沒(méi)理解是什么意思。
wthat is 'h'
It comes from the term "hyperscript", which is commonly used in many virtual-dom implementations. "Hyperscript" itself stands for "script that generates HTML structures" because HTML is the acronym for "hyper-text markup language".
所以基本上h函數(shù)的意義差不多就是createElement的意思
在snabbdom里面h函數(shù)創(chuàng)建一個(gè)vnode并返回,具體實(shí)現(xiàn)就不細(xì)說(shuō)了
diff算法(patch)
之前我們?cè)趕nabbdom的事例里面看到
var snabbdom = require('snabbdom');
var patch = snabbdom.init([ // Init patch function with chosen modules
require('snabbdom/modules/class').default, // makes it easy to toggle classes
require('snabbdom/modules/props').default, // for setting properties on DOM elements
require('snabbdom/modules/style').default, // handles styling on elements with support for animations
require('snabbdom/modules/eventlisteners').default, // attaches event listeners
]);
var h = require('snabbdom/h').default; // helper function for creating vnodes
var container = document.getElementById('container');
var vnode = h('div#container.two.classes', {on: {click: someFn}}, [
h('span', {style: {fontWeight: 'bold'}}, 'This is bold'),
' and this is just normal text',
h('a', {props: {href: '/foo'}}, 'I\'ll take you places!')
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);
var newVnode = h('div#container.two.classes', {on: {click: anotherEventHandler}}, [
h('span', {style: {fontWeight: 'normal', fontStyle: 'italic'}}, 'This is now italic type'),
' and this is still just normal text',
h('a', {props: {href: '/bar'}}, 'I\'ll take you places!')
]);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
可以看到patch函數(shù)是snabbdom.init出來(lái)的,而且傳入的參數(shù)既可以是用h函數(shù)返回的一個(gè)vnode又可以是實(shí)際的dom元素,現(xiàn)在我們看看init方法的代碼,一些實(shí)現(xiàn)鉤子之類(lèi)的代碼我們就不看了
// snabbdom的init方法
...
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
...
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
}
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
elm = oldVnode.elm as Node;
parent = api.parentNode(elm);
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
}
先會(huì)傳入一個(gè)modules,就是一個(gè)模組,這些模組會(huì)在一些階段加一些鉤子,以此實(shí)現(xiàn)一個(gè)模組功能。說(shuō)到模組功能我就想起了chartjs的模組。模組為第三方開(kāi)發(fā)者提供了個(gè)擴(kuò)展程序的一個(gè)接口,在chartjs里面它使用的是觀察者模式,在一開(kāi)始會(huì)register各個(gè)模組,通過(guò)在圖表創(chuàng)建,更新,銷(xiāo)毀等地方寫(xiě)了notify來(lái)通知各個(gè)模組,以此實(shí)現(xiàn)了一個(gè)模組功能。
回歸patch,我們它會(huì)先判斷是不是sameVnode(通過(guò)vnode的key和sel屬性是不是一樣的來(lái)判斷),如果原來(lái)不是同一個(gè)key的vnode的話那就沒(méi)必要用什么diff了,只能直接創(chuàng)建一個(gè)新元素。這里我們專注于看如果是同一key同一sel下的vnode發(fā)送了某些變化之后怎么進(jìn)行patch操作。
// patchVnode函數(shù)
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
let i: any, hook: any;
... prepatch的鉤子我也刪了
const elm = vnode.elm = (oldVnode.elm as Node);
let oldCh = oldVnode.children;
let ch = vnode.children;
if (oldVnode === vnode) return;
...update的鉤子,我刪了
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
api.setTextContent(elm, vnode.text as string);
}
... postpatch鉤子
}
可以看到主要邏輯里面先做了isUndef(vnode.text)
的判斷,因?yàn)槿绻皇俏谋竟?jié)點(diǎn)的話是不會(huì)有這個(gè)屬性的,如果是文本節(jié)點(diǎn)直接setTextContent就行了,如果不是的話我們還要繼續(xù)看。
在vnode不是文本節(jié)點(diǎn)的時(shí)候做了四個(gè)判斷
前三個(gè)是判斷vnode和oldVnode是不是都有兒子,或者一個(gè)有一個(gè)沒(méi)有。
第一種情況如果都有兒子的話會(huì)調(diào)用updateChildren然后遞歸的調(diào)用patchVnode函數(shù)。
第二種情況vnode有兒子而oldVnode沒(méi)有,那差異就可以直接調(diào)用addVnodes在DOM上插入兒子并更新insertedVnodeQueue記錄了。
第三種情況是vnode沒(méi)有兒子而oldVnode有,說(shuō)明差異是兒子的移除,直接調(diào)用removeVnodes在DOM上移除兒子并更新insertedVnodeQueue。
第四種情況就是這個(gè)節(jié)點(diǎn)是個(gè)文本節(jié)點(diǎn),然后差異是oldVnode有text,vnode沒(méi)有了,直接調(diào)用setTextContent設(shè)置值為空
比較核心的還是前后vnode都有孩子的情況也就是updateChildren里面,在進(jìn)updateChildren函數(shù)之前我還有一點(diǎn)想說(shuō)的。
兩個(gè)樹(shù)的完全的 diff 算法是一個(gè)時(shí)間復(fù)雜度為 O(n^3) 的問(wèn)題。但是在前端當(dāng)中,你很少會(huì)跨越層級(jí)地移動(dòng)DOM元素。所以 Virtual DOM 只會(huì)對(duì)同一個(gè)層級(jí)的元素進(jìn)行對(duì)比:
updateChildren更新兒子
這段邏輯是主要的diff算法部分,有點(diǎn)復(fù)雜,我看了2個(gè)多小時(shí)還結(jié)合其他資料才理解為什么要這樣寫(xiě)。
先貼一下代碼
function updateChildren(parentElm: Node,
oldCh: Array<VNode>,
newCh: Array<VNode>,
insertedVnodeQueue: VNodeQueue) {
let oldStartIdx = 0, newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx: any;
let idxInOld: number;
let elmToMove: VNode;
let before: any;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) { // New element
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any;
api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
我開(kāi)始閱讀的時(shí)候主要的疑惑點(diǎn)就是這一大坨if else,我們先來(lái)考慮一下以下幾種情況
newVnode 5 1 2 3 4
oldVnode 1 2 3 4 5
在代碼中首先會(huì)進(jìn)入
else if (sameVnode(oldEndVnode, newStartVnode))
會(huì)先遞歸調(diào)用patchNode對(duì)這個(gè)子vnode打patch
然后把例子中oldVnode的5插入到1前面
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
有了個(gè)例子之后我們看一下我盜的幾個(gè)圖,實(shí)際的DOM操作只有下面三種情況
上面這種情況的例子是
newVnode 0 2 3 1
oldVnode 0 1 2 3
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
上面這種情況就是我之前舉的例子
newVnode 0 3 12
oldVnode 0 1 2 3
對(duì)應(yīng)代碼
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
上面的情況的例子是newVnode里面的節(jié)點(diǎn)oldVnode里面沒(méi)有
newVnode 0 x 1 2 3
oldVnode 0 1 2 3
對(duì)應(yīng)代碼
else {
// 這一塊不用在意,這只是一個(gè)根據(jù)key去找index的表
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 看看當(dāng)前newStartVnode在不在oldVnode里面
idxInOld = oldKeyToIdx[newStartVnode.key as string];
// 這里就是圖中所示插入新節(jié)點(diǎn)到dom
if (isUndef(idxInOld)) { // New element
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
newStartVnode = newCh[++newStartIdx];
} else {
// 如果在newStartVnode在oldVnode中,
elmToMove = oldCh[idxInOld];
// 如果已經(jīng)不是一個(gè)vnode的東西了,直接新建節(jié)點(diǎn)插入到old頭探針之前
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
} else {
// 如果這個(gè)vnode還能搶救,遞歸調(diào)用patchVnode,把對(duì)應(yīng)的elmToMove插入到old頭探針之前
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any;
api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
}
newStartVnode = newCh[++newStartIdx];
}
}
結(jié)束的時(shí)候,也就是當(dāng)oldVnode或者newVnode有一個(gè)遍歷完的時(shí)候
如上圖,如果是old先遍歷完,則剩余的new里面的肯定要插進(jìn)來(lái)啊
對(duì)應(yīng)代碼
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
}
同理,如果new先遍歷完,說(shuō)明old里面有些元素要被移除
對(duì)應(yīng)代碼
else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
到此最麻煩的updateChildren就算解析完了。