本文章主要的目的就是讓大家:真正的、徹底的弄懂 虛擬DOM 和 diff演演算法,那麼何為真正、徹底的弄懂呢?就是我們自己要把它們的底層動手敲出來!從 虛擬DOM如何被渲染函數(h函數)產生(手寫h函數)
,到 diff演演算法原理(手寫diff演演算法)
、最後 虛擬DOM如何通過diff變為真正的DOM的(事實上,虛擬DOM變回真正的DOM,是涵蓋在diff演演算法裡面的)
,為了方便大家去理解,可能文章涉及的點比較多,內容比較長,希望大家耐心細品,最後希望各位大佬點一個贊!!!。
好了,廢話不多說,正式進入文章主題,讓你真正的、徹底掌握 虛擬DOM 和 diff演演算法。下面,我們一步步來實現虛擬DOM和diff演演算法。【相關推薦:】
先用一個簡單的例子來說一下 虛擬DOM
和 diff演演算法
:比如有一個戶型圖,現在我們需要對這個戶型圖進行下面的改造,
其實,這個就是相當於一個進行找茬的遊戲,讓我們找出與原來的不同之處。下面,我已經將不同之處圈了出來,
現在,我們已經知道了要進行哪些改造了,但是,我們該如何進行改造呢?最笨的方法就是全部拆了再重新建一次,但是,在我們實際中肯定不會進行拆除再新建,這樣效率太低了,而且代價太昂貴。確實是完成了改造,但是,這不是一個最小量的更新,所以我們想要的是 diff,
那麼diff是什麼呢?其實,diff
在我們計算機中就是代表著最小量更新的一個演演算法,會進行精細化對比,以最小量去更新。這樣你就會發現,它的代價比較小,也不會昂貴,也會比較優化,所以對應在我們 Vue底層中是非常關鍵的。
好了,現在迴歸到我們的Vue中,上面的戶型圖中就相當於vue中的 DOM節點,我們需要對這些節點進行改造(增刪調),然後以最小量去更新DOM
,這樣就會避免我們效能上面的開銷。
// 原先DOM <div class="box"> <h2>標題</h2> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> </div>
// 修改後的DOM <div class="box"> <h2>標題</h2> <span>青峰</span> <ul> <li>1</li> <li>2</li> <li>3</li> <li>4</li> </ul> </div>
在這裡,我們就可以利用 diff演演算法進行精細化對比,實現最小量更新
。
上面我們瞭解了什麼是diff,下面再來簡單瞭解一下什麼是虛擬DOM,
<div class="box"> <h2>標題</h2> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> </div>
{ sel: "div", elm: undefined, // 表示虛擬節點還沒有上樹 key: undefined, // 唯一標識 data: { class: { "box" : true} }, children: [ { sel: "h2", data: {}, text: "標題" }, { sel: "ul", data: {}, children: [ { sel: li, data: {}, text: "1"}, { sel: li, data: {}, text: "2"}, { sel: li, data: {}, text: "3"} ] } ] }
通過觀察可以發現,虛擬DOM
是一個 JavsScript物件
,裡面包含 sel選擇器
,data資料
,text文字內容
,children子標籤
等等,一層巢狀一層。這樣就表達了一個 虛擬DOM結構
,處理 虛擬DOM 的方式總比處理 真實的DOM 要簡單並且高效,所以 diff演演算法
是發生在 虛擬DOM
上的。
注意:diff演演算法 是發生在 虛擬DOM 上的。
減少DOM的操作
,不僅僅是DOM相對較慢,更是因為變動DOM會造成瀏覽器的迴流和重繪
,這些都會降低效能,因此,我們需要虛擬DOM,在patch(比較新舊虛擬DOM更新去更新檢視)
過程中儘可能的一次性將差異更新到DOM中
,這樣就保證了DOM不會出現了效能很差的情況。虛擬DOM
改變了當前的狀態不需要立即去更新DOM,而是對更新的內容進行更新,對於沒有改變的內容不做任何處理,通過前後兩次差異進行比較
。跨平臺
,比如node.js就沒有DOM,如果想實現 SSR(伺服器端渲染)
,那麼一個方式就是藉助Virtual DOM,因為 Virtual DOM本身是 JavaScript物件。作用:h函數 主要用來產生 虛擬節點(vnode)
第一個引數:標籤名字、元件的選項物件、函數
第二個引數:標籤對應的屬性 (可選)
第三個引數:子級虛擬節點,字串或者是陣列形式
h('a',{ props: {href: 'http://www.baidu.com'}, '百度'})
上面的h函數對應的虛擬節點為:
{ sel: 'a', data: { props: {href: 'http://www.baidu.com'}}, text: "百度"}
真正的DOM節點為:
<a href = "http://www.baidu.com">百度</a>
我們還可以巢狀的使用h函數,比如:
h('ul', {}, [ h('li', {}, '1'), h('li', {}, '2'), h('li', {}, '3'), ])
巢狀使用h函數,就會生成一個虛擬DOM樹。
{ sel: "ul", elm: undefined, key: undefined, data: {}, children: [ { sel: li, elm: undefined, key: undefined, data: {}, text: "1"}, { sel: li, elm: undefined, key: undefined, data: {}, text: "2"}, { sel: li, elm: undefined, key: undefined, data: {}, text: "3"} ] }
好了,上面我們已經知道了h函數是怎麼使用的了,下面我們手寫一個閹割版的h函數。
我們手寫的這個函數只考慮三種情況(帶三個引數),分別如下:
情況①:h('div', {}, '文字') 情況②:h('div', {}, []) 情況③:h('div', {}, h())
在手寫h函數之前,我們需要宣告一個函數,用來建立虛擬節點
// vnode.js 返回虛擬節點 export default function(sel, data, children, text, elm) { // sel 選擇器 、data 屬性、children 子節點、text 文字內容、elm 虛擬節點繫結的真實 DOM 節點 const key = data.key return { sel, data, children, text, elm, key } }
宣告好vnode函數之後,我們正式來手寫h函數,思路如下:
判斷第三個引數是否是字串或者是數位
。如果是字串或數位,直接返回 vnode
判斷第三個引數是否是一個陣列
。宣告一個陣列
,用來儲存子節點
,需要遍歷陣列,這裡需要判斷每一項是否是一個物件(因為 vnode 返回一個物件並且一定會有sel屬性)但是不需要執行每一項,因為在陣列裡面已經執行了h函數。其實,並不是函數遞迴進行呼叫(自己調自己),而是一層一層的巢狀
判斷都三個引數是否是一個物件
。直接將這個物件賦值給 children
,並會返回 vnode
// h.js h函數 import vnode from "./vnode"; // 情況①:h('div', {}, '文字') // 情況②:h('div', {}, []) // 情況③:h('div', {}, h()) export default function (sel, data, c) { // 判斷是否傳入三個引數 if (arguments.length !== 3) throw Error('傳入的引數必須是三個引數') // 判斷c的型別 if (typeof c === 'string' || typeof c === 'number') { // 情況① return vnode(sel, data, undefined, c, undefined) } else if(Array.isArray(c)) { // 情況② // 遍歷 let children = [] for(let i = 0; i < c.length; i++) { // 子元素必須是h函數 if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel'))) throw Error('陣列中有一項不是h函數') // 收集子節點 不需要執行 因為陣列裡面已經執行h函數來 children.push(c[i]) } return vnode(sel, data, children, undefined, undefined) } else if (typeof c === 'object' && c.hasOwnProperty('sel')) { // 直接將子節點放到children中 let children = [c] return vnode(sel, data, children, undefined, undefined) } else { throw Error('傳入的引數格式不對') } }
通過上面的程式碼,我們已經實現了一個簡單 h函數
的基本功能。
在講解 diff演演算法 之前,我們先來感受一下 diff演演算法 的強大之處。先利用 snabbdom 簡單來舉一個例子。
import { init, classModule, propsModule, styleModule, eventListenersModule, h, } from "snabbdom"; //建立出patch函數 const patch = init([ classModule, propsModule, styleModule, eventListenersModule, ]); //讓虛擬節點上樹 const container = document.getElementById("container"); const btn = document.getElementById("btn"); //建立虛擬節點 const myVnode1 = h('ul', {}, [ h('li', {}, 'A'), h('li', {}, 'B'), h('li', {}, 'C'), h('li', {}, 'D') ]) patch(container, myVnode1) const myVnode2 = h('ul', {}, [ h('li', {}, 'A'), h('li', {}, 'B'), h('li', {}, 'C'), h('li', {}, 'D'), h('li', {}, 'E'), ]) btn.addEventListener('click', () => { // 上樹 patch(myVnode1,myVnode2) })
當我們點接改變DOM的時候,發現會新增一個 li標籤 內容為 E,單單的點選事件,我們很難看出,是將 舊的虛擬DOM
全部替換掉 新的虛擬DOM
,然後再渲染成 真實DOM,還是直接在 舊的虛擬DOM
上直接在後面新增一個節點
,所以,在這裡我們可以巧妙的開啟測試工具,直接將標籤內容進行修改,如果點選之後是全部拆除,那麼標籤的內容就會發生改變,若內容沒有發生改變,則是將最後新增的。
點選改變 DOM 結構:
果然,之前修改的內容沒有發生變化,這一點,就可以驗證了是進行了 diff演演算法精細化的比較,以最小量進行更新
。
那麼問題就來了,如果我在前面新增一個節點呢?是不是也是像在最後新增一樣,直接在前面新增一個節點。我們不妨也來試一試看看效果:
... const container = document.getElementById("container"); const btn = document.getElementById("btn"); //建立虛擬節點 const myVnode1 = h('ul', {}, [ h('li', {}, 'A'), h('li', {}, 'B'), h('li', {}, 'C'), h('li', {}, 'D') ]) patch(container, myVnode1) const myVnode2 = h('ul', {}, [ h('li', {}, 'E'), // 將E移至前面 h('li', {}, 'A'), h('li', {}, 'B'), h('li', {}, 'C'), h('li', {}, 'D'), ]) btn.addEventListener('click', () => { // 上樹 patch(myVnode1,myVnode2) })
點選改變 DOM 結構
哦豁!!跟我們想的不一樣,你會發現,裡面的文字內容全部發生了變化,也就是說將之前的 DOM 全部拆除,然後將新的重新上樹。這時候,你是不是在懷疑其實 diff演演算法 沒有那麼強大,但是你這樣想就大錯特錯了,回想一下在學習 Vue 的過程中,在遍歷DOM節點 的時候,是不是特別的強調要寫上key唯一識別符號
,此時,key在這裡就發揮了它的作用。 我們帶上key再來看一下效果:
... const myVnode1 = h('ul', {}, [ h('li', { key: "A" }, 'A'), h('li', { key: "B" }, 'B'), h('li', { key: "C" }, 'C'), h('li', { key: "D" }, 'D') ]) patch(container, myVnode1) const myVnode2 = h('ul', {}, [ h('li', { key: "E" }, 'E'), h('li', { key: "A" }, 'A'), h('li', { key: "B" }, 'B'), h('li', { key: "C" }, 'C'), h('li', { key: "D" }, 'D'), ]) ...
點選改變 DOM 結構
看到上面的結果,此時此刻,你是不是恍然大悟了,頓時知道了key在迴圈當中有什麼作用了吧。我們可以推出的結論一就是:
key是當前節點的唯一標識,告訴 diff演演算法
,在更改前後它們是同一個 DOM節點
。
當我們修改父節點,此時新舊虛擬DOM的父節點不是同一個節點,繼續來觀察一下 diff演演算法是如何分析的
const myVnode1 = h('ul', {}, [ h('li', { key: "A" }, 'A'), h('li', { key: "B" }, 'B'), h('li', { key: "C" }, 'C'), h('li', { key: "D" }, 'D') ]) patch(container, myVnode1) const myVnode2 = h('ol', {}, [ h('li', { key: "A" }, 'A'), h('li', { key: "B" }, 'B'), h('li', { key: "C" }, 'C'), h('li', { key: "D" }, 'D'), ])
點接改變 DOM結構
你會發現,這裡將舊節點進行了全部的拆除,然後重新將新節點上樹。我們可以推出的結論二就是:
只有是同一個虛擬節點
,diff演演算法
才進行精細化比較,否則就是暴力刪除舊的、插入新的。判斷同一個虛擬節點的依據:選擇器(sel)相同且key相同。
那麼如果是同一個虛擬節點,但是子節點裡面不是同一層在比較的呢?
const myVnode1 = h('div', {}, [ h('li', { key: "A" }, 'A'), h('li', { key: "B" }, 'B'), h('li', { key: "C" }, 'C'), h('li', { key: "D" }, 'D') ]) patch(container, myVnode1) const myVnode2 = h('div', {}, h('section', {}, [ h('li', { key: "A" }, 'A'), h('li', { key: "B" }, 'B'), h('li', { key: "C" }, 'C'), h('li', { key: "D" }, 'D'), ] ))
點選改變DOM結構
你會發現,此時DOM結構同多了一層 section標籤 包裹著,然後,文字的內容也發生了變化,所以我們可以推出結論三:diff演演算法
只進行同層比較,不會進行跨層比較
。即使是同一片虛擬節點,但是跨層了,不進行精細化比較,而是暴力刪除舊的、然後插入新的。
綜上,我們得出diff演演算法的三個結論:
key
是當前節點的唯一標識,告訴 diff演演算法
,在更改前後它們是同一個 DOM節點
。
只有是同一個虛擬節點
,diff演演算法
才進行精細化比較,否則就是暴力刪除舊的、插入新的。判斷同一個虛擬節點的依據:選擇器(sel)相同
且 key相同
。
diff演演算法
只進行同層比較,不會進行跨層比較
。即使是同一片虛擬節點,但是跨層了,不進行精細化比較,而是暴力刪除舊的、然後插入新的。
看到這裡,相信你已經對 diff演演算法 已經有了很大的收穫了。
patch函數 的主要作用就是:判斷是否是同一個節點型別,是就在進行精細化對比,不是就進行暴力刪除,插入新的
。
我們在可以簡單的畫出patch函數現在的主要流程圖如下:
// patch.js patch函數 import vnode from "./vnode"; import sameVnode from "./sameVnode"; import createElement from "./createElement"; export default function (oldVnode, newVnode) { // 判斷oldVnode是否是虛擬節點 if (oldVnode.sel == '' || oldVnode.sel == undefined) { // console.log('不是虛擬節點'); // 建立虛擬DOM oldVnode = emptyNodeAt(oldVnode) } // 判斷是否是同一個節點 if (sameNode(oldVnode, newVnode)) { console.log('是同一個節點'); } else { // 暴力刪除舊節點,插入新的節點 // 傳入兩個引數,建立的節點 插入到指定標杆的位置 createElement(newVnode, oldVnode.elm) } } // 建立虛擬DOM function emptyNodeAt (elm) { return vnode(elm.tagName.toLowerCase(), {}, [], undefined, elm) }
在進行上DOM上樹之前,我們需要了解一下DOM中的insertBefore()方法、appendChild()方法,因為,只有你真正的知道它們兩者的用法,才會讓你在下面手寫上樹的時候更加的清晰。
appendChild()方法
appendChild() 方法
:可以向節點的子節點列表的末尾新增新的子節點。比如:appendChild(newchild)。
注意: appendChild()方法是在父節點中的子節點的末尾
新增新的節點。(相對於父節點來說)。
<body> <div class="box"> <span>青峰</span> <ul> <li>1</li> <li>2</li> </ul> </div> <script> const box = document.querySelector('.box') const appendDom = document.createElement('div') appendDom.style.backgroundColor = 'blue' appendDom.style.height = 100 + 'px' appendDom.style.width = 100 + 'px' // 在box裡面的末尾追加一個div box.appendChild(appendDom) </script> </body>
你會發現,建立的div是巢狀在box裡面的,div 屬於 box 的子節點,box 是 div 的子節點。
insertBefore()方法
insertBefore() 方法
:可在已有的位元組點前中插入一個新的子節點。比如:insertBefore(newchild,rechild)。
注意: insertBefore()方法是在已有的節點前
新增新的節點。(相對於子節點來說的)。
<body> <div class="box"> <span>青峰</span> <ul> <li>1</li> <li>2</li> </ul> </div> <script> const box = document.querySelector('.box') const insertDom = document.createElement('p') insertDom.innerText = '我是insertDOM' // 在body中 box前面新增新的節點 box.parentNode.insertBefore(insertDom, box) </script> </body>
我們發現,box 和 div 是同一層的,屬於兄弟節點。
處理不同節點
sameVnode 函數
作用:比較兩個節點是否是同一個節點
// sameVnode.js export default function sameVnode(oldVnode, newVnode) { return (oldVnode.data ? oldVnode.data.key : undefined) === (newVnode.data ? newVnode.data.key : undefined) && oldVnode.sel == newVnode.sel }
手寫第一次上樹
理解了上面的 appendChild()方法
、insertBefore()方法
之後,我們正式開始讓 真實DOM 上樹
,渲染頁面。
// patch.js patch函數 import vnode from "./vnode"; import sameVnode from "./sameVnode"; import createElement from "./createElement"; export default function (oldVnode, newVnode) { // 判斷oldVnode是否是虛擬節點 if (oldVnode.sel == '' || oldVnode.sel == undefined) { // console.log('不是虛擬節點'); // 建立虛擬DOM oldVnode = emptyNodeAt(oldVnode) } // 判斷是否是同一個節點 if (sameNode(oldVnode, newVnode)) { console.log('是同一個節點'); } else { // 暴力刪除舊節點,插入新的節點 // 傳入兩個引數,建立的節點 插入到指定標杆的位置 createElement(newVnode, oldVnode.elm) } } // 建立虛擬DOM function emptyNodeAt (elm) { return vnode(elm.tagName.toLowerCase(), {}, [], undefined, elm) }
上面我們已經明確的知道,patch的作用就是判斷是否是同一個節點,所以,我們需要宣告一個createElement函數,用來建立真實DOM。
createElement 函數
createElement主要用來 建立子節點的真實DOM。
// createElement.js export default function createElement(vnode,pivot) { // 建立上樹的節點 let domNode = document.createElement(vnode.sel) // 判斷有文字內容還是子節點 if (vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) { // 文字內容 直接賦值 domNode.innerText = vnode.text // 上樹 往body上新增節點 // insertBefore() 方法:可在已有的位元組點前中插入一個新的子節點。相對於子節點來說的 pivot.parentNode.insertBefore(domNode, pivot) } else if (Array.isArray(vnode.children) && vnode.children.length > 0) { // 有子節點 } }
// index.js import patch from "./mysnabbdom/patch"; import h from './mysnabbdom/h' const container = document.getElementById("container"); //建立虛擬節點 const myVnode1 = h('h1', {}, '文字') patch(container, myVnode1)
我們已經將建立的真實DOM成功的渲染到頁面上去了,但是這只是實現了最簡單的一種情況,那就是 h函數 第三個引數是字串的情況,所以,當第三個引數是一個陣列的時候,是無法進行上樹的,下面我們需要將 createElement函數 再進一步的優化,實現遞迴上樹。
遞迴建立子節點
我們發現,在第一次上樹的時候,createElement函數
有兩個引數,分別是:newVnode
(新的虛擬DOM),標杆
(用來上樹插入到某個節點的位置),在createElement內部
我們是使用 insertBefore()方法
進行上樹的,使用這個方法我們需要知道已有的節點是哪一個,當然,當有 text
(第三個引數是字串或數位)的時候,我們是可以找到要插入的位置的,但是當有 children
(子節點)的時候,我們是無法確定標杆的位置的,所以,我們要將上樹的工作放到 patch函數
中,即 createElement函數
就只負責建立節點
。
// index.js import patch from "./mysnabbdom/patch"; import h from './mysnabbdom/h' const container = document.getElementById("container"); //建立虛擬節點 const myVnode1 = h('ul', {}, [ h('li', {}, 'A'), h('li', {}, 'B'), h('li', {}, 'C'), h('li', {}, 'D') ]) patch(container, myVnode1)
// patch.js import vnode from "./vnode"; import sameVnode from "./sameVnode"; import createElement from "./createElement"; export default function (oldVnode, newVnode) { // 判斷oldVnode是否是虛擬節點 if (oldVnode.sel == '' || oldVnode.sel == undefined) { // console.log('不是虛擬節點'); // 建立虛擬DOM oldVnode = emptyNodeAt(oldVnode) } // 判斷是否是同一個節點 if (sameNode(oldVnode, newVnode)) { console.log('是同一個節點'); } else { // 暴力刪除舊節點,插入新的節點 // 傳入引數為建立的虛擬DOM節點 返回以一個真實DOM let newVnodeElm = createElement(newVnode) console.log(newVnodeElm); // oldVnode.elm.parentNode 為body 在body中 在舊節點的前面新增新的節點 if (oldVnode.elm.parentNode && oldVnode.elm) { oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm) } // 刪除老節點 oldVnode.elm.parentNode.removeChild(oldVnode.elm) } } // 建立虛擬DOM function emptyNodeAt (elm) { return vnode(elm.tagName.toLowerCase(), {}, [], undefined, elm) }
完善 createElement 函數
// createElement.js只負責建立真正節點 export default function createElement(vnode) { // 建立上樹的節點 let domNode = document.createElement(vnode.sel) // 判斷有文字內容還是子節點 if (vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) { // 文字內容 直接賦值 domNode.innerText = vnode.text // 上樹 往body上新增節點 // insertBefore() 方法:可在已有的位元組點前中插入一個新的子節點。相對於子節點來說的 } else if (Array.isArray(vnode.children) && vnode.children.length > 0) { // 有子節點 for(let i = 0; i < vnode.children.length; i++) { // console.log(vnode.children[i]); let ch = vnode.children[i] // 進行遞迴 一旦呼叫createElement意味著 建立了DOM 並且elm屬性指向了建立好的DOM let chDom = createElement(ch) // 新增節點 使用appendChild 因為遍歷下一個之前 上一個真實DOM(這裡的domVnode)已經生成了 所以可以使用appendChild domNode.appendChild(chDom) } } vnode.elm = domNode return vnode.elm }
經過上面的分析,我們已經完成了對createElem函數的完善,可能你對這個遞迴有點不瞭解,那麼大概捋一下進行的過程:
新的虛擬DOM的sel
屬性為 ul,建立的真實DOM節點為 ul,執行 createElement函數
發現,新的虛擬DOM裡面有children屬性
,children 屬性裡面又包含 h函數。呼叫crateElement函數
建立真實DOM
,上面第一次呼叫createElement的時候已經建立了ul,執行完第一項返回建立的虛擬DOM,然後使用 appendChild方法()
追加到 ul中,依次類推,執行後面的陣列項。所有真實DOM
返回出去,在 patch函數
中上樹。執行上面的程式碼,測試結果如下:
完美!我們成功的將遞迴子節點完成了,無論巢狀多少層,我們都可以通過遞迴將子節點渲染到頁面上。
前面,我們實現了不是同一個節點的時候,進行刪除舊節點和插入新節點的操作,下面,我們來實現是相同節點時的相關操作,這也是文章中最重要的部分,diff演演算法
就包含在其中!!!
處理相同節點
上面的 patch函數
流程圖中,我們已經處理了不同節點的時候,進行暴力刪除舊的節點,然後插入新的節點,現在我們進行處理相同節點的時候,進行精細化的比較,繼續完善 patch函數 的主流程圖:
看到上面的流程圖,你可能會有點疑惑,為什麼不在 newVnode 是否有 Text屬性 中繼續判斷 oldVnode 是否有 children 屬性而是直接判斷兩者之間的 Text 是否相同,這裡需要提及一個知識點
,當我們進行 DOM操作的時候,文字內容替換DOM的時候,會自動將DOM結構全部銷燬掉
,innerText改變了,DOM結構也會隨之被銷燬,所以這裡可以不用判斷 oldVnode 是否存在 children 屬性,如果插入DOM節點,此時的Text內容並不會被銷燬掉,所以我們需要手動的刪除。
這也是為什麼在流程圖後面,我們新增 newVnode 的children 的時候需要將 oldVnode 的 Text 手動刪除,而將 newVnode 的 Text 直接賦值
給oldVnode.elm.innerText
的原因。
知道上面流程圖是如何工作了,我們繼續來書寫patch函數中是同一個節點的程式碼。
// patch.js import vnode from "./vnode"; import sameVnode from "./sameVnode"; import createElement from "./createElement"; export default function (oldVnode, newVnode) { // 判斷oldVnode是否是虛擬節點 if (oldVnode.sel == '' || oldVnode.sel == undefined) { // console.log('不是虛擬節點'); // 建立虛擬DOM oldVnode = emptyNodeAt(oldVnode) } // 判斷是否是同一個節點 if (sameNode(oldVnode, newVnode)) { console.log('是同一個節點'); // 是否是同一個物件 if (oldVnode === newVnode) return // newVnode是否有text if (newVnode.text && (newVnode.children == undefined || newVnode.children.length == 0)) { // 判斷newVnode和oldVnode的text是否相同 if (!(newVnode.text === oldVnode.text)) { // 直接將text賦值給oldVnode.elm.innerText 這裡會自動銷燬oldVnode的cjildred的DOM結構 oldVnode.elm.innerText = newVnode.text } // 意味著newVnode有children } else { // oldVnode是否有children屬性 if (oldVnode.children != undefined && oldVnode.children.length > 0) { // oldVnode有children屬性 } else { // oldVnode沒有children屬性 // 手動刪除 oldVnode的text oldVnode.elm.innerText = '' // 遍歷 for (let i = 0; i < newVnode.children.length; i++) { let dom = createElement(newVnode.children[i]) // 追加到oldvnode.elm中 oldVnode.elm.appendChild(dom) } } } } else { // 暴力刪除舊節點,插入新的節點 // 傳入引數為建立的虛擬DOM節點 返回以一個真實DOM let newVnodeElm = createElement(newVnode) console.log(newVnodeElm); // oldVnode.elm.parentNode 為body 在body中 在舊節點的前面新增新的節點 if (oldVnode.elm.parentNode && oldVnode.elm) { oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm) } // 刪除老節點 oldVnode.elm.parentNode.removeChild(oldVnode.elm) } } // 建立虛擬DOM function emptyNodeAt(elm) { return vnode(elm.tagName.toLowerCase(), {}, [], undefined, elm) }
.... //建立虛擬節點 const myVnode1 = h('ul', {}, 'oldVnode有text') patch(container, myVnode1) const myVnode2 = h('ul', {}, [ h('li', {}, 'A'), h('li', {}, 'B'), h('li', {}, 'C'), h('li', {}, 'D') ]) btn.addEventListener('click', () => { patch(myVnode1, myVnode2) })
oldVnode 有 tex屬性 和 newVnode 有 children屬性 的效果如下:
... //建立虛擬節點 const myVnode1 = h('ul', {}, [ h('li', {}, 'A'), h('li', {}, 'B'), h('li', {}, 'C'), h('li', {}, 'D') ]) patch(container, myVnode1) const myVnode2 = h('ul', {}, 'newVnode 有text') btn.addEventListener('click', () => { patch(myVnode1, myVnode2) })
oldVode 有children屬性 和 newVnode 有 text屬性 的效果如下:
完美!現在我們就只差最後diff算了。
patchVnode 函數
在patch函數中,我們需要將同同一節點的比較分成一個單獨的模組patchVnode函數,方便我們在diff演演算法中進行遞迴運算。
patchVnode函數的主要作用就是:
判斷newVnode
和oldVnode
是否指向同一個物件,如果是,那麼直接return
如果他們都有text並且不相等 或者 oldVnode
有子節點而newVnode
沒有,那麼將oldVnode.elm
的文位元組點設定為newVnode
的文位元組點。
如果oldVnode
沒有子節點而newVnode
有,則將newVnode
的子節點真實化之後新增到oldVnode.elm
後面,然後刪除 oldVnode.elm
的 text
如果兩者都有子節點,則執行updateChildren
函數比較子節點,這一步很重要
// patchVnode.js export default function patchVnode(oldVnode, newVnode) { // 是否是同一個物件 if (oldVnode === newVnode) return // newVnode是否有text if (newVnode.text && (newVnode.children == undefined || newVnode.children.length == 0)) { // 判斷newVnode和oldVnode的text是否相同 if (!(newVnode.text === oldVnode.text)) { // 直接將text賦值給oldVnode.elm.innerText 這裡會自動銷燬oldVnode的cjildred的DOM結構 oldVnode.elm.innerText = newVnode.text } //說明 newVnode有 children } else { // oldVnode是否有children屬性 if (oldVnode.children != undefined && oldVnode.children.length > 0) { // oldVnode有children屬性 } else { // oldVnode沒有children屬性 // 手動刪除 oldVnode的text oldVnode.elm.innerText = '' // 遍歷 for (let i = 0; i < newVnode.children.length; i++) { let dom = createElement(newVnode.children[i]) // 追加到oldvnode.elm中 oldVnode.elm.appendChild(dom) } } } }
diff演演算法
精細化比較:diff演演算法 四種優化策略
這裡使用雙指標
的形式進行diff演演算法的比較,分別是舊前、舊後、新前、新後指標,(前指標往下移動,後指標往上移動
)
四種優化策略:(命中:key 和 sel 都要相同)
新前與舊前
新後與舊後
新後與舊前
新前與舊後
注意: 當只有第一種不命中的時候才會採取第二種,依次類推,如果四種都不命中,則需要通過迴圈
來查詢。
命中指標才會移動,否則不移動。
①、新前與舊前
如果就舊節點先回圈完畢,說明需要新節點中有需要插入的節點
。
②、新後與舊後
如果新節點先回圈完畢,舊節點還有剩餘節點,說明舊節點中有需要刪除的節點。
多刪除情況:當只有情況①命中,剩下三種都沒有命中,則需要進行迴圈遍歷,找到舊節點中對應的節點
,然後在舊的虛擬節點中將這個節點設定為undefined
。刪除的節點為舊前與舊後之間(包含舊前、舊後)。
③、新後與舊前
當③新後與舊前命中
的時候,此時要移動節點,移動 新後指向的節點
到舊節點的 舊後的後面
,並且找到舊節點中對應的節點
,然後在舊的虛擬節點中將這個節點設定為undefined
。
④、新前與舊後
當④新前與舊後
命中的時候,此時要移動節點,移動新前
指向的這個節點到舊節點的 舊前的前面
,並且找到舊節點中對應的節點
,然後在舊的虛擬節點中將這個節點設定為undefined
。
好了,上面通過動態講解的四種命中方式之後,動態gif圖片有水印,看著可能不是很舒服,但當然能夠理解是最重要的,那麼我們開始手寫 diff演演算法 的程式碼。
updateChildren 函數
updateChildren()方法
主要作用就是進行精細化比較,然後更新子節點
。這裡程式碼比較多,需要耐心的閱讀。
import createElement from "./createElement"; import patchVnode from "./patchVnode"; import sameVnode from "./sameVnode"; export default function updateChildren(parentElm, oldCh, newCh) { //parentElm 父節點位置 用來移動節點 oldCh舊節點children newCh新節點children // console.log(parentElm, oldCh, newCh); // 舊前 let oldStartIndex = 0 // 舊後 let oldEndIndex = oldCh.length - 1 // 新前 let newStartIndex = 0 // 舊後 let newEndIndex = newCh.length - 1 // 舊前節點 let oldStartVnode = oldCh[0] // 舊後節點 let oldEndVnode = oldCh[oldEndIndex] // 新前節點 let newStartVnode = newCh[0] // 新後節點 let newEndVnode = newCh[newEndIndex] // 儲存mapkey let keyMap // 迴圈 條件 舊前 <= 舊後 && 新前 <= 新後 while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 首先需要判斷是否已經處理過了 if (oldCh[oldStartIndex] == undefined) { oldStartVnode = oldCh[++oldStartIndex] } else if (oldCh[oldStartIndex] == undefined) { oldEndVnode = oldCh[--oldEndIndex] } else if (newCh[newStartIndex] == undefined) { newStartVnode = newCh[++newStartIndex] } else if (newCh[newEndIndex] == undefined) { newEndVnode = newCh[--newEndIndex] } else if (sameVnode(oldStartVnode, newStartVnode)) { // ①、新前與舊前命中 console.log('①、新前與舊前命中'); //呼叫 patchVnode 對比兩個節點的 物件 文字 children patchVnode(oldStartVnode, newStartVnode) // 指標下移改變節點 oldStartVnode = oldCh[++oldStartIndex] newStartVnode = newCh[++newStartIndex] } else if (sameVnode(oldEndVnode, newEndVnode)) { // ②、新後與舊後命中 console.log('②、新後與舊後命中'); //呼叫 patchVnode 對比兩個節點的 物件 文字 children patchVnode(oldStartVnode, newStartVnode) // 指標下移並改變節點 oldEndVnode = oldCh[--oldEndIndex] newEndVnode = newCh[--newEndIndex] } else if (sameVnode(oldStartVnode, newEndVnode)) { // ③、新後與舊前命中 patchVnode(oldStartVnode, newEndVnode) console.log(newEndVnode); // 移動節點 當③新後與舊前命中的時候,此時要移動節點, // 移動 新後(舊前兩者指向的是同一節點) 指向的節點到舊節點的 舊後的後面,並且找到舊節點中對應的節點,然後在舊的虛擬節點中將這個節點設定為undefined。 parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling) // 在上面動畫中 命中③是在舊節點的後面插入的 所以使用nextSibling // 指標下移並改變節點 oldStartVnode = oldCh[++oldStartIndex] newEndVnode = newCh[--newEndIndex] } else if (sameVnode(oldEndVnode, newStartVnode)) { // ④、新前與舊後命中 patchVnode(oldEndVnode, newStartVnode) // 移動節點 // 當`④新前與舊後`命中的時候,此時要移動節點,移動`新前(舊後指向同一個節點)`指向的這個節點到舊節點的 `舊前的前面`, //並且找到`舊節點中對應的節點`,然後在`舊的虛擬節點中將這個節點設定為undefined` parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm) //指標下移並改變節點 oldEndVnode = oldCh[--oldEndIndex] newStartVnode = newCh[++newStartIndex] } else { // 四種都沒有命中 console.log(11); //kepMap作為快取不用每次遍歷物件 if (!keyMap) { keyMap = {} // 遍歷舊的節點 for (let i = oldStartIndex; i <= oldEndIndex; i++) { // 獲取舊節點的key const key = oldCh[i].data.key if (key != undefined) { //key不為空 並且將key存放到keyMap物件中 keyMap[key] = i } } } // 取出newCh中的的key 並找出在keyMap中的位置 並對映到oldCh中 const oldIndex = keyMap[newStartVnode.key] if (oldIndex == undefined) { // 新增 console.log(111); parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm) } else { // 移動位置 // 取出需要移動的項 const elmToMove = oldCh[oldIndex] // 判斷是選擇器是否一樣 patchVnode(elmToMove, newStartVnode) // 標記已經處理過了 oldCh[oldIndex] = undefined // 移動節點 移動到舊前前面 因為舊前與舊後之間要被刪除 parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm) } // 只移動新的節點 newStartVnode = newCh[++newStartIndex] } } //迴圈結束 還有剩餘節點沒處理 if (newStartIndex <= newEndIndex) { //說明 新節點還有未處理的節點,意味著需要新增節點 console.log('新增節點'); // 建立標杆 console.log(newCh[newEndIndex + 1]); // 節點插入的標杆位置 官方原始碼的寫法 但是我們寫程式碼新的虛擬節點中,elm設定了undefined 所以這裡永遠都是會在後面插入 小bug // let before = newCh[newEndIndex + 1] == null ? null : newCh[newEndIndex + 1].elm // 若不想出現bug 可以在插入節點中直接oldCh[oldStartIndex].elm 但是也會出現不一樣的bug 所以重在學習思路 for (let i = newStartIndex; i <= newEndIndex; i++) { // 插入節點 因為舊節點遍歷完之後 新節點還有剩餘節點 這裡需要使用crateElement函數新建一個真實DOM節點 // insertBefore會自動識別null parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIndex].elm) } } else if (oldStartIndex <= oldEndIndex) { //說明舊節點還有剩餘節點還沒有處理 意味著需要刪除節點 console.log('刪除節點'); for (let i = oldStartIndex; i <= oldEndIndex; i++) { if(oldCh[i]) parentElm.removeChild(oldCh[i].elm) } } }
好了,以上就是 Vue2中 虛擬DO M和 diff演演算法 的閹割版程式碼,可能上面程式碼中有些許bug存在,但是這並不會影響你對diff演演算法的理解,只有你細心品味,肯定會有所收穫的!!! 最後淡淡我自己對虛擬DOM和diff演演算法的理解
在Javascript中,渲染 真實DOM
的開銷是非常大的,比如我們修改了某個資料,如果直接渲染到 真實DOM,會引起整個 DOM樹 的 迴流和重繪
。那麼有沒有可能實現只更新我們修改的那一小塊DOM而不會引起整個DOM更新?此時我們就需要先根據 真實DOM
生成 虛擬DOM
,當 虛擬DOM
某個節點的資料改變後會生成一個 新的Vnode
,然後 新的Vnode
和 舊的Vnodde
進行比較,發現有不一樣的地方就直接修改到 真實DOM 上
,然後使 舊的Vnode
的值變成 新的Vnode
。
diff演演算法
的過程就是 patch函數
的呼叫,比較新舊節點,一邊比較一邊給 真實的DOM 打修補程式。在採用 diff演演算法
比較新舊節點的時候,只會進行同層級的比較
。在 patch方法
中,首先比較新舊虛擬節
點是否是同一個節點,如果不是同一個節點
,那麼就會將舊的節點刪除掉,插入新的虛擬節點,然後再使用 createElement函數
建立 真實DOM,渲染到真實的 DOM樹。如果是同一個節點
,使用 patchVnode函數
比較新舊節點,包括屬性更新、文字更新、子節點更新,新舊節點均有子節點,則需要進行 diff演演算法
,呼叫updateChildren方法
,如果新節點沒有文字內容而舊節點有文字內容,則需要將舊節點的文字刪除,然後再增加子節點,如果新節點有文字內容,則直接替換舊節點的文字內容。
updateChildren方法
將新舊節點的子節點都提取出來,然後使用的是 雙指標
的方式進行四種優化策略
迴圈比較。分別是:①、新前與舊前比較 ②、新後與舊後比較 ③、新後與舊前比較 ④、新前與舊後比較。
如果四種優化策略方法均沒有命中,則會進行遍歷方法進行比較(原始碼中使用了Map物件進行了快取,加快了比較的速率
),如果設定了 key,就會使用key
進行比較,找到當前的新節點的子節點在 Map 中的對映位置
,如果不存在,則需要新增節點
,存在則需要移動節點
。最後,迴圈結束之後,如果新節點還有剩餘節點
,則說明需要新增節點
,如果舊節點還有剩餘節點
,則說明需要刪除節點
。
以上,就是我對Vue2中的 虛擬DOM 和 diff演演算法 的理解,希望讀完這篇文章對你理解Vue2中的虛擬DOM和diff演演算法有所幫助!!最後希望各位大佬能夠給個贊!!!!
(學習視訊分享:、)
以上就是一文徹底的弄懂Vue中的虛擬DOM和 Diff 演演算法的詳細內容,更多請關注TW511.COM其它相關文章!