時至今日,前端對於知識的考量是愈來愈有水平了,逼格高大上了css
各種框架你們已經能夠說不管是工做仍是平常中都已經或多或少的使用過了前端
曾經據說不少人被問到過虛擬DOM和DOM-diff算法是如何實現的,有沒有研究過?vue
想必問出此問題的也是高手高手之高高手了,不少人都半開玩笑的說:「面試造航母,工做擰螺絲」node
那麼,話很少說了,今天就讓咱們也來一塊兒研究研究這個東東react
好飯不怕晚,沉澱下來收收心!咱們雖然走的慢,可是卻從未停下腳步git
首先神奇不神奇的咱們先不去關注,先來簡單說說何爲虛擬DOMgithub
虛擬DOM簡而言之就是,用JS去按照DOM結構來實現的樹形結構對象,你也能夠叫作DOM對象面試
好了,一句話就把這麼偉大的東西給解釋了,那麼再也不耽誤時間了,趕忙進入主環節吧算法
固然,這裏還有整個項目的地址方便查看npm
在親自上陣以前,咱們讓糧草先行,先發個圖,來看一下整個目錄結構是什麼樣子的
// 全局安裝
npm i create-react-app -g
// 生成項目
create-react-app dom-diff
// 進入項目目錄
cd dom-diff
// 編譯
npm run start
複製代碼
如今咱們開始正式寫吧,從建立虛擬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文件入手來看看是否成功吧
在主入口文件裏,咱們主要作的操做就是來建立一個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
下面來看一下打印出來的虛擬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
};
複製代碼
既然寫完了,那就趕快來看當作果吧
再次回到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-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算法了,因此你們先不要盲動,不要盲動,且聽風吟,讓我一一道來
根據這些規則,咱們再來看一下diff代碼中的walk方法這位關鍵先生
walk方法都作了什麼?
if (!newNode) {
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 > 0) {
// 將元素和補丁對應起來,放到大補丁包中
patches[index] = current;
}
複製代碼
以上就是關於diff算法的分析過程了,沒太明白的話不要緊,再反覆看幾遍試試,意外老是不期而遇的
diff已經完事了,那麼最後一步就是你們所熟知的打補丁了
補丁要怎麼打?那麼讓久違的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;
複製代碼
看完代碼還須要再來簡單的分析一下
屬性ATTR for in去遍歷attrs對象,當前的key值若是存在,就直接設置屬性setAttr; 若是不存在對應的key值那就直接刪除這個key鍵的屬性
文字TEXT 直接將補丁的text賦值給node節點的textContent便可
替換REPLACE 新節點替換老節點,須要先判斷新節點是否是Element的實例,是的話調用render方法渲染新節點;
不是的話就代表新節點是個文本節點,直接建立一個文本節點就OK了。
以後再經過調用父級parentNode的replaceChild方法替換爲新的節點
刪除REMOVE 直接調用父級的removeChild方法刪除該節點
好了,一切都安靜下來了。讓咱們迴歸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被更新了,以下圖
咱們來梳理一下整個DOM-diff的過程:
行了,就這四句話吧,說多了就有點多此一舉了。很久沒有寫文章了,很感謝小夥伴們的觀看,辛苦各位了,886
參考: