所謂虛擬DOM,就是 用JavaScript對象的方式去描述真實DOM。因爲真實DOM的建立、修改、刪除會形成頁面的重排和重繪, 頻繁操做真實DOM會影響頁面的性能,頁面中會有數據、樣式的更新, 操做真實DOM是不可避免的,而虛擬DOM的產生是爲了 最大限度的減小對真實DOM的操做,由於虛擬DOM能夠 將真實DOM操做映射爲JavaScript對象操做,儘可能複用真實的DOM。
好比如下一段HTML代碼,咱們能夠看到這是一個div元素節點,這個div元素節點上有一個屬性id,值爲container,而且這個div元素節點有兩個子節點,一個子節點是span元素節點,span元素節點有style屬性,屬性值爲color: red,span元素節點內也有一個子節點,hello文本節點;另外一個子節點是world文本節點
<div id="container"> hello world </div>
// 對應的JavaScript對象描述爲html
{ _type: "VNODE_TYPE", tag: "div", key: undefined, props: { "id": "container" }, children: [ { _type: "VNODE_TYPE", tag: undefined, key: undefined, props: undefined, children: undefined, text: "hello world", domNode: undefined } ], text: undefined, domNode: undefined }
本項目須要經過webpack進行打包、並經過webpack-dev-server啓動項目,因此須要安裝webpack
、webpack-cli
、webpack-dev-server
。
① 新建一個dom-diff項目,並執行npm init --yes
生成項目的package.json文件。
② 修改package.json文件,添加build和dev腳本,build用於webpack打包項目,dev用於webpack-dev-server啓動項目,如:node
// 修改package.json 文件的scripts部分webpack
{ "scripts": { "build": "webpack --mode=development", "dev": "webpack-dev-server --mode=development --contentBase=./dist" } }
③ 在項目根目錄下新建一個src目錄,而後在src目錄下,新建一個index.js文件,webpack默認入口文件爲src目錄下的index.js,默認輸出目錄爲 項目根目錄下的dist目錄web
// index.js文件初始化內容算法
console.log("hello virtual dom-diff.");
④ 首先執行npm run bulid打包輸出,會在項目根目錄下生成一個dist目錄,並在dist目錄下打包輸出一個main.js,而後在dist目錄下,新建一個index.html文件,其引入打包輸出後的main.js,如:npm
// dist/index.html文件內容json
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Vue DOM DIFF</title> <style> </style> </head> <body> <div id="app"></div> <script src="./main.js"></script> </body> </html>
⑤ 執行npm run dev啓動項目,而後在瀏覽器中輸入http://localhost:8080
,若是控制檯中輸出了hello virtual dom-diff.
表示項目初始化成功。數組
因爲虛擬DOM本質就是一個JavaScript對象,因此建立虛擬DOM節點就是建立一個JavaScript對象, 關鍵在於這個JavaScript對象上有哪些屬性。Vue中建立虛擬DOM節點使用的是 h()方法,因此要建立虛擬DOM,主要就是實現這個h()方法。咱們須要知道 要建立的虛擬DOM的標籤名tag、 屬性名對象props(有多個屬性)、 子節點數組children(有多個子節點)、 key(節點的惟一標識)、 text(若是是文本節點則有對應的text)、 真實DOM節點domNode、還有一個就是 節點類型_type(是不是虛擬DOM節點),如:
① 在src目錄下新建一個vdom
目錄,建立一個index.js、vnode.js、h.js。瀏覽器
// src/vdom/index.js主要是導出h.js中暴露的h()方法app
import h from "./h"; // 引入h方法 export { h // 對外暴露h方法 }
// src/vdom/vnode.js主要就是提供了一個vnode方法,用於接收虛擬DOM節點的屬性並生成對應的虛擬DOM節點
const VNODE_TYPE = "VNODE_TYPE"; // 虛擬DOM節點 function vnode(tag, key, props, children = [], text, domNode) { return { _type: VNODE_TYPE, // 表示這是一個虛擬DOM節點 tag, // 對應的標籤類型 key, // DOM節點的惟一標識 props, // DOM節點上對應的屬性集 children, // DOM節點的子節點 text, // DOM節點(文本節點)對應的文本內容 domNode // 建立的真實DOM節點 } } export default vnode;
// src/vdom/h.js主要就是提供了一個h()方法用於解析傳遞過來的參數,即從所有屬性中分離出key,而後建立對應的vnode
import vnode from "./vnode"; const hasOwnProperty = Object.prototype.hasOwnProperty; function h(tag, attrs, ...children) { const props = {}; // 屬性對象,移除key後的屬性集 let key; // 從所有屬性中分離出key值 if (attrs && attrs.key) { key = attrs.key; } // 迭代attrs中的每個屬性,生成一個將key移除後的屬性集對象 for(let propName in attrs) { if (hasOwnProperty.call(attrs, propName) && propName !== "key") { props[propName] = attrs[propName]; } } return vnode(tag, key, props, children.map((child) => { // 若是子節點是一個純文本節點,那麼生成一個文本節點對應的vnode(其餘屬性均爲undefined,可是text屬性爲對應文本) // 若是已是虛擬節點了,那麼直接返回便可 return typeof child == "string" || typeof child == "number" ? vnode( undefined, undefined, undefined, undefined, child ) : child; })); } export default h;
② 以後咱們就能夠經過h()方法建立虛擬節點了,修改項目根目錄下的index.js並建立對應的虛擬DOM節點,如:
// src/index.js
import { h } from "./vdom"; // 引入h()方法,用於建立虛擬DOM const oldVnode = h("div", {id: "container"}, h("span", {style: {color: "red"}}, "hello"), // 參數中的函數會先執行 "world" ); console.log(oldVnode);
要想將虛擬DOM節點mount出來,那麼必須 將虛擬DOM節點轉換爲真實的DOM節點, 而後將其添加進真實的DOM中。掛載DOM節點很是簡單,只須要獲取到真實的掛載點DOM元素,而後經過其append()方法便可掛載上去,因此 其關鍵點就在於將虛擬DOM轉換爲真實的DOM節點。
① 在vdom目錄中新建一個mount.js文件,裏面對外暴露一個mount()方法和createDOMElementByVnode()方法,如:
// src/vdom/mount.js
// 傳入一個新的虛擬DOM節點,和舊的虛擬DOM的props進行比較並更新 export function updateProperties(newVnode, oldProps = {}) { } // 經過虛擬DOM節點建立真實的DOM節點 export function createDOMElementByVnode(vnode) { } // mount方法用於接收一個虛擬DOM節點,和一個真實的父DOM節點,即掛載點 // mount方法內部會首先將這個虛擬DOM節點轉換爲真實的DOM節點,而後將其添加到真實的掛載點元素上 function mount(vnode, parentNode) { let newDOMNode = createDOMElementByVnode(vnode); // 將虛擬DOM轉換爲真實的DOM parentNode.append(newDOMNode); // 再將真實的DOM掛載到父節點中 } export default mount;
② 在src/vdom/index.js文件中引入mount.js中的mount()方法並對外暴露
// src/vdom/index.js文件
import h from "./h"; import mount from "./mount"; export { h, mount }
③ src/index.js中引入mount()方法並傳入虛擬DOM和掛載點對虛擬DOM進行掛載
// src/index.js文件
import { h, mount } from "./vdom"; // 引入h()方法,用於建立虛擬DOM const oldVnode = h("div", {id: "container"}, h("span", {style: {color: "red"}}, "hello"), // 參數中的函數會先執行 "world" ); console.log(oldVnode); // 掛載虛擬DOM const app = document.getElementById("app"); mount(oldVnode, app);
④ 接下來就是要實現createDOMElementByVnode()方法,將虛擬DOM轉換爲真實的DOM節點,就能夠將其掛載到id爲app的元素內了。其轉換過程主要爲:
// createDOMElementByVnode()方法實現
export function createDOMElementByVnode(vnode) { // 從虛擬DOM節點中獲取到對應的標籤類型及其中的子節點 const {tag, children} = vnode; if (tag) { // 若是虛擬DOM上存在tag,說明是元素節點,須要根據這個tag類型建立出對應的DOM元素節點 // 建立真實DOM元素並保存到虛擬DOM節點上的domNode屬性上,方便操做DOM添加屬性 vnode.domNode= document.createElement(tag); // 根據虛擬DOM的type建立出對應的DOM節點 // DOM節點建立出來以後,就須要更新DOM節點上的屬性了 updateProperties(vnode); // 更新虛擬DOM上的屬性,更新節點屬性 // DOM節點上的屬性更新完成後,就須要更新子節點了 if (Array.isArray(children)) { // 若是有children屬性,則遍歷子節點,將子節點添加進去,即更新子節點 children.forEach((child) => { const domNode = createDOMElementByVnode(child); // 遞歸遍歷子節點並繼續建立子節點對應的真實DOM元素 vnode.domNode.appendChild(domNode); }); } } else { // 若是虛擬DOM上不存在tag,說明是文本節點,直接建立一個文本節點便可 vnode.domNode = document.createTextNode(vnode.text); } return vnode.domNode; }
⑤ 此時已經把真實的DOM節點建立出來了,可是DOM節點上的屬性未更新,因此須要實現updateProperties()方法,其更新過程爲:
// 傳入一個新的虛擬DOM節點,和舊的虛擬DOM的props進行比較並更新 export function updateProperties(newVnode, oldProps = {}) { // 若是未傳遞舊節點屬性,那麼將舊節點屬性設置空對象 const newProps = newVnode.props; // 取出新虛擬DOM節點上的屬性對象 const domNode = newVnode.domNode; // 取出新虛擬DOM上保存的真實DOM節點方便屬性更新 // 先處理樣式屬性, 由於style也是一個對象 const oldStyle = oldProps.style || {}; const newStyle = newProps.style || {}; // 遍歷節點屬性對象中的style,若是老的樣式屬性在新的style樣式對象裏面沒有,則須要刪除, // 即新節點上沒有該樣式了,那麼須要刪除該樣式 for (let oldAttrName in oldStyle) { if (!newStyle[oldAttrName]) { domNode.style[oldAttrName] = ""; // 老節點上的樣式屬性,新節點上已經沒有了,則清空真實DOM節點上不存在的老樣式屬性 } } // 再處理非style屬性,把老的屬性對象中有,新的屬性對象中沒有的刪除 // 即新節點上沒有該屬性了,就須要刪除該屬性 for (let oldPropName in oldProps) { if (!newProps[oldPropName]) { domNode.removeAttribute(oldPropName); // 老節點上的屬性,新節點上已經沒有了,那麼刪除不存在的屬性 } } // 移除新節點上不存在的樣式和屬性後,遍歷新節點上的屬性,並將其更新到節點上 for (let newPropName in newProps) { if (newPropName === "style") { let styleObject = newProps.style; // 取出新的樣式對象 for (let newAttrName in styleObject) { domNode.style[newAttrName] = styleObject[newAttrName]; // 更新新老節點上都存在的樣式 } } else { domNode.setAttribute(newPropName, newProps[newPropName]); // 更新新老節點上都存在的屬性 } } }
DOM-DIFF算法的核心就是 對新舊虛擬DOM節點進行比較, 根據新舊虛擬DOM節點是否發生變化來決定是否複用該DOM。爲了模擬新舊節點變化,首先咱們建立一箇舊的虛擬DOM節點並mount出來,而後經過定時器,設置3秒後建立一個新的虛擬DOM節點並進行比較更新。
① 首先在src/vdom目錄下新建一個patch.js,裏面對外暴露一個patch(oldVnode, newVnode)方法,傳入新舊節點進行比較更新,patch方法具體實現後面實現,一樣的方式將patch()方法暴露出去,以便src/index.js可以引入這個patch()方法,這裏同上不重複了。
// src/vdom/patch.js
// 用於比較新舊虛擬DOM節點並進行相應的更新 function patch(oldVnode, newVnode) { } export default patch;
// 更新src/index.js
import { h, mount, patch } from "./vdom"; // 引入h()方法,用於建立虛擬DOM const oldVnode = h("ul", {id: "container"}, h("li", {style: {background: "red"}, key: "A"}, "A"), // 參數中的函數會先執行 h("li", {style: {background: "green"}, key: "B"}, "B"), h("li", {style: {background: "blue"}, key: "C"}, "C"), h("li", {style: {background: "yellow"}, key: "D"}, "D") ); console.log(oldVnode); // 掛載虛擬DOM const app = document.getElementById("app"); mount(oldVnode, app); // 首先將舊虛擬DOM節點mount出來 setTimeout(() => { const newVnode = h("div", {id: "container"}, "hello world"); // 3秒後建立一個新的虛擬DOM節點 patch(oldVnode, newVnode); // 新建虛擬DOM進行比較並更新 }, 3000);
② 實現patch()方法
patch主要用於比較新舊虛擬DOM節點的變化,根據不一樣的變化決定是否複用真實DOM,其存在比較多種狀況:
import {createDOMElementByVnode} from "./mount"; // 用於比較新舊虛擬DOM節點並進行相應的更新 function patch(oldVnode, newVnode) { // 1. 若是新的虛擬DOM節點類型tag不同,必須重建DOM if(oldVnode.tag !== newVnode.tag) { // 經過舊虛擬DOM的domNode獲取到其父節點而後調用createDOMElementByVnode()方法建立出新虛擬DOM節點對應的真實DOM,並替換掉舊節點 oldVnode.domNode.parentNode.replaceChild(createDOMElementByVnode(newVnode), oldVnode.domNode); } } export default patch;
function patch(oldVnode, newVnode) { // 1. 若是新的虛擬DOM節點類型tag不同,必須重建DOM if(oldVnode.tag !== newVnode.tag) { // 經過舊虛擬DOM的domNode獲取到其父節點而後調用createDOMElementByVnode()方法建立出新虛擬DOM節點對應的真實DOM,並替換掉舊節點 oldVnode.domNode.parentNode.replaceChild(createDOMElementByVnode(newVnode), oldVnode.domNode); } // 若是類型同樣,則複用當前父元素domElement,要繼續往下比較 const domNode = newVnode.domNode = oldVnode.domNode; // 獲取到新的或老的真實DOM節點,由於類型一致,因此新舊節點是同樣的能夠直接複用 // 首先判斷是元素節點仍是文本節點, 好比比較的是兩個文本節點,可是值不一樣,則直接更新文本節點的值便可 if (typeof newVnode.text !== "undefined") { // 若是新節點是一個文本節點 return oldVnode.domNode.textContent = newVnode.text; } // 父節點複用後,傳入新的虛擬DOM節點和老的屬性對象,更新DOM節點上的屬性 updateProperties(newVnode, oldVnode.props); // 更新子節點 let oldChildren = oldVnode.children; // 老的虛擬DOM節點的子節點數組 let newChildren = newVnode.children; // 新的虛擬DOM節點的子節點數組 if (oldChildren.length > 0 && newChildren.length > 0) { // 若是兩個li標籤而且都有兒子,那麼接着比較兩個兒子節點 // 若是新舊節點都有子節點,那麼繼續比較兒子節點,並進行相應更新 updateChildren(domNode, oldChildren, newChildren); } else if (oldChildren.length > 0) { // 老節點有子節點,新節點沒子節點 domNode.innerHTML = ""; // 直接清空 } else if (newChildren.length > 0) { // 老節點沒有子節點,新節點有子節點 for (let i = 0; i < newChildren.length; i++) { // 遍歷新節點上的子節點 domNode.appendChild(createDOMElementByVnode(newChildren[i])); // 建立對應的真實DOM並添加進去 } } }
③ 實現updateChildren()方法
對於上一步中提到第一種狀況,就新舊虛擬DOM節點中都有子節點的狀況,那麼咱們須要進一步比較其子節點,看子節點可否複用,子節點的比較又分爲五種狀況:
這裏先定義一下什麼的節點纔算是相同的節點?即 標籤名相同而且 key也相同,因此須要在src/vdom/vnode.js
中添加一個isSameNode()方法,傳遞新舊虛擬DOM節點比較兩個節點是不是相同的節點。
// src/vdom/vnode.js中添加一個isSameNode方法並對外暴露
export function isSameNode(oldVnode, newVnode) { // 若是兩個虛擬DOM節點的key同樣而且tag同樣,說明是同一種節點,能夠進行深度比較 return oldVnode.key === newVnode.key && oldVnode.tag === newVnode.tag; }
function updateChildren(parentDomNode, oldChildren, newChildren) { let oldStartIndex = 0; // 老的虛擬DOM節點子節點開始索引 let oldStartVnode = oldChildren[0]; // 老的虛擬DOM節點開始子節點(第一個子節點) let oldEndIndex = oldChildren.length - 1; // 老的虛擬DOM節點子節點結束索引 let oldEndVnode = oldChildren[oldEndIndex];// 老的虛擬DOM節點結束子節點(最後一個子節點) let newStartIndex = 0; // 新的虛擬DOM節點子節點開始索引 let newStartVnode = newChildren[0]; // 新的虛擬DOM節點開始子節點(第一個子節點) let newEndIndex = newChildren.length - 1; // 新的虛擬DOM節點子節點結束索引 let newEndVnode = newChildren[newEndIndex];// 新的虛擬DOM節點結束子節點(最後一個子節點) // 每次比較新舊虛擬DOM節點的開始索引或者結束索引都會進行向前或向後移動,每比較一次,新舊節點都會少一個,直到有一個隊列比較完成才中止比較 while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { if(isSameNode(oldStartVnode, newStartVnode)) { // 舊節點的第一個子節點和新節點的第一個子節點相同,即頭部相同,能夠複用 patch(oldStartVnode, newStartVnode); // 更新可複用的兩個隊列的頭部節點的屬性及其子節點 // 第一次新舊節點頭部比較完成後,頭部索引須要日後移,更新新舊節點的頭部節點位置 oldStartVnode = oldChildren[++oldStartIndex]; newStartVnode = newChildren[++newStartIndex]; } } // 因爲子節點數量不同,因此循環結束後,可能有一個隊列會多出一些還未比較的節點 // 若是舊節點的子節點比新節點的子節點數量少,那麼新節點則會有剩餘節點未比較完成 if (newStartIndex <= newEndIndex) { // 老的隊列處理完了,新的隊列沒有處理完 for (let i = newStartIndex; i <= newEndIndex; i++) { // 遍歷新隊列中多出的未比較的節點,這些節點確定沒法複用,必須建立真實的DOM並插入到隊列後面 // newEndIndex是會發生變化移動的,根據此時newEndIndex的值,將多出的節點插入到newEndIndex的後面或者說是newEndIndex + 1的前面 const beforeDOMNode = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].domNode; parentDomNode.insertBefore(createDOMElementByVnode(newChildren[i]), beforeDOMNode); // 爲了通用能夠用insertBefore代替appendChild,insertBefore第二參數爲null就是在末尾插入,不爲null則是在當前元素前插入 // parentDomNode.appendChild(createDOMElementByVnode(newChildren[i])); } } // 若是舊節點的子節點比新節點的子節點數量多,那麼舊節點則會有剩餘節點未比較完成 if (oldStartIndex <= oldEndIndex) { // 新的隊列處理完了,舊的隊列尚未處理完 for (let i = oldStartIndex; i <= oldEndIndex; i++) { // 遍歷舊隊列中多出的未比較的節點,並移除 parentDomNode.removeChild(oldChildren[i].domNode); } } }
const oldVnode = h("ul", {id: "container"}, h("li", {style: {background: "red"}, key: "A"}, "A") ); const newVnode = h("ul", {id: "container"}, h("li", {style: {background: "red"}, key: "A"}, "A1"), // 參數中的函數會先執行 h("li", {style: {background: "green"}, key: "B"}, "B") );
其比較過程就是: ① 舊節點與新節點的第一個子節點進行比較,因爲 key都爲A,因此是相同的節點, 直接調用patch()方法進行屬性更新,即將A更新爲A1 ② 新舊節點的頭部索引都加1,向後移,此時舊節點的全部子節點都比較完成了,因此 退出while循環 ③ 可是 新節點中還有一個B節點未比較,因此遍歷多出的未比較的子節點, 轉換成真實的DOM節點並追加到隊列末尾,便可完成 A 到 A B的更新,此時 A被複用了。
// 更新while循環,添加一個else if便可
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { if(isSameNode(oldStartVnode, newStartVnode)) { // 舊節點的第一個子節點和新節點的第一個子節點相同,即頭部相同,能夠複用 console.log("頭部相同"); } else if (isSameNode(oldEndVnode, newEndVnode)) { // 舊節點的最後一個子節點和新節點的最後一個子節點相同,即尾部相同,能夠複用 patch(oldEndVnode, newEndVnode); // 更新可複用的兩個隊列的尾部節點的屬性及其子節點 // 第一次新舊節點尾部比較完成後,尾部索引須要往前移,更新新舊節點的尾部節點位置 oldEndVnode = oldChildren[--oldEndIndex]; newEndVnode = newChildren[--newEndIndex]; } }
const oldVnode = h("ul", {id: "container"}, h("li", {style: {background: "red"}, key: "A"}, "A") ); const newVnode = h("ul", {id: "container"}, h("li", {style: {background: "green"}, key: "B"}, "B"), h("li", {style: {background: "red"}, key: "A"}, "A1"), // 參數中的函數會先執行 );
其比較過程就是: ① 舊節點與新節點的最後一個子節點進行比較,因爲 key都爲A,因此是相同的節點, 直接調用patch()方法進行屬性更新,即將A更新爲A1 ② 新舊節點的尾部索引都減1,向前移,此時舊節點的全部子節點都比較完成了,因此 退出while循環 ③ 可是 新節點中還有一個B節點未比較,因此遍歷多出的未比較的子節點, 轉換成真實的DOM節點並追加到隊列末尾,便可完成 A 到 B A的更新,此時 A被複用了。
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { if(isSameNode(oldStartVnode, newStartVnode)) { // 舊節點的第一個子節點和新節點的第一個子節點相同,即頭部相同,能夠複用 console.log("頭部相同"); } else if (isSameNode(oldEndVnode, newEndVnode)) { // 舊節點的最後一個子節點和新節點的最後一個子節點相同,即尾部相同,能夠複用 console.log("尾部相同"); } else if (isSameNode(oldEndVnode, newStartVnode)) { // 舊節點的最後一個子節點和新節點的第一個子節點相同,即尾頭相同,尾部節點能夠複用 console.log("尾頭相同"); patch(oldEndVnode, newStartVnode); // 更新可複用的兩個隊列的尾頭部節點的屬性及其子節點 // 尾部節點能夠複用,因此須要將舊節點的尾部移動到頭部 parentDomNode.insertBefore(oldEndVnode.domNode, oldStartVnode.domNode); // 舊節點的尾部移動到頭部後,至關於舊節點的尾部已經比較過了,舊節點的尾部節點位置須要更新,舊節點的尾部索引向前移 oldEndVnode = oldChildren[--oldEndIndex]; // 舊節點的尾部移動到頭部後,至關於新節點的頭部已經比較過了,新節點的頭部節點位置須要更新,下一次比較的是新節點原來頭部的下一個位置 newStartVnode = newChildren[++newStartIndex]; } }
const oldVnode = h("ul", {id: "container"}, h("li", {style: {background: "red"}, key: "A"}, "A"), h("li", {style: {background: "green"}, key: "B"}, "B"), h("li", {style: {background: "blue"}, key: "C"}, "C") ); const newVnode = h("ul", {id: "container"}, h("li", {style: {background: "blue"}, key: "C"}, "C1"), h("li", {style: {background: "red"}, key: "A"}, "A1"), // 參數中的函數會先執行 h("li", {style: {background: "green"}, key: "B"}, "B1") );
其比較過程就是: ① 舊節點的最後一個子節點與新節點的第一個子節點進行比較,因爲 key都爲C,因此是相同的節點, 直接調用patch()方法進行屬性更新,即將C更新爲C1,而且將C移動到頭部 ② 舊節點的尾部索引減1,向前移,新節點的頭部索引加1日後移,繼續while循環,此時新舊節點都剩下 A、B,又開始檢測頭部是否相同,頭部都爲A,故相同,此時將 A更新爲A1 ③ 此時新舊節點都剩下B,又開始檢測頭部是否相同,頭部都爲B,故相同,此時將B更新爲B1④此時新舊隊列都已經比較完成,退出while循環,便可完成 A B C 到 C A B的更新,此時 A、B、C都被複用了。
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { if(isSameNode(oldStartVnode, newStartVnode)) { // 舊節點的第一個子節點和新節點的第一個子節點相同,即頭部相同,能夠複用 console.log("頭部相同"); } else if (isSameNode(oldEndVnode, newEndVnode)) { // 舊節點的最後一個子節點和新節點的最後一個子節點相同,即尾部相同,能夠複用 console.log("尾部相同"); } else if (isSameNode(oldEndVnode, newStartVnode)) { // 舊節點的最後一個子節點和新節點的第一個子節點相同,即尾頭相同,尾部節點能夠複用 console.log("尾頭相同"); } else if (isSameNode(oldStartVnode, newEndVnode)) { // 舊節點的第一個子節點和新節點的最後一個子節點相同,即頭尾相同,頭部節點能夠複用 console.log("頭尾相同"); patch(oldStartVnode, newEndVnode); // 更新可複用的兩個隊列的頭尾部節點的屬性及其子節點 // 頭部節點能夠複用,因此須要將舊節點的頭部移動到尾部 parentDomNode.insertBefore(oldStartVnode.domNode, oldEndVnode.domNode.nextSibling); // 舊節點的頭部移動到尾部後,至關於舊節點的頭部已經比較過了,舊節點的頭部節點位置須要更新,舊節點的頭部索引向後移 oldStartVnode = oldChildren[++oldStartIndex]; // 舊節點的頭部移動到尾部後,至關於新節點的尾部已經比較過了,新節點的尾部節點位置須要更新,新節點的尾部索引向前移 newEndVnode = newChildren[--newEndIndex]; } }
const oldVnode = h("ul", {id: "container"}, h("li", {style: {background: "red"}, key: "A"}, "A"), h("li", {style: {background: "green"}, key: "B"}, "B"), h("li", {style: {background: "blue"}, key: "C"}, "C") ); const newVnode = h("ul", {id: "container"}, h("li", {style: {background: "green"}, key: "B"}, "B1"), h("li", {style: {background: "blue"}, key: "C"}, "C1"), h("li", {style: {background: "red"}, key: "A"}, "A1"), // 參數中的函數會先執行 );
其比較過程就是: ① 舊節點的第一個子節點與舊節點的最後一個子節點進行比較,因爲 key都爲A,因此是相同的節點, 直接調用patch()方法進行屬性更新,即將A更新爲A1,而且將A移動到尾部 ② 舊節點的頭部索引加1,向後移,新節點的尾部索引減1往前移,繼續while循環,此時新舊節點都剩下 B、C,又開始檢測頭部是否相同,頭部都爲B,故相同,此時將 B更新爲B1 ③ 此時新舊節點都剩下C,又開始檢測頭部是否相同,頭部都爲C,故相同,此時將C更新爲C1④此時新舊隊列都已經比較完成,退出while循環,便可完成 A B C 到 B C A的更新,此時 A、B、C都被複用了。
// 添加一個createKeyToIndexMap方法
// 生成key和index索引的對應關係 function createKeyToIndexMap(children) { let map = {}; for (let i = 0; i< children.length; i++) { let key = children[i].key; if (key) { map[key] = i; } } return map; }
const oldKeyToIndexMap = createKeyToIndexMap(oldChildren); // 生成對應的key和index映射關係 while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 進行順序錯亂比較後,會清空找到的節點,爲不影響前面四種狀況比較, 若是節點被清空了,須要進行相應的移動 if (!oldStartVnode) { // 若是舊的start節點被清空了,則舊的頭部索引日後移,更新頭部節點 oldStartVnode = oldChildren[++oldStartIndex]; } else if (!oldEndVnode) { // 若是舊的End節點被清空了,則舊的尾部索引往前移,更新尾部節點 oldEndVnode = oldChildren[--oldEndIndex]; } else if(isSameNode(oldStartVnode, newStartVnode)) { // 舊節點的第一個子節點和新節點的第一個子節點相同,即頭部相同,能夠複用 console.log("頭部相同"); } else if (isSameNode(oldEndVnode, newEndVnode)) { // 舊節點的最後一個子節點和新節點的最後一個子節點相同,即尾部相同,能夠複用 console.log("尾部相同"); } else if (isSameNode(oldEndVnode, newStartVnode)) { // 舊節點的最後一個子節點和新節點的第一個子節點相同,即尾頭相同,尾部節點能夠複用 console.log("尾頭相同"); } else if (isSameNode(oldStartVnode, newEndVnode)) { // 舊節點的第一個子節點和新節點的最後一個子節點相同,即頭尾相同,頭部節點能夠複用 console.log("頭尾相同"); } else { // 順序錯亂比較 console.log("順序錯亂"); let oldIndexByKey = oldKeyToIndexMap[newStartVnode.key]; // 傳入新節點的第一個子節點的key,獲取到對應的索引 if (oldIndexByKey == null) { // 若是索引爲null,那麼表示這是一個新的節點,沒法複用,直接建立並插入到舊節點中當前頭部的前面 parentDomNode.insertBefore(createDOMElementByVnode(newStartVnode), oldStartVnode.domNode); } else { // 若是索引不爲null,則找到了相同key的節點 const oldVnodeToMove = oldChildren[oldIndexByKey]; // 獲取到舊節點中具備相同key的節點 if (oldVnodeToMove.tag !== newStartVnode.tag) { // key相同可是類型不一樣,也要建立一個新的DOM,並插入到舊節點中當前頭部的前面 parentDomNode.insertBefore(createDOMElementByVnode(newStartVnode), oldStartVnode.domNode); } else { // 找到了相同key和tag都相同的元素,則可用複用 patch(oldVnodeToMove, newStartVnode); // 更新找到節點 oldChildren[oldIndexByKey] = undefined; // 將舊節點中找到的元素設爲undefined,清除找到節點 // 將找到的元素插入到oldStartVnode前面 parentDomNode.insertBefore(oldVnodeToMove.domNode, oldStartVnode.domNode); } } newStartVnode = newChildren[++newStartIndex]; // 比較新節點中的下一個子節點 } }
const oldVnode = h("ul", {id: "container"}, h("li", {style: {background: "red"}, key: "A"}, "A"), h("li", {style: {background: "green"}, key: "B"}, "B"), h("li", {style: {background: "blue"}, key: "C"}, "C") ); const newVnode = h("ul", {id: "container"}, h("li", {style: {background: "yellow"}, key: "D"}, "D"), // 參數中的函數會先執行 h("li", {style: {background: "green"}, key: "B"}, "B1"), h("li", {style: {background: "red"}, key: "A"}, "A1"), h("li", {style: {background: "blue"}, key: "C"}, "C1"), h("li", {style: {background: "green"}, key: "E"}, "E"), );
其比較過程就是: ① 因爲以上四種狀況都不符合,故進行 順序錯亂比較,首先調用createKeyToIndexMap方法拿到key和index的對應關係
② 重新節點的第一個子節點開始比較,即D,此時傳入其key爲D,到oldKeyToIndexMap映射對象中進行查找,確定找不到,爲null,故 不可複用,須要建立一個新節點並插入到頭部 ③ 此時舊節點中剩下 A、B、C,
新節點中剩下 B、A、C、E,仍然不匹配以上四種狀況, 再次進行順序錯亂比較,比較B,此時能夠在oldKeyToIndexMap映射對象中找到對應的索引爲1,而後將B更新爲B1,而後清空舊節點中的B,舊節點當前的頭部索引爲0,索引插入到A的前面 ④ 此時舊節點中剩下 A undefined C,新節點中剩下 A C E,此時符合 頭部相同的狀況,直接將A更新爲A1,舊節點中頭部索引日後移, 變爲undefined,新節點頭部索引也日後移,變爲C ⑤此時舊節點中剩下 undefined C,新節點中剩下 C E,再次進入while循環,因爲 舊節點的頭部節點變爲了undefined,故 舊節點頭部索引日後移動, 頭部節點變爲了C ⑥此時舊節點中 剩下C,新節點中仍是 剩下C E,此時符合 頭部相同,將C更新爲C1便可 ⑦此時 舊節點已經比較完成,新節點中 剩下一個E,直接遍歷E並建立DOM插入到末尾便可,此時完成了 A B C 到 D B A C E的更新。
虛擬DOM就是 用JavaScript對象來描述真實的DOM節點,主要包括 標籤名、 屬性集對象、 子節點數組、 節點類型、 節點惟一標識key、 文本節點內容text、 對應的真實DOM引用。而DOM-DIFF算法則是,經過新舊節點子節點的 頭頭、 尾尾、 頭尾、 尾頭、 key查找五種方式進行匹配, 找到key相同的虛擬DOM節點,而後 再根據虛擬DOM的tag判斷該節點是否能夠複用,若是 tag也相同,那麼能夠複用,則進行 差別化更新DOM節點屬性便可,若是 tag不一樣,那麼也不能複用,則須要 建立一個新的DOM節點並掛載上去,從而實現儘量的複用DOM。