什么是虚拟DOM
简单来说,虚拟DOM是指使用JS对象来描述一个DOM节点。
<div class="a" id="b">我是内容</div>
{
tag:'div', // 元素标签
attrs:{ // 属性
class:'a',
id:'b'
},
text:'我是内容', // 文本内容
children:[] // 子元素
}
为什么要使用虚拟DOM?
由于浏览器的标准,真实DOM对象本身被设计的非常复杂,而且由于JS引擎、DOM引擎和排版引擎共享浏览器的一个主线程,多次使用JS操作DOM会造成频繁的上下文切换,进而导致性能急剧下降(一些测试)。所以虚拟DOM真正要解决的问题是减少不必要的DOM API调用,通过DOM-Diff对比数据变化前后的状态,计算出哪些地方需要更新。
实际上对于单次的DOM API操作来说,使用虚拟DOM不一定会效率更高,但是框架提供了一个普适的性能能够接受的解决方案。Evan You的回答
Vue的虚拟DOM
通过Vue中的VNode类,我们可以实例化出不同类型的虚拟DOM节点,而一棵虚拟DOM树就是由VNode组成的。
Vue虚拟DOM的实现方式取材于一个虚拟DOM库snabbdom
// 源码位置:src/core/vdom/vnode.js
export default class VNode {
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag /*当前节点的标签名*/
this.data = data /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
this.children = children /*当前节点的子节点,是一个数组*/
this.text = text /*当前节点的文本*/
this.elm = elm /*当前虚拟节点对应的真实dom节点*/
this.ns = undefined /*当前节点的名字空间*/
this.context = context /*当前组件节点对应的Vue实例*/
this.fnContext = undefined /*函数式组件对应的Vue实例*/
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key /*节点的key属性,被当作节点的标志,用以优化*/
this.componentOptions = componentOptions /*组件的option选项*/
this.componentInstance = undefined /*当前节点对应的组件的实例*/
this.parent = undefined /*当前节点的父节点*/
this.raw = false /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
this.isStatic = false /*静态节点标志*/
this.isRootInsert = true /*是否作为跟节点插入*/
this.isComment = false /*是否为注释节点*/
this.isCloned = false /*是否为克隆节点*/
this.isOnce = false /*是否有v-once指令*/
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
get child (): Component | void {
return this.componentInstance
}
}
VNode类中包含了描述一个节点的必要属性,如tag表示节点的标签名,text表示节点中包含的文本,children表示该节点包含的子节点等,elm表示当前VNode对应的真实DOM节点。
我们在视图渲染之前,把写好的template模板先编译成VNode并缓存下来,等到数据发生变化页面需要重新渲染的时候,我们把数据发生变化后生成的VNode与前一次缓存下来的VNode进行对比,找出差异,然后有差异的VNode对应的真实DOM节点就是需要重新渲染的节点,最后根据有差异的VNode创建出真实的DOM节点再插入到视图中,最终完成一次视图更新。
下面要讨论的重点自然是如何找出数据发生变化后的NewVNode和变化前OldVnode的差异了。
DOM-Diff
Vue中的DOM-Diff叫做patch过程。从语义角度来理解,patch即打补丁,通过对比新旧两份VNode的变化,以新的newVNode为基准对比旧的newVNode,来更新real DOM,使之成为newVNode映射的real DOM。通过patch函数作为入口,执行的动作无非有三种,创建节点、删除节点、更新节点。
创建节点
能够被插入到DOM的节点类型共有三种,元素节点、文本节点和注释节点,对于这三种节点类型Vue也是封装了不同的创建方法。
// 源码位置: /src/core/vdom/patch.js
function createElm (vnode, parentElm, refElm) {
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) {
vnode.elm = nodeOps.createElement(tag, vnode) // 创建元素节点
createChildren(vnode, children, insertedVnodeQueue) // 创建元素节点的子节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text) // 创建注释节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else {
vnode.elm = nodeOps.createTextNode(vnode.text) // 创建文本节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
}
}
代码中的nodeOps是Vue为了跨平台兼容性,对所有节点操作进行了封装,例如nodeOps.createTextNode()在浏览器端等同于document.createTextNode()
删除节点
function removeNode (el) {
const parent = nodeOps.parentNode(el) // 获取父节点
if (isDef(parent)) {
nodeOps.removeChild(parent, el) // 调用父节点的removeChild方法
}
}
更新节点
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
// vnode与oldVnode是否完全一样?若是,退出程序
if (oldVnode === vnode) {
return
}
/**
* vnode的elm对象属性、oldVnode的elm对象属性以及elm都指向同一个内存地址
* 这样改变elm就会改变所有
*/
const elm = vnode.elm = oldVnode.elm
// vnode与oldVnode是否都是静态节点?若是,退出程序
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
return
}
const oldCh = oldVnode.children
const ch = vnode.children
// vnode有text属性?若没有:
if (isUndef(vnode.text)) {
// vnode的子节点与oldVnode的子节点是否都存在?
if (isDef(oldCh) && isDef(ch)) {
// 若都存在,判断子节点是否相同,不同则更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
// 若只有vnode的子节点存在
else if (isDef(ch)) {
/**
* 判断oldVnode是否有文本?
* 若没有,则把vnode的子节点添加到真实DOM中
* 若有,则清空Dom中的文本,再把vnode的子节点添加到真实DOM中
*/
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
// 若只有oldnode的子节点存在
else if (isDef(oldCh)) {
// 清空DOM中的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 若vnode和oldnode都没有子节点,但是oldnode中有文本
else if (isDef(oldVnode.text)) {
// 清空oldnode文本
nodeOps.setTextContent(elm, '')
}
// 上面两个判断一句话概括就是,如果vnode中既没有text,也没有子节点,那么对应的oldnode中有什么就清空什么
}
// 若有,vnode的text属性与oldVnode的text属性是否相同?
else if (oldVnode.text !== vnode.text) {
// 若不相同:则用vnode的text替换真实DOM的文本
nodeOps.setTextContent(elm, vnode.text)
}
}
所谓静态节点,就是指内容固定、与Vue data无关的节点,比如
<p>我是不会变化的文字</p>
下面将会主要介绍更新子节点的updateChildren方法。
更新子节点
// 循环更新子节点如果发现新旧节点相同则递归更新两者的子节点...
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
// oldChildren开始索引
let oldStartIdx = 0
// oldChildren结束索引
let oldEndIdx = oldCh.length - 1
// 记录当前oldChildren所有未被处理的第一个
let oldStartVnode = oldCh[0]
// 记录当前oldChildren所有未被处理的最后一个
let oldEndVnode = oldCh[oldEndIdx]
// newChildren开始索引
let newStartIdx = 0
// newChildren结束索引
let newEndIdx = newCh.length - 1
// 记录当前newChildren所有未被处理的第一个
let newStartVnode = newCh[0]
// 记录当前newChildren所有未被处理的最后一个
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// 与transition相关,可以忽略
const canMove = !removeOnly
// 非生产环境会校验newCh是否有重复的key
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
// 注意此时结束索引已经确定,所以之后的oldCh数组改变并不会影响指针的遍历
// 以"头头"、"尾尾"、"头尾"、"尾头"的方式开始比对节点
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
// 以上对oldVnode为undefined的情况做了跳过处理。为什么会有undefined?下面会解释
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 如果新前与旧前节点相同,就递归地调用patchVnode,近一步更新二者的子节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 与上类似
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 如果新后与旧前节点相同,先把两个节点进行patch更新,然后把旧前节点移动到oldChilren中所有未处理节点之后,即当前oldEndVnode的下一个节点之前
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 如果新前与旧后节点相同,先把两个节点进行patch更新,然后把旧后节点移动到oldChilren中所有未处理节点之前
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
/**
* 如果不属于以上四种情况,就先试图通过key来找
* 对oldCh生成oldKeyToIdx:{key:index}
* 通过这个map来找和当前newCh的key相同的oldCh的index
* 但是如果newCh没有key,就只能循环暴力去找在oldCh中的索引了
*/
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 如果在oldChildren里找不到当前循环的newChildren里的子节点
if (isUndef(idxInOld)) { // New element
// 新增节点并插入到合适位置
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 如果在oldChildren里找到了当前循环的newChildren里的子节点
vnodeToMove = oldCh[idxInOld]
// 如果两个节点相同
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
/**
* 在这里把会被移动的oldCh[idxInOld]置为undefined
* 因为这种非特殊的更新只会移动newStartIdx
* 所以在之后的循环中随着可能的oldIdx的移动,这个oldCh节点还有可能被遍历到
* 当他被遍历到时由于被标注为已移动即undefined,所以就会走入代码开头的判断中
*/
oldCh[idxInOld] = undefined
// canmove表示是否需要移动节点,如果为true表示需要移动,则移动节点,如果为false则不用移动
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
/**
* 如果oldChildren比newChildren先循环完毕,
* 那么newChildren里面剩余的节点都是需要新增的节点,
* 把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中
*/
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
/**
* 如果newChildren比oldChildren先循环完毕,
* 那么oldChildren里面剩余的节点都是需要删除的节点,
* 把[oldStartIdx, oldEndIdx]之间的所有节点都删除
*/
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
另附sameVnode函数:
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
最主要解决的问题是,如何快速的定位到与新节点相同的老节点(或者确定没有这样的老节点),如果每次都是暴力的双循环,则时间复杂度平均在O(n^2)数量级。但是根据前端变化DOM实际的频繁操作,增加了头尾组合的比较,通过O(1)的时间复杂度快速锁定与头尾有关的变化,并且通过双指针夹逼的方式不断更新索引,记录需要移动节点的被移动位置并在一个数组遍历完成后,获得纯粹需要删除或者新增的子节点。当出现头尾比较不能解决的问题时,兜底策略一是使用key,帮助锁定带有someKey的新子节点在老子节点中有相同key的老字子节点的位置,再进行sameVnode的比较,并在需要移动老子节点后将当前位置设为undefined避免二次查找;如果新节点不存在key或老节点不存在key,都会触发最终的兜底策略,即遍历oldCh暴力寻找是否存在相同节点。整个过程newCh并没有变化,而是不断的拿newCh的节点在oldCh中寻找相同节点并更新、移动,来达到patch的效果。
几个例子
Vue2 Diff算法的特点
Vue2的这种DOM-Diff算法(即snabbdom的算法)和React的diff算法相同之处在于都认为跨层移动出现的很少,所以只对同一层级的进行比较,不考虑跨层级的变化,通过这种tricky的方式,将O(n^3)的树的编辑距离问题(最后会提到关于编辑距离的简单介绍)变为接近于O(n)时间复杂度的列表更新问题,并且二者都采用了列表key的方式来加速更新和节点复用;不同之处在于React在比较列表的过程中只会使用lastIndex记录相同元素在老列表出现的最大位置,如果遍历发现新的相同元素出现位置小于lastIndex,则需要移动。Vue2 引入了双端比较的算法,通过在新旧列表中分别采用两个指针指向列表头部和尾部,每次执行新旧列表中四次指针比较判断是否存在节点复用,从而避免 React 算法中尾部元素移动至列表头部的低效问题。
编辑距离问题
编辑距离问题是一道经典的动态规划问题,问题大致是给定两个字符串word1和word2,求将word1转换为word2最少需要多少次操作,操作包括新增、删除和替换(leetcode#72)。解决这个问题需要一个二维的dp table,每一个位置(i, j)代表word1.substr(0, i)转化到word2.substr(0, j)的最少操作次数,我们可以发现这个dp table的状态转移方程是:
注意dp table的(i,j)对应的是子字符串长度,所以在获得这个长度的最后一个字符时,要使用word1[i - 1],word2[j - 1]
dp[i][j] = dp[i-1][j-1] (if word1[i-1] === word2[j-1])
min(dp[i-1][j-1]+1, dp[i-1][j]+1, dp[i][j-1]+1) (else)
解释一下,如果在dp table新的(i,j)位置,对应word1子串的最后一位于word2子串的最后一位相等,那么什么都不用做,此时最小的操作次数和dp [i-1][j-1]即两者都减少最后一个字符串的状态是一样的;当最后一位不相等时,需要从别的状态进行某种操作才能到达要求的状态,还记得我们有三种操作方式新增、删除和替换,从dp[i][j-1]状态转化来需要新增,从dp[i-1][j]转化来 需要删除,从dp[i-1][j-1]转化来需要替换,这三者各自+1的最小值便是dp[i][j]对应的最小操作次数。直到完成dp table,dp[word1.length][word2.length]的值即为这两个字符串的编辑距离,时间复杂度是填满二维的dp table,即O(n^2)级别的。
// dp[i][j] *** 表示word1.substr(0,i)(即word1从idx0开始截取i长的子串)和word2.substr[0,j]的编辑距离 ***
// dp[i][j] = dp[i-1][j-1] (if word1[i-1] === word2[j-1])
// min(dp[i-1][j-1]+1, dp[i-1][j]+1, dp[i][j-1]+1) (else)
var minDistance = function(word1, word2) {
const dp = new Array(word1.length + 1).fill(0).map(_=>new Array(word2.length + 1).fill(0))
for(let i = 1; i <= word1.length;i++) {
dp[i][0] = i
}
for(let j = 1; j <= word2.length;j++) {
dp[0][j] = j
}
for (let i = 1; i <= word1.length; i++) {
for (let j = 1; j <= word2.length; j++){
if(word1[i-1] === word2[j-1]){
dp[i][j] = dp[i-1][j-1]
} else {
dp[i][j] = Math.min(dp[i-1][j-1]+1, dp[i-1][j]+1, dp[i][j-1]+1)
}
}
}
return dp[word1.length][word2.length]
所以其实DOM的Diff在传统意义上说就是两颗虚拟DOM树的编辑距离问题,但显然这个问题更加复杂,时间复杂度是O(n^3)数量级,具体可以阅读这篇论文,能力有限,我就不在此赘述了。
References: