原文地址:https://github.com/livoras/blog/issues/13
从0手写自己的虚拟DOM - 简书
目录
一、用JS对象模拟DOM树
二、比较两棵虚拟DOM树的差异
2.1 深度优先遍历,记录差异
2.2 差异类型
2.3 列表对比算法
三、把差异应用到真正的DOM树上
四、试试效果
function Element(tagName, props, children) {
this.tagName = tagName
this.props = props
this.children = children
}
function createElement(tagName, props, children){
return new Element(tagName, props, children)
}
上面的DOM结构可以简单的表示:
let ul = createElement('ul', { id: 'list' }, [
createElement('li', { class: 'item' }, ['Item 1']),
createElement('li', { class: 'item' }, ['Item 2']),
createElement('li', { class: 'item' }, ['Item 3']),
])
查看ul结构
现在ul
只是一个 JavaScript 对象表示的 DOM 结构,页面上并没有这个结构。我们可以根据这个ul
构建真正的
:
Element.prototype.render = function () { // 渲染
let el = document.createElement(this.tagName)
let props = this.props
// console.log('props', props)
for (let propsName in props) { // 设置节点的DOM属性
el.setAttribute(propsName, props[propsName])
}
let children = this.children || []
children.forEach(child => {
let childEl = child instanceof Element
? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
: document.createTextNode(child) // 如果字符串,只构建文本节点
el.appendChild(childEl)
})
return el
}
挂在到界面上
let ulRoot = ul.render()
document.body.appendChild(ulRoot)
正如你所预料的,比较两棵DOM树的差异是 Virtual DOM 算法最核心的部分,这也是所谓的 Virtual DOM 的 diff 算法。两个树的完全的 diff 算法是一个时间复杂度为 O(n^3) 的问题。但是在前端当中,你很少会跨越层级地移动DOM元素。所以 Virtual DOM 只会对同一个层级的元素进行对比:
上面的div
只会和同一层级的div
对比,第二层级的只会跟第二层级对比。这样算法复杂度就可以达到 O(n)。
在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个唯一的标记:
在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个对象里面。
在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个对象里面。
function diff(oldTree, newTree) {
let patches = {}
let walks ={
index: 0
}
dfsWalk(oldTree, newTree, walks, patches)
return patches
}
function dfsWalk(oldTree, newTree, walks, patches) {
let patch = []
let index = walks.index
if (typeof oldTree === 'string' && typeof newTree === 'string') {
if (oldTree !== newTree) {
patch.push({
type: TEXT,
text: newTree
})
}
} else if (!newTree) {
patch.push({
type: REMOVE,
node: newTree
})
} else if (oldTree.tagName === newTree.tagName) {//决定props的同时,还需要处理children
let props = diffProps(oldTree, newTree)
if (JSON.stringify(props) !== '{}') {
patch.push({
type: PROPS,
props
})
}
diffChildren(oldTree.children, newTree.children, walks, patches)
} else {
patch.push({
type: REPLACE,
node: newTree
})
}
if (patch.length > 0) {
patches[index] = patch
}
}
function diffChildren(oldTree, newTree, walks, patches) {
if (oldTree && oldTree.length > 0) {
oldTree.forEach((oldchild, i) => {
walks.index++
let newChild = newTree[i]
dfsWalk(oldchild, newChild, walks, patches)
})
}
}
上面说的节点的差异指的是什么呢?对 DOM 操作可能会:
div
换成了section
div
的子节点,把p
和ul
顺序互换Virtual DOM 2
。所以我们定义了几种差异类型:
const TEXT = 0 // 文本节点
const PROPS = 1 // 属性节点
const REMOVE = 2 // 删除节点
const REPLACE = 3 // 替换节点
假设现在可以英文字母唯一地标识每一个子节点:
旧的节点顺序:
a b c d e f g h i
现在对节点进行了删除、插入、移动的操作。新增j
节点,删除e
节点,移动h
节点:
新的节点顺序:
a b c h d f g i j
现在知道了新旧的顺序,求最小的插入、删除操作(移动可以看成是删除和插入操作的结合)。这个问题抽象出来其实是字符串的最小编辑距离问题(Edition Distance),最常见的解决算法是 Levenshtein Distance,通过动态规划求解,时间复杂度为 O(M * N)。但是我们并不需要真的达到最小的操作,我们只需要优化一些比较常见的移动情况,牺牲一定DOM操作,让算法时间复杂度达到线性的(O(max(M, N))。具体算法细节比较多,这里不累述,有兴趣可以参考代码。
我们能够获取到某个父节点的子节点的操作,就可以记录下来:
patches[0] = [{
type: REORDER,
moves: [{remove or insert}, {remove or insert}, ...]
}]
但是要注意的是,因为tagName
是可重复的,不能用这个来进行对比。所以需要给子节点加上唯一标识key
,列表对比的时候,使用key
进行对比,这样才能复用老的 DOM 树上的节点。
这样,我们就可以通过深度优先遍历两棵树,每层的节点进行对比,记录下每个节点的差异了。完整 diff 算法代码可见 diff.js。
因为步骤一所构建的 JavaScript 对象树和render
出来真正的DOM树的信息、结构是一样的。所以我们可以对那棵DOM树也进行深度优先的遍历,遍历的时候从步骤二生成的patches
对象中找出当前遍历的节点差异,然后进行 DOM 操作。
function diffProps(oldProps, newProps) {
let props = {}
for (let key in oldProps) {
if (oldProps[key] !== newProps[key]) {
props[key] = newProps
}
}
for (let key in newProps) {
if (!oldProps.hasOwnProperty(key)) {
props[key] = newProps
}
}
return props
}
applyPatches,根据不同类型的差异对当前节点进行 DOM 操作:
function applyPatches(node, currentPatches) {
currentPatches.forEach(currentPatch => {
// console.log('currentPatch', currentPatch)
// console.log('node', node)
switch (currentPatch.type) {
case REPLACE:
node.parentNode.replaceChild(currentPatch.newTree.render(), node)
break
case REMOVE:
node.parentNode.removeChild( node)
break
case PROPS:
for(let key in currentPatch.props){
let propsVal = currentPatch.props[key][key]
if(propsVal){
if(key === 'value'){
if( node.tagName.toUpperCase() === 'INPUT'
|| node.tagName.toUpperCase() === 'TEXTAREA' ){
node.value = propsVal
}
} else {
node.setAttribute(key, propsVal)
}
}
}
break
case TEXT:
node.textContent = currentPatch.text
break
default:
throw new Error('Unknow patch type' + currentPatch.type)
}
})
}
let elRoot = tree.render()
document.body.appendChild(elRoot)
let newTree = createElement('div', {id: 'container'}, [
createElement('h1', {style:'color: red'}, ['s1imple virtal dom']),
// createElement('p',{style:'color: blue'}, ['hello virtual-dom']),
createElement('ul', {style:'color: blue'}, [createElement('li', {}, ['name'])]),
])
let patches = diff(tree, newTree)
patch(elRoot, patches)