目录
- 一次弄懂 Vue2 和 Vue3 的 nextTick 实现原理
- Vue2 中的 nextTick
- 异步任务队列
- 宏任务和微任务
- 总结
- Vue3 中的 nextTick
- Promise 在浏览器中的问题
- Vue3 中解决 Promise 缺陷的方法
- 总结
- 结论
一次弄懂 Vue2 和 Vue3 的 nextTick 实现原理
今天是 Wed Apr 26 2023 14:29:19 GMT+0800 (China Standard Time),我们来聊一下 Vue 的异步更新机制中的 nextTick。Vue 中的数据绑定和模板渲染都是异步的,那么如何在更新完成后执行回调函数呢?这就需要用到 Vue 的 nextTick 方法了。
Vue2 中的 nextTick
在 Vue2 中,nextTick 的实现基于浏览器的异步任务队列和微任务队列。
异步任务队列
在浏览器中,每个宏任务结束后会检查微任务队列,如果有任务则依次执行。当所有微任务执行完成后,才会执行下一个宏任务。因此可以通过将任务作为微任务添加到微任务队列中,来确保任务在所有宏任务执行完毕后立即执行。
而使用 setTimeout 可以将任务添加到异步任务队列中,在下一轮事件循环中执行。
在 Vue2 中,如果没有指定执行环境,则会优先使用 Promise.then / MutationObserver,否则使用 setTimeout。
javascript复制代码 | |
// src/core/util/next-tick.js | |
/* istanbul ignore next */ | |
const callbacks = [] | |
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 microTimerFunc | |
let macroTimerFunc | |
let useMacroTask = false | |
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { | |
// 使用 setImmediate | |
macroTimerFunc = () => { | |
setImmediate(flushCallbacks) | |
} | |
} else if ( | |
typeof MessageChannel !== 'undefined' && | |
(isNative(MessageChannel) || | |
// PhantomJS | |
MessageChannel.toString() === '[object MessageChannelConstructor]') | |
) { | |
const channel = new MessageChannel() | |
const port = channel.port2 | |
channel.port1.onmessage = flushCallbacks | |
macroTimerFunc = () => { | |
port.postMessage(1) | |
} | |
} else { | |
// 使用 setTimeout | |
macroTimerFunc = () => { | |
setTimeout(flushCallbacks, 0) | |
} | |
} | |
if (typeof Promise !== 'undefined' && isNative(Promise)) { | |
// 使用 Promise.then | |
const p = Promise.resolve() | |
microTimerFunc = () => { | |
p.then(flushCallbacks) | |
} | |
} else { | |
// 使用 MutationObserver | |
const observer = new MutationObserver(flushCallbacks) | |
const textNode = document.createTextNode(String(1)) | |
observer.observe(textNode, { | |
characterData: true | |
}) | |
microTimerFunc = () => { | |
textNode.data = String(1) | |
} | |
} | |
export function nextTick(cb?: Function, ctx?: Object) { | |
let _resolve | |
callbacks.push(() => { | |
if (cb) { | |
try { | |
cb.call(ctx) | |
} catch (e) { | |
handleError(e, ctx, 'nextTick') | |
} | |
} else if (_resolve) { | |
_resolve(ctx) | |
} | |
}) | |
if (!pending) { | |
pending = true | |
if (useMacroTask) { | |
macroTimerFunc() | |
} else { | |
microTimerFunc() | |
} | |
} | |
if (!cb && typeof Promise !== 'undefined') { | |
return new Promise(resolve => { | |
_resolve = resolve | |
}) | |
} | |
} |
宏任务和微任务
在 Vue2 中,可以通过设置 useMacroTask 来使 nextTick 方法使用宏任务或者微任务。
Vue2 中默认使用微任务,在没有原生 Promise 和 MutationObserver 的情况下,才会改用 setTimeout。
javascript复制代码 | |
let microTimerFunc | |
let macroTimerFunc | |
let useMacroTask = false // 默认使用微任务 | |
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { | |
// 使用 setImmediate | |
macroTimerFunc = () => { | |
setImmediate(flushCallbacks) | |
} | |
} else if ( | |
typeof MessageChannel !== 'undefined' && | |
(isNative(MessageChannel) || | |
// PhantomJS | |
MessageChannel.toString() === '[object MessageChannelConstructor]') | |
) { | |
const channel = new MessageChannel() | |
const port = channel.port2 | |
channel.port1.onmessage = flushCallbacks | |
macroTimerFunc = () => { | |
port.postMessage(1) | |
} | |
} else { | |
// 使用 setTimeout | |
macroTimerFunc = () => { | |
setTimeout(flushCallbacks, 0) | |
} | |
} | |
if (typeof Promise !== 'undefined' && isNative(Promise)) { | |
// 使用 Promise.then | |
const p = Promise.resolve() | |
microTimerFunc = () => { | |
p.then(flushCallbacks) | |
} | |
} else { | |
// 使用 MutationObserver | |
const observer = new MutationObserver(flushCallbacks) | |
const textNode = document.createTextNode(String(1)) | |
observer.observe(textNode, { | |
characterData: true | |
}) | |
microTimerFunc = () => { | |
textNode.data = String(1) | |
} | |
} | |
export function nextTick(cb?: Function, ctx?: Object) { | |
let _resolve | |
callbacks.push(() => { | |
if (cb) { | |
try { | |
cb.call(ctx) | |
} catch (e) { | |
handleError(e, ctx, 'nextTick') | |
} | |
} else if (_resolve) { | |
_resolve(ctx) | |
} | |
}) | |
if (!pending) { | |
pending = true | |
if (useMacroTask) { | |
macroTimerFunc() | |
} else { | |
microTimerFunc() | |
} | |
} | |
if (!cb && typeof Promise !== 'undefined') { | |
return new Promise(resolve => { | |
_resolve = resolve | |
}) | |
} | |
} |
总结
在 Vue2 中,nextTick 的实现原理基于浏览器的异步任务队列和微任务队列。Vue2 默认使用微任务,在没有原生 Promise 和 MutationObserver 的情况下才会改用 setTimeout。
Vue3 中的 nextTick
在 Vue3 中,nextTick 的实现有了较大变化,主要是为了解决浏览器对 Promise 的缺陷和问题。
Promise 在浏览器中的问题
在浏览器中,Promise 有一个缺陷:如果 Promise 在当前事件循环中被解决,那么在 then 回调函数之前添加的任务将不能在同一个任务中执行。
例如:
javascript复制代码 | |
Promise.resolve().then(() => { | |
console.log('Promise 1') | |
}).then(() => { | |
console.log('Promise 2') | |
}) | |
console.log('Hello') |
输出结果为:
复制代码 | |
Hello | |
Promise 1 | |
Promise 2 |
这是因为 Promise 虽然是微任务,但是需要等到当前宏任务结束才能执行。
Vue3 中解决 Promise 缺陷的方法
在 Vue3 中,通过使用 MutationObserver 和 Promise.resolve().then() 来解决 Promise 在浏览器中的缺陷。具体实现如下:
javascript复制代码 | |
const queue: Array<Function> = [] | |
let has: { [key: number]: boolean } = {} | |
let flushing = false | |
let index = 0 | |
function resetSchedulerState() { | |
queue.length = 0 | |
has = {} | |
flushing = false | |
} | |
function flushSchedulerQueue() { | |
flushing = true | |
let job | |
while ((job = queue.shift())) { | |
if (!has[job.id]) { | |
has[job.id] = true | |
job() | |
} | |
} | |
resetSchedulerState() | |
} | |
let macroTimerFunc | |
let microTimerFunc | |
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { | |
macroTimerFunc = () => { | |
setImmediate(flushSchedulerQueue) | |
} | |
} else { | |
macroTimerFunc = () => { | |
setTimeout(flushSchedulerQueue, 0) | |
} | |
} | |
if (typeof Promise !== 'undefined' && isNative(Promise)) { | |
const p = Promise.resolve() | |
microTimerFunc = () => { | |
p.then(flushSchedulerQueue) | |
if (isIOS) setTimeout(noop) | |
} | |
} else { | |
microTimerFunc = macroTimerFunc | |
} | |
export function nextTick(fn?: Function): Promise<void> { | |
const id = index++ | |
const job = fn.bind(null) | |
queue.push(job) | |
if (!flushing) { | |
if (useMacroTask) { | |
macroTimerFunc() | |
} else { | |
microTimerFunc() | |
} | |
} | |
if (!fn && typeof Promise !== 'undefined') { | |
return new Promise(resolve => { | |
resolvedPromise.then(() => { | |
if (has[id] || !queue.includes(job)) { | |
return | |
} | |
queue.splice(queue.indexOf(job), 1) | |
resolve() | |
}) | |
}) | |
} | |
} |
在 Vue3 中,nextTick 的实现原理基于MutationObserver 和 Promise.resolve().then(),通过 MutationObserver 监测 DOM 变化,在下一个微任务中执行回调函数。
而如果当前浏览器不支持原生 Promise,则使用 setTimeout 来模拟 Promise 的行为,并在回调函数执行前添加一个空的定时器来强制推迟执行(解决 iOS 中 setTimeout 在非激活标签页中的问题)。
如果需要等待所有回调函数执行完成,则可以通过返回一个 Promise 对象来实现。
javascript复制代码 | |
export function nextTick(fn?: Function): Promise<void> { | |
const id = index++ | |
const job = fn.bind(null) | |
queue.push(job) | |
if (!flushing) { | |
if (useMacroTask) { | |
macroTimerFunc() | |
} else { | |
microTimerFunc() | |
} | |
} | |
if (!fn && typeof Promise !== 'undefined') { | |
return new Promise(resolve => { | |
resolvedPromise.then(() => { | |
if (has[id] || !queue.includes(job)) { | |
return | |
} | |
queue.splice(queue.indexOf(job), 1) | |
resolve() | |
}) | |
}) | |
} | |
} |
总结
在 Vue3 中,nextTick 的实现原理基于 MutationObserver 和 Promise.resolve().then()。如果浏览器不支持原生 Promise,则使用 setTimeout 来模拟 Promise 的行为,并在回调函数执行前添加一个空的定时器来强制推迟执行。
结论
无论是在 Vue2 还是 Vue3 中,nextTick 都是用来处理 DOM 更新完毕后执行回调函数的方法。在 Vue2 中,nextTick 的实现基于浏览器的异步任务队列和微任务队列,而在 Vue3 中,为了解决浏览器对 Promise 的缺陷和问题,使用 MutationObserver 和 Promise.resolve().then() 来实现。同时,Vue3 中的 nextTick 方法也支持返回 Promise 对象,方便等待所有回调函数执行完成后再进行下一步操作。
需要注意的是,尽管 Vue3 中使用了 MutationObserver 和 Promise.resolve().then() 来解决 Promise 在浏览器中的缺陷,但在某些情况下(例如非激活标签页中),仍然可能会出现问题。因此,在实际使用中,还需要根据具体情况选择合适的方案。
总之,了解 nextTick 的实现原理可以帮助我们更好地理解 Vue 中的异步更新机制,从而更好地优化和调试应用程序。