作者:小土豆biubiubiu
微信公眾號:土豆媽的碎碎念(掃碼關(guān)注,一起吸貓,一起聽(tīng)故事,一起學(xué)習前端技術(shù))
作者文章的內容均來(lái)源于自己的實(shí)踐,如果覺(jué)得有幫助到你的話(huà),可以點(diǎn)贊給個(gè)鼓勵或留下寶貴意見(jiàn)
2020年,vue3.0 beta 和 vue3 rc陸續發(fā)布,優(yōu)秀的人也早已開(kāi)始各種實(shí)踐新版本的新特性,而我還不懂虛擬DOM,所以趕緊跟學(xué)起來(lái)。
?? 黑發(fā)不知勤學(xué)早,白首方悔讀書(shū)也不遲
當我們打開(kāi)一個(gè)頁(yè)面,點(diǎn)擊查看元素,就能在開(kāi)發(fā)中工具中看到頁(yè)面對應的DOM節點(diǎn)。
假如我們將這些DOM節點(diǎn)使用一個(gè)js對象去表示,那這個(gè)js對象就可以被稱(chēng)之為虛擬DOM。
下面有這樣一段DOM節點(diǎn)。
<div id='app' > <h3>內容</h3> <ul class='list'> <li>選項一</li> <li>選項二</li> </ul></div>復制代碼我將這段DOM節點(diǎn)手動(dòng)轉化為一個(gè)JS對象。
vdom = { type: 'div', // 節點(diǎn)的類(lèi)型,也就是節點(diǎn)的標簽名 props: { // 節點(diǎn)設置的所有屬性 'id': 'content' }, children: [ // 當前節點(diǎn)的子節點(diǎn) { type: 'h3', props: '', children:['內容'] }, { type: 'ul', props: { 'class': 'list' }, children: { { type: 'li', props: '', children: ['選項一'] }, { type: 'li', props: '', children: ['選項二'] } } } ]}復制代碼手動(dòng)轉化出來(lái)的vdom對象就是我們所描述的虛擬DOM。
前面我們手動(dòng)將DOM節點(diǎn)轉化虛擬DOM,那這一節將使用代碼實(shí)現這個(gè)轉化。
本篇文章的示例使用npm進(jìn)行搭建,最終的一個(gè)目錄結構如下:
virtual-dom | dist webpack打包后的文件目錄 | node_modules | src 源代碼目錄 | index.html 測試的html文件 | index.js 打包的入口文件 | package-lock.json | package.json | webpack.config.js webpack配置文件復制代碼首先我們先將虛擬DOM的三個(gè)屬性定義出來(lái):type、props、children。
// 代碼位置:/virtual-dom/src/virtualDOM.js/** @params: {String} type 標簽元素的類(lèi)型,也就是標簽名稱(chēng)* @params: {Object} props 標簽元素設置的屬性* @params: {Array} children 標簽元素的子節點(diǎn)*/function VirtualDOM(type, props, children){ this.type = type; this.props = props; this.children = children; }復制代碼接著(zhù)定義一個(gè)創(chuàng )建虛擬dom的方法。
// 代碼位置:/virtual-dom/src/virtualDOM.js/** 創(chuàng )建虛擬DOM的方法* @method create* @return {VirtualDOM} 返回創(chuàng )建出來(lái)的虛擬DOM對象*/function create(type, props, children){ return new VirtualDOM(type, props, children)}export { VirtualDOM, create } 復制代碼該方法用來(lái)創(chuàng )建虛擬
DOM對象,這樣就不用我們每次都使用new關(guān)鍵字進(jìn)行創(chuàng )建
最后就是調用create方法,傳入對應的參數。
// 代碼位置:/virtual-dom/index.jsimport {create} from './src/virtualDOM'let vdom = create('div', {'class': 'content'}, [ create('h3', {}, ['內容']), create('ul', { 'style': 'list-style-type: none;border: 1px solid;padding: 20px;'}, [ create('li', {}, ['選項一']), create('li', {}, ['選項二']) ])])console.log(vdom);復制代碼最后我們看一下代碼生成的結果:
可以看到跟我們前面手動(dòng)轉化的vdom結果一致。
虛擬DOM它實(shí)際就是存儲在內存中的一個(gè)數據,那終極目標是需要將這個(gè)數據轉化為真實(shí)的DOM節點(diǎn)展示到瀏覽器上,所以接下來(lái)我們再來(lái)實(shí)現一下將虛擬DOM轉化為真實(shí)的DOM節點(diǎn)。
將虛擬DOM轉化為真實(shí)節點(diǎn)的思路和步驟大致如下:
根據type屬性創(chuàng )建節點(diǎn) 設置節點(diǎn)屬性 處理子節點(diǎn):根據子節點(diǎn)的type創(chuàng )建子節點(diǎn)、設置子節點(diǎn)屬性,添加子節點(diǎn)到父節點(diǎn)中復制代碼前兩個(gè)步驟很簡(jiǎn)單也很容易理解,最后一個(gè)步驟實(shí)際上是前兩個(gè)步驟的重復執行,因此最后一個(gè)步驟我們會(huì )使用遞歸進(jìn)行實(shí)現。
那么接下來(lái)就代碼實(shí)現一下。
// 代碼位置:/virtual-dom/src/render.js/** 將虛擬節點(diǎn)轉化為真實(shí)的DOM節點(diǎn)并返回* @method render* @params {VirtualDOM} vdom 虛擬DOM對象* @return {HMTLElement} element 返回真實(shí)的DOM節點(diǎn) */function render(vdom){ var type = vdom.type; var props = vdom.props; var children = vdom.children; // 根據type屬性創(chuàng )建節點(diǎn) var element = document.createElement(vdom.type); return element;}export { render };復制代碼這里我們將邏輯寫(xiě)在
render函數中,并且返回創(chuàng )建好的真實(shí)DOM節點(diǎn)
// 代碼位置:/virtual-dom/src/render.js/* * 為DOM節點(diǎn)設置屬性* @method setProps* @params {HTMLElement} element dom元素* @params {Object} props 元素的屬性*/function setProps(element, props){ for (var key in props) { element.setAttribute(key,props[key]); }}export { render };復制代碼設置節點(diǎn)的屬性這個(gè)功能由
setProps函數實(shí)現
然后我們需要在render函數中調用setProps方法,實(shí)現節點(diǎn)屬性的設置。
// 代碼位置:/virtual-dom/src/render.js/** 將虛擬節點(diǎn)轉化為真實(shí)的DOM節點(diǎn)并返回* @method render* @params {VirtualDOM} vdom 虛擬DOM對象* @return {HMTLElement} element 返回真實(shí)的DOM節點(diǎn) */function render(vdom){ var type = vdom.type; var props = vdom.props; var children = vdom.children; // 根據type屬性創(chuàng )建節點(diǎn) var element = document.createElement(vdom.type); // 設置屬性 setProps(element, props); return element;}/* * 為DOM節點(diǎn)設置屬性* @method setProps* @params {HTMLElement} element dom元素* @params {Object} props 元素的屬性*/function setProps(element, props){ for (var key in props) { element.setAttribute(key,props[key]); }}export { render };復制代碼// 代碼位置:/virtual-dom/src/render.jsimport { VirtualDOM } from './virtualDOM';/** 將虛擬節點(diǎn)轉化為真實(shí)的DOM節點(diǎn)并返回* @method render* @params {VirtualDOM} vdom 虛擬DOM對象* @return {HMTLElement} element 返回真實(shí)的DOM節點(diǎn) */function render(vdom){ let type = vdom.type; let props = vdom.props; let children = vdom.children; // 根據type屬性創(chuàng )建節點(diǎn) let element = document.createElement(vdom.type); // 設置屬性 setProps(element, props); // 設置子節點(diǎn) children.forEach(child => { // 子節點(diǎn)是虛擬VirtualDOM的實(shí)例 遞歸創(chuàng )建節點(diǎn)、設置屬性 if(child instanceof VirtualDOM){ let childEle = render(child); }else{ // 子節點(diǎn)是文本 let childEle = document.createTextNode(child); } // 添加子節點(diǎn)到父節點(diǎn)中 element.appendChild(childEle); }); return element;}/* * 為DOM節點(diǎn)設置屬性* @method setProps* @params {HTMLElement} element dom元素* @params {Object} props 元素的屬性*/function setProps(element, props){ for (let key in props) { element.setAttribute(key,props[key]); }}export { render };復制代碼在設置子節點(diǎn)的時(shí)候,有一個(gè)邏輯判斷:判斷子節點(diǎn)是否為虛擬VirtualDOM的實(shí)例,如果是的話(huà),則需要遞歸調用render函數處理子節點(diǎn);否則的話(huà)就說(shuō)明子節點(diǎn)是文本內容。這個(gè)判斷邏輯的處理是根據前面兩節虛擬DOM創(chuàng )建的結果而定的。
這塊邏輯判斷不是固定的寫(xiě)法,假如前面在生成
虛擬DOM時(shí)文本類(lèi)型是另外一種表示方式,那這個(gè)邏輯判斷也就是另外一種寫(xiě)法了。
那最后一步我們把前面的virtualDOM.js和render.js整合到一起,實(shí)現真實(shí)DOM轉化為虛擬DOM,在將虛擬DOM轉化為真實(shí)DOM,最后在將生成后的真實(shí)DOM添加到頁(yè)面的body元素中。
// 代碼位置:/virtual-dom/index.jsimport { create} from './src/virtualDOM'import { render } from './src/render'// 創(chuàng )建虛擬DOMlet vdom = create('div', {'class': 'content'}, [ create('h3', {}, ['內容']), create('ul', { 'style': 'list-style-type: none;border: 1px solid;padding: 20px;'}, [ create('li', {}, ['選項一']), create('li', {}, ['選項二']) ])])// 將虛擬DOM轉化為真實(shí)DOMlet realdom = render(vdom);// 將真實(shí)DOM插入body元素中document.body.appendChild(realdom);復制代碼最后瀏覽器中打開(kāi)這個(gè)index.html文件。
可以看到,由vdom轉化后的readldom插入到頁(yè)面后和原始的真實(shí)DOM是一樣的,說(shuō)明我們這個(gè)轉化是成功的。
前面總結了那么多關(guān)于虛擬DOM的內容,最后就是核心的dom-diff算法了。 dom-diff算法做的事情就是比較之前舊的虛擬DOM和當前新的虛擬DOM兩者之間的差異,然后將這部分差異的內容進(jìn)行更新到文檔中。
上文描述的差異稱(chēng)之為補?。?code>patches
那差異是怎么進(jìn)行比較的呢?回歸到我們的實(shí)現的虛擬DOM上。
/** @params: {String} type 標簽元素的類(lèi)型,也就是標簽名稱(chēng)* @params: {Object} props 標簽元素設置的屬性* @params: {Array} children 標簽元素的子節點(diǎn)*/function VirtualDOM(type, props, children){ this.type = type; this.props = props; this.children = children; }復制代碼虛擬DOM對象最基本的就三個(gè)屬性:標簽類(lèi)型、標簽元素的屬性、標簽元素的子節點(diǎn),所以說(shuō)當兩個(gè)虛擬DOM對象進(jìn)行一個(gè)差異比較時(shí),比較的也就是這三個(gè)屬性。
那具體怎么個(gè)比較法呢,接下來(lái)我手動(dòng)比一比下面兩個(gè)虛擬DOM。

手動(dòng)比較出來(lái)oldDom和newDom這兩個(gè)的差異(patches):

這個(gè)是我們手動(dòng)比較出來(lái)的兩個(gè)DOM的差異,這些差異基本上包含了DOM屬性的變化、文本內容的變化、DOM節點(diǎn)的刪除以及替換。
這樣的比較結果使用一個(gè)js數據去表示,大概是這樣的結構:
patches = { '0': [ { type: 'props', // 屬性發(fā)生變化 props: { class: 'box', id: 'wapper' } } ], '1': [ { type: 'replace', // 節點(diǎn)發(fā)生替換 content: { type: 'h4', {}, children: ['內容'] } } ], '5':[ { type: 'text', // 文本內容變化 content: '內容一' } ], '6': [ { type: 'remove', // 節點(diǎn)被移除 } ]}復制代碼這樣的比較結果也比較清晰明了,不過(guò)這個(gè)手動(dòng)的比較結果怎么用代碼去實(shí)現呢?這個(gè)就是我們大名鼎鼎的DOM-Diff算法。
DOM-diff算法發(fā)核心就是對虛擬DOM節點(diǎn)進(jìn)行深度優(yōu)先遍歷并對每一個(gè)虛擬DOM節點(diǎn)進(jìn)行編號,在遍歷的過(guò)程中對同一個(gè)層級的節點(diǎn)進(jìn)行比較,最終得到比較后的差異:patches。


注意
dom-diff在比較差異時(shí)只會(huì )對同一層級的節點(diǎn)進(jìn)行比較,因為如果進(jìn)行完全的比較,算法實(shí)際復雜度會(huì )過(guò)高,所以舍棄了這種完全的比較方式,而采用同層比較(這里參考其他文章,因為算法不精,沒(méi)有具體研究過(guò))
那話(huà)不多說(shuō),我們這就來(lái)用代碼簡(jiǎn)單實(shí)現一下這個(gè)比較。
// 代碼位置:/virtual-dom/src/diff.js/** * @name: traversal * @description: 深度優(yōu)先遍歷虛擬DOM,計算出patches * @param {type} 參數 * @return {type} 返回值 */function traversal(oldNode, newNode, o, patches){ let currentPatches = []; if(newNode == undefined){ //節點(diǎn)被刪除 currentPatches.push({'type': 'remove'}); patches[o.nid] = currentPatches; }else if(oldNode instanceof VirtualDOM && newNode instanceof VirtualDOM){ // 如果是VirtualDOM類(lèi)型 if(oldNode.type != newNode.type){ // 節點(diǎn)發(fā)生替換 currentPatches.push({'type': 'replace', 'content': newNode.type}) patches[o.nid] = currentPatches; }else{ let resultDiff = diffProps(oldNode, newNode); // 屬性存在差異 if(Object.keys(resultDiff).length != 0){ currentPatches.push({'type': 'props', 'props': resultDiff}) patches[o.nid] = currentPatches; } } oldNode.children.forEach((element,index) => { o.nid++; traversal(element, newNode.children[index], o, patches); }); }else{ // 文本類(lèi)型 if(!diffText(oldNode, newNode)){ currentPatches.push({'type': 'text', 'content': newNode}); patches[o.nid] = currentPatches; } }}function diff(oldNode, newNode){ let patches = {}; //舊節點(diǎn)和新節點(diǎn)之間的差異結果 let o = {nid: 0}; // 節點(diǎn)的編號 // 遞歸遍歷oldNode、newNode 將差異結果保存到patches中 traversal(oldNode, newNode, o, patches) return patches;}export {diff};復制代碼最后在index.js中調用這個(gè)方法,看看生成的patches是否正確。
// 創(chuàng )建一個(gè)新的nodelet newNode = create('div', {'class': 'wapper', 'id': 'box'}, [ create('h4', {}, ['內容']), create('ul', { 'style': 'list-style-type: none;border: 1px solid;padding: 20px;'}, [ create('li', {}, ['內容一']) ])])let patches = diff(vdom, newNode);console.log('最終的patches');console.log(patches);復制代碼最后我們將代碼生成的patches和手動(dòng)生成的patches進(jìn)行一個(gè)對比,看看結果是否一樣。

可以看到兩者是一樣的,所以證明我們的diff是成功實(shí)現了。
到此我簡(jiǎn)單畫(huà)個(gè)圖總結一下前面我們已經(jīng)完成的功能。

那我們的最后一步就是將diff出來(lái)的patches應用到realdom上。
這里呢,我先直接將代碼貼出來(lái)。
// 代碼位置:/virtual-dom/src/patch.jsimport {render} from './render'/** * @name: walk * @description: 遍歷patches 將差異應用到真實(shí)的DOM節點(diǎn)上 * @param {HTMLElement} 真實(shí)的DOM節點(diǎn) * @param {Object} 虛擬節點(diǎn)的編號 編號從0開(kāi)始,從patches中獲取編號為o.nid的虛擬DOM的差異 * @param {Object} 使用diff算法比較出來(lái)新的虛擬節點(diǎn)和舊的虛擬節點(diǎn)的差異 */function walk(realdom, o, patchs){ // 獲取當前節點(diǎn)的差異 const currentPatch = patchs[o.nid]; // 對當前節點(diǎn)進(jìn)行DOM操作 if (currentPatch) { applyPatch(realdom, currentPatch) } for(let i=0; i < realdom.childNodes.length; i++){ let childNode = realdom.childNodes[i]; o.nid++; walk(childNode, o, patchs); }}/** * @name: applyPatch * @description: 應用差異到真實(shí)節點(diǎn)上 * @param {HTMLElement} 需要更新的真實(shí)DOM節點(diǎn) * @param {Array} 節點(diǎn)需要更新的內容 */function applyPatch(currentRealNode, currentPatch){ currentPatch.forEach(patch => { const type = patch['type']; switch(type){ case 'props': const props = patch['props']; for(const propKey in props){ currentRealNode.setAttribute(propKey, props[propKey]) } break; case 'replace': let content = patch['content']; let newEle = null; if(typeof(content) == 'string'){ newEle = document.createTextNode(content); }else{ // 調用render將替換的節點(diǎn)渲染成真實(shí)的dom newEle = render(content); } currentRealNode.parentNode.replaceChild(newEle, currentRealNode); break; case 'text': currentRealNode.textContent = patch['content'] break; case 'remove': currentRealNode.parentNode.removeChild(currentRealNode) } });}export {walk};復制代碼接下來(lái)我們就分析一下patch.js中的代碼。
applyPatch函數的功能就是將差異對象應用到真實(shí)的DOM節點(diǎn)上。
函數的兩個(gè)參數為:currentRealNode 和 currentPatch,分別表示的是需要更新的真實(shí)DOM節點(diǎn)和節點(diǎn)需要更新的內容。
舉個(gè)例子,如下:

前面我們生成的patches共有四種不同的類(lèi)型,分別為:節點(diǎn)屬性變化、節點(diǎn)類(lèi)型被替換、節點(diǎn)被移除、節點(diǎn)文本內容變化,所以在applyPatch函數中使用switch語(yǔ)句分別處理這四種不同的情況。
我們只需要將新的屬性(patch['props'])設置到當前節點(diǎn)上即可。 復制代碼節點(diǎn)類(lèi)型被替換以后,我們的patch['type']值為'replace',對應的patch['content']為替換后虛擬DOM節點(diǎn)。對于我們這篇文章中的示例來(lái)說(shuō),當執行到h3節點(diǎn)的時(shí)候,currentPatch的值為:復制代碼 [ { type: 'replace', // 節點(diǎn)發(fā)生替換 content: { type: 'h4', {}, children: ['內容'] } } ]復制代碼所以我們需要將patch['content']這個(gè)虛擬節點(diǎn)轉化為真實(shí)的節點(diǎn),更新到整個(gè)文檔節點(diǎn)中。復制代碼由于本次我們的示例將
h3節點(diǎn)替換成了h4,實(shí)際上有可能替換成文本內容,在replace的邏輯中會(huì )patch['content']的類(lèi)型做了判斷,如果替換成文本內容,則只需要創(chuàng )建文本節點(diǎn)即可。
節點(diǎn)文本內容發(fā)生變化,只需要為文本節點(diǎn)的textContet屬性賦新值即可。復制代碼節點(diǎn)被移除,調用當前節點(diǎn)的父級節點(diǎn)的removeChild移除當前節點(diǎn)即可。復制代碼
applyPatch方法內部都是一些操作原生DOM節點(diǎn)的邏輯
到此本篇文章就結束了,在此我們做一個(gè)簡(jiǎn)單的總結。
將真實(shí)的DOM節點(diǎn)抽象成為一個(gè)js對象,這個(gè)js對象就稱(chēng)之為是虛擬DOM。
dom-diff算法核心的幾個(gè)點(diǎn)就是:
1.將真實(shí)的DOM節點(diǎn)使用虛擬DOM表示(create) 2.將虛擬DOM渲染到瀏覽器頁(yè)面上(render) 3.當用戶(hù)操作界面修改數據后,會(huì )生成一個(gè)新的虛擬DOM,將新的虛擬DOM和舊的虛擬DOM進(jìn)行對比,生成差異對象patches(diff) 4.將差異對象應用到真實(shí)的DOM節點(diǎn)上(patch) 復制代碼那在了解了虛擬DOM以及和虛擬DOM相關(guān)的dom-diff算法以后,我們肯定會(huì )思考為什么需要虛擬DOM這樣的東西。
虛擬DOM基于JavaScript對象,而真實(shí)的DOM要基于瀏覽器平臺,所以虛擬DOM可以跨平臺使用。
我們都知道瀏覽器將一個(gè)HTML文檔轉化為真實(shí)的內容呈現到瀏覽器上的整個(gè)過(guò)程是需要經(jīng)歷一系列的步驟:構建DOM樹(shù)、構建CSS規則樹(shù)、基于DOM樹(shù)和CSS規則樹(shù)構建呈現樹(shù)(呈現樹(shù)是文檔的可視化表示)、根據呈現樹(shù)進(jìn)行布局和繪制。當有用戶(hù)交互需要改變文檔結構時(shí),很大程度上會(huì )再一次觸發(fā)這一系列的操作。
假如用戶(hù)在一次交互中修改了10次DOM結構,那么就會(huì )觸發(fā)10次上述的步驟,所以說(shuō)操作DOM的代價(jià)是很大的。
所以我們使用一個(gè)js對象來(lái)表示真實(shí)的DOM,當用戶(hù)在一次交互中修改了10次DOM結構時(shí),我們就可以將這10次的修改映射到這個(gè)js對象,之后比較之前的虛擬DOM和修改后的虛擬DOM,最后在將比較的差異應用到文檔中。那這樣的操作顯然會(huì )比直接更新10次真實(shí)的DOM要節省性能。
本篇文章只針對虛擬DOM和dom-diff做了簡(jiǎn)單的總結和實(shí)踐,而vue框架內部在diff的時(shí)候還有一些更細節的處理,后續在vue源碼學(xué)習時(shí)會(huì )在做總結。
本文的源代碼可以 戳這里 獲取
深入剖析:Vue核心之虛擬DOM
讓虛擬DOM和DOM-diff不再成為你的絆腳石
vue核心之虛擬DOM(vdom)
詳解Vue中的虛擬DOM
小土豆biubiubiu
一個(gè)努力學(xué)習的前端小菜鳥(niǎo),知識是無(wú)限的。堅信只要不停下學(xué)習的腳步,總能到達自己期望的地方
同時(shí)還是一個(gè)喜歡小貓咪的人,家里有一只美短小母貓,名叫土豆
土豆媽的碎碎念
微信公眾號的初衷是記錄自己和身邊的一些故事,同時(shí)會(huì )不定期更新一些技術(shù)文章
歡迎大家掃碼關(guān)注,一起吸貓,一起聽(tīng)故事,一起學(xué)習前端技術(shù)
小小總結,歡迎大家指導~
聯(lián)系客服