一、异步更新队列
可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 Watcher
被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。
Vue 在内部对异步队列尝试使用原生的 Promise.then
、MutationObserver
和 setImmediate
,如果执行环境不支持,则会采用 setTimeout(fn, 0)
代替。
例如,当你设置 vm.someData = 'new value'
,该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环 “tick” 中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。
虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。例如:
{{message}}
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
});
vm.message = 'new message'; // 更改数据
vm.$el.textContent === 'new message'; // false
Vue.nextTick(function () {
vm.$el.textContent === 'new message'; // true
});
在组件内使用 vm.$nextTick() 实例方法特别方便,因为它不需要全局 Vue,并且回调函数中的 this 将自动绑定到当前的 Vue 实例上:
Vue.component('example', {
template: '{{ message }}',
data: function () {
return {
message: '未更新'
};
},
methods: {
updateMessage: function () {
this.message = '已更新';
console.log(this.$el.textContent); // => '未更新'
this.$nextTick(function () {
console.log(this.$el.textContent); // => '已更新'
});
}
}
});
因为 $nextTick() 返回一个 Promise 对象,所以你可以使用新的 ES2017 async/await 语法完成相同的事情:
methods: {
updateMessage: async function () {
this.message = '已更新';
console.log(this.$el.textContent); // => '未更新'
await this.$nextTick();
console.log(this.$el.textContent); // => '已更新'
}
}
nextTick
接收一个回调函数作为参数,并将这个回调函数延迟到DOM更新后才执行;nextTick
是在下次 DOM 更新循环结束之后执行延迟回调,在修改数据之后使用nextTick,则可以在回调中获取更新后的 DOM
使用场景:想要操作 基于最新数据的生成DOM 时,就将这个操作放在 nextTick
的回调中;
二、前置知识
nextTick
函数的作用可以理解为异步执行传入的函数,这里先介绍一下什么是异步执行,从 JS 运行机制说起。
2.1 JS 运行机制
JS 的执行是单线程的,所谓的单线程就是事件任务要排队执行,前一个任务结束,才会执行后一个任务,这就是同步任务,为了避免前一个任务执行了很长时间还没结束,那下一个任务就不能执行的情况,引入了异步任务的概念。JS 运行机制简单来说可以按以下几个步骤。
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个任务队列(task queue)。只要异步任务有了运行结果,会把其回调函数作为一个任务添加到任务队列中。
- 一旦执行栈中的所有同步任务执行完毕,就会读取任务队列,看看里面有那些任务,将其添加到执行栈,开始执行。
- 主线程不断重复上面的第三步。也就是常说的事件循环(Event Loop)。
2.2 异步任务的类型
nextTick
函数异步执行传入的函数,是一个异步任务。异步任务分为两种类型。
主线程的执行过程就是一个 tick
,而所有的异步任务都是通过任务队列来一一执行。任务队列中存放的是一个个的任务(task)。规范中规定 task 分为两大类,分别是宏任务(macro task)和微任务 (micro task),并且每个 macro task
结束后,都要清空所有的 micro task
。
用一段代码形象介绍 task的执行顺序。
for (macroTask of macroTaskQueue) {
handleMacroTask();
for (microTask of microTaskQueue) {
handleMicroTask(microTask);
}
}
在浏览器环境中, 常见的创建 macro task
的方法有
setTimeout
、setInterval
、postMessage
、MessageChannel
(队列优先于setTimeiout
执行)网络请求IO
- 页面交互:DOM、鼠标、键盘、滚动事件
- 页面渲染
常见的创建 micro task
的方法
Promise.then
MutationObserve
process.nexttick
在 nextTick
函数要利用这些方法把通过参数 cb
传入的函数处理成异步任务。
三、nextTick 实现原理
将传入的回调函数包装成异步任务,异步任务又分微任务和宏任务,为了尽快执行所以优先选择微任务;nextTick
提供了四种异步方法 Promise.then
、MutationObserver
、setImmediate
、setTimeOut(fn,0)
3.1 Vue.nextTick 内部逻辑
在执行 initGlobalAPI(Vue)
初始化 Vue 全局 API 中,这么定义Vue.nextTick
:
function initGlobalAPI(Vue) {
//...
Vue.nextTick = nextTick;
}
可以看出是直接把 nextTick 函数赋值给 Vue.nextTick,就可以了,非常简单。
3.2 vm.$nextTick 内部逻辑
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
};
可以看出是vm.$nextTick
内部也是调用 nextTick
函数。
3.3 源码解读
nextTick
的源码位于 src/core/util/next-tick.js
nextTick
源码主要分为两块:
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'
// 上面三行与核心代码关系不大,了解即可
// noop 表示一个无操作空函数,用作函数默认值,防止传入 undefined 导致报错
// handleError 错误处理函数
// isIE, isIOS, isNative 环境判断函数,
// isNative 判断是否原生支持,如果通过第三方实现支持也会返回 false
export let isUsingMicroTask = false // nextTick 最终是否以微任务执行
const callbacks = [] // 存放调用 nextTick 时传入的回调函数
let pending = false // 标识当前是否有 nextTick 在执行,同一时间只能有一个执行
// 声明 nextTick 函数,接收一个回调函数和一个执行上下文作为参数
export function nextTick(cb?: Function, ctx?: Object) {
let _resolve
// 将传入的回调函数存放到数组中,后面会遍历执行其中的回调
callbacks.push(() => {
if (cb) { // 对传入的回调进行 try catch 错误捕获
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// 如果当前没有在 pending 的回调,就执行 timeFunc 函数选择当前环境优先支持的异步方法
if (!pending) {
pending = true
timerFunc()
}
// 如果没有传入回调,并且当前环境支持 promise,就返回一个 promise
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
可以看到在 nextTick
函数中把通过参数 cb 传入的函数,做一下包装然后 push 到 callbacks
数组中。
然后用变量 pending 来保证执行一个事件循环中只执行一次 timerFunc()。
最后执行 if (!cb && typeof Promise !== 'undefined')
,判断参数 cb
不存在且浏览器支持 Promise,则返回一个 Promise 类实例化对象。例如 nextTick().then(() => {})
,当 _resolve
函数执行,就会执行 then 的逻辑中。
来看一下 timerFunc
函数的定义,先只看用 Promise 创建一个异步执行的 timerFunc
函数 。
// 判断当前环境优先支持的异步方法,优先选择微任务
// 优先级:Promise---> MutationObserver---> setImmediate---> setTimeout
// setTimeOut 最小延迟也要4ms,而 setImmediate 会在主线程执行完后立刻执行
// setImmediate 在 IE10 和 node 中支持
// 多次调用 nextTick 时 ,timerFunc 只会执行一次
let timerFunc
// 判断当前环境是否支持 promise
if (typeof Promise !== 'undefined' && isNative(Promise)) { // 支持 promise
const p = Promise.resolve()
timerFunc = () => {
// 用 promise.then 把 flushCallbacks 函数包裹成一个异步微任务
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
// 标记当前 nextTick 使用的微任务
isUsingMicroTask = true
// 如果不支持 promise,就判断是否支持 MutationObserver
// 不是IE环境,并且原生支持 MutationObserver,那也是一个微任务
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
let counter = 1
// new 一个 MutationObserver 类
const observer = new MutationObserver(flushCallbacks)
// 创建一个文本节点
const textNode = document.createTextNode(String(counter))
// 监听这个文本节点,当数据发生变化就执行 flushCallbacks
observer.observe(textNode, { characterData: true })
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter) // 数据更新
}
isUsingMicroTask = true // 标记当前 nextTick 使用的微任务
// 判断当前环境是否原生支持 setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => { setImmediate(flushCallbacks) }
} else {
// 以上三种都不支持就选择 setTimeout
timerFunc = () => { setTimeout(flushCallbacks, 0) }
}
其中 isNative
方法是如何定义,代码如下。
function isNative(Ctor) {
return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
}
在其中发现 timerFunc
函数就是用各种异步执行的方法调用 flushCallbacks
函数。
来看一下 flushCallbacks
函数
// 如果多次调用 nextTick,会依次执行上面的方法,将 nextTick 的回调放在 callbacks 数组中
// 最后通过 flushCallbacks 函数遍历 callbacks 数组的拷贝并执行其中的回调
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0) // 拷贝一份
callbacks.length = 0 // 清空 callbacks
for (let i = 0; i < copies.length; i++) { // 遍历执行传入的回调
copies[i]()
}
}
// 为什么要拷贝一份 callbacks
// callbacks.slice(0) 将 callbacks 拷贝出来一份,
// 是因为考虑到 nextTick 回调中可能还会调用 nextTick 的情况,
// 如果 nextTick 回调中又调用了一次 nextTick,则又会向 callbacks 中添加回调,
// nextTick 回调中的 nextTick 应该放在下一轮执行,
// 如果不将 callbacks 复制一份就可能一直循环
执行 pending = false
使下个事件循环中能 nextTick
函数中调用 timerFunc
函数。
执行 var copies = callbacks.slice(0);callbacks.length = 0
; 把要异步执行的函数集合 callbacks
克隆到常量 copies
,然后把 callbacks
清空。
然后遍历 copies
执行每一项函数。回到 nextTick
中是把通过参数 cb
传入的函数包装后 push 到 callbacks
集合中。来看一下怎么包装的。
function() {
if (cb) {
try {
cb.call(ctx);
} catch (e) {
handleError(e, ctx, 'nextTick');
}
} else if (_resolve) {
_resolve(ctx);
}
}
逻辑很简单。若参数 cb 有值。在 try 语句中执行 cb.call(ctx)
,参数 ctx 是传入函数的参数。 如果执行失败执行 handleError(e, ctx, 'nextTick')
。
若参数 cb 没有值。执行 _resolve(ctx)
,因为在 nextTick
函数中如何参数 cb 没有值,会返回一个 Promise 类实例化对象,那么执行 _resolve(ctx)
,就会执行 then 的逻辑中。
到这里 nextTick
函数的主线逻辑就很清楚了。定义一个变量 callbacks
,把通过参数 cb 传入的函数用一个函数包装一下,在这个中会执行传入的函数,及处理执行失败和参数 cb 不存在的场景,然后 添加到 callbacks。
调用 timerFunc
函数,在其中遍历 callbacks
执行每个函数,因为 timerFunc
是一个异步执行的函数,且定义一个变量 pending
来保证一个事件循环中只调用一次 timerFunc
函数。这样就实现了 nextTick
函数异步执行传入的函数的作用了。
那么其中的关键还是怎么定义 timerFunc
函数。因为在各浏览器下对创建异步执行函数的方法各不相同,要做兼容处理,下面来介绍一下各种方法。
3.4 为什么优先使用微任务:
按照上面事件循环的执行顺序,执行下一次宏任务之前会执行一次 UI 渲染,等待时长比微任务要多很多。所以在能使用微任务的时候优先使用微任务,不能使用微任务的时候才使用宏任务,优雅降级。*
四、参考文献
深入响应式原理 — Vue.js
nextTick实现原理,必拿下! - 掘金
Vue源码——nextTick实现原理 - 掘金