该系列是本人准备面试的笔记,或许有描述不当的地方,请在评论区指出,感激不尽。
其他篇章:
- Promise.try 和 Promise.withResolvers,你了解多少呢?
- 从 babel 编译看 async/await
- 挑战ChatGPT提供的全网最复杂“事件循环”面试题
- Vue 怎么监听 Set,WeakSet,Map,WeakMap 变化?
- Vue 是怎么从<HelloWorld />、<component is='HelloWorld'>找到HelloWorld.vue
接着事件循环的学习热浪走,我们来看看实际项目中关于宏任务微任务以及 Promise 的运用。让我们走进老生常谈的一道面试题—— Vue.nextTick
的原理是什么?
注:来自 antfu 粉丝群的聊天图片
基本信息
姓名:nextTick
籍贯:Vue 全局 API
出生年月:2013.12
功能:延迟执行回调函数,在下一次 DOM 更新循环结束后调用。
由来:Vue 使用虚拟 DOM 来优化渲染性能,组件状态的变化并不会立即更新实际的 DOM,而是等到下一个更新周期(微任务)才应用更新。因此,当需要在 DOM 更新完成后执行操作时,Vue 提供了 nextTick
。
<template>
<div id="app">{{ message }}</div>
</template>
<script>
export default {
data() {
return { message: "Hello" };
},
methods: {
updateMessage() {
this.message = "Hello Vue!";
console.log(this.$refs.app.innerText); // 此时还是旧值 "Hello"
},
},
};
</script>
在以上代码中,message
更新后,DOM 并不会立即更新。直接读取 DOM 状态可能会出现不一致的问题。
专业技能:
- 获取最新 DOM 状态:在数据改变后,立即读取更新后的 DOM 信息。
- 与第三方库交互:在数据更新后,初始化动画、插件等需要基于 DOM 的操作。
- 延迟执行回调:确保任务在 DOM 完成更新后运行。
工作经历
以下 Vue.nextTick
说明只涉及重大改动,细微改动不做讨论。因为篇幅问题,请点击文件链接跳转查看。
v0.7.0
文件位置:src/utils.js
commit:dom method callbacks should be async.
逻辑相当简单,就是使用 requestAnimationFrame
或者 setTimeout
来实现异步操作 dom。
requestAnimationFrame(() => {
console.log('rAF');
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
console.log('Sync');
宏任务的执行顺序取决于主线程是否空闲:只有当前的所有同步任务和微任务执行完成后,才会从宏任务队列中取出任务执行。它不关心浏览器的渲染节奏,只要时间到了且主线程空闲,回调就会执行。
requestAnimationFrame
的回调会在 渲染阶段 之前执行,它是专门为优化动画帧设计的。每一帧开始时,浏览器会清空 rAF 队列,然后尝试重绘页面。rAF 保证了回调函数总是在浏览器即将绘制页面前执行,能确保动画的平滑性。它不会进入宏任务或微任务队列,而是被浏览器单独管理。
如果当前主线程空闲,执行当前同步任务,然后将 setTimeout
注册为宏任务,而 requestAnimationFrame
在渲染阶段前执行,那么在同步任务结束后,setTimeout
> rAF
。
反之,如果处于事件循环中,setTimeout
会被注册为下轮事件循环宏任务,而 requestAnimationFrame
会在本轮事件循环的渲染阶段前回调,那么就是 rAF
> setTimeout
。
特性 | webkitRequestAnimationFrame / requestAnimationFrame | setTimeout |
---|---|---|
设计目的 | 为动画和页面重绘优化而设计,与浏览器刷新同步 | 通用的定时器,适用于所有任务 |
回调时间 | 精确到下一次浏览器刷新帧(通常是 16ms) | 由开发者指定,但实际时间可能延迟,受系统和事件循环限制 |
暂停机制 | 当页面不可见或后台运行时,浏览器会自动暂停调用 | 不会暂停,继续执行,可能浪费资源 |
性能 | 更高效,避免多余的帧绘制 | 可能因频繁触发导致主线程负担 |
适配性 | 专为浏览器环境设计,不能在 Node.js 等环境使用 | 浏览器和 Node.js 都支持 |
浏览器兼容性 | 现代浏览器支持 requestAnimationFrame ,旧浏览器使用 webkit 前缀 | 广泛支持,包括旧浏览器 |
调用频率 | 与显示器刷新率一致(通常每秒 60 次,即每帧 16.67ms) | 受事件循环和定时器精度限制 |
requestAnimationFrame
/webkitRequestAnimationFrame
:- 用于实现动画(如滚动、过渡效果)。
- 在需要与刷新同步的任务中更高效。
setTimeout
:- 用于非动画任务,例如定时触发的逻辑计算、数据更新等。
- 用在与帧频无关的任务中。
v0.9.3
文件位置:src/utils.js
commit:setTimeout(0) is faster than rAF
这次改动是直接使用 setTimeout
,因为尤大在 bench 测试中发现 setTimeout(0)
比 requestAnimationFrame
更快。但是该 commit 有评论持反对意见,认为应该 revert
。
使用
setTimeout
可能让代码看起来“运行得更快”(不被渲染循环影响),但用户会感受到更新不够流畅,因为它无法重绘。
v0.10.4
文件位置:src/utils.js
commit:bring back the rAF
恢复 requestAnimationFrame
的使用。
v0.11.5
文件位置:src/util/env.js
commit:re-implement nextTick with MutationObserver
引入 MutationObserver
微任务:通过 document.createTextNode
创建一个 node 节点,使用 MutationObserver
对其进行监听 characterData。当 characterData 变化时,记录当前队列长度 l,取出回调执行,更新队列(避免执行回调过程添加新任务,所以切割前面记录的队列长度)。当调用 nextTick
时,将 cb 塞入队列,同时改变 node 节点的内容,触发监听回调执行。
v0.11.6
文件位置:src/util/env.js
commit:improve nextTick
语法改为 IIFE
,本质上还是 MutationObserver
微任务执行,同时 setTimeout
进行兼容处理。当多个回调函数通过 nextTick
注册时,pending
确保只会安排一次 handle
的异步调用。这样避免了重复触发和资源浪费。
v1.0.27
文件位置:src/util/env.js
commit:adjust nextTick implementation (fix #3730)
涉案 issue:IOS10 微信页面滑动过程中产生tap事件,会导致Vue渲染不出来
使用 postMessage
代替 MutationObserver
,监听 message 事件,判断是否当前 window 触发以及 data 是否为 __vue__nextTick__
,然后执行回调队列。postMessage
也是属于宏任务,但是无需等待定时器计时完成,直接插入当前任务队列,在当时 setTimeout(0)
因为系统时间以及计时器模块问题,所以 postMessage
延迟更小。而在事件循环中,postMessage
作为当前轮次的宏任务被执行,而 setTimeout
的回调会等待到下一轮事件循环才触发。
但是因为 postMessage
是宏任务,而 MutationObserver
是微任务,微任务的优先级是高于宏任务的,所以在该 commit 的评论区有人指出他的测试用例没有通过。
v1.0.28
文件位置:src/util/env.js
commit:revert nextTick to microtask semantics by using Promise.then
恢复了 MutationObserver
的使用,同时加入 Promise.then
来解决 IOS10 微信页面滑动过程中产生tap事件,会导致Vue渲染不出来。但是从 comment 中,我们可以知道在某些 iOS 版本中,Promise.then()
所触发的微任务队列可能会在执行几次后停止工作。也就是说,尽管回调函数已被加入微任务队列,但它们不会被执行,直到浏览器做其他工作(比如处理定时器)。这种问题会导致回调函数无法按预期执行。所以 if (isIOS) setTimeout(noop)
通过调用 setTimeout
,来触发浏览器处理一个定时器任务,这就迫使浏览器退出微任务队列并处理它,确保微任务队列中的回调能够被执行。
经过搜索,导致微任务无法按预期执行有可能是 PromiseRejectionEvent
的问题。
One of the conditions for
babel-polyfill
to decides whether to use nativePromise
or not isisNode || typeof PromiseRejectionEvent == 'function'
. In mobile phone (some of my testers that I've tested),PromiseRejectionEvent
is undefined, soPromise
was rewritten. (vuex
requires a Promise polyfill in some browser) —— Weird Promise in UIWebView in iOS >= 9.3.3
iOS do not implement
PromiseRejectionEvent
, so it use polyfill. But it seems promise polyfill has bug in iOS UIWebview when scrolling. —— iOS UIWebview scroll, promise polyfill bug
v2.5.0
文件位置:src/util/env.js
commit:fix: use MessageChannel for nextTick
涉案 issue:checkbox can not be selected if it's in a element with @click listener?,click would trigger event other vnode @click event.,Select value is not updated correctly when input handler triggers class change
因为 Promise/MutationObserver
微任务优先级过高,在本应该是顺序执行的事件之间触发,甚至在事件的冒泡过程中频繁触发(举个例子,click 事件在当前元素捕获,执行完回调方法后会接着微任务的执行,继续冒泡到上一层父级元素,再次执行父级元素的回调方法,又到微任务的轮次,导致 nextTick
的更新频繁执行),所以改成宏任务执行,又因为 setImmediate
并不是所有环境都支持,所以使用 MessageChannel
兼容实现,而在没有 DOM 事件的环境继续使用 Promise.then
。
setImmediate
被设计为在当前事件循环中的 I/O 事件处理完成之后立即执行任务(但优先于任何新的渲染和用户交互任务)。它比微任务(如Promise
和MutationObserver
)的优先级低,但高于宏任务(如setTimeout
和requestAnimationFrame
)。
console.log('start')
const NEXT_TICK_TOKEN = '__vue__nextTick__'
window.addEventListener('message', e => {
if (e.source === window && e.data === NEXT_TICK_TOKEN) {
console.log('postMessage')
}
})
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = () => console.log('MessageChannel')
// postMessage > MessageChannel > setTimeout
window.postMessage(NEXT_TICK_TOKEN, '*')
port.postMessage(1)
setTimeout(() => console.log('setTimeout'), 0)
// postMessage > setTimeout > MessageChannel
window.postMessage(NEXT_TICK_TOKEN, '*')
setTimeout(() => console.log('setTimeout'), 0)
port.postMessage(1)
// MessageChannel > postMessage > setTimeout
port.postMessage(1)
window.postMessage(NEXT_TICK_TOKEN, '*')
setTimeout(() => console.log('setTimeout'), 0)
// MessageChannel > setTimeout > postMessage
port.postMessage(1)
setTimeout(() => console.log('setTimeout'), 0)
window.postMessage(NEXT_TICK_TOKEN, '*')
// setTimeout > postMessage > MessageChannel
setTimeout(() => console.log('setTimeout'), 0)
window.postMessage(NEXT_TICK_TOKEN, '*')
port.postMessage(1)
// setTimeout > MessageChannel > postMessage
setTimeout(() => console.log('setTimeout'), 0)
port.postMessage(1)
window.postMessage(NEXT_TICK_TOKEN, '*')
Promise.resolve().then(() => console.log('Promise'))
console.log('end')
从我的测试看,postMessage
,MessageChannel
,setTimeout(0)
的输出顺序是按照代码顺序执行的,并不存在优先级问题。
大胆猜测这里选择使用 MessageChannel
的原因是:
window.addEventListener('message', e => { ... })
会接收所有message
事件,同时window.postMessage(NEXT_TICK_TOKEN, '*')
向所有窗口进行广播,并不优雅。setTimeout(0)
在2017年还存在最小 4ms 的延时误差。
如果有人更清楚情况,请在评论区告知。
v2.5.2
文件位置:src/core/util/next-tick.js
commit:fix: further adjust nextTick strategy
涉案 issue:v-show is firing late on 2.5.1
nextTick
终于有了它自己的独栋大别墅。
因为当前事件循环产生的宏任务会在下次事件循环执行,排在了当前渲染任务后面,所以会出现视图更新不及时,出现闪屏的情况,动画效果不理想。所以划分宏任务和微任务机制处理,默认使用微任务更新,但在涉及 DOM 事件监听时,使用宏任务。
Q:如果使用 requestAnimationFrame
,能否解决问题?
v2.6.0
文件位置:src/core/util/next-tick.js
commit:fix: async edge case fix should apply to more browsers
移除宏任务,使用 Promise.then
> MutationObserver
> setImmediate
> setTimeout
的优先级进行处理。
但是微任务会重新导致@click would trigger event other vnode @click event.,为了避免这种重复触发,代码中通过 isUsingMicroTask
判断是否正在执行微任务更新机制。如果是,它会记录当前的时间戳 currentFlushTimestamp
,并将该时间戳与事件的 timeStamp
属性进行比较。只有当事件的时间戳大于或等于这个记录的时间戳时,才会调用原始的事件处理函数。这样可以确保事件处理函数只在事件实际发生后才被触发,避免了微任务触发时的多次调用。
function add (
name: string,
handler: Function,
capture: boolean,
passive: boolean
) {
if (isUsingMicroTask) {
const attachedTimestamp = currentFlushTimestamp
const original = handler
handler = original._wrapper = function (e) {
if (e.timeStamp >= attachedTimestamp) {
return original.apply(this, arguments)
}
}
}
target.addEventListener(
name,
handler,
supportsPassive
? { capture, passive }
: capture
)
}
前半生历程
从以上的源码变化,可以看出 Vue2 nextTick
的主要问题在于宏任务微任务的优先级问题。
- 优先级问题:由于宏任务和微任务的优先级不同,某些情况下
nextTick
的执行可能不如预期,可能会影响复杂的 DOM 更新操作或用户交互响应。 - 多重异步调度:如果多个
nextTick
被连续调用,可能导致多个回调堆积在微任务队列中,可能影响性能,尤其是在大规模更新时。
v3.0.0 - v3.5.13
文件位置:packages\runtime-core\src\scheduler.ts
commit:因为从 v3.0.0 开始,nextTick
都是调度器 + Promise.then
微任务的思想,所以直接解读最新版本 v3.5.13 的源码。相关 commits 请自行查阅。
从文件名就可以看出,以上逻辑是任务调度器。
核心数据:
queue
:存储主任务队列,按优先级顺序执行。pendingPostFlushCbs
:存储“后置回调”,这些任务在主任务完成后执行。activePostFlushCbs
:当前正在执行的“后置回调”队列。currentFlushPromise
:当前调度器的 Promise,用于支持异步操作。
关键方法:
-
queueJob(job: SchedulerJob): void
-
功能:将任务加入主队列。主要为:
watch
,$forceUpdate
,组件的更新渲染,hmr
中强制父实例重新渲染。 -
逻辑:
- 检查任务是否已被加入队列 (
QUEUED
标志)。 - 根据任务的
id
确定插入位置,确保队列按优先级排序。 - 添加
QUEUED
标志,调用queueFlush
启动任务调度。
- 检查任务是否已被加入队列 (
-
-
queueFlush(): void
-
功能:启动任务调度器。
-
逻辑:
- 如果当前没有正在执行的调度器任务,创建一个基于
Promise
的微任务来执行flushJobs
。
- 如果当前没有正在执行的调度器任务,创建一个基于
-
-
flushJobs(seen?: CountMap): void
-
功能:执行主任务队列。
-
逻辑:
- 遍历队列,执行每个任务,同时处理递归任务和错误。
- 清理任务的
QUEUED
标志,并执行所有pendingPostFlushCbs
。 - 如果在执行过程中有新任务加入队列,递归调用自身继续处理。
-
-
queuePostFlushCb(cb: SchedulerJobs): void
-
功能:将任务加入“后置回调”队列。
-
逻辑:
- 支持单个或多个任务。
- 去重后将任务加入
pendingPostFlushCbs
,调用queueFlush
启动调度。
-
-
flushPostFlushCbs(seen?: CountMap): void
-
功能:执行所有“后置回调”任务。多为
effect
等需要等 DOM 更新后再执行的任务和更新后脏数据的清理。 -
逻辑:
- 对
pendingPostFlushCbs
去重并排序。 - 执行所有回调,确保任务不会递归触发自身。
- 清理执行状态。
- 对
-
-
flushPreFlushCbs(instance?: ComponentInternalInstance, seen?: CountMap, i: number = flushIndex + 1): void
-
功能:执行“前置任务”。主要为:由于 props 变更触发的
watch
、watchEffect
回调函数,以及watch
中的flush
不为sync
和post
。 -
逻辑:
- 从主队列中找到
PRE
标志任务,优先执行这些任务。 - 如果任务允许递归执行(
ALLOW_RECURSE
),保留其QUEUED
状态。
- 从主队列中找到
-
-
findInsertionIndex(id: number): number
- 功能:使用二分搜索找到任务在队列中的插入位置。保证队列中任务按
id
排序,以支持父组件任务优先于子组件任务执行。
- 功能:使用二分搜索找到任务在队列中的插入位置。保证队列中任务按
-
checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob): boolean
-
功能:检测是否有任务递归触发自身。
-
逻辑:
- 使用
seen
记录任务的触发次数。 - 超过限制 (
RECURSION_LIMIT
) 时,抛出错误并阻止任务继续执行。
- 使用
-
这也是整个 Vue3 的异步更新策略,当组件更新被触发(如响应式数据变更)时,更新任务被加入调度器队列(queue
)。通过 queueFlush()
异步启动调度逻辑,调用 flushJobs
来清空 queue
队列,调用 flushPostFlushCbs
清空 pendingPostFlushCbs
队列。
- 优先级管理:通过任务
id
和标志位管理任务的执行顺序。 - 递归保护:防止任务无限递归触发。
- 异步调度:通过
Promise
微任务确保任务在正确时机执行。 - 分阶段执行:支持“前置任务”、“主任务”和“后置回调”分阶段执行。
- 错误处理:为任务执行提供错误捕获和报告。
关于“我”
nextTick
返回一个基于 currentFlushPromise
或默认 resolvedPromise
的微任务 (Promise.then
),即在当前事件循环的微任务队列中执行。而微任务的优先级高于事件循环中的宏任务,因此它可以保证在浏览器渲染前执行。而 currentFlushPromise
是 flushJobs
执行所有调度任务后的 Promise
,即 nextTick
被放置在所有调度任务(包括 DOM 更新)执行完毕后,确保它获取的 DOM 信息是最新的。
喜欢这篇文章的朋友不要忘了点赞收藏评论三连哦~