如何快速實現一個虛擬 DOM 系統
- 2021 年 7 月 13 日
- 筆記
虛擬 DOM 是目前主流前端框架的技術核心之一,本文闡述如何實現一個簡單的虛擬 DOM 系統。
為什麼需要虛擬 DOM?
虛擬 DOM 就是一棵由虛擬節點組成的樹,這棵樹展現了真實 DOM 的結構。這些虛擬節點是輕量的、無狀態的,一般是字元串或者僅僅包含必要欄位的 JavaScript 對象。虛擬節點可以被組裝成節點樹樹,通過特定的 “diff” 演算法對兩個節點樹進行對比,找出其中細微的變更點,然後更新到真實 DOM 上去。
之所以會有虛擬 DOM,是因為直接更新真實 DOM 非常昂貴。通過新比對虛擬 DOM,然後只將變化的部分更新到真實 DOM 上去。這麼做都是操作純 JavaScript 對象,盡量避免了直接操作 DOM,讀寫成本低很多。
如何實現虛擬 DOM
在開始之前,我們需要明確一個虛擬 DOM 系統應該包含哪些必要的組成部分?
首先,我們要定義清楚什麼是虛擬節點。一個虛擬節點可以是一個普通 JavaScript 對象,也可以是一個字元串。
我們定義一個函數 createNode
來創建虛擬節點。一個虛擬節點至少包含三個資訊:
tag
:保存虛擬節點的標籤名,字元串props
:保存虛擬節點的 properties/attributes,普通對象children
:保存虛擬節點的子節點,數組
下面的程式碼是 createNode
實現樣例:
const createNode = (tag, props, children) => ({
tag,
props,
children,
});
我們通過 createNode
可以輕鬆的創建虛擬節點:
createNode('div', { id: 'app' }, ['Hello World']);
// 返回如下:
{
tag: 'div',
props: { id: 'app' },
children: ['Hello World'],
}
現在,我們需要定義一個 createElement
函數來根據虛擬節點創建真實的 DOM 元素。
在 createElement
中,我們需要創建一個新的 DOM 元素,然後遍歷虛擬節點的 props 屬性,將其中的屬性添加到 DOM 元素上去,之後再遍歷 children 屬性。如下程式碼是一個實現樣例:
const createElement = vnode => {
if (typof vnode === 'string') {
return document.createTextNode(vnode); // 如果是字元串就直接返迴文本元素
}
const el = document.createElement(vnode.tag);
if (vnode.props) {
Object.entries(vnode.props).forEach(([name, value]) => {
el[name] = value;
});
}
if (vnode.children) {
vnode.children.forEach(child => {
el.appendChild(createElement(child));
});
}
return el;
}
現在,我們可以通過 createElement
將虛擬節點轉變成真實 DOM 了。
createElement(createNode("div", { id: "app" }, ["Hello World"]));
// 輸出: <div id="app">Hello World</div>
我們再來定義一個 diff
函數來實現 ‘diff’ 演算法。這個 diff
函數接收三個參數,一個是已經存在的 DOM 元素,一個是舊的虛擬節點,一個是新的虛擬節點。在這個函數中,我們將對比兩個虛擬節點,在需要的時候,將舊的元素替換掉。
const diff = (el, oldVNode, newVNode) => {
const replace = () => el.replaceWith(createElement(newVNode));
if (!newVNode) return el.remove();
if (!oldVNode) return el.appendChild(createElement(newVNode));
// 處理純文本的情況
if (typeof oldVNode === 'string' || typeof newVNode === 'string') {
if (oldVNode !== newVNode) return replace();
} else {
// 對比標籤名
if (oldVNode.tag !== newVNode.tag) return replace();
// 對比 props
if (!oldVNode.props?.some((prop) => oldVNode.props?[prop] === newVNode.props?[prop])) return replace();
// 對比 children
[...el.childNodes].forEach((child, i) => {
diff(child, oldVNode.children?[i], newVNode.children?[i]);
});
}
}
在這個函數中,我們先處理純文本的情況,如果新舊兩個字元串不相同,則直接替換。之後,我們就可以假定兩個虛擬節點都是對象了。我們先對比兩個節點的標籤名是否相同,不同則直接替換。之後對比兩個節點的 props 是否相同,不同也直接替換。最後我們在遞歸的使用 diff
函數對比兩個虛擬節點的 children。
至此,我們就實現了一個簡版虛擬 DOM 系統所必須的所有功能。下面是使用樣例:
const oldVNode = createNode("div", { id: "app" }, ["Hello World"]);
const newVNode = createNode("div", { id: "app" }, ["Goodbye World"]);
const el = createElement(oldVNode);
// <div id="app">Hello World</div>
diff(el, oldVNode, newVNode);
// el will become: <div id="app">Goodbye World</div>
文中的實現側重於展示虛擬 DOM 的實現原理,在實現程式碼中並未考慮性能等其他因素。