如何實現一個 Virtual DOM 及原始碼分析
Virtual DOM演算法
web頁面有一個對應的DOM樹,在傳統開發頁面時,每次頁面需要被更新時,都需要手動操作DOM來進行更新,但是我們知道DOM操作對效能來說是非常不友好的,會影響頁面的重排,從而影響頁面的效能。因此在React和VUE2.0+引入了虛擬DOM的概念,他們的原理是:把真實的DOM樹轉換成javascript物件樹,也就是虛擬DOM,每次資料需要被更新的時候,它會生成一個新的虛擬DOM,並且和上次生成的虛擬DOM進行對比,對發生變化的資料做批量更新。---(因為操作JS物件會更快,更簡單,比操作DOM來說)。
我們知道web頁面是由一個個HTML元素巢狀組合而成的,當我們使用javascript來描述這些元素的時候,這些元素可以簡單的被表示成純粹的JSON物件。
比如如下HTML程式碼:
<div id="container" class="container"> <ul id="list"> <li class="item">111</li> <li class="item">222</li> <li class="item">333</li> </ul> <button class="btn btn-blue"><em>提交</em></button> </div>
上面是真實的DOM樹結構,我們可以使用javascript中的json物件來表示的話,變成如下:
var element = { tagName: 'div', props: { // DOM的屬性 id: 'container', class: 'container' }, children: [ { tagName: 'ul', props: { id: 'list' }, children: [ {tagName: 'li', props: {class: 'item'}, children: ['111']}, {tagName: 'li', props: {class: 'item'}, children: ['222']}, {tagName: 'li', props: {class: 'item'}, children: ['333']} ] }, { tagName: 'button', props: { class: 'btn btn-blue' }, children: [ { tagName: 'em', children: ['提交'] } ] } ] };
因此我們可以使用javascript物件表示DOM的資訊和結構,當狀態變更的時候,重新渲染這個javascript物件的結構,然後可以使用新渲染的物件樹去和舊的樹去對比,記錄兩顆樹的差異,兩顆樹的差異就是我們需要對頁面真正的DOM操作,然後把他們應用到真正的DOM樹上,頁面就得到更新。檢視的整個結構確實全渲染了,但是最後操作DOM的時候,只變更不同的地方。
因此我們可以總結一下 Virtual DOM演算法:
1. 用javascript物件結構來表示DOM樹的結構,然後用這個樹構建一個真正的DOM樹,插入到文件中。
2. 當狀態變更的時候,重新構造一顆新的物件樹,然後使用新的物件樹與舊的物件樹進行對比,記錄兩顆樹的差異。
3. 把記錄下來的差異用到步驟1所構建的真正的DOM樹上。檢視就更新了。
演算法實現:
2-1 使用javascript物件模擬DOM樹。
使用javascript來表示一個DOM節點,有如上JSON的資料,我們只需要記錄它的節點型別,屬性和子節點即可。
element.js 程式碼如下:
function Element(tagName, props, children) { this.tagName = tagName; this.props = props; this.children = children; } Element.prototype.render = function() { var el = document.createElement(this.tagName); var props = this.props; // 遍歷子節點,依次設定子節點的屬性 for (var propName in props) { var propValue = props[propName]; el.setAttribute(propName, propValue); } // 儲存子節點 var childrens = this.children || []; // 遍歷子節點,使用遞迴的方式 渲染 childrens.forEach(function(child) { var childEl = (child instanceof Element) ? child.render() // 如果子節點也是虛擬DOM,遞迴構建DOM節點 : document.createTextNode(child); // 如果是字串的話,只構建文字節點 el.appendChild(childEl); }); return el; }; module.exports = function(tagName, props, children) { return new Element(tagName, props, children); }
入口index.js程式碼如下:
var el = require('./element'); var element = el('div', {id: 'container', class: 'container'}, [ el('ul', {id: 'list'},[ el('li', {class: 'item'}, ['111']), el('li', {class: 'item'}, ['222']), el('li', {class: 'item'}, ['333']), ]), el('button', {class: 'btn btn-blue'}, [ el('em', {class: ''}, ['提交']) ]) ]); var elemRoot = element.render(); document.body.appendChild(elemRoot);
開啟頁面即可看到效果。
2-2 比較兩顆虛擬DOM樹的差異及差異的地方進行dom操作
上面的div只會和同一層級的div對比,第二層級的只會和第二層級的對比,這樣的演算法的複雜度可以達到O(n).
但是在實際程式碼中,會對新舊兩顆樹進行一個深度優先的遍歷,因此每個節點都會有一個標記。如下圖所示:
在遍歷的過程中,每次遍歷到一個節點就把該節點和新的樹進行對比,如果有差異的話就記錄到一個物件裡面。
現在我們來看下我的目錄下 有哪些檔案;然後分別對每個檔案程式碼進行解讀,看看做了哪些事情,舊的虛擬dom和新的虛擬dom是如何比較的,且是如何更新頁面的 如下目錄:
目錄結構如下:
vdom ---- 工程名 | | ---- index.html html頁面 | | ---- element.js 例項化元素組成json資料 且 提供render方法 渲染頁面 | | ---- util.js 提供一些公用的方法 | | ---- diff.js 比較新舊節點資料 如果有差異儲存到一個物件裡面去 | | ---- patch.js 對當前差異的節點資料 進行DOM操作 | | ---- index.js 頁面程式碼初始化呼叫
首先是 index.js檔案 頁面渲染完成後 變成如下html結構
<div id="container"> <h1 style="color: red;">simple virtal dom</h1> <p>the count is :1</p> <ul> <li>Item #0</li> </ul> </div>
假如發生改變後,變成如下結構
<div id="container"> <h1 style="color: blue;">simple virtal dom</h1> <p>the count is :2</p> <ul> <li>Item #0</li> <li>Item #1</li> </ul> </div>
可以看到 新舊節點頁面資料的改變,h1標籤從屬性 顏色從紅色 變為藍色,p標籤的文字發生改變,ul新增了一項元素li。
基本的原理是:先渲染出頁面資料出來,生成第一個模板頁面,然後使用定時器會生成一個新的頁面資料出來,對新舊兩顆樹進行一個深度優先的遍歷,因此每個節點都會有一個標記。
然後呼叫diff方法對比物件新舊節點遍歷進行對比,找出兩者的不同的地方存入到一個物件裡面去,最後通過patch.js找出物件不同的地方,分別進行dom操作。
index.js程式碼如下:
var el = require('./element'); var diff = require('./diff'); var patch = require('./patch'); var count = 0; function renderTree() { count++; var items = []; var color = (count % 2 === 0) ? 'blue' : 'red'; for (var i = 0; i < count; i++) { items.push(el('li', ['Item #' + i])); } return el('div', {'id': 'container'}, [ el('h1', {style: 'color: ' + color}, ['simple virtal dom']), el('p', ['the count is :' + count]), el('ul', items) ]); } var tree = renderTree() var root = tree.render() document.body.appendChild(root) setInterval(function () { var newTree = renderTree() var patches = diff(tree, newTree) console.log(patches) patch(root, patches) tree = newTree }, 1000);
執行 var tree = renderTree()方法後,會呼叫element.js,
1. 依次遍歷子節點(從內到外呼叫)依次為 li, h1, p, ul, li和h1和p有一個文字子節點,因此遍歷完成後,count就等於1,
但是遍歷ul的時候,因為有一個子節點li,因此 count += 1; 所以呼叫完成後,ul的count等於2. 因此會對每個element屬性新增count屬性。對於最外層的container容器就是對每個子節點的依次增加,h1子節點預設為1,迴圈完成後 +1;因此變為2, p節點預設為1,迴圈完成後 +1,因此也變為2,ul為2,迴圈完成後 +1,因此變為3,因此container節點的count=2+2+3 = 7;
element.js部分程式碼如下:
function Element(tagName, props, children) { if (!(this instanceof Element)) { // 判斷子節點 children 是否為 undefined if (!utils.isArray(children) && children !== null) { children = utils.slice(arguments, 2).filter(utils.truthy); } return new Element(tagName, props, children); } // 如果沒有屬性的話,第二個引數是一個陣列,說明第二個引數傳的是子節點 if (utils.isArray(props)) { children = props; props = {}; } this.tagName = tagName; this.props = props || {}; this.children = children || []; // 儲存key鍵 如果有屬性 儲存key,否則返回undefined this.key = props ? props.key : void 0; var count = 0; utils.each(this.children, function(child, i) { // 如果是元素的實列的話 if (child instanceof Element) { count += child.count; } else { // 如果是文字節點的話,直接賦值 children[i] = '' + child; } count++; }); this.count = count; }
oldTree資料最終變成如下:
var oldTree = { tagName: 'div', key: undefined, count: 7, props: {id: 'container'}, children: [ { tagName: 'h1', key: undefined count: 1 props: {style: 'colod: red'}, children: ['simple virtal dom'] }, { tagName: 'p', key: undefined count: 1 props: {}, children: ['the count is :1'] }, { tagName: 'ul', key: undefined count: 2 props: {}, children: [ { tagName: 'li', key: undefined, count: 1, props: {}, children: ['Item #0'] } ] }, ] };
定時器 執行 var newTree = renderTree()後,呼叫方法步驟還是和第一步一樣:
2. 依次遍歷子節點(從內到外呼叫)依次為 li, h1, p, ul, li和h1和p有一個文字子節點,因此遍歷完成後,count就等於1,因為有2個子元素li,count都為1,因此ul每次遍歷依次在原來的基礎上加1,因此遍歷完成第一個li時候,ul中的count為2,當遍歷完成第二個li的時候,ul的count就為4了。因此ul中的count為4. 對於最外層的container容器就是對每個子元素依次增加。
所以 container節點的count = 2 + 2 + 5 = 9;
newTree資料最終變成如下資料:
var newTree = { tagName: 'div', key: undefined, count: 9, props: {id: 'container'}, children: [ { tagName: 'h1', key: undefined count: 1 props: {style: 'colod: red'}, children: ['simple virtal dom'] }, { tagName: 'p', key: undefined count: 1 props: {}, children: ['the count is :1'] }, { tagName: 'ul', key: undefined count: 4 props: {}, children: [ { tagName: 'li', key: undefined, count: 1, props: {}, children: ['Item #0'] }, { tagName: 'li', key: undefined, count: 1, props: {}, children: ['Item #1'] } ] }, ] }
var patches = diff(oldTree, newTree);
呼叫diff方法可以比較新舊兩棵樹節點的資料,把兩顆樹的不同節點找出來。(注意,檢視diff對比資料的方法,找到不同的節點,可以檢視這篇文章diff演算法)如下呼叫程式碼:
function diff (oldTree, newTree) { var index = 0; var patches = {}; deepWalk(oldTree, newTree, index, patches); return patches; }
執行deepWalk如下程式碼:
function deepWalk(oldNode, newNode, index, patches) { var currentPatch = []; // 節點被刪除掉 if (newNode === null) { // 真正的DOM節點時,將刪除執行重新排序,所以不需要做任何事 } else if(utils.isString(oldNode) && utils.isString(newNode)) { // 替換文字節點 if (newNode !== oldNode) { currentPatch.push({type: patch.TEXT, content: newNode}); } } else if(oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) { // 相同的節點,但是新舊節點的屬性不同的情況下 比較屬性 // diff props var propsPatches = diffProps(oldNode, newNode); if (propsPatches) { currentPatch.push({type: patch.PROPS, props: propsPatches}); } // 不同的子節點 if (!isIgnoreChildren(newNode)) { diffChildren( oldNode.children, newNode.children, index, patches, currentPatch ) } } else { // 不同的節點,那麼新節點替換舊節點 currentPatch.push({type: patch.REPLACE, node: newNode}); } if (currentPatch.length) { patches[index] = currentPatch; } }
1. 判斷新節點是否為null,如果為null,說明節點被刪除掉。
2. 判斷新舊節點是否為字串,如果為字串說明是文字節點,並且新舊兩個文字節點不同的話,存入陣列裡面去,如下程式碼:
currentPatch.push({type: patch.TEXT, content: newNode});
patch.TEXT 為 patch.js裡面的 TEXT = 3;content屬性為新節點。
3. 如果新舊tagName相同的話,並且新舊節點的key相同的話,繼續比較新舊節點的屬性,如下程式碼:
var propsPatches = diffProps(oldNode, newNode);
diffProps方法的程式碼如下:
function diffProps(oldNode, newNode) { var count = 0; var oldProps = oldNode.props; var newProps = newNode.props; var key, value; var propsPatches = {}; // 找出不同的屬性值 for (key in oldProps) { value = oldProps[key]; if (newProps[key] !== value) { count++; propsPatches[key] = newProps[key]; } } // 找出新增屬性 for (key in newProps) { value = newProps[key]; if (!oldProps.hasOwnProperty(key)) { count++; propsPatches[key] = newProps[key]; } } // 如果所有的屬性都是相同的話 if (count === 0) { return null; } return propsPatches; }
diffProps程式碼解析如下:
for (key in oldProps) { value = oldProps[key]; if (newProps[key] !== value) { count++; propsPatches[key] = newProps[key]; } }
如上程式碼是 判斷舊節點的屬性值是否在新節點中找到,如果找不到的話,count++; 把新節點的屬性值賦值給 propsPatches 儲存起來。
for (key in newProps) { value = newProps[key]; if (!oldProps.hasOwnProperty(key)) { count++; propsPatches[key] = newProps[key]; } }
如上程式碼是 判斷新節點的屬性是否能在舊節點中找到,如果找不到的話,count++; 把新節點的屬性值賦值給 propsPatches 儲存起來。
if (count === 0) { return null; } return propsPatches;
最後如果count 等於0的話,說明所有屬性都是相同的話,所以不需要做任何變化。否則的話,返回新增的屬性。
如果有 propsPatches 的話,執行如下程式碼:
if (propsPatches) { currentPatch.push({type: patch.PROPS, props: propsPatches}); }
因此currentPatch陣列裡面也有對應的更新的屬性,props就是需要更新的屬性物件。
繼續程式碼:
// 不同的子節點 if (!isIgnoreChildren(newNode)) { diffChildren( oldNode.children, newNode.children, index, patches, currentPatch ) } function isIgnoreChildren(node) { return (node.props && node.props.hasOwnProperty('ignore')); }
如上程式碼判斷子節點是否相同,diffChildren程式碼如下:
function diffChildren(oldChildren, newChildren, index, patches, currentPatch) { var diffs = listDiff(oldChildren, newChildren, 'key'); newChildren = diffs.children; if (diffs.moves.length) { var recorderPatch = {type: patch.REORDER, moves: diffs.moves}; currentPatch.push(recorderPatch); } var leftNode = null; var currentNodeIndex = index; utils.each(oldChildren, function(child, i) { var newChild = newChildren[i]; currentNodeIndex = (leftNode && leftNode.count) ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1; // 遞迴 deepWalk(child, newChild, currentNodeIndex, patches); leftNode = child; }); }
如上程式碼:var diffs = listDiff(oldChildren, newChildren, 'key'); 新舊節點按照key來比較,目前key為undefined,所以diffs 為如下:
diffs = { moves: [], children: [ { tagName: 'h1', key: undefined count: 1 props: {style: 'colod: blue'}, children: ['simple virtal dom'] }, { tagName: 'p', key: undefined count: 1 props: {}, children: ['the count is :2'] }, { tagName: 'ul', key: undefined count: 4 props: {}, children: [ { tagName: 'li', key: undefined, count: 1, props: {}, children: ['Item #0'] }, { tagName: 'li', key: undefined, count: 1, props: {}, children: ['Item #1'] } ] } ] };
newChildren = diffs.children;
oldChildren資料如下:
oldChildren = [ { tagName: 'h1', key: undefined count: 1 props: {style: 'colod: red'}, children: ['simple virtal dom'] }, { tagName: 'p', key: undefined count: 1 props: {}, children: ['the count is :1'] }, { tagName: 'ul', key: undefined count: 2 props: {}, children: [ { tagName: 'li', key: undefined, count: 1, props: {}, children: ['Item #0'] } ] } ];
接著就是遍歷 oldChildren, 第一次遍歷時 leftNode 為null,因此 currentNodeIndex = currentNodeIndex + 1 = 0 + 1 = 1; 不是第一次遍歷,那麼leftNode都為上一次遍歷的子節點,因此不是第一次遍歷的話,那麼 currentNodeIndex = currentNodeIndex + leftNode.count + 1;
然後遞迴呼叫 deepWalk(child, newChild, currentNodeIndex, patches); 方法,接著把child賦值給leftNode,leftNode = child;
所以一直遞迴遍歷,最終把不相同的節點 會儲存到 currentPatch 陣列內。最後執行
if (currentPatch.length) { patches[index] = currentPatch; }
把對應的currentPatch 儲存到 patches物件內中的對應項,最後就返回 patches物件。
4. 返回到index.js 程式碼內,把兩顆不相同的樹節點的提取出來後,需要呼叫patch.js方法傳進;把不相同的節點應用到真正的DOM上.
不相同的節點 patches資料如下:
patches = { 1: [{type: 2, props: {style: 'color: blue'}}], 4: [{type: 3, content: 'the count is :2'}], 5: [ { type: 1, moves: [ { index: 1, item: { tagName: 'li', props: {}, count: 1, key: undefined, children: ['Item #1'] } } ] } ] }
如下程式碼呼叫:
patch(root, patches);
執行patch方法,程式碼如下:
function patch(node, patches) { var walker = {index: 0}; deepWalk(node, walker, patches); }
deepWalk 程式碼如下:
function deepWalk(node, walker, patches) { var currentPatches = patches[walker.index]; // node.childNodes 返回指定元素的子元素集合,包括HTML節點,所有屬性,文字節點。 var len = node.childNodes ? node.childNodes.length : 0; for (var i = 0; i < len; i++) { var child = node.childNodes[i]; walker.index++; // 深度複製 遞迴遍歷 deepWalk(child, walker, patches); } if (currentPatches) { applyPatches(node, currentPatches); } }
1. 首次呼叫patch的方法,root就是container的節點,因此呼叫deepWalk方法,因此 var currentPatches = patches[0] = undefined,
var len = node.childNodes ? node.childNodes.length : 0; 因此 len = 3; 很明顯該子節點的長度為3,因為子節點有 h1, p, 和ul元素;
2. 然後進行for迴圈,獲取該父節點的子節點,因此第一個子節點為 h1 元素,walker.index++; 因此walker.index = 1; 再進行遞迴 deepWalk(child, walker, patches); 此時子節點為h1, walker.index為1, 因此獲取 currentPatches = patches[1]; 獲取值,再獲取 h1的子節點的長度,len = 1; 然後再for迴圈,獲取child為文字節點,此時 walker.index++; 所以此時walker.index 為2, 在呼叫deepwalk方法遞迴,因此再繼續獲取 currentPatches = patches[2]; 值為undefined,再獲取len = 0; 因為文字節點麼有子節點,所以for迴圈跳出,所以判斷currentPatches是否有值,因為此時 currentPatches 為undefined,所以遞迴結束,再返回到 h1元素上來,所以currentPatches = patches[1]; 所以有值,所以呼叫 applyPatches()方法來更新dom元素。
3. 繼續迴圈 i, 此時i = 1; 獲取子節點 child = p元素,walker.index++,此時walker.index = 3, 繼續呼叫 deepWalk方法,獲取 var currentPatches = patches[walker.index] = patches[3]的值,var len = 1; 因為p元素下有一個子節點(文字節點),再進for迴圈,此時 walker.index++; 因此walker.index = 4; child此時為文字節點,在呼叫 deepwalk方法的時候,再獲取var currentPatches = patches[walker.index] = patches[4]; 再執行len 程式碼的時候 len = 0;因此跳出for迴圈,判斷 currentPatches是否有值,有值的話,更新對應的DOM元素。
4. 繼續迴圈i = 2; 獲取子節點 child = ul元素,walker.index++; 此時walker.index = 5; 在呼叫deepWalk方法遞迴,因此再獲取 var currentPatches = patches[walker.index] = patches[5]; 然後len = 1, 因為ul元素下有一個li元素,在繼續for迴圈遍歷,獲取子節點li,此時walker.index++; walker.index = 6; 再遞迴呼叫deepwalk方法,再獲取var currentPatches = patches[walker.index] = patches[6]; len = 1; 因為li的元素下有一個文字節點,再進行for迴圈,此時child為文字節點,walker.index++;此時walker.index = 7; 再執行 deepwalk方法,再獲取 var currentPatches = patches[walker.index] = patches[7]; 這時候 len = 0了,因此跳出for迴圈,判斷 當前的currentPatches是否有值,沒有,就跳出,然後再返回ul元素,獲取該自己li的時候,walker.index 等於5,因此var currentPatches = patches[walker.index] = patches[5]; 然後判斷 currentPatches是否有值,有值就進行更新DOM元素。
最後就是 applyPatches 方法更新dom元素了,如下程式碼:
function applyPatches(node, currentPatches) { utils.each(currentPatches, function(currentPatch) { switch (currentPatch.type) { case REPLACE: var newNode = (typeof currentPatch.node === 'string') ? document.createTextNode(currentPatch.node) : currentPatch.node.render(); node.parentNode.replaceChild(newNode, node); break; case REORDER: reorderChildren(node, currentPatch.moves); break; case PROPS: setProps(node, currentPatch.props); break; case TEXT: if(node.textContent) { node.textContent = currentPatch.content; } else { // ie bug node.nodeValue = currentPatch.content; } break; default: throw new Error('Unknow patch type' + currentPatch.type); } }); }
判斷型別,替換對應的屬性和節點。
最後就是對子節點進行排序的操作,程式碼如下:
// 對子節點進行排序 function reorderChildren(node, moves) { var staticNodeList = utils.toArray(node.childNodes); var maps = {}; utils.each(staticNodeList, function(node) { // 如果是元素節點 if (node.nodeType === 1) { var key = node.getAttribute('key'); if (key) { maps[key] = node; } } }) utils.each(moves, function(move) { var index = move.index; if (move.type === 0) { // remove Item if (staticNodeList[index] === node.childNodes[index]) { node.removeChild(node.childNodes[index]); } staticNodeList.splice(index, 1); } else if(move.type === 1) { // insert item var insertNode = maps[move.item.key] ? maps[move.item.key].cloneNode(true) : (typeof move.item === 'object') ? move.item.render() : document.createTextNode(move.item); staticNodeList.splice(index, 0, insertNode); node.insertBefore(insertNode, node.childNodes[index] || null); } }); }
遍歷moves,判斷moves.type 是等於0還是等於1,等於0的話是刪除操作,等於1的話是新增操作。比如現在moves值變成如下:
moves = { index: 1, type: 1, item: { tagName: 'li', key: undefined, props: {}, count: 1, children: ['#Item 1'] } };
node節點 就是 'ul'元素,var staticNodeList = utils.toArray(node.childNodes); 把ul的舊子節點li轉成Array形式,由於沒有屬性key,所以直接跳到下面遍歷程式碼來,遍歷moves,獲取某一項的索引index,判斷move.type 等於0 還是等於1, 目前等於1,是新增一項,但是沒有key,因此呼叫move.item.render(); 渲染完後,對staticNodeList陣列裡面的舊節點的li項從第二項開始插入節點li,然後執行node.insertBefore(insertNode, node.childNodes[index] || null); node就是ul父節點,insertNode節點插入到 node.childNodes[1]的前面。因此把在第二項的前面插入第一項。
檢視github上原始碼