前情提要
上期的响应式原理分析文章内,我们大致了解了Vue响应式系统中Observer、Dep和Watcher的基础概念,并分析了Vue为什么会这样设计。之后从源码实现的角度去分析了 依赖收集 / 派发更新 操作的具体实现细节,并顺带把Computed和Watch API的实现方法做了概括
整体的响应式逻辑已经完成了覆盖,但还有部分细节会在这次分享中补充完毕:
- 派发更新在组件中的diff流程
- 派发更新的中nextTick的实现逻辑
这期内容也不少,直接开始,GOGOGO~
diff in Vue
前面我们了解到,当数据发生变化时,会触发watcher执行回调函数,进而执行组件更新的操作。我们在介绍实例化的时候,只分析过第一次执行updateComponent的过程,现在接着这部分,进行补充
diff入口
我们回顾下,实例化代码的关键内容:
src/core/instance/lifecycle.js 【第一层】
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
// ...
} else {
updateComponent = () => {
debugger
vm._update(vm._render(), hydrating)
}
}
// ...
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
// ...
}
接下来看看第二层代码,在这里,Vue判断了是否是首次渲染,并通过不同的参数,控制执行不同的代码逻辑
src/core/instance/lifecycle.js 【第二层】
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
// ...
const prevVnode = vm._vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
// ...
}
我们接着来分析patch函数,对于diff的具体实现
patch
关于__patch__的实现我们在第一次实例化分享中,做了详细介绍,大家感兴趣的话也可以回去回顾一下
这里我们直接看源码的指向部分
src/core/vdom/patch.js
// ...
return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
// ...
if (isUndef(oldVnode)) {
// init mount
// ...
} else {
// update
// ...
// 1. 判断是否为同一个VNode
// 2-1. 是同一个VNode,递归diff子节点
// 2-2. 不是同一个VNode,替换已存在节点
}
// ...
return vnode.elm
}
关于新旧结点是否相同,Vue的处理逻辑比较多,我们拆分成小节详细分析
我们先来看看,新旧结点不相同的情况,这个情况相对于新旧结点相同来说,会简单很多,因为这个不存在diff的逻辑,不相同,直接用新节点直接替换即可
那Vue内是如何判断是否相同的呢,看看sameVnode函数的实现把,这里可以理解为工具函数,就不再分析代码栈的层次了
src/core/vdom/patch.js
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)
)
)
)
}
这里的判断逻辑如下:
- 第一层:key是否相同
- 第二层:对于同步组件,判断 tag / isComment / data / input类型 是否相同
- 第二层:对于异步组件,判断 asyncFactory 是否相同
新旧结点不相同
简单来说,分为三步:
- 创建并插入新节点
- 更新父节点的占位符节点
- 删除旧节点
来看看各阶段的代码
首先是创建并插入新节点
// create new node
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
接下来是更新占位符的逻辑,找到当前 vnode 的父的占位符节点,先执行各个 module 的 destroy 的钩子函数,如果当前占位符是一个可挂载的节点,则执行 module 的 create 钩子函数
在之后是删除旧节点,通过dom操作移除自己,并递归执行子组件的destoryhook
// destroy old node
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
新旧节点相同
对于新旧节点相同的情况,Vue会调用patchVNode方法做diff操作
srccore/vdom/patch.js
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
// 1. 判断两个节点是否完全全等,全等则返回
// 2. 执行prepatch函数
// 3. 执行update钩子函数
// 4. 完成patch过程 (diff)
// 5. 执行 postpatch 钩子函数
}
关于这个流程的代码,有一幅图非常直接的说明了patch过程中的判断逻辑
大家基本上跟这个这个过一遍代码就ok了,这里面有一个比较复杂的地方,就是当vnode和oldVnode都有子节点的时候,做子节点diff的时候,会有些复杂,我们单独来讲
子节点diff「updateChildren」
这里新旧节点的子节点的diff的逻辑比较绕,目的当然都是最小成本的完成组件diff操作
简要概括下来就是:
- 头头比较
- 尾尾比较
- 尾头比较
- 头尾比较
举个例子,比如
- 新节点的子节点数组为:【D / C / B / A / E】
- 旧节点的子节点数组为:【A / B / C / D】
那么整个diff的过程,按照上面的原则就会这样进行
第一步:
第二步:
第三步:
第四步:
总结
组件更新的过程核心就是新旧 vnode diff,对新旧节点相同以及不同的情况分别做不同的处理
新旧节点不同的更新流程是创建新节点->更新父占位符节点->删除旧节点;而新旧节点相同的更新流程是去获取它们的 children,根据不同情况做不同的更新逻辑
最复杂的情况是新旧节点相同且它们都存在子节点,那么会执行 updateChildren 逻辑,这块儿可以借助画图的方式配合理解
nextTick
首先nextTick是一个Node中才有的概念,感兴趣的话可以看看我之前的分享文章
本质目的,是使用微任务的性质,在主线程宏任务执行完毕后,统一的执行微任务队列的所有任务,类似这个为代码
for (macroTask of macroTaskQueue) {
// 1. Handle current MACRO-TASK
handleMacroTask();
// 2. Handle all MICRO-TASK
for (microTask of microTaskQueue) {
handleMicroTask(microTask);
}
}
在浏览器环境中,常见的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate;常见的 micro task 有 MutationObsever 和 Promise.then
Vue的实现代码行数很少,这里就贴一个文件目录吧
src/core/util/next-tick.js
next-tick.js 申明了 microTimerFunc 和 macroTimerFunc 2 个变量,它们分别对应的是 micro task 的函数和 macro task 的函数
macro / micro task的Vue实现
对于 macro task 的实现,优先检测是否支持原生 setImmediate,这是一个高版本 IE 和 Edge 才支持的特性,不支持的话再去检测是否支持原生的 MessageChannel,如果也不支持的话就会降级为 setTimeout 0
对于 micro task 的实现,则检测浏览器是否原生支持 Promise,不支持的话直接指向 macro task 的实现。
nextTick中暴露的API
next-tick.js 对外暴露了 2 个函数,先来看 nextTick,这就是我们在上一节执行 nextTick(flushSchedulerQueue) 所用到的函数
它的逻辑也很简单,把传入的回调函数 cb 压入 callbacks 数组,最后一次性地根据 useMacroTask 条件执行 macroTimerFunc 或者是 microTimerFunc,而它们都会在下一个 tick 执行 flushCallbacks,flushCallbacks 的逻辑非常简单,对 callbacks 遍历,然后执行相应的回调函数
这里使用 callbacks 而不是直接在 nextTick 中执行回调函数的原因是保证在同一个 tick 内多次执行 nextTick,不会开启多个异步任务,而把这些异步任务都压成一个同步任务,在下一个 tick 执行完毕
next-tick.js 还对外暴露了 withMacroTask 函数,它是对函数做一层包装,确保函数执行过程中对数据任意的修改,触发变化执行 nextTick 的时候强制走 macroTimerFunc。比如对于一些 DOM 交互事件,如 v-on 绑定的事件回调函数的处理,会强制走 macro task
总结
通过这一节对 nextTick 的分析,并结合上一节的 setter 分析,我们了解到数据的变化到 DOM 的重新渲染是一个异步过程,发生在下一个 tick。这就是我们平时在开发的过程中,比如从服务端接口去获取数据的时候,数据做了修改,如果我们的某些方法去依赖了数据修改后的 DOM 变化,我们就必须在 nextTick 后执行
比如下面的伪代码:
getData(res).then(()=>{
this.xxx = res.data
this.$nextTick(() => {
// 这里我们可以获取变化后的 DOM
})
})
小结
关于Vue的响应式内容,我们基本上就完成了整体的分析,关于diff的实现我们可以先按自己的思路实现个简单的demo,再和Vue的实现做对比,能够加深我们的印象
下一节我们会分析组件化部分的内容了,下期见