0.前言
“孩子,你會唱diff算法嗎”
“twinkle,twinkle,diff start”
1. 主角1:Element構造函數
先介紹一下虛擬dom的數據結構,我們都知道源碼裏面有createElement函數,通過他創建虛擬dom,然後調用render函數。還記得VUE腳手架住入口文件那句足夠裝逼的h=>h(App)
嗎,其實就是類似createElement(App)這樣子的過程。我們看一下他簡單的結構:
createElement('ul',{class:'ul'},[ createElement('li',{class:'li'},['1']), createElement('li',{class:'li'},['2']) ]) 複製代碼
createElement (type, props, children)傳入三個參數,節點類型、屬性集合、子節點集合
function Element(type, props, children) { this.type = type this.props = props this.children = children || [] } function createElement (type, props, children) { return new Element(type, props, children) } 複製代碼
複製代碼,自己造兩個節點打印一下,在控制檯觀察一下。
2. 主角2:render函數
這個就是把虛擬dom轉化爲真正的dom的函數。vue裏面把虛擬節點叫做vnode,那我們翻版,也要翻版得像一點才行:
function render (vnode) { let el = document.createElement(vnode.type)//創建html元素 for(let key in vnode.props){//遍歷虛擬dom的屬性集合,給新建的html元素加上 el.setAttribute(key, vnode.props[key]) } vnode.children&&vnode.children.forEach(child=>{//遞歸子節點,如果是文本節點則直接插入 child = (child instanceof Element) ? render(child)://不是文本節點,則遞歸render document.createTextNode(child) el.appendChild(child) }) return el } 複製代碼
這個是真正的dom喔,是不是飢渴難耐了,那好,可以試一下document.body.appendChild(el)
,看見新節點沒
3. 大主角: diff函數
都虛擬dom了,還不diff幹啥呢。
function diff (oldTree, newTree) { const patches = {}//差異表記錄差異,這個記錄一個樹的所有差異 let index = 0//記錄開始索引,我們給節點編號用的 dfswalk(oldTree, newTree, index, patches)//先序深度優先遍歷,涉及到樹的遍歷,這是必須的 return patches } //老節點、新節點、第幾個節點、差異表 function dfswalk (oldNode, newNode, index, patches) { const currentPatch = [] //...一系列寫入差異的過程 //最後將當前差異數組寫入差異表 currentPatch.length && (patches[index] = currentPatch) } 複製代碼
3.1 結果預想
我們要的最終結果,大概是舊節點根據patches來變成新節點,最終結果的基本雛形:
let el = render(vnode)//老的虛擬dom樹生成老html節點 document.body.appendChild(el) //掛載dom節點 let patches = diff(vnode,newvnode) //對虛擬dom進行diff得到差異表 update (el, patches) //老節點根據差異表更新,這個函數包括了dom操作 複製代碼
3.2 深度優先搜索
我們現在要開始完善dfs內部的邏輯
考慮幾種情況:
- 兩個節點類型一樣,那我們應該對比他的屬性和子節點(ATTR)
- 兩個節點類型不一樣,我們把他視爲被替換(REPLACE)
- 兩個節點都是文本節點,直接用等號比吧(TEXT)
- 節點被刪除(DELETE)
function dfswalk (oldNode, newNode, index, patches) { const currentPatch = [] if(!newNode){//判斷節點是否被刪除,記錄被刪的index currentPatch.push({type: 'REMOVE',index}) }else if(typeof oldNode === 'string' && typeof newNode === 'string'){//處理文本節點 if(oldNode !== newNode){ currentPatch.push({type: 'TEXT',text:newNode}) } }else if(oldNode.type === newNode.type){//如果節點類型相同 //對比屬性 let patch = props_diff(oldNode.props, newNode.props) //如果屬性有差異則寫入當前的差異數組 Object.keys(patch).length && (currentPatch.push({type: 'ATTR',patch})) //對比子節點 children_diff(oldNode.children, newNode.children, index, patches) }else{//節點類型不同 currentPatch.push({type: 'REPLACE',newNode}) } //將當前差異數組寫入差異表 currentPatch.length && (patches[index] = currentPatch) } 複製代碼
對比屬性:
我們傳入新節點和老節點的屬性集合,進行遍歷
function props_diff(oldProp, newProp){ const patch = {} //判斷新老屬性的差別 for(let k in oldProp){ //如果屬性不同,寫入patch,老屬性有,新屬性沒有或者不同,寫入差異表 oldProp[k] !== newProp[k] && (patch[k] = newProp[k]) } //新節點新屬性 for(let k in newProp){ //判斷老節點的屬性在新節點裏面是否存在,沒有就寫入patch !oldProp.hasOwnProperty(k) && (patch[k] = newProp[k]) } return patch } 複製代碼
對比子節點:
let allIndex = 0 function children_diff (oldChildren, newChildren, index, patches) { //對每一個子節點深度優先遍歷 oldChildren&&oldChildren.forEach((child,i)=>{ //allIndex在每一次進dfs的時候要加一,作爲唯一key。注意這個是全局的、共有的allIndex,表示節點樹的哪一個節點,0是根節點,子節點再走一遍dfs dfswalk(child, newChildren[i], ++allIndex, patches) }) } 複製代碼
4. 更新
前面我們已經大概構思了一個最終雛形:update (el, patches)
,我們順着這條路開始吧
let allPatches //全局存放差異表 //這裏是真的html元素喔,接下來是dom操作了 function update (HTMLNode, patches) {//根據差異表更新html元素,vnode轉換爲真正的節點 allPatches = patches htmlwalk(HTMLNode)//遍歷節點,最開始從第一個節點遍歷 } 複製代碼
let Index = 0//索引從第一個節點開始,同上面的allIndex一樣的道理,全局標記 function htmlwalk (HTMLNode) { const currentPatch = allPatches[Index++]//遍歷一個節點,就下一個節點 const childNodes = HTMLNode.childNodes //有子節點就後序深度優先遍歷 childNodes && childNodes.forEach(node=>{ htmlwalk (node) }) //對當前的差異數組進行遍歷,根據差異還原元素 currentPatch && currentPatch.length && currentPatch.forEach(patch=>{ doPatch(HTMLNode, patch)//根據差異還原 }) } 複製代碼
差異還原:
function doPatch (node, patch) {//還原過程,其實就是dom操作 switch (patch.type) { case 'REMOVE' ://熟悉的刪除節點操作 node.parentNode.removeChild(node) break case 'TEXT' ://熟悉的textContent node.textContent = patch.text break case 'ATTR' : for(let k in patch.patch){//熟悉的setAttribute const v = patch.patch[k] if(v){ node.setAttribute(k, v) }else{ node.removeAttribute(k) } } break case 'REPLACE' ://如果是元素節點,用render渲染出來替換掉。如果是文本,自己新建一個 const newNode = (patch.newNode instanceof Element) ? render(patch.newNode) : document.createTextNode(patch.newNode) node.parentNode.replaceChild(newNode, node) break } } 複製代碼
5. 完成
已經完成了,我們試一下吧:
//隨便命名的,就別計較了 //創建虛擬dom var v = createElement('ul',{class:'ul'},[ createElement('li',{class:'li'},['a']), createElement('li',{class:'li1'},['b']), createElement('li',{class:'a'},['c']) ]) //dom diff var d = diff(v,createElement('ul',{class:'ul'},[ createElement('li',{class:'li'},['aaaaaaaaaaa']), createElement('div',{class:'li'},['b']), createElement('li',{class:'li'},['b']) ])) //vnode渲染成真正的dom var el = render(v) //掛載dom document.body.appendChild(el) //diff後更新dom update (el, d) 複製代碼
全部代碼:(希望大家別來這裏複製,一步步看下來自己做一遍是最好的)
function Element(type, props, children) { this.type = type this.props = props this.children = children || [] } function createElement (type, props, children) { return new Element(type, props, children) } //將vnode轉化爲真正的dom function render (vnode) { let el = document.createElement(vnode.type) for(let key in vnode.props){ el.setAttribute(key, vnode.props[key]) } vnode.children&&vnode.children.forEach(child=>{//遞歸節點,如果是文本節點則直接插入 child = (child instanceof Element) ? render(child): document.createTextNode(child) el.appendChild(child) }) return el } let allIndex = 0 function diff (oldTree, newTree) { const patches = {}//差異表記錄差異 let index = 0//記錄開始索引 dfswalk(oldTree, newTree, index, patches)//先序深度優先遍歷 return patches } function dfswalk (oldNode, newNode, index, patches) { const currentPatch = [] if(!newNode){//判斷節點是否被刪除,記錄被刪的index currentPatch.push({type: 'REMOVE',index}) }else if(typeof oldNode === 'string' && typeof newNode === 'string'){//處理文本節點 if(oldNode !== newNode){ currentPatch.push({type: 'TEXT',text:newNode}) } }else if(oldNode.type === newNode.type){//如果節點類型相同 //對比屬性 let patch = props_diff(oldNode.props, newNode.props) //如果屬性有差異則寫入當前的差異數組 Object.keys(patch).length && (currentPatch.push({type: 'ATTR',patch})) //對比子節點 children_diff(oldNode.children, newNode.children, index, patches) }else{//節點類型不同 currentPatch.push({type: 'REPLACE',newNode}) } //將當前差異數組寫入差異表 currentPatch.length && (patches[index] = currentPatch) } function children_diff (oldChildren, newChildren, index, patches) { //對每一個子節點深度優先遍歷 oldChildren.forEach((child,i)=>{ //index在每一次進dfs的時候要加一,作爲唯一key dfswalk(child, newChildren[i], ++allIndex, patches) }) } function props_diff(oldProp, newProp){ const patch = {} //判斷新老屬性的差別 for(let k in oldProp){ //如果屬性不同,寫入patch oldProp[k] !== newProp[k] && (patch[k] = newProp[k]) } //新節點新屬性 for(let k in newProp){ //判斷老節點的屬性在新節點裏面是否存在,沒有就寫入patch !oldProp.hasOwnProperty(k) && (patch[k] = newProp[k]) } return patch } let allPatches//根據差異還原dom,記錄差異表 let Index = 0//索引從第一個節點開始 function update (HTMLNode, patches) {//根據差異表更新html元素,vnode轉換爲真正的節點 allPatches = patches htmlwalk(HTMLNode)//遍歷節點,最開始從第一個節點遍歷 } function htmlwalk (HTMLNode) { const currentPatch = allPatches[Index++]//遍歷一個節點,就下一個節點 const childNodes = HTMLNode.childNodes //有子節點就後序dfs childNodes && childNodes.forEach(node=>{ htmlwalk (node) }) //對當前的差異數組進行遍歷,根據差異還原元素 currentPatch && currentPatch.length && currentPatch.forEach(patch=>{ doPatch(HTMLNode, patch) }) } function doPatch (node, patch) { switch (patch.type) { case 'REMOVE' : node.parentNode.removeChild(node) break case 'TEXT' : node.textContent = patch.text break case 'ATTR' : for(let k in patch.patch){ const v = patch.patch[k] if(v){ node.setAttribute(k, v) }else{ node.removeAttribute(k) } } break case 'REPLACE' : const newNode = (patch.newNode instanceof Element) ? render(patch.newNode) : document.createTextNode(patch.newNode) node.parentNode.replaceChild(newNode, node) break } } 複製代碼
過程差不多是這樣子的。我寫的有很多bug,別吐槽了,我懂,以後會更新的