如何實現一個 Virtual DOM 及源碼分析
如何實現一個 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上源碼
如何實現一個 Virtual DOM 及源碼分析