前言
當我們操作Dom其實是一件非常耗效能的事,每個元素都涵蓋了許多的屬性,因為瀏覽器的標準就把 DOM 設計的非常複雜。而Virtual Dom就是用一個原生的JS物件去描述一個DOM節點,即VNode,所以它比建立一個真實的Dom元素所產生代價要小得多。而我們主流的框架React和Vue正是採用了這種做法,那我們來看下如何實現一個簡單的Virtual Dom。完整程式碼GitHub。喜歡的話希望點個小星星哦 ^_^~~~
核心
- 用 JavaScript 物件結構表示 DOM 樹的結構;然後用這個樹構建一個真正的 DOM 樹
- 當狀態變更的時候,重新構造一棵新的物件樹。然後用新的樹和舊的樹進行比較,記錄兩棵樹差異
- 把2所記錄的差異應用到步驟1所構建的真正的DOM樹上,檢視就更新了
構建vDOM
首先我們需要構建vDom, 用js物件來描述真正的dom tree,構建好了vDom之後就需要將其render到我們的頁面上了
// createElement.js
// give some default value.
export default (tagName, {attrs = {}, children = []} = {}) => {
return {
tagName,
attrs,
children
}
}
// main.js
import createElement from './vdom/createElement'
const createVApp = (count) => createElement('div', {
attrs: {
id: 'app',
dataCount: count
},
children: [
createElement('input'), // dom重繪使得Input失焦
String(count), // 文字節點
createElement('img', {
attrs: {
src: 'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1555610261877&di=6619e67b4f45768a359a296c55ec1cc3&imgtype=0&src=http%3A%2F%2Fimg.bimg.126.net%2Fphoto%2Fmr7DezX-Q4GLNBM_VPVaWA%3D%3D%2F333829322379622331.jpg'
}
})
]
})
let count = 0;
let vApp = createVApp(count);
複製程式碼
下面這個就是構建的 vDom 啦!
然後我我們看看render 方法,這個方法就是將我們的 vDom 轉化成真是的 element.
// render.js
const renderElem = ({ tagName, attrs, children}) => {
// create root element
let $el = document.createElement(tagName);
// set attributeds
for (const [k, v] of Object.entries(attrs)) {
$el.setAttribute(k, v);
}
// set children (Array)
for (const child of children) {
const $child = render(child);
$el.appendChild($child);
}
return $el;
}
const render = (vNode) => {
// if element node is text, and createTextNode
if (typeof vNode === 'string') {
return document.createTextNode(vNode);
}
// otherwise return renderElem
return renderElem(vNode);
}
export default render
複製程式碼
然後我們回到main.js中
// 引入 render.js 模組
const $app = render(vApp); // 開始構建真實的dom
let $rootEl = mount($app, document.getElementById('app'));
// 建立 mount.js
export default ($node, $target) => {
// use $node element replace $target element!
$target.replaceWith($node);
return $node;
}
複製程式碼
最後你就可以看到效果了. 是不是很帥 ? O(∩_∩)O哈哈 ~~~~
現在我們來做一些好玩的事兒。回到 main.js 中,我們加入如下這段程式碼:
setInterval(() => {
count++;
$rootEl = mount(render(createVApp(count)), $rootEl); // $rootEl 就是整顆real dom
}, 1000)
複製程式碼
然後回到我們的頁面,發現什麼了嗎? 你可以嘗試在 input 裡面輸入一些東西,然後發現了什麼異常了嗎 ?
檢視原始碼,原來,每隔一秒我們就重新整理了一次頁面。可是我們只改變了 count ,就重繪一次頁面,未免也誇張了吧,假如我們填寫一個表單,填的手都要斷了,結果重新整理了頁面,你猜會怎麼著? 會不會想砸電腦呢 ? 別急,diff 演算法能幫我們解決這給令人頭疼的問題 !
diff
diff 演算法的概念我就在這兒就不介紹了,大家可以在網上搜到很多答案。直接上程式碼 !
// diff.js
import render from './render'
const zip = (xs, ys) => {
const zipped = [];
for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
zipped.push([xs[i], ys[i]]);
}
return zipped;
};
const diffAttributes = (oldAttrs, newAttrs) => {
const patches = [];
// set new attributes
// oldAttrs = {dataCount: 0, id: 'app'}
// newAttrs = {dataCount: 1, id: 'app'}
// Object.entries(newAttrs) => [['dataCount', 1], ['id', 'app']]
for(const [k, v] of Object.entries(newAttrs)) {
patches.push($node => {
$node.setAttribute(k, v);
return $node;
})
}
// remove old attribute
for(const k in oldAttrs) {
if (!(k in newAttrs)) {
// $node 是整顆真實的 dom tree
patches.push($node => {
$node.removeAttribute(k);
return $node;
})
}
}
return $node => {
for (const patch of patches) {
patch($node);
}
}
}
const diffChildren = (oldVChildren, newVChildren) => {
const childPatches = [];
for (const [oldVChild, newVChild] of zip(oldVChildren, newVChildren)) {
childPatches.push(diff(oldVChild, newVChild));
}
const additionalPatches = [];
for (const additionalVChild of additionalPatches.slice(oldVChildren.length)) {
additionalPatches.push($node => {
$node.appendChild(render(additionalVChild));
return $node;
})
}
return $parent => {
for (const [patch, child] of zip(childPatches, $parent.childNodes)) {
patch(child);
}
for (const patch of additionalPatches) {
patch($parent);
}
return $parent;
}
}
const diff = (vOldNode, vNewNode) => {
// remove all
if (vNewNode === 'undefined') {
return $node => {
// Node.remove() 方法,把物件從它所屬的DOM樹中刪除。
$node.remove();
return undefined;
};
}
// when element is textnode (like count)
if (typeof vOldNode === 'string' || typeof vNewNode === 'string') {
if (vOldNode !== vNewNode) {
return $node => {
const $newNode = render(vNewNode);
$node.replaceWith($newNode);
return $newNode;
};
} else {
return $node => undefined;
}
}
if (vOldNode.tagName !== vNewNode.tagName) {
return $node => {
const $newNode = render(vNewNode);
$node.replaceWith($newNode);
return $newNode;
};
}
const patchAttrs = diffAttributes(vOldNode.attrs, vNewNode.attrs);
const patchChildren = diffChildren(vOldNode.children, vNewNode.children);
return $node => {
patchAttrs($node);
patchChildren($node);
return $node;
};
};
export default diff;
// main.js
setInterval(() => {
count++;
// 每隔一秒,重繪一次頁面,input失焦(缺點)
// $rootEl = mount(render(createVApp(count)), $rootEl)
// 衍生出 diff 演算法
const vNewApp = createVApp(count); // 新的 vDom
const patch = diff(vApp, vNewApp); // 對比差異
$rootEl = patch($rootEl);
vApp = vNewApp; // 每一秒之後都有更新,儲存起來以供下次比對。
}, 1000)
複製程式碼
廢話少說,先看效果 (: ~~
可以發現,input 沒有情況,也就是說頁面沒有重新整理,setInterval每次將count++, 頁面上也只更新了變化了的屬性以及文字,這就是diff演算法的威力。
分析一波
- diff
diff 函式接收兩個引數,vOldNode 和 vNewNode.
- 判斷 vNewNode 是不是 undefined,假如整顆樹都給刪了呢 ? 那就 $node.remove() 移出就好了
- 如果只是改了標籤名,那好辦,直接 render ,然後 replaceWith 就好了。
- 如果新老節點是 'string' 型別,那還得判斷新老節點是否相等 !
- 所有得到的差異結果都扔進 patches 中, 注意,是個函式哦 , 接收的引數就是 $rootEl
- diffAttributes
比對屬性好辦,就是拿到新的 vDom 的屬性,然後遍歷老的 vDom 的屬性,判斷老的 vDom 的屬性是否存在於新的 vDom 中。關鍵點我將它描述出來
- Object.entries()方法返回一個給定物件自身可列舉屬性的鍵值對陣列,其排列與使用 for...in 迴圈遍歷該物件時返回的順序一致(區別在於 for-in 迴圈也列舉原型鏈中的屬性)
- 通過for of 遍歷oldAttrs,拿到所有老的 vDom 中的key
- 通過 in 操作符 來判斷 2 中的 key 是否存在於 newAttrs 中.
- 最後返回一個函式,接收 $rootEl,遍歷屬性對比出來的 patches.每一項是一個函式.
- diffChildren
最後就是要對比 children 了。
- 接收倆引數,oldVChildren 和 newVChildren
- 這裡最主要的還是 zip 函式了。得到新老節點的 child, 將每個節點的老的節點和新的節點存放到一個陣列中,如圖:
- 然後遍歷這個 zipped 陣列.繼續diff, 並且儲存 diff 後的結果
for (const [oldVChild, newVChild] of zip(oldVChildren, newVChildren)) {
childPatches.push(diff(oldVChild, newVChild));
}
複製程式碼
結語
Virtual DOM 最核心的部分就是 diff 演算法了,這裡還是比較複雜的,需要多加練習反覆琢磨,好了,今天的介紹就到這了,如果喜歡你就點點贊哦 !