讓虛擬DOM和DOM-diff不再成為你的絆腳石
時至今日,前端對於知識的考量是越來越有水平了,逼格高大上了
各類框架大家已經可以說無論是工作還是日常中都已經或多或少的使用過了
曾經聽說很多人被問到過虛擬DOM和DOM-diff演算法是如何實現的,有沒有研究過?
想必問出此問題的也是高手高手之高高手了,很多人都半開玩笑的說:“面試造航母,工作擰螺絲”
那麼,話不多說了,今天就讓我們也來一起研究研究這個東東
好飯不怕晚,沉澱下來收收心!我們雖然走的慢,但是卻從未停下腳步
神奇的虛擬DOM
首先神奇不神奇的我們先不去關注,先來簡單說說何為虛擬DOM
虛擬DOM簡而言之就是,用JS去按照DOM結構來實現的樹形結構物件,你也可以叫做 DOM物件
好了,一句話就把這麼偉大的東西給解釋了,那麼不再耽誤時間了,趕緊進入主環節吧
當然,這裡還有整個專案的 地址 方便檢視
實現一下虛擬DOM
在親自上陣之前,我們讓糧草先行,先發個圖,來看一下整個目錄結構是什麼樣子的

直接生成的,也是為了方便編譯除錯
// 全域性安裝 npm i create-react-app -g // 生成專案 create-react-app dom-diff // 進入專案目錄 cd dom-diff // 編譯 npm run start 複製程式碼
現在我們開始正式寫吧,從建立虛擬DOM及渲染DOM起步吧
建立虛擬DOM
在element.js檔案中要實現如何建立虛擬DOM以及將創建出來的虛擬DOM渲染成真實的DOM
首先實現一下如何建立虛擬DOM,看程式碼:
// element.js // 虛擬DOM元素的類,構建例項物件,用來描述DOM class Element { constructor(type, props, children) { this.type = type; this.props = props; this.children = children; } } // 建立虛擬DOM,返回虛擬節點(object) function createElement(type, props, children) { return new Element(type, props, children); } export { Element, createElement } 複製程式碼
寫好了方法,我們就從index.js檔案入手來看看是否成功吧
呼叫createElement方法
在主入口檔案裡,我們主要做的操作就是來建立一個DOM物件,渲染DOM以及通過diff後去打補丁更新DOM,不囉嗦了,直接看程式碼:
// index.js // 首先引入對應的方法來建立虛擬DOM import { createElement } from './element'; let virtualDom = createElement('ul', {class: 'list'}, [ createElement('li', {class: 'item'}, ['周杰倫']), createElement('li', {class: 'item'}, ['林俊杰']), createElement('li', {class: 'item'}, ['王力巨集']) ]); console.log(virtualDom); 複製程式碼
createElement方法也是vue和react用來建立虛擬DOM的方法,我們也叫這個名字,方便記憶。接收三個引數,分別是 type , props 和 children
- 引數分析
- type: 指定元素的標籤型別,如'li', 'div', 'a'等
- props: 表示指定元素身上的屬性,如class, style, 自定義屬性等
- children: 表示指定元素是否有子節點,引數以陣列的形式傳入
下面來看一下打印出來的虛擬DOM,如下圖

到目前為止,已經輕而易舉的實現了建立虛擬DOM。那麼,接下來進行下一步,將其渲染為真實的DOM,別猶豫,繼續回到element.js檔案中
渲染虛擬DOM
// element.js class Element { // 省略 } function createElement() { // 省略 } // render方法可以將虛擬DOM轉化成真實DOM function render(domObj) { // 根據type型別來建立對應的元素 let el = document.createElement(domObj.type); // 再去遍歷props屬性物件,然後給建立的元素el設定屬性 for (let key in domObj.props) { // 設定屬性的方法 setAttr(el, key, domObj.props[key]); } // 遍歷子節點 // 如果是虛擬DOM,就繼續遞迴渲染 // 不是就代表是文字節點,直接建立 domObj.children.forEach(child => { child = (child instanceof Element) ? render(child) : document.createTextNode(child); // 新增到對應元素內 el.appendChild(child); }); return el; } // 設定屬性 function setAttr(node, key, value) { switch(key) { case 'value': // node是一個input或者textarea就直接設定其value即可 if (node.tagName.toLowerCase() === 'input' || node.tagName.toLowerCase() === 'textarea') { node.value = value; } else { node.setAttribute(key, value); } break; case 'style': // 直接賦值行內樣式 node.style.cssText = value; break; default: node.setAttribute(key, value); break; } } // 將元素插入到頁面內 function renderDom(el, target) { target.appendChild(el); } export { Element, createElement, render, setAttr, renderDom }; 複製程式碼
既然寫完了,那就趕快來看看成果吧
呼叫render方法
再次回到index.js檔案中,修改為如下程式碼
// index.js // 引入createElement、render和renderDom方法 import { createElement, render, renderDom } from './element'; let virtualDom = createElement('ul', {class: 'list'}, [ createElement('li', {class: 'item'}, ['周杰倫']), createElement('li', {class: 'item'}, ['林俊杰']), createElement('li', {class: 'item'}, ['王力巨集']) ]); console.log(virtualDom); // +++ let el = render(virtualDom); // 渲染虛擬DOM得到真實的DOM結構 console.log(el); // 直接將DOM新增到頁面內 renderDom(el, document.getElementById('root')); // +++ 複製程式碼
通過呼叫render方法轉為真實DOM,並呼叫renderDom方法直接將DOM新增到了頁面內
下圖為列印後的結果:

截止目前,我們已經實現了虛擬DOM並進行了渲染真實DOM到頁面中。那麼接下來我們就有請DOM-diff隆重登場,來看一下這大有來頭的diff演算法是如何發光發熱的吧!
DOM-diff閃亮登場
說到DOM-diff那一定要清楚其存在的意義,給定任意兩棵樹,採用 先序深度優先遍歷 的演算法找到最少的轉換步驟
DOM-diff比較兩個虛擬DOM的區別,也就是在比較兩個物件的區別。
作用:根據兩個虛擬物件創建出補丁,描述改變的內容,將這個補丁用來更新DOM
已經瞭解到DOM-diff是幹嘛的了,那就沒什麼好說的了,繼續往下寫吧
// diff.js function diff(oldTree, newTree) { // 宣告變數patches用來存放補丁的物件 let patches = {}; // 第一次比較應該是樹的第0個索引 let index = 0; // 遞迴樹 比較後的結果放到補丁裡 walk(oldTree, newTree, index, patches); return patches; } function walk(oldNode, newNode, index, patches) { // 每個元素都有一個補丁 let current = []; if (!newNode) { // rule1 current.push({ type: 'REMOVE', index }); } else if (isString(oldNode) && isString(newNode)) { // 判斷文字是否一致 if (oldNode !== newNode) { current.push({ type: 'TEXT', text: newNode }); } } else if (oldNode.type === newNode.type) { // 比較屬性是否有更改 let attr = diffAttr(oldNode.props, newNode.props); if (Object.keys(attr).length > 0) { current.push({ type: 'ATTR', attr }); } // 如果有子節點,遍歷子節點 diffChildren(oldNode.children, newNode.children, patches); } else {// 說明節點被替換了 current.push({ type: 'REPLACE', newNode}); } // 當前元素確實有補丁存在 if (current.length) { // 將元素和補丁對應起來,放到大補丁包中 patches[index] = current; } } function isString(obj) { return typeof obj === 'string'; } function diffAttr(oldAttrs, newAttrs) { let patch = {}; // 判斷老的屬性中和新的屬性的關係 for (let key in oldAttrs) { if (oldAttrs[key] !== newAttrs[key]) { patch[key] = newAttrs[key]; // 有可能還是undefined } } for (let key in newAttrs) { // 老節點沒有新節點的屬性 if (!oldAttrs.hasOwnProperty(key)) { patch[key] = newAttrs[key]; } } return patch; } // 所有都基於一個序號來實現 let num = 0; function diffChildren(oldChildren, newChildren, patches) { // 比較老的第一個和新的第一個 oldChildren.forEach((child, index) => { walk(child, newChildren[index], ++num, patches); }); } // 預設匯出 export default diff; 複製程式碼
程式碼雖然又臭又長,但是這些程式碼就讓我們實現了diff演算法了,所以大家先不要盲動,不要盲動,且聽風吟,讓我一一道來
比較規則
- 新的DOM節點不存在{type: 'REMOVE', index}
- 文字的變化{type: 'TEXT', text: 1}
- 當節點型別相同時,去看一下屬性是否相同,產生一個屬性的補丁包{type: 'ATTR', attr: {class: 'list-group'}}
- 節點型別不相同,直接採用替換模式{type: 'REPLACE', newNode}
根據這些 規則 ,我們再來看一下diff程式碼中的 walk方法 這位關鍵先生
walk方法都做了什麼?
- 每個元素都有一個補丁,所以需要建立一個放當前補丁的陣列
- 如果沒有new節點的話,就直接將type為REMOVE的型別放到當前補丁裡
if (!newNode) { current.push({ type: 'REMOVE', index }); } 複製程式碼
- 如果新老節點是文字的話,判斷一下文字是否一致,再指定型別TEXT並把新節點放到當前補丁
else if (isString(oldNode) && isString(newNode)) { if (oldNode !== newNode) { current.push({ type: 'TEXT', text: newNode }); } } 複製程式碼
- 如果新老節點的型別相同,那麼就來比較一下他們的屬性props
- 屬性比較
- diffAttr
- 去比較新老Attr是否相同
- 把newAttr的鍵值對賦給patch物件上並返回此物件
- diffAttr
- 然後如果有子節點的話就再比較一下子節點的不同,再調一次walk
- diffChildren
- 遍歷oldChildren,然後遞迴呼叫walk再通過child和newChildren[index]去diff
- diffChildren
- 屬性比較
else if (oldNode.type === newNode.type) { // 比較屬性是否有更改 let attr = diffAttr(oldNode.props, newNode.props); if (Object.keys(attr).length > 0) { current.push({ type: 'ATTR', attr }); } // 如果有子節點,遍歷子節點 diffChildren(oldNode.children, newNode.children, patches); } 複製程式碼
- 上面三個如果都沒有發生的話,那就表示節點單純的被替換了,type為REPLACE,直接用newNode替換即可
else { current.push({ type: 'REPLACE', newNode}); } 複製程式碼
- 當前補丁裡確實有值的情況,就將對應的補丁放進大補丁包裡
if (current.length > 0) { // 將元素和補丁對應起來,放到大補丁包中 patches[index] = current; } 複製程式碼
以上就是關於diff演算法的分析過程了,沒太明白的話沒關係,再反覆看幾遍試試,意外總是不期而遇的
diff已經完事了,那麼最後一步就是大家所熟知的 打補丁 了
補丁要怎麼打?那麼讓久違的patch出來吧
patch補丁更新
打補丁需要傳入兩個引數,一個是要打補丁的元素,另一個就是所要打的補丁了,那麼直接看程式碼
import { Element, render, setAttr } from './element'; let allPatches; let index = 0;// 預設哪個需要打補丁 function patch(node, patches) { allPatches = patches; // 給某個元素打補丁 walk(node); } function walk(node) { let current = allPatches[index++]; let childNodes = node.childNodes; // 先序深度,繼續遍歷遞迴子節點 childNodes.forEach(child => walk(child)); if (current) { doPatch(node, current); // 打上補丁 } } function doPatch(node, patches) { // 遍歷所有打過的補丁 patches.forEach(patch => { switch (patch.type) { case 'ATTR': for (let key in patch.attr) { let value = patch.attr[key]; if (value) { setAttr(node, key, value); } else { node.removeAttribute(key); } } break; case 'TEXT': node.textContent = patch.text; break; case 'REPLACE': let newNode = patch.newNode; newNode = (newNode instanceof Element) ? render(newNode) : document.createTextNode(newNode); node.parentNode.replaceChild(newNode, node); break; case 'REMOVE': node.parentNode.removeChild(node); break; default: break; } }); } export default patch; 複製程式碼
看完程式碼還需要再來簡單的分析一下
patch做了什麼?
- 用一個變數來得到傳遞過來的所有補丁allPatches
- patch方法接收兩個引數(node, patches)
- 在方法內部呼叫walk方法,給某個元素打上補丁
- walk方法裡獲取所有的子節點
- 給子節點也進行先序深度優先遍歷,遞迴walk
- 如果當前的補丁是存在的,那麼就對其打補丁(doPatch)
- doPatch打補丁方法會根據傳遞的patches進行遍歷
- 判斷補丁的型別來進行不同的操作
-
屬性ATTR for in去遍歷attrs物件,當前的key值如果存在,就直接設定屬性setAttr; 如果不存在對應的key值那就直接刪除這個key鍵的屬性
-
文字TEXT 直接將補丁的text賦值給node節點的textContent即可
-
替換REPLACE 新節點替換老節點,需要先判斷新節點是不是Element的例項,是的話呼叫render方法渲染新節點;
不是的話就表明新節點是個文字節點,直接建立一個文字節點就OK了。
之後再通過呼叫父級parentNode的replaceChild方法替換為新的節點
-
刪除REMOVE 直接呼叫父級的removeChild方法刪除該節點
-
- 判斷補丁的型別來進行不同的操作
- 將patch方法預設匯出方便呼叫
好了,一切都安靜下來了。讓我們迴歸index.js檔案中,去呼叫一下diff和patch這兩個重要方法,看看奇蹟會不會發生吧
迴歸
// index.js import { createElement, render, renderDom } from './element'; // +++ 引入diff和patch方法 import diff from './diff'; import patch from './patch'; // +++ let virtualDom = createElement('ul', {class: 'list'}, [ createElement('li', {class: 'item'}, ['周杰倫']), createElement('li', {class: 'item'}, ['林俊杰']), createElement('li', {class: 'item'}, ['王力巨集']) ]); let el = render(virtualDom); renderDom(el, window.root); // +++ // 建立另一個新的虛擬DOM let virtualDom2 = createElement('ul', {class: 'list-group'}, [ createElement('li', {class: 'item active'}, ['七里香']), createElement('li', {class: 'item'}, ['一千年以後']), createElement('li', {class: 'item'}, ['需要人陪']) ]); // diff一下兩個不同的虛擬DOM let patches = diff(virtualDom, virtualDom2); console.log(patches); // 將變化打補丁,更新到el patch(el, patches); // +++ 複製程式碼
將修改後的程式碼儲存,會在瀏覽器裡看到DOM被更新了,如下圖

到這裡就finish了,內容有些多,可能不是很好的消耗,不過沒關係,就讓我用最後幾句話來總結一下實現的整個過程吧
四句話
我們來梳理一下整個 DOM-diff 的過程:
- 用JS物件模擬DOM(虛擬DOM)
- 把此虛擬DOM轉成真實DOM並插入頁面中(render)
- 如果有事件發生修改了虛擬DOM,比較兩棵虛擬DOM樹的差異,得到差異物件(diff)
- 把差異物件應用到真正的DOM樹上(patch)
行了,就這四句話吧,說多了就有點畫蛇添足了。好久沒有寫文章了,很感謝小夥伴們的觀看,辛苦各位了,886