实现虚拟DOM
原文来自:https://www.jianshu.com/p/af0b398602bc
例如一个真实的DOM节点
<div id="real-container">
<p class="my-class">Real Dom</p>
</div>
用JS模拟
{
tagName : 'div',
props : {},
children : [
{
tagName : 'p',
props : {
class : 'my-class'
},
key : '',
count : 0
}
],
key : '',
count : 0 // 子元素数量(不是后代元素)
}
这样就模拟出了一个虚拟dom树结构
虚拟DOM
我们再把构造函数写出来
function Element(type, props, children) {
// 构造函数的安全模式
if (!(this instanceof Element)) {
return new Element(type, props, children)
}
this.type = type
this.props = props || {}
this.children = children || []
this.key = props ? props.key : undefined
// 子元素个数 用于先序遍历时记录的index
let count = 0;
this.children.forEach(child => {
if (child instanceof Element) {
count += child.count
}
count++
})
this.count = count
}
实际上,上面这种函数,可以直接生成一颗树。
const oldTree = Element('div', { id: 'virtual-root' }, [
Element('h1', {}, ['Virtual DOM']),
Element('h1', {}, ['un unpdate']),
Element('ul', {}, [
Element('li', { class: 'item' }, ['Iteme 1']),
Element('li', { class: 'item' }, ['this node will be remove']),
Element('li', { class: 'item' }, ['Iteme 3']),
]),
])
此外,它还为这个对象添加render方法,渲染成真实dom
Element.prototype.render = function () {
const el = document.createElement(this.type)
const props = this.props
for (const propName in props) {
el.setAttribute(propName, props[propName])
}
this.children.forEach(child => {
const childEl = (child instanceof Element) ? child.render() : document.createTextNode(child)
el.append(childEl)
})
return el
}
上面已经初步完成了创建虚拟DOM并映射成真实DOM,这样的更新都可以先反应到虚拟DOM上,需要使用臭名昭著的diff算法.
听说两颗树完全比较时间复杂度为 O(n)^3
,但是我们知道它们的比较方式有三种,一种是真实树与虚拟树比较、一种是组件与组件间比较,一种是节点与节点的比较。
diff算法流程是这样的 : 新树与旧树比较、创建补丁patches->对真实树进行打补丁
步骤一: 新树与旧树比较创建补丁
function diff(oldTree, newTree) {
// 声明变量patches用来存放补丁的对象
let patches = []
// 初始索引为0
let index = 0
dfsWalk(oldTree, newTree, index, patches)
return patches;
}
function isString(data) {
return typeof data === 'string'
}
function dfsWalk(oldNode, newNode, index, patches) {
// 每个元素都有一个补丁
let current = []
// judge1 : 如果新节点不存在啊
if (!newNode) {
current.push({ type: 'REMOVE', index });
// judge2 : 判断是否为文本节点
} else if ( isString(oldNode) && isString(newNode) ) {
// 判断文本是否一致
if (oldNode !== newNode) {
current.push({
type: 'TEXT',
text: newNode
})
}
// judge3 : 节点类型一致(即节点存在),比较他的props是否修改
} else if (oldNode.type === newNode.type) {
// 比较属性是否有更改
let attr = diffAttr(oldNode, newNode)
if (Object.keys(attr).length > 0) {
current.push({ type: 'ATTR', attr });
}
// 递归子节点,默认为空,所以不用做判断
diffChildren(oldNode.children, newNode.children, index, patches);
// finally judge : 如123都不符合,则说明节点被替换了
} else {
// 说明节点被替换了
current.push({ type: 'REPLACE', newNode });
}
if (current.length) {
// 如果小补丁存在, 则放入大补丁中
patches[index] = current;
}
}
function diffChildren(oldChildren, newChildren, index, patches) {
let leftNode = null
let currentNodeIndex = index
// 比较老的第一个和新的第一个
oldChildren.forEach((child, i) => {
let newChlid = newChildren[i]
// 计算节点的index
if (leftNode && leftNode.count) {
currentNodeIndex = currentNodeIndex + leftNode.count + 1
} else {
currentNodeIndex = currentNodeIndex + 1
}
dfsWalk(child, newChlid, currentNodeIndex, patches);
leftNode = child
});
}
/**
* 返回新旧节点的attribute的差异并集
* @param {Element} oldNode
* @param {Element} newNode
*/
function diffAttr(oldNode, newNode) {
const oldProps = oldNode.props || {},
newProps = newNode.props
const propsPatches = {}
// 比较旧新两颗树的attr
for (let key in oldNode) {
if (newProps[key] !== oldProps[key]) {
// 如果其中的新树的Props不等于旧树的props
// 将不同的attr赋值给propsPatches
propsPatches[key] = newProps[key]
}
}
// 比较新树是否增加了attr
for (let key in newProps) {
if (!oldProps.hasOwnProperty(key)) {
propsPatches[key] = newProps[key]
}
}
// 分析完毕,返回有差异的props
// 包括 : 原有props值改变、新树增加新props(这个东西又叫补丁)
return propsPatches
}
首先,你要知道,patches补丁包是以二维数组形式存在的,它所对于的索引,即节点(oldTree)的遍历顺序,而根据 dfsWalk
方法可知,它的遍历是先序遍历,即它会遍历最深处的子节点,如下图所示
来讲一下diff比较算法
- 创建当前节点的补丁包current,以下判断情况使用if-else
- 首先新节点是否存在,不存在即代表被移除了, 补丁包存放
REMOVE
类型对象 - 判断两个节点是否为字符串(文本节点),如果字符串改变了,则向补丁包添加
TEXT
类型对象 - 判断两个节点是否为同一类型,同类型比较节点的属性,若改变,则向补丁包添加
ATTR
类型 - 以上三种都不符合,说明节点被替换了,则向补丁包添加
REPLACE
类型 - 返回补丁包,数据结构如下
patches : [ 1 : [{type: "REPLACE", newNode: {}}] ]
步骤二、打补丁
因为一颗树的JS对象与render出来的真正DOM树的信息,结构是一样的,而且顺序也是一致,所以我们也对真正的DOM树进行深度遍历,根据patches对象的type来使用不同策略进行DOM的更新。
function run(node,patches) {
let current = patches[index++]
let childNodes = node.childNodes
childNodes.forEach(child => run(child,patches))
if (current) {
doPatch(node, current) // 打补丁
}
}
let index // 使用一个全局变量记录当前遍历节点index
function patch(node, patches) {
index = 0
run(node,patches)
}
以上代码是path的入口,因为我们要记录节点的索引,并且真实DOM没有虚拟DOM的count属性的,所以我们只能通过一个全局变量记录索引的值,每访问一i个节点就++,以下是打补丁的方法。
function doPatch(node, patches) {
// 遍历所有打过的补丁
patches.forEach(patch => {
switch (patch.type) {
case 'ATTR': {
for (let key in patch.attr) {
let value = patch.attr[key]
if (value) {
setAtrr(node, key, value)
} else {
node.removeAttribute(key)
}
}
break;
}
case 'TEXT': {
node.textContent = patch.text;
break;
}
case 'REPLACE': {
let newNode = patch.newNode
newNode = (newNode instanceof Element) ? newNode.render() : document.createTextNode(newNode)
node.parentNode.replaceChild(newNode, node)
break;
}
case 'REMOVE': {
node.parentNode.removeChild(node);
break;
}
default:
break
}
})
}
会出现四种情况:
- ATTR,即属性发生了变化,使用DOM.setAttribute方法
- TEXT,即说明文本节点发生变化,使用DOM.textContent方法
- REPCACE,则使用DOM.replaceChild
- REMOVE,则使用DOM.removeChild(node)
当使用完这个函数后,就可以将DOM树更新了。
唯一不足的是: 上面方法不能处理节点增加的情况,不过这对于新手也应对diff有一定了解了。
Virtual DOM 算法主要是实现上面步骤的三个函数:element,diff,patch。
总结: 实现diff需要四步 :
- 用JS数据结构,模拟DOM,插入文档中
- 当状态变更时,重新生产一颗对象树,然后新旧树比较,记录差异,生成补丁(patches)
- 将差异应用到真实dom中,完成
但是实际上,组件化开发过程中,diff的新旧树只会在组件下,所以肯定是不会进行从根节点遍历的。s
网友评论