1.Vue中虚拟DOM的诞生原因
1.1 Vue中的MVVM框架
1. View: DOM和CSSOM是View部分,负责视图
2. ViewModel: Vue框架是ViewModel部分,负责双向数据绑定
3. Model: Vue框架下的JavaScript代码是是Model部分,负责提供数据和操作
4. 双向数据绑定: 在“Vue深入响应式原理”这篇博客中,已经探究并实现了Vue中的数据绑定。即在ViewModel中实现绑定机制,使Model中的数据变化会导致View的变化。这是一个Model到View的单向数据绑定。从View到Model的数据绑定需要用户自己实现,只需要用户监听到View中的事件后修改Model中的数据即可。
1.2 MVVM设计模式解决的问题
MVVM设计模式让开发者将更多精力放在Model中的数据维护上面。在传统的开发中要维护的状态很多,必须考虑到对DOM的操作。下面举例描述:
1.2.1 传统方法解决:
1.2.2 MVVM设计模式解决:
1.2.3 改进的MVVM设计模式解决:
实际上改进的MVVM设计模式就是虚拟DOM,这就是虚拟DOM诞生的原因,是为了解决MVVM设计模式中的视图更新问题。 下文将继续介绍Vue中虚拟DOM诞生的原因。
1.3 Vue组件化开发带来的渲染问题
Vue大型项目的开发是组件化的,每个组件都应用了上文提到的MVVM设计模式
1.3.1 组件化开发中高细粒度数据绑定:
在Vue1.x系列中Vue实现了MVVM高细粒度的数据绑定,监听每个组件中的所有数据,一旦某个数据发生改变,就修改DOM。
1.3.2 组件化开发中中细粒度数据绑定:
在Vue2.x系列中实现了MVVM中等细粒度的数据绑定,监听每个组件,一旦某个组件发生变化,就更新这个组件的DOM。
1.3.3 Vue虚拟DOM产生原因:
为了解决Vue2.x数据中等细粒度数据绑中低性能渲染DOM的问题,Vue2.x开始采取了虚拟DOM来解决这个问题。虚拟DOM使得Vue2.x能够比Vue1.x节省能存开销的同时,也能像Vue1.x中那样修改DOM,而不是组件变化后刷新组件整个DOM。虚拟DOM的职责就是数据变化后寻找组件中哪些DOM需要发生改变。
1.4 总结:
对于虚拟DOM来说,可以用下述语句来概括
-
(Vue虚拟DOM产生原因) Vue中虚拟DOM产生的原因是为了解决Vue2.x版本中等细粒读数据绑定的组件的渲染性能问题。
-
(虚拟DOM产生原因) 虚拟DOM的产生是为了解决MVVM设计模式中视图更新的渲染性能问题。
-
(虚拟DOM不优化原生DOM操作) 虚拟DOM并不是优化原生DOM操作,原生DOM的优化是减少回流重绘的次数。(可参考另两篇博客“浏览器渲染”和“浏览器事件”中对渲染问题的描述)
2.Vue中虚拟DOM的数据结构
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
functionalContext: Component | void; // only for functional component root nodes
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions
) {
/*当前节点的标签名*/
this.tag = tag
/*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
this.data = data
/*当前节点的子节点,是一个数组*/
this.children = children
/*当前节点的文本*/
this.text = text
/*当前虚拟节点对应的真实dom节点*/
this.elm = elm
/*当前节点的名字空间*/
this.ns = undefined
/*编译作用域*/
this.context = context
/*函数化组件作用域*/
this.functionalContext = undefined
/*节点的key属性,被当作节点的标志,用以优化*/
this.key = data && data.key
/*组件的option选项*/
this.componentOptions = componentOptions
/*当前节点对应的组件的实例*/
this.componentInstance = undefined
/*当前节点的父节点*/
this.parent = undefined
/*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
this.raw = false
/*静态节点标志*/
this.isStatic = false
/*是否作为跟节点插入*/
this.isRootInsert = true
/*是否为注释节点*/
this.isComment = false
/*是否为克隆节点*/
this.isCloned = false
/*是否有v-once指令*/
this.isOnce = false
}
// DEPRECATED: alias for componentInstance for backwards compat.
/* istanbul ignore next https://github.com/answershuto/learnVue*/
get child (): Component | void {
return this.componentInstance
}
}
上述代码是Vue源代码中创建虚拟DOM节点用到的节点数据结构。
需要注意几点:
- 虚拟DOM节点持有对应DOM节点的引用
- 虚拟DOM节点分为如下几类:
- 注释节点
- 文本节点
- 元素节点(一定有children,可以为空)
- 组件节点(web组件,可参考《JavaScript权威指南》关于web组件的描述)
- 函数式节点
- 克隆节点
3.Vue虚拟DOM中的补丁算法(patching algorithm)
3.1 补丁算法
- 在Vue2.x版本中补丁算法工作时间是组件中数据发生变化时。补丁算法会对遍历虚拟DOM的所有节点,并和旧虚拟DOM树的节点做对比,比较是否发生变化,从而决定DOM操作。下面的算法流程是对源代码中“patch函数”的描述,即对一对新旧虚拟DOM节点的对比流程。
- 下述流程图可以大致分为两个部分。部分一:新旧虚拟DOM节点的简单判断,即新旧DOM节点不考虑子节点的情况。部分二:即为红框内的部分,是对比新旧虚拟DOM节点的子节点的变化,是补丁算法的核心部分。
3.2 补丁算法核心算法——子节点更新算法
当vnode和oldVnode存在children时进行子节点更新算法。vnode和oldVnode的子节点进行对比,对比过程中对DOM进行更新。
3.2.1 子节点插入
-
从vnode的第一个子节点开始遍历,oldVnode子节点标记为未处理。
-
如果在oldVnode的子节点找不到与当前节点相同的节点,那么将vnode的当前节点插入DOM中第一个未处理节点之前。
3.2.2 子节点更新
- 从vnode的第一个子节点开始遍历,oldVnode子节点标记为未处理。
- 如果在oldVnode的子节点的相同位置找到与当前节点相同的节点,判断两者是否相同,若不同,则在DOM上对应位置更新。
- oldVnode被找到的这个节点标记为已处理。
3.2.3 子节点移动
- 从vnode的第一个子节点开始遍历,oldVnode子节点标记为未处理。
- 如果在oldVnode的子节点的不同位置找到与当前节点相同的节点,在DOM上移动当前oldVnode对应节点到该节点在vnode中的位置,之后以vnode中的节点更新此节点。
- oldVnode被找到的这个节点标记为已处理。
3.2.4 子节点删除
- 从vnode的第一个子节点开始遍历,oldVnode子节点标记为未处理。
- 当遍历结束后,oldVnode中剩下的节点都从DOM中删除。
3.3 子节点更新算法优化
在3.2中描述了基本的子节点更新算法,但实际上Vue中并不是这样实现子节点更新。上述子节点更新中每一个vnode中的子节点都需要在oldVnode的子节点中遍历一遍,来确定是否有节点和自己相同。这样的时间复杂度比较高,下面介绍Vue源码中的实际优化策略。
3.3.1 优化策略1:4指针查询
源代码中设置了四个指针:newStartIdx,newEndIdx,oldStartIdx,oldEndVnode
。前两个指向vnode子节点的第一个元素和最后一个元素,后两个指向oldVnode子节点的第一个元素和最后一个元素。
实现优化:
3.3.2 优化策略2:key映射
当优化策略1不起作用时意味着我们不得不遍历oldVnode的子节点来确定是否有一个元素与vnode子节点中当前节点相同。但是还有一个优化策略来避免这种情况发生。
实现优化:
注意:Vue官方文档做了以下提示,key值不允许对象,key值应该是一个稳定不变值,否则上述key映射将不起作用。
3.3.3 子节点更新举例
整体策略为:深度优先,同层比较
-
比较只会在同层级进行, 不会跨层级比较
-
比较的过程中,循环从两边向中间收拢
下面举个更新的例子:
新旧
VNode
节点如下图所示:注:下面的diff实际指的就是真实的DOM第一次循环后,发现旧节点D与新节点D相同,直接复用旧节点D作为
diff
后的第一个真实节点,同时旧节点endIndex
移动到C,新节点的startIndex
移动到了 C -
第二次循环后,同样是旧节点的末尾和新节点的开头(都是 C)相同,同理,diff
后创建了 C 的真实节点插入到第一次创建的 B 节点后面。同时旧节点的 endIndex
移动到了 B,新节点的 startIndex
移动到了 E
第三次循环中,发现E没有找到,这时候只能直接创建新的真实节点 E,插入到第二次创建的 C 节点之后。同时新节点的 startIndex
移动到了 A。旧节点的 startIndex
和 endIndex
都保持不动
第四次循环中,发现了新旧节点的开头(都是 A)相同,于是 diff
后创建了 A 的真实节点,插入到前一次创建的 E 节点后面。同时旧节点的 startIndex
移动到了 B,新节点的startIndex
移动到了 B
第五次循环中,情形同第四次循环一样,因此 diff
后创建了 B 真实节点 插入到前一次创建的 A 节点后面。同时旧节点的 startIndex
移动到了 C,新节点的 startIndex 移动到了 F
新节点的 startIndex
已经大于 endIndex
了,需要创建 newStartIdx
和 newEndIdx
之间的所有节点,也就是节点F,直接创建 F 节点对应的真实节点放到 B 节点后面
3.3.4 updateChildren函数源代码
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // 旧头索引
let newStartIdx = 0 // 新头索引
let oldEndIdx = oldCh.length - 1 // 旧尾索引
let newEndIdx = newCh.length - 1 // 新尾索引
let oldStartVnode = oldCh[0] // oldVnode的第一个child
let oldEndVnode = oldCh[oldEndIdx] // oldVnode的最后一个child
let newStartVnode = newCh[0] // newVnode的第一个child
let newEndVnode = newCh[newEndIdx] // newVnode的最后一个child
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
// 如果oldStartVnode和oldEndVnode重合,并且新的也都重合了,证明diff完了,循环结束
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 如果oldVnode的第一个child不存在
if (isUndef(oldStartVnode)) {
// oldStart索引右移
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
// 如果oldVnode的最后一个child不存在
} else if (isUndef(oldEndVnode)) {
// oldEnd索引左移
oldEndVnode = oldCh[--oldEndIdx]
// oldStartVnode和newStartVnode是同一个节点
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// patch oldStartVnode和newStartVnode, 索引左移,继续循环
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
// oldEndVnode和newEndVnode是同一个节点
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// patch oldEndVnode和newEndVnode,索引右移,继续循环
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
// oldStartVnode和newEndVnode是同一个节点
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// patch oldStartVnode和newEndVnode
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
// 如果removeOnly是false,则将oldStartVnode.eml移动到oldEndVnode.elm之后
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// oldStart索引右移,newEnd索引左移
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
// 如果oldEndVnode和newStartVnode是同一个节点
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
// patch oldEndVnode和newStartVnode
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
// 如果removeOnly是false,则将oldEndVnode.elm移动到oldStartVnode.elm之前
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
// oldEnd索引左移,newStart索引右移
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
// 如果都不匹配
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 尝试在oldChildren中寻找和newStartVnode的具有相同的key的Vnode
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 如果未找到,说明newStartVnode是一个新的节点
if (isUndef(idxInOld)) { // New element
// 创建一个新Vnode
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
// 如果找到了和newStartVnodej具有相同的key的Vnode,叫vnodeToMove
} else {
vnodeToMove = oldCh[idxInOld]
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !vnodeToMove) {
warn(
'It seems there are duplicate keys that is causing an update error. ' +
'Make sure each v-for item has a unique key.'
)
}
// 比较两个具有相同的key的新节点是否是同一个节点
//不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。
if (sameVnode(vnodeToMove, newStartVnode)) {
// patch vnodeToMove和newStartVnode
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
// 清除
oldCh[idxInOld] = undefined
// 如果removeOnly是false,则将找到的和newStartVnodej具有相同的key的Vnode,叫vnodeToMove.elm
// 移动到oldStartVnode.elm之前
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
// 如果key相同,但是节点不相同,则创建一个新的节点
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
}
}
// 右移
newStartVnode = newCh[++newStartIdx]
}
}