一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。
虚拟dom是什么?
众所周知,vue中很重要的一个部分就是虚拟dom,那么它到底是什么?并且承担着什么样的角色呢?官方文档中是这样定义的:
虚拟
dom是用一个原生的JS对象去描述一个DOM节点,不需要包含操作DOM的方法,所以它比创建一个DOM的代价要小很多。
对比一下真实dom和虚拟dom
- 真实
dom
<div>
<p>我是p元素</p>
<div>
我是div元素
</div>
</div>
- 虚拟
dom
const Vnode = {
sel: 'div', //选择器
data: {}, //属性
// 子节点,与文本节点中的文本内容text互斥
children: [
{
sel: 'p',
text: '我是p元素',
data: {}
},
{
sel: 'div',
text: '我是div元素',
data: {}
}
]
}
为什么要虚拟dom?
vue是数据驱动视图的,数据发生变化视图就要随之更新,在更新视图的时候难免要操作DOM,而操作真实DOM又是非常耗费性能的。最直观的解决思路就是不要盲目的去更新视图,而是通过对比数据变化前后的状态,计算出视图中哪些地方需要更新,只更新需要更新的地方。
虚拟dom的应用
- 能够维护视图和状态的关系;
- 复杂视图情况下提升渲染性能:注意是复杂,首次渲染时会额外地创建虚拟
dom; - 能够跨平台使用,如浏览器中服务端渲染和小程序等等
虚拟dom的实现
vue2.0使用snabbdom来实现,源码link
那么接下来就来仔细分析一下snabbdom是如何实现虚拟dom映射到真实的 DOM, 核心就是patch过程。
源码核心步骤
init()设置模块,最后返回patchh()创建vnodepatch中比较新旧节点- 把变化的内容更新到真实
dom树中
init
function init (modules: Array<Partial<Module>>, domApi?: DOMAPI){
...
}
- 第一个参数是一个数组,传入是将来会使用到的模块,如
attributesModule,eventListenersModule等 - 第二个参数
domapi,用来把vnode对象转化为其他平台下的元素,若不传,默认操作浏览器dom,这里分析不传该参数的情况
h函数
- 创建
vnode对象 - 处理重载(处理参数),最后返回
vnode - 可以传递钩子函数
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
// 处理参数,实现重载
...
// 返回vnode
return vnode(sel, data, children, text, undefined)
};
vnode
它是用key唯一标识的vnode对象,有六个属性:
export interface VNode {
// 选择器
sel: string | undefined
// data中可以传递vnode钩子函数和真实dom的属性
data: VNodeData | undefined
// 与text互斥,子节点
children: Array<VNode | string> | undefined
// 存储vnode转换真实dom的元素
elm: Node | undefined
// 文本节点中的文本内容
text: string | undefined
// 唯一标识vnode
key: Key | undefined
}
patch
- 第一个参数:真实
dom,将来会通过emptyNodeAt()转换成vnode
function emptyNodeAt (elm: Element) {
const id = elm.id ? '#' + elm.id : ''
const c = elm.className ? '.' + elm.className.split(' ').join('.') : ''
// 返回vnode
return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm)
}
- 第二个参数:新的
vnode - 返回值:新的
vnode,会作为下一次比较的旧vnode patch步骤
patch时判断是否为sameVnode(判断key与sel分别一致)- 若相等,进入
patchVnode - 若不相等,将
vnode生成真实节点,插入到旧节点原来的位置上
- 若相等,进入
// 首次渲染时,patch的第一个参数传递的是element:真实dom
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
// 新插入节点的队列,为了触发这些节点上的钩子函数
const insertedVnodeQueue: VNodeQueue = []
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
if (!isVnode(oldVnode)) {
// 传递的是第一个参数:dom对象,转化成vnode
oldVnode = emptyNodeAt(oldVnode)
}
// 查找两个节点间的差异,并更新差异
if (sameVnode(oldVnode, vnode)) {
// 若相等,继续patchVnode
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// 创建新的vnode的dom元素,新创建的插入到dom树中,并删除旧的元素
elm = oldVnode.elm!
// 找到elm的父元素
parent = api.parentNode(elm) as Node
// 创建vnode对应的dom元素
createElm(vnode, insertedVnodeQueue)
if (parent !== null) {
// 指定一参考的位置,父元素的兄弟节点
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
// 删除旧节点
removeVnodes(parent, [oldVnode], 0, 0)
}
}
// 具有insert钩子函数的新的vnode节点,钩子函数是用户传递的,dom元素插入后执行
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
// 最后返回vnode
return vnode
}
patchVnode
patchVnode是如何比较两个节点的呢?
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
// 触发prepatch和update钩子函数
const hook = vnode.data?.hook
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
if (oldVnode === vnode) return
if (vnode.data !== undefined) {
// 用户修改的可以覆盖模块的
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
vnode.data.hook?.update?.(oldVnode, vnode)
}
// 新节点text属性未定义
if (isUndef(vnode.text)) {
//新旧节点都有children
if (isDef(oldCh) && isDef(ch)) {
// 新旧节点的children不相等
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
} else if (isDef(ch)) {
// 只有新节点有children
// 清空文本节点的textContent,同时添加新节点
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 只有老节点有children,新节点既没有children也没有text,移除所有的老节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 只有老节点有text,新节点既没有children也没有text,清空文本节点的textContent
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 新节点有text属性,且不等于旧节点的text属性
// 只有旧节点有children属性
if (isDef(oldCh)) {
// 删除旧节点的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// dom元素未重新创建,重用原来的,仅仅是更新了文本的内容,
// 设置为新节点的textContent内容
api.setTextContent(elm, vnode.text!)
}
// 触发postpatch,可以从dom获取到最新的数据
hook?.postpatch?.(oldVnode, vnode)
}
updatechildren
在进入updateChildren之前,先了解一下diff算法。
diff算法
它有不同的实现形式,在传统的diff算法中会去对比每一个节点,如跨级别操作节点,如父节点移动到子节点的位置,而snabbdom根据dom的特点对传统的diff算法做了优化:只比较同级别的节点,降低了比较次数。
snbbdom中的diff过程
- 设置四个索引值
- 旧开始
oldstart - 旧结束
oldend - 新开始
newstart - 新结束
newend
🤔:连线表示四种比较,每次依次比较这四种情况
- 进行四种比较
-
比较
oldstart和newstart是否为samevnode,若是- 调用
patchVnode对比和更新节点 oldstart++,newstart++
- 调用
oldstart和newstart不是samevnode,开始比较oldend和newend是否为samevnode,若是- 调用
patchvnode对比和更新节点 oldend--,newend--
- 调用
-
oldend和newend不是samevnode,开始比较oldstart和newend是否为samevnode,若是- 调用
patchvnode对比和更新节点 - 把旧开始对应的
dom元素移动到旧节点list的最后 oldstart++,newend--
- 调用
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
oldstart和newend不是samevnode,开始比较oldend和newstart是否为samevnode,若是- 调用
patchvnode对比和更新节点 - 把旧开始对应的
dom元素移动到旧节点list的最前面 oldstart--,newend++
- 调用
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
- 四种比较都不满足
- 从新节点开始遍历,在旧节点的数组中查到是否有和新节点数组
key值相同的节点; - 如果在旧节点中找不到时,此时的开始节点是新的节点,创建新的
dom元素插入到旧节点最前面; - 如果在旧节点中找到时,将旧节点赋值给常量
elmtomove;
-
- 判断
elmtomove与新节点的sel是否相同;
- 判断
-
-
- 若
sel不相同,说明节点被修改,创建新的开始节点对应的dom元素插入到旧的开始节点之前 - 若相同,
elmtomove和新节点通过patchvnode比较差异,然后将elmtomove插入到旧节点最前面
- 若
-
if (oldKeyToIdx === undefined) {
// 存储老节点的key和索引
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// 找到老节点的索引,不一定找到,新的开始节点可能不在老的节点中
idxInOld = oldKeyToIdx[newStartVnode.key as string]
if (isUndef(idxInOld)) { // New element
// 新节点在老节点中没有对应的值
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
// 若找到了,将老节点赋值赋值给elmtomove
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
// 节点被修改,创建新的开始节点对应的dom元素插入到老的开始节点之前
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
// 节点被处理后
oldCh[idxInOld] = undefined as any
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
}
}
newStartVnode = newCh[++newStartIdx]
- 当循环结束后
- 旧节点的所有子节点先遍历完成,新节点有剩余,向旧节点中插入剩余节点
- 旧节点有剩余时,删除剩余的节点
key的意义
为什么要设置key呢,不设置key的时候明明从代码上来看,会最大程度地重用元素,对比新旧节点认为是相同的(sel相同,key都是undefined),当text不同只会修改dom的内容,这样效率不是更高吗?那为什么还要多此一举呢?
因为最大程度重用元素是有问题的,下面以checkbox为例进行说明。
function view(data) {
let arr =[]
data.forEach(item => {
// 不设置key的情况
arr.push(h('li', [h('input', {attrs: {type: 'checkbox'}}), h('span',item)]))
// 设置key的情况
// arr.push(h('li', {key: item},[h('input', {attrs: {type: 'checkbox'}}), h('span',item)]))
});
let vnode = h('div#app',[
h('button',{on:{click: function(){
data.unshift(100)
vnode = view(data)
oldVnode = patch(oldVnode,vnode)
}}},'按钮'),h('ul',arr)])
return vnode
}
let app = document.querySelector("#app")
oldVnode = patch(app,view(data))
这里创建了四个列表项,包含checkout类型input,假定已选中了第一项,当点击按钮的时候,列表项最上面添加了一项100,会发现,新添加的一项被选中了,而原来被选中的取消了选中。
- 当未设置
key时
1和100两个li节点比较,key都是undefined,sel相等,于是patchVnode两个节点,在比较到1和100两个text节点时,key依旧相等,patchVnode两个值,直接改变oldVnode的text为100,但是该节点被选中的属性checked并未改变。
- 当设置
key后
1和100两个li节点的key不同,于是移动索引,开始倒序比较,直到新节点有剩余,在最前面插入100。
总结:当设置key时,对比新旧开始节点,key相同才会重用元素,key值不同,会重新创建dom元素。