這一節,依然是深刻剖析Vue源碼系列,上幾節內容介紹了
Virtual DOM
是Vue在渲染機制上作的優化,而渲染的核心在於數據變化時,如何高效的更新節點,這就是diff算法。因爲源碼中關於diff
算法部分流程複雜,直接剖析每一個流程不易於理解,因此這一節咱們換一個思路,參考源碼來手動實現一個簡易版的diff
算法。javascript
以前講到Vue
在渲染機制的優化上,引入了Virtual DOM
的概念,利用Virtual DOM
描述一個真實的DOM
,本質上是在JS
和真實DOM
之間架起了一層緩衝層。當咱們經過大量的JS
運算,並將最終結果反應到瀏覽器進行渲染時,Virtual DOM
能夠將多個改動合併成一個批量的操做,從而減小 dom
重排的次數,進而縮短了生成渲染樹和繪製節點所花的時間,達到渲染優化的目的。以前的章節,咱們簡單的介紹了Vue
中Vnode
的概念,以及建立Vnode
到渲染Vnode
再到真實DOM
的過程。若是有忘記流程的,能夠參考前面的章節分析。html
**從render
函數到建立虛擬DOM
,再到渲染真實節點,這一過程是完整的,也是容易理解的。然而引入虛擬DOM
的核心不在這裏,而在於當數據發生變化時,如何最優化數據變更到視圖更新的過程。這一個過程纔是Vnode
更新視圖的核心,也就是常說的diff
算法。**下面跟着我來實現一個簡易版的diff
算法java
代碼編寫過程會遇到不少基本類型的判斷,第一步須要先將這些方法封裝。node
class Util {
constructor() {}
// 檢測基礎類型
_isPrimitive(value) {
return (typeof value === 'string' || typeof value === 'number' || typeof value === 'symbol' || typeof value === 'boolean')
}
// 判斷值不爲空
_isDef(v) {
return v !== undefined && v !== null
}
}
// 工具類的使用
const util = new Util()
複製代碼
Vnode
這個類在以前章節已經分析過源碼,本質上是用一個對象去描述一個真實的DOM
元素,簡易版關注點在於元素的tag
標籤,元素的屬性集合data
,元素的子節點children
,text
爲元素的文本節點,簡單的描述類以下:算法
class VNode {
constructor(tag, data, children) {
this.tag = tag;
this.data = data;
this.children = children;
this.elm = ''
// text屬性用於標誌Vnode節點沒有其餘子節點,只有純文本
this.text = util._isPrimitive(this.children) ? this.children : ''
}
}
複製代碼
接下來須要建立另外一個類模擬將render
函數轉換爲Vnode
,並將Vnode
渲染爲真實DOM
的過程,咱們將這個類定義爲Vn
,Vn
具備兩個基本的方法createVnode, createElement
, 分別實現建立虛擬Vnode
,和建立真實DOM
的過程。瀏覽器
createVnode
模擬Vue
中render
函數的實現思路,目的是將數據轉換爲虛擬的Vnode
,先看具體的使用和定義。app
// index.html
<script src="diff.js">
<script> // 建立Vnode let createVnode = function() { let _c = vn.createVnode; return _c('div', { attrs: { id: 'test' } }, arr.map(a => _c(a.tag, {}, a.text))) } // 元素內容結構 let arr = [{ tag: 'i', text: 2 }, { tag: 'span', text: 3 }, { tag: 'strong', text: 4 }] </script>
// diff.js
(function(global) {
class Vn {
constructor() {}
// 建立虛擬Vnode
createVnode(tag, data, children) {
return new VNode(tag, data, children)
}
}
global.vn = new Vn()
}(this))
複製代碼
這是一個完整的Vnode
對象,咱們已經能夠用這個對象來簡單的描述一個DOM
節點,而createElement
就是將這個對象對應到真實節點的過程。最終咱們但願的結果是這樣的。dom
Vnode對象函數
渲染結果工具
渲染真實DOM
的過程就是遍歷Vnode
對象,遞歸建立真實節點的過程,這個不是本文的重點,因此咱們能夠粗糙的實現。
class Vn {
createElement(vnode, options) {
let el = options.el;
if(!el || !document.querySelector(el)) return console.error('沒法找到根節點')
let _createElement = vnode => {
const { tag, data, children } = vnode;
const ele = document.createElement(tag);
// 添加屬性
this.setAttr(ele, data);
// 簡單的文本節點,只要建立文本節點便可
if (util._isPrimitive(children)) {
const testEle = document.createTextNode(children);
ele.appendChild(testEle)
} else {
// 複雜的子節點須要遍歷子節點遞歸建立節點。
children.map(c => ele.appendChild(_createElement(c)))
}
return ele
}
document.querySelector(el).appendChild(_createElement(vnode))
}
}
複製代碼
setAttr
是爲節點設置屬性的方法,利用DOM
原生的setAttribute
爲每一個節點設置屬性值。
class Vn {
setAttr(el, data) {
if (!el) return
const attrs = data.attrs;
if (!attrs) return;
Object.keys(attrs).forEach(a => {
el.setAttribute(a, attrs[a]);
})
}
}
複製代碼
至此一個簡單的 **數據 -> Virtual DOM
=> 真實DOM
**的模型搭建成功,這也是數據變化、比較、更新的基礎。
更新組件的過程首先是響應式數據發生了變化,數據頻繁的修改若是直接渲染到真實DOM
上會引發整個DOM
樹的重繪和重排,頻繁的重繪和重排是極其消耗性能的。如何優化這一渲染過程,Vue
源碼中給出了兩個具體的思路,其中一個是在介紹響應式系統時提到的將屢次修改推到一個隊列中,在下一個tick
去執行視圖更新,另外一個就是接下來要着重介紹的diff
算法,將須要修改的數據進行比較,並只渲染必要的DOM
。
數據的改變最終會致使節點的改變,因此diff
算法的核心在於在儘量小變更的前提下找到須要更新的節點,直接調用原生相關DOM
方法修改視圖。不論是真實DOM
仍是前面建立的Virtual DOM
,均可以理解爲一顆DOM
樹,算法比較節點不一樣時,只會進行同層節點的比較,不會跨層進行比較,這也大大減小了算法複雜度。
在以前的基礎上,咱們實現一個思路,1秒以後數據發生改變。
// index.html
setTimeout(function() {
arr = [{
tag: 'span',
text: 1
},{
tag: 'strong',
text: 2
},{
tag: 'i',
text: 3
},{
tag: 'i',
text: 4
}]
// newVnode 表示改變後新的Vnode樹
const newVnode = createVnode();
// diffVnode會比較新舊Vnode樹,並完成視圖更新
vn.diffVnode(newVnode, preVnode);
})
複製代碼
diffVnode
的邏輯,會對比新舊節點的不一樣,並完成視圖渲染更新
class Vn {
···
diffVnode(nVnode, oVnode) {
if (!this._sameVnode(nVnode, oVnode)) {
// 直接更新根節點及全部子節點
return ***
}
this.generateElm(vonde);
this.patchVnode(nVnode, oVnode);
}
}
複製代碼
新舊節點的對比是算法的第一步,若是新舊節點的根節點不是同一個節點,則直接替換節點。這聽從上面提到的原則,只進行同層節點的比較,節點不一致,直接用新節點及其子節點替換舊節點。爲了理解方便,咱們假定節點相同的判斷是tag
標籤是否一致(實際源碼要複雜)。
class Vn {
_sameVnode(n, o) {
return n.tag === o.tag;
}
}
複製代碼
generateElm
的做用是跟蹤每一個節點實際的真實節點,方便在對比虛擬節點後實時更新真實DOM
節點。雖然Vue
源碼中作法不一樣,可是這不是分析diff
的重點。
class Vn {
generateElm(vnode) {
const traverseTree = (v, parentEl) => {
let children = v.children;
if(Array.isArray(children)) {
children.forEach((c, i) => {
c.elm = parentEl.childNodes[i];
traverseTree(c, c.elm)
})
}
}
traverseTree(vnode, this.el);
}
}
複製代碼
執行generateElm
方法後,咱們能夠在舊節點的Vnode
中跟蹤到每一個Virtual DOM
的真實節點信息。
patchVnode
是新舊Vnode
對比的核心方法,對比的邏輯以下。
代碼邏輯以下:
class Vn {
patchVnode(nVnode, oVnode) {
if(nVnode.text && nVnode.text !== oVnode) {
// 當前真實dom元素
let ele = oVnode.elm
// 子節點爲文本節點
ele.textContent = nVnode.text;
} else {
const oldCh = oVnode.children;
const newCh = nVnode.children;
// 新舊節點都存在。對比子節點
if (util._isDef(oldCh) && util._isDef(newCh)) {
this.updateChildren(ele, newCh, oldCh)
} else if (util._isDef(oldCh)) {
// 新節點沒有子節點
} else {
// 老節點沒有子節點
}
}
}
}
複製代碼
上述例子在patchVnode
過程當中,新舊子節點都存在,因此會走updateChildren
分支。
子節點的對比,咱們經過文字和畫圖的形式分析,經過圖解的形式能夠很清晰看到diff
算法的巧妙之處。
大體邏輯是:
oldStartIndex
,截至位置爲oldEndIndex
,新節點的起始位置爲newStartIndex
,截至位置爲newEndIndex
。children
的起始位置的元素兩兩對比,順序是newStartVnode, oldStartVnode
; newEndVnode, oldEndVnode
;newEndVnode, oldStartVnode
;newStartIndex, oldEndIndex
newStartVnode, oldStartVnode
節點相同,執行一次patchVnode
過程,也就是遞歸對比相應子節點,並替換節點的過程。oldStartIndex,newStartIndex
都像右移動一位。newEndVnode, oldEndVnode
節點相同,執行一次patchVnode
過程,遞歸對比相應子節點,並替換節點。oldEndIndex, newEndIndex
都像左移動一位。newEndVnode, oldStartVnode
節點相同,執行一次patchVnode
過程,並將舊的oldStartVnode
移動到尾部,oldStartIndex
右移一味,newEndIndex
左移一位。newStartIndex, oldEndIndex
節點相同,執行一次patchVnode
過程,並將舊的oldEndVnode
移動到頭部,oldEndIndex
左移一味,newStartIndex
右移一位。newStartVnode
執行patchVnode
過程。oldStartIndex
不斷逼近oldEndIndex
,newStartIndex
不斷逼近newEndIndex
。當oldEndIndex <= oldStartIndex
說明舊節點已經遍歷完了,此時只要批量增長新節點便可。當newEndIndex <= newStartIndex
說明舊節點還有剩下,此時只要批量刪除舊節點便可。結合前面的例子:
第一步:
第二步:
第三步:
第三步:
第四步:
根據這些步驟,代碼實現以下:
class Vn {
updateChildren(el, newCh, oldCh) {
// 新children開始標誌
let newStartIndex = 0;
// 舊children開始標誌
let oldStartIndex = 0;
// 新children結束標誌
let newEndIndex = newCh.length - 1;
// 舊children結束標誌
let oldEndIndex = oldCh.length - 1;
let oldKeyToId;
let idxInOld;
let newStartVnode = newCh[newStartIndex];
let oldStartVnode = oldCh[oldStartIndex];
let newEndVnode = newCh[newEndIndex];
let oldEndVnode = oldCh[oldEndIndex];
// 遍歷結束條件
while (newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
// 新children開始節點和舊開始節點相同
if (this._sameVnode(newStartVnode, oldStartVnode)) {
this.patchVnode(newCh[newStartIndex], oldCh[oldStartIndex]);
newStartVnode = newCh[++newStartIndex];
oldStartVnode = oldCh[++oldStartIndex]
} else if (this._sameVnode(newEndVnode, oldEndVnode)) {
// 新childre結束節點和舊結束節點相同
this.patchVnode(newCh[newEndIndex], oldCh[oldEndIndex])
oldEndVnode = oldCh[--oldEndIndex];
newEndVnode = newCh[--newEndIndex]
} else if (this._sameVnode(newEndVnode, oldStartVnode)) {
// 新childre結束節點和舊開始節點相同
this.patchVnode(newCh[newEndIndex], oldCh[oldStartIndex])
// 舊的oldStartVnode移動到尾部
el.insertBefore(oldCh[oldStartIndex].elm, null);
oldStartVnode = oldCh[++oldStartIndex];
newEndVnode = newCh[--newEndIndex];
} else if (this._sameVnode(newStartVnode, oldEndVnode)) {
// 新children開始節點和舊結束節點相同
this.patchVnode(newCh[newStartIndex], oldCh[oldEndIndex]);
el.insertBefore(oldCh[oldEndIndex].elm, oldCh[oldStartIndex].elm);
oldEndVnode = oldCh[--oldEndIndex];
newStartVnode = newCh[++newStartIndex];
} else {
// 都不符合的處理,查找新節點中與對比舊節點相同的vnode
this.findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
}
}
// 新節點比舊節點多,批量增長節點
if(oldEndIndex <= oldStartIndex) {
for (let i = newStartIndex; i <= newEndIndex; i++) {
// 批量增長節點
this.createElm(oldCh[oldEndIndex].elm, newCh[i])
}
}
}
createElm(el, vnode) {
let tag = vnode.tag;
const ele = document.createElement(tag);
this._setAttrs(ele, vnode.data);
const testEle = document.createTextNode(vnode.children);
ele.appendChild(testEle)
el.parentNode.insertBefore(ele, el.nextSibling)
}
// 查找匹配值
findIdxInOld(newStartVnode, oldCh, start, end) {
for (var i = start; i < end; i++) {
var c = oldCh[i];
if (util.isDef(c) && this.sameVnode(newStartVnode, c)) { return i }
}
}
}
複製代碼
前面有個分支,當四種比較節點都找不到匹配時,會調用findIdxInOld
找到舊節點中和新的比較節點一致的節點。節點搜索在數量級較大時是緩慢的。查看Vue
的源碼,發現它在這一個環節作了優化,也就是咱們常常在編寫列表時被要求加入的惟一屬性key,有了這個惟一的標誌位,咱們能夠對舊節點創建簡單的字典查詢,只要有key
值即可以方便的搜索到符合要求的舊節點。修改代碼:
class Vn {
updateChildren() {
···
} else {
// 都不符合的處理,查找新節點中與對比舊節點相同的vnode
if (!oldKeyToId) oldKeyToId = this.createKeyMap(oldCh, oldStartIndex, oldEndIndex);
idxInOld = util._isDef(newStartVnode.key) ? oldKeyToId[newStartVnode.key] : this.findIdxInOld(newStartVnode, oldCh, oldStartIndex, oldEndIndex);
// 後續操做
}
}
// 創建字典
createKeyMap(oldCh, start, old) {
const map = {};
for(let i = start; i < old; i++) {
if(oldCh.key) map[key] = i;
}
return map;
}
}
複製代碼
最後咱們思考一個問題,Virtual DOM
的重繪性能真的比單純的innerHTML
要好嗎,其實並非這樣的,做者的解釋
innerHTML: render html string O(template size) +
從新建立全部DOM
元素O(DOM size)
Virtual DOM: render Virtual DOM + diff O(template size) +
必要的DOM
更新O(DOM change)
Virtual DOM render + diff
顯然比渲染 html 字符串要慢,可是!它依然是純 js 層面的計算,比起後面的DOM
操做來講,依然便宜了太多。能夠看到,innerHTML
的總計算量不論是js
計算仍是DOM
操做都是和整個界面的大小相關,但Virtual DOM
的計算量裏面,只有js
計算和界面大小相關,DOM 操做是和數據的變更量相關的。