關注前端小謳,閲讀更多原創技術文章
-
Virtual DOM是當今主流框架普遍採用的提高 web 頁面性能的方案,其原理是:- 1.把真實的 DOM 樹轉換成 js 對象(虛擬 DOM)
- 2.數據更新時生成新的 js 對象(新的虛擬 DOM)
- 3.二者比對後僅對發生變化的數據進行更新
完整代碼參考 →
js 對象模擬 DOM 樹
- 假設有如下 html 結構(見
index.html)
<div id="virtual-dom" style="color:red">
<p>Virtual DOM</p>
<ul id="list">
<li class="item">Item 1</li>
<li class="item">Item 2</li>
<li class="item">Item 3</li>
</ul>
<div>Hello World</div>
</div>
- 用 js 對象表示該結構,標籤作為
tagName屬性,id、class等作為props屬性,標籤內再嵌套的標籤或文本均作為children
const elNode = {
tagName: "div",
props: { id: "virtual-dom" },
children: [
{ tagName: "p", children: ["Virtual DOM"] }, // 沒有props
{
tagName: "ul",
props: { id: "list" },
children: [
{
tagName: "li",
props: { class: "item" },
children: ["Item 1"],
},
{
tagName: "li",
props: { class: "item" },
children: ["Item 2"],
},
{
tagName: "li",
props: { class: "item" },
children: ["Item 3"],
},
],
},
{ tagName: "div", props: {}, children: ["Hello World"] },
],
};
- 創建
VNode類,用以將以上 js 結構轉換成 VNode 節點對象(見vnode.js),並創建調用Vnode的方法createElement(見create-element.js)
export default class VNode {
constructor(tagName, props, children) {
if (props instanceof Array) {
// 第二個參數是數組,説明傳的是children,即沒有傳props
children = props; // 把props賦給原本應是子節點的第三個參數
props = {}; // props被賦值為空對象
}
this.tagName = tagName;
this.props = props;
this.children = children;
}
// render 將virdual-dom 對象渲染為實際 DOM 元素
render() {
// console.log(this.tagName, this.props, this.children);
let el = document.createElement(this.tagName);
let props = this.props;
// 設置節點的DOM屬性
for (let propName in props) {
let propValue = props[propName];
el.setAttribute(propName, propValue);
}
// 保存子節點
let children = this.children || [];
children.forEach((child) => {
let childEl =
child instanceof VNode
? child.render() // 如果子節點也是虛擬DOM,遞歸構建DOM節點
: document.createTextNode(child); // 如果字符串,只構建文本節點
el.appendChild(childEl); // 子節點dom
});
return el;
}
}
export function createElement(tagName, props, children) {
return new VNode(tagName, props, children);
}
- 注掉頁面原本的
html結構並調用createElement方法(見index.html),可渲染同樣的內容
<!-- <div id="virtual-dom">
<p>Virtual DOM</p>
<ul id="list">
<li class="item">Item 1</li>
<li class="item">Item 2</li>
<li class="item">Item 3</li>
</ul>
<div>Hello World</div>
</div> -->
<script type="module">
import { createElement } from "./create-element.js";
let elNode = createElement("div", { id: "virtual-dom", color: "red" }, [
createElement("p", ["Virtual DOM"]), // 沒有props
createElement("ul", { id: "list" }, [
createElement("li", { class: "item" }, ["Item 1"]),
createElement("li", { class: "item" }, ["Item 2"]),
createElement("li", { class: "item" }, ["Item 3"]),
]),
createElement("div", ["Hello World"]),
]);
let elRoot = elNode.render(); // 調用VNode原型上的render方法,創建相應節點
document.body.appendChild(elRoot); // 頁面可渲染與注掉相同的內容
</script>
比較兩顆虛擬 DOM 樹
- 假設上文渲染的內容,想要變成如下 html 結構
<div id="virtual-dom2">
<p>New Virtual DOM</p>
<ul id="list">
<li class="item" style="height: 30px">Item 1</li>
<li class="item">Item 2</li>
<li class="item">Item 3</li>
<li class="item">Item 4</li>
</ul>
<div>Hello World</div>
</div>
- 仍舊是先用虛擬 dom 表示該結構(見
index.html)
let elNodeNew = createElement("div", { id: "virtual-dom2" }, [
createElement("p", { color: "red" }, ["New Virtual DOM"]),
createElement("ul", { id: "list" }, [
createElement("li", { class: "item", style: "height: 30px" }, ["Item 1"]),
createElement("li", { class: "item" }, ["Item 2"]),
createElement("li", { class: "item" }, ["Item 3"]),
createElement("li", { class: "item" }, ["Item 4"]),
]),
createElement("div", {}, ["Hello World"]),
]);
VNode類追加count和key,key用作遍歷時的唯一標識,count用作後續比對(見vnode.js)
export default class VNode {
constructor(tagName, props, children) {
if (props instanceof Array) {
// 第二個參數是數組,説明傳的是children,即沒有傳props
children = props; // 把props賦給原本應是子節點的第三個參數
props = {}; // props被賦值為空對象
}
this.tagName = tagName;
this.props = props;
this.children = children;
// 保存key鍵:如果有屬性則保存key,否則返回undefined
this.key = props ? props.key : void 0;
let count = 0;
this.children.forEach((child, i) => {
// 如果是元素的實列的話
if (child instanceof VNode) {
count += child.count;
} else {
// 如果是文本節點的話,直接賦值
children[i] = "" + child;
}
count++; // 每遍歷children後,count都會+1
});
this.count = count;
}
render() {
// ...
}
}
/* elNode為例,追加後查看打印:
VNode {
tagName: 'div',
props: { id: 'virtual-dom' },
children: [
VNode { tagName: 'p', props: {}, children: ['Virtual DOM'], count: 1, key: undefined },
VNode {
tagName: 'ul',
props: { id: 'list' },
children: [
VNode { tagName: 'li', props: { class: 'item' }, children: ['Item 1'], count: 1, key: undefined },
VNode { tagName: 'li', props: { class: 'item' }, children: ['Item 2'], count: 1, key: undefined },
VNode { tagName: 'li', props: { class: 'item' }, children: ['Item 3'], count: 1, key: undefined },
],
count: 6,
key: undefined
},
VNode { tagName: 'div', props: {}, children: ['Hello World'], count: 1, key: undefined }
],
count: 11,
key: undefined
}
*/
比對elNode和elNodeNew
- 調用
diff()方法(見diff.js)
export function diff(oldTree, newTree) {
let index = 0; // 當前節點的標誌
let patches = {}; // 用來記錄每個節點差異的對象
deepWalk(oldTree, newTree, index, patches);
return patches;
}
-
核心方法
deepWalk(),對兩棵樹進行深度優先遍歷(見diff.js):- 如果節點被刪除,則無需操作
- 如果替換文本(肯定無 children),則記錄更新文字
-
如果標籤相同
- 如果屬性不同,則記錄更新屬性
- 比較子節點(如果新節點有
ignore屬性,則不需要比較),調用diffChildren()方法,比較子元素的變化
- 如果標籤不同,則記錄整體重置
- 前置 1:在
patch.js中設置不同的操作類型(patch.js)
let REPLACE = 0; // 整體重置 let REORDER = 1; // 重新排序 let PROPS = 2; // 更新屬性 let TEXT = 3; // 更新文字 patch.REPLACE = REPLACE; patch.REORDER = REORDER; patch.PROPS = PROPS; patch.TEXT = TEXT;- 前置 2:判斷新節點是否有
ignore屬性的方法isIgnoreChildren()
function isIgnoreChildren(node) { return node.props && node.props.hasOwnProperty("ignore"); }
import { patch } from "./patch.js";
function deepWalk(oldNode, newNode, index, patches) {
// console.log(oldNode, newNode);
let currentPatch = [];
if (newNode === null) {
// 節點被刪除掉(真正的DOM節點時,將刪除執行重新排序,所以不需要做任何事)
} else if (typeof oldNode === "string" && typeof newNode === "string") {
// 替換文本節點
if (newNode !== oldNode) {
currentPatch.push({ type: patch.TEXT, content: newNode }); // type為3,content為新節點文本內容
}
} else if (
oldNode.tagName === newNode.tagName &&
oldNode.key === newNode.key
) {
// 相同的節點,但是新舊節點的屬性不同的情況下 比較屬性
let propsPatches = diffProps(oldNode, newNode);
if (propsPatches) {
currentPatch.push({ type: patch.PROPS, props: propsPatches }); // type為2
}
// console.log(currentPatch);
// 比較子節點,如果新節點有'ignore'屬性,則不需要比較
if (!isIgnoreChildren(newNode)) {
diffChildren(
oldNode.children,
newNode.children,
index,
patches,
currentPatch
);
}
} else {
// 不同的節點,那麼新節點替換舊節點
currentPatch.push({ type: patch.REPLACE, node: newNode }); // type為0
}
// console.log(currentPatch);
if (currentPatch.length) {
patches[index] = currentPatch; // 把對應的currentPatch存儲到patches對象內中的對應項
}
// console.log(patches);
}
-
deepWalk()對兩顆樹進行比對後,如果節點的標籤相同,則還需調用diffChildren()比較子節點(見diff.js)- 新舊節點,採用
list-diff算法(見listDiff.js),根據key做比對,返回如{ moves: moves, children: children }的數據結構(有關list-diff算法可參見這篇詳解 →,本文不多做贅述) moves為需要操作的步驟,遍歷後記錄為重新排序- 遞歸,子節點繼續調用
deepWalk()方法
- 新舊節點,採用
function diffChildren(oldChildren, newChildren, index, patches, currentPatch) {
// console.log(oldChildren, newChildren, index);
let diffs = listDiff(oldChildren, newChildren, "key"); // 新舊節點按照字符串'key'來比較
console.log(diffs);
newChildren = diffs.children; // diffs.children同listDiff方法中的simulateList,即要操作的相似列表
if (diffs.moves.length) {
let recorderPatch = { type: patch.REORDER, moves: diffs.moves };
currentPatch.push(recorderPatch);
}
let leftNode = null;
let currentNodeIndex = index;
oldChildren.forEach((child, i) => {
let newChild = newChildren[i];
currentNodeIndex =
leftNode && leftNode.count
? currentNodeIndex + leftNode.count + 1 // 非首次遍歷時,leftNode為上一次遍歷的子節點
: currentNodeIndex + 1; // 首次遍歷時,leftNode為null,currentNodeIndex被賦值為1
deepWalk(child, newChild, currentNodeIndex, patches); // 遞歸遍歷,直至最內層
leftNode = child;
});
}
- 在頁面中調用
diff()方法,比對elNode和elNodeNew(見index.html),返回值即為從elNode變化到elNodeNew需要進行的完整操作
<script type="module">
import { createElement } from "./create-element.js";
import { diff } from "./diff.js";
// let elNode = ...
// let elNodeNew = ...
let elRoot = elNode.render(); // 調用VNode原型上的render方法,創建相應節點
document.body.appendChild(elRoot); // 頁面可渲染與注掉相同的內容
setTimeout(() => {
let patches = diff(elNode, elNodeNew);
console.log(patches);
/*
{
0: [{ props: {id: 'virtual-dom2', style: undefined}, type: 2 }],
1: [{ props: {color: 'red'}, type: 2 }],
2: [{ type: 3, content: 'New Virtual DOM' }],
3: [{
moves: [{
index: 3,
item: VNode{
children: ['Item 4'],
count: 1,
key: undefined,
props: {class: 'item'},
tagName: "li"
},
type: 1
}],
type: 1
}],
4: [{ props: {id: 'virtual-dom2', style: undefined}, type: 2 }],
}
*/
}, 1000);
</script>
對發生變化的數據進行更新
patch()方法,對elRoot(變化前的)和patches(調用diff()返回值)進行操作(見patch.js)
export function patch(node, patches) {
let walker = { index: 0 }; // 從key為0開始遍歷patches
deepWalk(node, walker, patches); // 調用patch.js裏的deepWalk方法,不是diff.js裏的
}
-
調用
deepWalk()方法,對elRoot的全部子節點進行遍歷和遞歸(見patch.js)walker.index初始為 0,每次遍歷加 1- 如果在
patches中有對應walker.index屬性的項,則調用applyPatches()針對當前節點進行相應操作 - 重點:
diff.js的index和patch.js的walker.index,都是針對elNode的每個節點逐一遍歷直至最內層,因此迴文patches裏的key與walker.index相對應,對當前遍歷到的node執行applyPatches()即可
function deepWalk(node, walker, patches) {
// console.log(node, walker, patches);
let currentPatches = patches[walker.index];
let len = node.childNodes ? node.childNodes.length : 0; // node.childNodes返回包含指定節點的子節點的集合,包括HTML節點、所有屬性、文本節點
// console.log(node.childNodes, len);
for (let i = 0; i < len; i++) {
let child = node.childNodes[i];
walker.index++;
deepWalk(child, walker, patches); // 遞歸遍歷,直至最內層(node.childNodes.length為0)
}
// console.log(currentPatches);
if (currentPatches) {
applyPatches(node, currentPatches); // 在patches中有對應的操作,則執行
}
}
applyPatches()方法會根據傳入的type類型,對節點進行相應操作(見patch.js)
function applyPatches(node, currentPatches) {
// console.log(node, currentPatches);
currentPatches.forEach((currentPatch) => {
switch (currentPatch.type) {
case REPLACE: // 整體重置
let newNode =
typeof currentPatch.node === "string"
? document.createTextNode(currentPatch.node) // 字符串節點
: currentPatch.node.render(); // dom節點
node.parentNode.replaceChild(newNode, node); // 替換子節點
break;
case REORDER: // 重新排序
reorderChildren(node, currentPatch.moves);
break;
case PROPS: // 更新屬性
setProps(node, currentPatch.props);
break;
case TEXT: // 更新文字
if (node.textContent) {
node.textContent = currentPatch.content;
} else {
// ie bug
node.nodeValue = currentPatch.content;
}
break;
default:
throw new Error("Unknow patch type" + currentPatch.type);
}
});
}
reorderChildren()方法對子節點進行排序(見patch.js)
function reorderChildren(node, moves) {
// console.log(node, moves);
let staticNodeList = Array.from(node.childNodes);
// console.log(staticNodeList);
let maps = {};
staticNodeList.forEach((node) => {
// 如果是元素節點
if (node.nodeType === 1) {
let key = node.getAttribute("key");
if (key) {
maps[key] = node;
}
}
});
moves.forEach((move) => {
let index = move.index;
if (move.type === 0) {
// 移除項
if (staticNodeList[index] === node.childNodes[index]) {
node.removeChild(node.childNodes[index]); // 移除該子節點
}
staticNodeList.splice(index, 1); // 從staticNodeList數組中移除
} else if (move.type === 1) {
// 插入項
let insertNode = maps[move.item.key]
? maps[move.item.key].cloneNode(true)
: typeof move.item === "object" // 插入節點對象
? move.item.render() // 直接渲染
: document.createTextNode(move.item); // 插入文本
// console.log(insertNode);
staticNodeList.splice(index, 0, insertNode); // 插入
node.insertBefore(insertNode, node.childNodes[index] || null);
}
});
}
setProps()方法設置屬性(見patch.js)
function setProps(node, props) {
// console.log(node, props);
for (let key in props) {
if (props[key] === void 0) {
node.removeAttribute(key); // 沒有屬性->移除屬性
} else {
let value = props[key];
utils.setAttr(node, key, value); // 有屬性->重新賦值
}
}
}
- 給屬性重新賦值時,需區分屬性為
style和value兩種情況,屬性為value時還需判斷標籤是否為文本框或文本域(見utils.js) utils.js為提供公用方法庫,為方便閲讀簡化代碼,本文解析時未使用源碼中的其他方法,不影響效果
let obj = {
setAttr: function (node, key, value) {
switch (key) {
case "style":
node.style.cssText = value; // 更新樣式
break;
case "value":
let tagName = node.tagName || "";
tagName = tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea") {
// 輸入框 或 文本域
node.value = value; // 更新綁定值
} else {
// 其餘
node.setAttribute(key, value); // 更新屬性
}
break;
default:
node.setAttribute(key, value); // 更新屬性
break;
}
},
};
export { obj as utils };
效果實現
- 在頁面中將
elRoot和patches傳給patch()並調用即可(見index.html)
<script type="module">
import { createElement } from "./create-element.js";
import { diff } from "./diff.js";
import { patch } from "./patch.js";
let elNode = createElement("div", { id: "virtual-dom", style: "color:red" }, [
createElement("p", ["Virtual DOM"]), // 沒有props
createElement("ul", { id: "list" }, [
createElement("li", { class: "item" }, ["Item 1"]),
createElement("li", { class: "item" }, ["Item 2"]),
createElement("li", { class: "item" }, ["Item 3"]),
]),
createElement("div", ["Hello World"]),
]);
let elRoot = elNode.render(); // 調用VNode原型上的render方法,創建相應節點
document.body.appendChild(elRoot);
let elNodeNew = createElement("div", { id: "virtual-dom2" }, [
createElement("p", { color: "red" }, ["New Virtual DOM"]),
createElement("ul", { id: "list" }, [
createElement("li", { class: "item", style: "height: 30px" }, ["Item 1"]),
createElement("li", { class: "item" }, ["Item 2"]),
createElement("li", { class: "item" }, ["Item 3"]),
createElement("li", { class: "item" }, ["Item 4"]),
]),
createElement("div", {}, ["Hello World"]),
]);
setTimeout(() => {
let patches = diff(elNode, elNodeNew);
console.log(patches);
patch(elRoot, patches); // 執行patch方法
}, 1000); // 1秒後,由elNode變化成elNodeNew,elRoot本身沒有重新掛載,實現虛擬dom更新
</script>
核心 dom 方法
- 虛擬 dom 只是節省了節點更新次數,但萬變不離其宗,最終還是要更新真實 dom 的,大體涉及到的方法如下
document.createTextNode(txt); // 創建文本節點
node.setAttribute(key, value); // 設置節點屬性
node.removeAttribute(key); // 移除節點屬性
parentNode.replaceChild(newNode, node); // 替換子節點
parentNode.removeChild(node); // 移除子節點
parentNode.insertBefore(node, existNode); // 追加子節點