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,別吐槽了,我懂,以後會更新的