Vue2.0 —— Vue.nextTick(this.$nextTick)源码探秘
《工欲善其事,必先利其器》
一、知识储备
在学习这个 API 之前,我们需要进行一定量的知识储备,并且是从最基础的开始:
nextTick
,译为:下一个刻度,可理解为下一个事件,下一个要去做的事情;- 浏览器的进程和线程,进程包含着线程,需理解多进程的概念;
- 了解 JavaScript 的事件循环机制,包括宏任务和微任务的区别。
(截图来自极客时间网站)
首先说明,这里不是广告,也不是盗图。只是这位老师的课程讲的实在是特别好!强烈建议还没学习的小伙伴可以去学习这门课程,我希望看到的是大家一起进步,而不是得过且过。
- 现代的浏览器分为多个模块的进程,它们之间互不干扰又部分通信共享,这种底层的技术称为
IPC (Inter Process Communication)
,译为:进程间通信,属于半双工通信,例如我们现实生活中的对讲机。 - 现代浏览器属于多进程架构,分别有主进程、网络进程、渲染进程、
GPU
进程和插件进程。其中,渲染进程运行在沙箱模式下,即:一个Tabs
就代表一个渲染进程。我们熟知的 JavaScript 线程就运行在这个进程之中。 - JavaScript 线程又将执行任务分为宏任务和微任务。在 JS 引擎工作的过程中,会产生一个
执行栈
,里面用于执行宏任务;如果在宏任务执行的过程中遇见微任务,JS 引擎会将微任务提炼到任务队列
中,当执行栈栈顶的宏任务执行完之后,在GPU
渲染之前,执行任务队列中属于该宏任务的微任务。如此循环以往,称之为事件循环机制
。 - 宏任务有:主代码块、
setTimeout
、setInterval
、setImmidiate
以及I/O流
和requestAnimationFrame
。 - 微任务有:
Promise
、Object.observe
和MutationObserver
以及process.nextTick
(node)。
好了,下面开始进入正题,话不多说,上号!
二、为什么会有这个 API?
由官方的解释引入:
在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
在之前我写过一篇文章 —— 《Vue2.0 —— 关于虚拟节点和 Diff 算法的浅析》 一文中提到,开发者们为了提升 SPA
的性能可谓是绞尽脑汁,不仅应用了虚拟节点的技术,还实现了 Diff 算法
,目的就是提升更为优异的性能。最后我们得出的结论是:Vue 只会在执行完 Diff 算法
之后渲染一次 DOM。
但你有没有想过,这与我们上面做的知识储备是否背道而驰?明明每次执行完宏任务,就会进行一次 GPU
渲染,那为什么官网还倡导我们在数据修改之后立即使用这个方法去获取更新后的 DOM 呢?
那我们不妨大胆猜想,Vue
如果没有指定立即刷新视图(sync 关键字),那么他的 render
调用视图更新方法,极有可能就是异步的,而且是属于 微任务
(事实上也的确如此,后面的源码会分析)。Fine,事情开始变的有趣了起来。
三、使用方式
Vue.nextTick
vm.msg = "Hello";
// DOM 还没更新
/**
* {Function} callback
* {Object} context
*/
Vue.nextTick(function() {
// DOM 更新了
})
这个方法属于全局应用,值得注意的是,如果没有提供回调且在支持 Promise
的环境中,则返回一个 Promise
。请注意 Vue
不自带 Promise
的 polyfill,所以如果你的目标浏览器不是原生支持 Promise
(IE:你们都看我干嘛),你得自行 polyfill。
vm.$set
new Vue({
// ...
methods: {
// ...
example: function () {
// 修改数据
this.message = 'changed'
// DOM 还没有更新
/**
* {Function} callback
* {Object} context
*/
this.$nextTick(function () {
// DOM 现在更新了
// `this` 绑定到当前实例
this.doSomethingElse()
})
}
}
})
这个方法是应用在组件内的方式,与上面的全局方法在本质上实现并无二致,后者仅仅是前者的一个别名。
四、源码探秘
接下来是官方的原话:
可能你还没有注意到,
Vue
在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue
将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个watcher
被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick
”中,Vue
刷新队列并执行实际 (已去重的) 工作。Vue
在内部对异步队列尝试使用原生的Promise.then
、MutationObserver
和setImmediate
,如果执行环境不支持,则会采用setTimeout(fn, 0)
代替。
皇天不负有心人,我们上面的猜想被验证了。无独有偶,在 JavaScript 的发展历程中,鉴于 JS 单线程引擎的工作原理,我们的前辈也想要在浏览器主代码执行完之后、浏览器渲染之前,可以做一些操作。因此 JavaScript 的 事件循环机制
以及 宏任务微任务
的概念就诞生了。
而我们的 Vue
框架,为此也向程序猿们提供了本文这个 API (Vue.nextTick)。并且,这个方法最终也成为了 Vue
渲染视图的主要手段,造成了异步更新 DOM 的现象,间接的提升了 Vue
框架的性能。
- 第一,我们修改响应式数据之后,触发
dep.notify
;
- 第二,
Dep
利用观察者模式通知已经收集好的Watcher
进行视图更新;
- 第三,每个
Watcher
将自己推入到任务队列里面;
- 第四,
Sheduler
对Watcher
进行识别判断,如果属于同一Watcher
则会被忽略;
- 第五,重点来了:
Vue.config.async
默认是true
,如果没有设置,那么Vue
会利用nextTick
方法,将flushSchedulerQueue()
处理流函数,提取到异步任务队列之中。如果设置了,那么就是立即同步执行,这就是典型的,“异步渲染机制”。
flushScheduleQueue()
处理流函数用于执行 Watcher
的 run
回调方法,以及部分生命周期的更新,重置 Schedule
实例等。
- 第六,才是我们今天的主题,
Vue.nextTick
。
// 执行微任务标识
export let isUsingMicroTask = false
// 储存任务数组
const callbacks: Array<Function> = []
// 执行标识,默认为false
let pending = false
// 执行任务数组里面的回调函数
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// 声明回调函数集
let timerFunc
// Vue 默认使用 Promise 作为异步任务,上面分析过浏览器的差异,所以下面要判断其他方法
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
// 允许使用 Promise 浏览器情况下的回调函数赋值
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (
!isIE &&
typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
// 允许使用 MO 浏览器情况下的回调函数赋值
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// 允许使用 setImmediate 浏览器情况下的回调函数赋值
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
// 摆烂情况下的赋值
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
export function nextTick(): Promise<void>
export function nextTick<T>(this: T, cb: (this: T, ...args: any[]) => any): void
export function nextTick<T>(cb: (this: T, ...args: any[]) => any, ctx: T): void
/**
* @internal
*/
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
let _resolve
// 这里就没啥了,回调函数加入数组
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e: any) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
// 执行回调函数集
timerFunc()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
捋一下吧:Vue
内部默认使用异步渲染机制,最终调用 nextTick
方法,这个 API 的作用就是,先是把所有的回调函数加入“回调函数集合”数组,再把不同浏览器可用的微任务做了一个判断、适配和循环赋值(flushCallbacks
),最终添加完了之后,执行回调函数集合。
当然了,这个 API 也是对外暴露的,任何开发者都可以使用。
五、用例测试
输出:40,这个应该是没有什么问题对吧,我们再看一组。
这里即使 age
在 nextTick
函数后面,但你前面已经执行修改 gender
触发了收集依赖,所以,微任务就会等主代码执行完之后,再执行回调,所以这里打印出来的,依然是更新之后的 DOM ,输出: 40。
同样,这里也会输出:40;即便你多次执行同一个 Watcher
的更新,Vue
会对其进行去重操作,并不会修改一次属性就更新一次视图,这部分是为了性能做的优化。
这个输出:20。因为 nextTick
的回调在异步渲染的回调之前执行,所以获取不到更新后的 DOM。
最后,感谢你的阅读,愿你的未来一片光明~