看懂這2篇文章 你就懂了
https://juejin.im/post/5c8e5e4951882545c109ae9c
https://www.zhihu.com/question/31809713/answer/53544875
Keep Moving
時至今日,前端對於知識的考量是越來越有水平了,逼格高大上了
各類框架大家已經可以說無論是工作還是日常中都已經或多或少的使用過了
曾經聽說很多人被問到過虛擬DOM和DOM-diff算法是如何實現的,有沒有研究過?
想必問出此問題的也是高手高手之高高手了,很多人都半開玩笑的說:“面試造航母,工作擰螺絲”
那麼,話不多說了,今天就讓我們也來一起研究研究這個東東
好飯不怕晚,沉澱下來收收心!我們雖然走的慢,但是卻從未停下腳步
神奇的虛擬DOM
首先神奇不神奇的我們先不去關注,先來簡單說說何爲虛擬DOM
虛擬DOM簡而言之就是,用JS去按照DOM結構來實現的樹形結構對象,你也可以叫做DOM對象
好了,一句話就把這麼偉大的東西給解釋了,那麼不再耽誤時間了,趕緊進入主環節吧
當然,這裏還有整個項目的地址方便查看
實現一下虛擬DOM
在親自上陣之前,我們讓糧草先行,先發個圖,來看一下整個目錄結構是什麼樣子的
這個目錄結構是用create-react-app腳手架直接生成的,也是爲了方便編譯調試
// 全局安裝
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)