核心原理&源碼
Diff 算法
diff 算法的進化
關於 diff 算法的最經典的就是 Matt Esch 的 virtual-dom,以及 snabbdom(被整合進 vue 2.0中)。
最開始出現的是 virtual-dom 這個庫,是大家好奇 React 爲什麼這麼快而搞鼓出來的。它的實現是非常學院風格,通過深度優先搜索與 in-order tree 來實現高效的 diff 。
然後是 cito.js 的橫空出世,它對今後所有虛擬 DOM 的算法都有重大影響。它採用兩端同時進行比較的算法,將 diff 速度拉高到幾個層次。
緊隨其後的是 kivi.js,在 cito.js 的基出提出兩項優化方案,使用 key 實現移動追蹤以及及基於 key 的最長自增子序列算法應用(算法複雜度 爲O(n^2))。
但這樣的 diff 算法太過複雜了,於是後來者 snabbdom 將 kivi.js 進行簡化,去掉編輯長度矩離算法,調整兩端比較算法。速度略有損失,但可讀性大大提高。再之後,就是著名的vue2.0 把sanbbdom整個庫整合掉了。
下面我們就來講講這幾個虛擬 DOM 庫 diff 算法的具體實現:
virtual-dom
virtual-dom 作爲虛擬 DOM 開天闢地的作品,採用了對 DOM 樹進行了深度優先的遍歷的方法。
體現到代碼上:(可以看成僞代碼)
<script>
function diff(oldTree, newTree) {
let index = 0; // 當前節點的標誌(樹形層數)
let patches = [] // 用來記錄每個節點差異的對象
dfsWalk(oldTree, newTree, patches, index); // 進行深度優先遍歷
return patches;
}
// 對兩棵樹進行深度優先遍歷
function dfsWalk(oldNode, newNode, patches, index) {
if (newNode === oldNode) {
return
}
const patch = { type: 'update', vNode: newNode }
const oldChildren = oldNode.children;
const newChildren = newNode.children;
const oldLen = oldChildren.length;
const newLen = newChildren.length;
const len = oldLen > newLen ? oldLen : newLen // 取長的
// 找到對應的子節點進行比較
for (let i = 0; i < len; i++) {
const oldChild = oldChildren[i];
const newChild = newChildren[i];
index++;
// 相同節點進行比對
dfsWalk(oldChild, newChild, patches, index)
if (isArray(oldChild.children)) {
index += oldChild.children.length
}
}
if (patch) {
patches[index] = patch
}
}
</script>
VDOM 節點的對比
上面代碼只是對 VDOM 進行了簡單的深度優先遍歷,在遍歷中,還需要對每個 VDOM 進行一些對比,具體分爲以下幾種情況:
- 舊節點不存在,插入新節點;新節點不存在,刪除舊節點
- 新舊節點如果都是 VNode,且新舊節點 tag 相同
- 對比新舊節點的屬性
- 對比新舊節點的子節點差異,通過 key 值進行重排序,key 值相同節點繼續向下遍歷
- 新舊節點如果都是 VText,判斷兩者文本是否發生變化
- 其他情況直接用新節點替代舊節點
詳細代碼加詳細註釋
<script>
function diff(oldTree, newTree) {
let index = 0; // 當前節點的標誌(樹形層數)
let patches = [] // 用來記錄每個節點差異的對象
dfsWalk(oldTree, newTree, patches, index); // 進行深度優先遍歷
return patches;
}
import { isVNode, isVText, isArray } from './utils/type.js'
// 對兩棵樹進行深度優先遍歷
function dfsWalk(oldNode, newNode, patches, index) {
if (newNode === oldNode) {
return
}
let patch = patches[index];
if (!oldNode) {
// 舊節點不存在,直接插入
// appendPatch 是用來存節點之間差異的
patch = appendPatch(patch, {
type: PATCH.INSERT,
vNode: newNode,
})
} else if (!newNode) {
// 新節點不存在,刪除舊節點
patch = appendPatch(patch, {
type: PATCH.REMOVE,
vNode: null
})
} else if (isVNode(newNode)) { // 新節點是 VNode,就相當於前面寫的Element
if (isVNode(oldNode)) { // 舊節點也是 VNode,就要比較這兩個節點的 tagName是否一致
// 新舊節點 tagName 一致,並且 key 也一致。
if (newNode.tagName === oldNode.tagName && newNode.key === oldNode.key) {
// 新老節點屬性的對比, diffProps方法就是對新舊節點自身屬性的對比
// 屬性如果有差異,propsPatch的長度 > 0,且差異存在 propsPatch中
const propsPatch = diffProps(newNode.props, oldNode.props)
if (propsPatch && propsPatch.length > 0) {
patch = appendPatch(patch, {
type: PATCH.PROPS, // props這個表示是節點的屬性差異
patches: propsPatch // 這裏存的是差異的內容
})
}
// 新老節點子節點的對比
// diffChildren 方法是專門來對比子節點的。
patch = diffChildren(oldNode, newNode, patches, patch, index)
}
} else {
// 舊節點不是 VNode, 新節點替換舊節點
patch = appendPatch(patch, {
type: PATCH.REPLACE,
vNode: newNode
})
}
} else if (isVText(newNode)) { // 既然新節點不是 VNode,就判斷新節點是否是文本節點
// 舊節點不是文本節點
if (!isText(oldNode)) {
// 將舊節點替換成文本節點
patch = appendPatch(patch, {
type: PATCH.VTEXT,
vNode: newNode,
})
} else if (newNode.text !== oldNode.text) { // 判斷兩者內容是否相等
// 替換文本
patch = appendPatch(patch, {
type: PATCH.VTEXT,
vNode: newNode,
})
}
}
if (patch) {
// 將補丁放入對應位置
patches[index] = patch
}
}
</script>
屬性的對比
<script>
function diffProps(newProps, oldProps) {
const patches = [];
// 將新舊屬性都淺拷貝進 props
const props = Object.assign({}, newProps, oldProps)
// 將props對象的鍵轉換成數組
Object.keys(props).forEach(key => {
// 如果新屬性裏有這個鍵,就能獲取到這個鍵的屬性值
const newVal = newProps[key];
// 舊屬性也一樣
const oldVal = newProps[key];
// 新屬性這個鍵不存在
if (!newVal) {
// 那就直接用舊的
patches.push({
type: PATCH.REMOVE_PROP,
key,
value: oldVal,
})
}
// 舊的不存在或者新的不等於舊的
if (oldVal === undefined || newVal !== oldVal) {
patches.push({
type: PATCH.SET_PROP,
key,
value: newVal,
})
}
})
}
</script>
子節點的對比
這一部分可以說是 diff 算法中,變動最多的部分,因爲前面的部分,各個庫對比的方向基本一致,而關於子節點的對比,各個倉庫都在前者基礎上不斷得進行改進。
首先需要明白,爲什麼需要改進子節點的對比方式。如果我們直接按照深度優先遍歷的方式,一個個去對比子節點,子節點的順序發生改變,那麼就會導致 diff 算法認爲所有子節點都需要進行 replace,重新將所有子節點的虛擬 DOM 轉換成真實 DOM,這種操作是十分消耗性能的。
但是,如果我們能夠找到新舊虛擬 DOM 對應的位置,然後進行移動,那麼就能夠儘量減少 DOM 的操作。
virtual-dom 在一開始就進行了這方面的嘗試,對子節點添加 key 值,通過 key 值的對比,來判斷子節點是否進行了移動。通過 key 值對比子節點是否移動的模式,被各個庫沿用,這也就是爲什麼主流的視圖庫中,子節點如果缺失 key 值,會有 warning 的原因。
具體是怎麼對比的,我們先看代碼:
<script>
function diffChildren(oldNode, newNode, patches, patch, index) {
const oldChildren = oldNode.children;
// 新節點按照舊節點的順序重新排序
const sortedSet = sortChildren(oldChildren, newNode.children)
// 拿到新節點的子節點
const newChildren = sortedSet.children;
const oldLen = oldChildren.length;
const newLen = newChildren.length;
const len = oldLen > newLen ? oldLen : newLen
for (let i = 0; i < len; i++) {
let leftNode = oldChildren[i];
let rightNode = newChildren[i];
index++;
if (!leftNode) {
if (rightNode) {
// 舊節點不存在,新節點存在,進行插入操作
patch = appendPatch(patch, {
type: PATCH.INSERT,
vNode: rightNode,
})
}
} else {
// 相同節點進行比對
dfsWalk(leftNode, rightNode, patches, index)
}
if (isVNode(leftNode) && isArray(leftNode.children)) {
index += leftNode.children.length
}
}
if (sortedSet.moves) {
// 最後進行重新排序
patch = appendPatch(patch, {
type: PATCH.ORDER,
moves: sortedSet.moves,
})
}
return patch
}
</script>
這裏首先需要對新的子節點進行重排序,先進行相同節點的 diff ,最後把子節點按照新的子節點順序重新排列。
這裏有個較複雜的部分,就是對子節點的重新排序。