該系列會逐步更新,完整的講解目前主流框架中底層相通的技術,接下來的代碼內容都會更新在 這里
為什么需要 Virtual Dom
眾所周知,操作 DOM 是很耗費性能的一件事情,既然如此,我們可以考慮通過 JS 對象來模擬 DOM 對象,畢竟操作 JS 對象比操作 DOM 省時的多。
舉個例子
// 假設這里模擬一個 ul,其中包含了 5 個 li
[1, 2, 3, 4, 5]
// 這里替換上面的 li
[1, 2, 5, 4]
從上述例子中,我們一眼就可以看出先前的 ul 中的第三個 li 被移除了,四五替換了位置。
如果以上操作對應到 DOM 中,那么就是以下代碼
// 刪除第三個 li
ul.childNodes[2].remove()
// 將第四個 li 和第五個交換位置
let fromNode = ul.childNodes[4]
let toNode = node.childNodes[3]
let cloneFromNode = fromNode.cloneNode(true)
let cloenToNode = toNode.cloneNode(true)
ul.replaceChild(cloneFromNode, toNode)
ul.replaceChild(cloenToNode, fromNode)
當然在實際操作中,我們還需要給每個節點一個標識,作為判斷是同一個節點的依據。所以這也是 Vue 和 React 中官方推薦列表里的節點使用唯一的 key
來保證性能。
那么既然 DOM 對象可以通過 JS 對象來模擬,反之也可以通過 JS 對象來渲染出對應的 DOM
以下是一個 JS 對象模擬 DOM 對象的簡單實現
export default class Element {/*** @param {String} tag 'div'* @param {Object} props { class: 'item' }* @param {Array} children [ Element1, 'text']* @param {String} key option*/constructor(tag, props, children, key) {this.tag = tagthis.props = propsif (Array.isArray(children)) {this.children = children} else if (isString(children)) {this.key = childrenthis.children = null}if (key) this.key = key}// 渲染render() {let root = this._createElement(this.tag,this.props,this.children,this.key)document.body.appendChild(root)return root}create() {return this._createElement(this.tag, this.props, this.children, this.key)}// 創建節點_createElement(tag, props, child, key) {// 通過 tag 創建節點let el = document.createElement(tag)// 設置節點屬性for (const key in props) {if (props.hasOwnProperty(key)) {const value = props[key]el.setAttribute(key, value)}}if (key) {el.setAttribute('key', key)}// 遞歸添加子節點if (child) {child.forEach(element => {let childif (element instanceof Element) {child = this._createElement(element.tag,element.props,element.children,element.key)} else {child = document.createTextNode(element)}el.appendChild(child)})}return el}
}
Virtual Dom 算法簡述
既然我們已經通過 JS 來模擬實現了 DOM,那么接下來的難點就在于如何判斷舊的對象和新的對象之間的差異。
DOM 是多叉樹的結構,如果需要完整的對比兩顆樹的差異,那么需要的時間復雜度會是 O(n ^ 3),這個復雜度肯定是不能接受的。于是 React 團隊優化了算法,實現了 O(n) 的復雜度來對比差異。
實現 O(n) 復雜度的關鍵就是只對比同層的節點,而不是跨層對比,這也是考慮到在實際業務中很少會去跨層的移動 DOM 元素。
所以判斷差異的算法就分為了兩步
- 首先從上至下,從左往右遍歷對象,也就是樹的深度遍歷,這一步中會給每個節點添加索引,便于最后渲染差異
- 一旦節點有子元素,就去判斷子元素是否有不同
Virtual Dom 算法實現
樹的遞歸
首先我們來實現樹的遞歸算法,在實現該算法前,先來考慮下兩個節點對比會有幾種情況
- 新的節點的
tagName
或者key
和舊的不同,這種情況代表需要替換舊的節點,并且也不再需要遍歷新舊節點的子元素了,因為整個舊節點都被刪掉了 - 新的節點的
tagName
和key
(可能都沒有)和舊的相同,開始遍歷子樹 - 沒有新的節點,那么什么都不用做
import { StateEnums, isString, move } from './util'
import Element from './element'export default function diff(oldDomTree, newDomTree) {// 用于記錄差異let pathchs = {}// 一開始的索引為 0dfs(oldDomTree, newDomTree, 0, pathchs)return pathchs
}function dfs(oldNode, newNode, index, patches) {// 用于保存子樹的更改let curPatches = []// 需要判斷三種情況// 1.沒有新的節點,那么什么都不用做// 2.新的節點的 tagName 和 `key` 和舊的不同,就替換// 3.新的節點的 tagName 和 key(可能都沒有) 和舊的相同,開始遍歷子樹if (!newNode) {} else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {// 判斷屬性是否變更let props = diffProps(oldNode.props, newNode.props)if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props })// 遍歷子樹diffChildren(oldNode.children, newNode.children, index, patches)} else {// 節點不同,需要替換curPatches.push({ type: StateEnums.Replace, node: newNode })}if (curPatches.length) {if (patches[index]) {patches[index] = patches[index].concat(curPatches)} else {patches[index] = curPatches}}
}
判斷屬性的更改
判斷屬性的更改也分三個步驟
- 遍歷舊的屬性列表,查看每個屬性是否還存在于新的屬性列表中
- 遍歷新的屬性列表,判斷兩個列表中都存在的屬性的值是否有變化
- 在第二步中同時查看是否有屬性不存在與舊的屬性列列表中
function diffProps(oldProps, newProps) {// 判斷 Props 分以下三步驟// 先遍歷 oldProps 查看是否存在刪除的屬性// 然后遍歷 newProps 查看是否有屬性值被修改// 最后查看是否有屬性新增let change = []for (const key in oldProps) {if (oldProps.hasOwnProperty(key) && !newProps[key]) {change.push({prop: key})}}for (const key in newProps) {if (newProps.hasOwnProperty(key)) {const prop = newProps[key]if (oldProps[key] && oldProps[key] !== newProps[key]) {change.push({prop: key,value: newProps[key]})} else if (!oldProps[key]) {change.push({prop: key,value: newProps[key]})}}}return change
}
判斷列表差異算法實現
這個算法是整個 Virtual Dom 中最核心的算法,且讓我一一為你道來。 這里的主要步驟其實和判斷屬性差異是類似的,也是分為三步
- 遍歷舊的節點列表,查看每個節點是否還存在于新的節點列表中
- 遍歷新的節點列表,判斷是否有新的節點
- 在第二步中同時判斷節點是否有移動
PS:該算法只對有 key
的節點做處理
function listDiff(oldList, newList, index, patches) {// 為了遍歷方便,先取出兩個 list 的所有 keyslet oldKeys = getKeys(oldList)let newKeys = getKeys(newList)let changes = []// 用于保存變更后的節點數據// 使用該數組保存有以下好處// 1.可以正確獲得被刪除節點索引// 2.交換節點位置只需要操作一遍 DOM// 3.用于 `diffChildren` 函數中的判斷,只需要遍歷// 兩個樹中都存在的節點,而對于新增或者刪除的節點來說,完全沒必要// 再去判斷一遍let list = []oldList &&oldList.forEach(item => {let key = item.keyif (isString(item)) {key = item}// 尋找新的 children 中是否含有當前節點// 沒有的話需要刪除let index = newKeys.indexOf(key)if (index === -1) {list.push(null)} else list.push(key)})// 遍歷變更后的數組let length = list.length// 因為刪除數組元素是會更改索引的// 所有從后往前刪可以保證索引不變for (let i = length - 1; i >= 0; i--) {// 判斷當前元素是否為空,為空表示需要刪除if (!list[i]) {list.splice(i, 1)changes.push({type: StateEnums.Remove,index: i})}}// 遍歷新的 list,判斷是否有節點新增或移動// 同時也對 `list` 做節點新增和移動節點的操作newList &&newList.forEach((item, i) => {let key = item.keyif (isString(item)) {key = item}// 尋找舊的 children 中是否含有當前節點let index = list.indexOf(key)// 沒找到代表新節點,需要插入if (index === -1 || key == null) {changes.push({type: StateEnums.Insert,node: item,index: i})list.splice(i, 0, key)} else {// 找到了,需要判斷是否需要移動if (index !== i) {changes.push({type: StateEnums.Move,from: index,to: i})move(list, index, i)}}})return { changes, list }
}function getKeys(list) {let keys = []let textlist &&list.forEach(item => {let keyif (isString(item)) {key = [item]} else if (item instanceof Element) {key = item.key}keys.push(key)})return keys
}
遍歷子元素打標識
對于這個函數來說,主要功能就兩個
- 判斷兩個列表差異
- 給節點打上標記
總體來說,該函數實現的功能很簡單
function diffChildren(oldChild, newChild, index, patches) {let { changes, list } = listDiff(oldChild, newChild, index, patches)if (changes.length) {if (patches[index]) {patches[index] = patches[index].concat(changes)} else {patches[index] = changes}}// 記錄上一個遍歷過的節點let last = nulloldChild &&oldChild.forEach((item, i) => {let child = item && item.childrenif (child) {index =last && last.children ? index + last.children.length + 1 : index + 1let keyIndex = list.indexOf(item.key)let node = newChild[keyIndex]// 只遍歷新舊中都存在的節點,其他新增或者刪除的沒必要遍歷if (node) {dfs(item, node, index, patches)}} else index += 1last = item})
}
渲染差異
通過之前的算法,我們已經可以得出兩個樹的差異了。既然知道了差異,就需要局部去更新 DOM 了,下面就讓我們來看看 Virtual Dom 算法的最后一步驟
這個函數主要兩個功能
- 深度遍歷樹,將需要做變更操作的取出來
- 局部更新 DOM
整體來說這部分代碼還是很好理解的
let index = 0
export default function patch(node, patchs) {let changes = patchs[index]let childNodes = node && node.childNodes// 這里的深度遍歷和 diff 中是一樣的if (!childNodes) index += 1if (changes && changes.length && patchs[index]) {changeDom(node, changes)}let last = nullif (childNodes && childNodes.length) {childNodes.forEach((item, i) => {index =last && last.children ? index + last.children.length + 1 : index + 1patch(item, patchs)last = item})}
}function changeDom(node, changes, noChild) {changes &&changes.forEach(change => {let { type } = changeswitch (type) {case StateEnums.ChangeProps:let { props } = changeprops.forEach(item => {if (item.value) {node.setAttribute(item.prop, item.value)} else {node.removeAttribute(item.prop)}})breakcase StateEnums.Remove:node.childNodes[change.index].remove()breakcase StateEnums.Insert:let domif (isString(change.node)) {dom = document.createTextNode(change.node)} else if (change.node instanceof Element) {dom = change.node.create()}node.insertBefore(dom, node.childNodes[change.index])breakcase StateEnums.Replace:node.parentNode.replaceChild(change.node.create(), node)breakcase StateEnums.Move:let fromNode = node.childNodes[change.from]let toNode = node.childNodes[change.to]let cloneFromNode = fromNode.cloneNode(true)let cloenToNode = toNode.cloneNode(true)node.replaceChild(cloneFromNode, toNode)node.replaceChild(cloenToNode, fromNode)breakdefault:break}})
}
最后
Virtual Dom 算法的實現也就是以下三步
- 通過 JS 來模擬創建 DOM 對象
- 判斷兩個對象的差異
- 渲染差異
let test4 = new Element('div', { class: 'my-div' }, ['test4'])
let test5 = new Element('ul', { class: 'my-div' }, ['test5'])let test1 = new Element('div', { class: 'my-div' }, [test4])let test2 = new Element('div', { id: '11' }, [test5, test4])let root = test1.render()let pathchs = diff(test1, test2)
console.log(pathchs)setTimeout(() => {console.log('開始更新')patch(root, pathchs)console.log('結束更新')
}, 1000)
當然目前的實現還略顯粗糙,但是對于理解 Virtual Dom 算法來說已經是完全足夠的了。
文章中的代碼你可以在?這里?閱讀。本系列更新的文章都會更新在這個倉庫中,有興趣的可以關注下。
下篇文章的內容將會是狀態管理,敬請期待。
原文發布時間為:2018年06月02日
原文作者:夕陽
本文來源:?掘金?如需轉載請聯系原作者