源码阅读:Vue.nextTick()
1. 知识储备
在阅读源代码之前请按顺序阅读这些文章/视频:
Vue.js:异步更新队列
从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
Philip Roberts: What the heck is the event loop anyway? | JSConf EU 2014
MDN:MutationObserver
MDN:MessageChannel
Tasks, microtasks, queues and schedules
Vue.js 升级踩坑小记(可省略,但是这篇文章给我的收获还是很大的)
2. 知识点小结:
这里只做一个最简单粗暴的知识点小结,不包含任何解释。
宏任务(macrotask)
主代码块,setTimeout,setInterval, setImmediate,MessageChannel,postMessage
微任务(microtask)
promise,MutationObserver
任务执行顺序以及渲染的执行
macrotask -> microtask -> 渲染 -> macrotask -> microtask -> 渲染 -> ......
3. Vue如何实现 .nextTick()
Vue 在内部尝试对异步队列使用原生的 Promise.then 和 MessageChannel,如果执行环境不支持,会采用 setTimeout(fn, 0) 代替。
异步队列很明显提高了性能,但是如果我们想要在DOM更新之后做点什么,可能就有点麻烦了(详情看这),因为主代码块在微任务列表之前,而Vue是在微任务或者下一个宏任务中才更新DOM的,这时候就需要使用.nextTick()
了。
Vue.js 2.5
之前,几乎都是用 microtask 来模拟 Node.js 的.nextTick()
:
- 浏览器是否支持
Promise
?是则使用Promise
,否则进行下一步 - 浏览器是否支持
MutationObserver
,是则使用MutationObserver
,否则进行下一步 -
setTimeout
(此时是一个macrotask)
Vue.js 2.5
之后,默认使用 microtask ,在DOM事件强制使用 macrotask:
- 先确定使用 macrotask 时用哪个API,优先级为:
setImmediate
->MessageChannel
->setTimeout
- 确定使用 microtask 时用哪个API,优先级为:
Promise
->macroTimerFunc
(和macrotask一致) - 判断是否使用 macrotask ,是则调用
macroTimerFunc
,否则调用microTimerFunc
- DOM事件默认会包裹一层函数来强制其使用 macrotask
4. 正题
Vue.js 2.5
之前,.nextTick()
放在env.js
中,使用Promise
, MutationObserver
, setTimeout
来实现异步队列:
// env.js
/* @flow */
/* globals MutationObserver */
import { noop } from 'shared/util'
// 能否使用 __proto__?
export const hasProto = '__proto__' in {}
// 浏览器环境检测,和本文无关,可忽略
export const inBrowser = typeof window !== 'undefined'
export const UA = inBrowser && window.navigator.userAgent.toLowerCase()
export const isIE = UA && /msie|trident/.test(UA)
export const isIE9 = UA && UA.indexOf('msie 9.0') > 0
export const isEdge = UA && UA.indexOf('edge/') > 0
export const isAndroid = UA && UA.indexOf('android') > 0
export const isIOS = UA && /iphone|ipad|ipod|ios/.test(UA)
// this needs to be lazy-evaled because vue may be required before
// vue-server-renderer can set VUE_ENV
let _isServer
export const isServerRendering = () => {
if (_isServer === undefined) {
/* istanbul ignore if */
if (!inBrowser && typeof global !== 'undefined') {
// detect presence of vue-server-renderer and avoid
// Webpack shimming the process
_isServer = global['process'].env.VUE_ENV === 'server'
} else {
_isServer = false
}
}
return _isServer
}
export const devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__
/* istanbul ignore next */
function isNative (Ctor: Function): boolean {
return /native code/.test(Ctor.toString())
}
// 相关代码在这里
export const nextTick = (function () {
const callbacks = [] // 存放回调函数
let pending = false // 是否有异步队列(callbacks)正在等待执行
let timerFunc // 处理异步队列的函数(Promise,MutationObserver,setTimeout)
function nextTickHandler () { // 清空callbacks列表,执行callback列表中的函数
pending = false // 表示没有异步队列在等待了
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// nextTick利用了微任务队列,微任务队列可以用原生的Promise或者MutationObserver来实现
// MutationObserver被广泛支持,但是在iOS >= 9.3.3上会有严重的bug。
// 因此优先使用Promise
/* istanbul ignore if */
if (typeof Promise !== 'undefined' && isNative(Promise)) { // 用Promise把回调函数推入微任务队列
var p = Promise.resolve()
var logError = err => { console.error(err) }
timerFunc = () => {
p.then(nextTickHandler).catch(logError)
// 在UIWebViews中虽然Promise.then没有完全break,但是会陷入一个很奇怪的状态
//回调函数都被推入微任务队列中,但是在浏览器处理别的任务(比如timer)之前队列不会被清空。
// 因此添加一个空的timer来强制清空微任务队列。
if (isIOS) setTimeout(noop)
}
} else if (typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
// Promise不能用则用MutationObserver,MutationObserver也属于微任务
// e.g. PhantomJS IE11, iOS7, Android 4.4
var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter)) // 创建一个看不见的文本节点,让MutationObserver来监听它
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
} else { // Promise和MutationObserver都不能用
// 用setTimeout代替,setTimeout为宏任务
/* istanbul ignore next */
timerFunc = () => {
setTimeout(nextTickHandler, 0)
}
}
return function queueNextTick (cb?: Function, ctx?: Object) { // 添加回调函数,调用VUe.nextTick即调用这个函数,注意到可以传入一个对象做为该函数的上下文!
let _resolve
callbacks.push(() => { // 包裹传入的函数,绑定其上下文,并push到callbacks中
if (cb) cb.call(ctx)
if (_resolve) _resolve(ctx)
})
if (!pending) { // 如果没有异步队列在等待执行,那么处理当前的异步队列
pending = true
timerFunc()
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
})()
let _Set
/* istanbul ignore if */
if (typeof Set !== 'undefined' && isNative(Set)) { // 浏览器支持Set
// use native Set when available.
_Set = Set
} else { // 浏览器不支持Set
// a non-standard Set polyfill that only works with primitive keys.
_Set = class Set {
set: Object;
constructor () {
this.set = Object.create(null)
}
has (key: string | number) { // set[key]是否存在
return this.set[key] === true
}
add (key: string | number) { // 添加一个元素
this.set[key] = true
}
clear () { // 清空对象内所有元素
this.set = Object.create(null)
}
}
}
export { _Set }
Vue.js 2.5+
把nestTick
单独成一个文件了:next-tick.js
:
// next-tick.js
/* @flow */
/* globals MessageChannel */
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIOS, isNative } from './env'
const callbacks = [] // 存储回调函数
let pending = false // 当前是否有异步队列在等待执行?
function flushCallbacks () { // 执行任务队列中的回调函数
pending = false // pending为false,表示异步队列已经被清空
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// 在<2.4 的版本,nextTick几乎都是使用microtasks来实现
// 但这会导致一些问题(下面讲)
// 所以 2.5+ 默认使用microtasks,但某些场景下会强制使用 macrotasks(比如,v-on绑定的事件)
let microTimerFunc
let macroTimerFunc
let useMacroTask = false // 是否使用macrotask来处理nextTick?默认为否
// 决定macrotask的实现。
// 在技术上 setImmediate 是最理想的,但是它只能在IE中使用。
// 让回调函数始终排队在 同一个事件循环中触发的DOM事件 之后的唯一polyfill就是使用MessageChannel
/* istanbul ignore if */
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 { // 不支持 setImmediate 和 MessageChannel 时用 setTimeout 代替
/* istanbul ignore next */
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
// 决定MicroTask的实现。
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
// 在UIWebViews中虽然Promise.then没有完全break,但是会陷入一个很奇怪的状态
// 回调函数都被推入微任务队列中,但是在浏览器处理别的任务(比如timer)之前不会执行这些任务
// 因此添加一个空的timer来强制清空微任务队列。
if (isIOS) setTimeout(noop)
}
} else {
// 不支持Promise则用macrotask代替
// MutationObeserver因为兼容性问题被抛弃了
microTimerFunc = macroTimerFunc
}
// 包裹一个函数,强制其使用macrotask
// 默认会给每一个DOM事件的回调函数调用withMacroTask
export function withMacroTask (fn: Function): Function {
return fn._withTask || (fn._withTask = function () {
useMacroTask = true // 使用macrotask
const res = fn.apply(null, arguments)
useMacroTask = false // 状态重新设置为false,不然其他回调函数也会用macrotask
return res
})
}
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()
}
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
5. 为什么默认使用 microtask 的一点补充
(摘自知乎,原链接:Vue 中如何使用 MutationObserver 做批量处理?)
根据HTML Standard,在每个 task 运行完以后,UI 都会重渲染(知识点小结那块有说到任务执行顺序以及什么时候渲染),那么在 microtask 中就完成数据更新,当前 task 结束就可以得到最新的 UI 了。反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。(当然,浏览器实现有不少不一致的地方,上面 Jake 那篇文章里已经有提到。)