小白的diff演算法試試水之旅

lhyt發表於2019-02-26

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內部的邏輯

考慮幾種情況:

  1. 兩個節點型別一樣,那我們應該對比他的屬性和子節點(ATTR)
  2. 兩個節點型別不一樣,我們把他視為被替換(REPLACE)
  3. 兩個節點都是文字節點,直接用等號比吧(TEXT)
  4. 節點被刪除(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,別吐槽了,我懂,以後會更新的

相關文章