1. 前言
nextTick
是 Vue
中的一个核心功能,在 Vue
内部实现中也经常用到 nextTick
。在介绍 nextTick
实现原理之前,我们有必要先了解一下这个东西到底是什么,为什么要有它,它是干嘛用的。
2. nextTick到底是什么
官方文档对 nextTick
的功能如是说明:
在下次 DOM
更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM
。
// 修改数据 vm.msg = 'Hello' // DOM 还没有更新 Vue.nextTick(function () { // DOM 更新了 }) // 作为一个 Promise 使用 (2.1.0 起新增,详见接下来的提示) Vue.nextTick() .then(function () { // DOM 更新了 })
2.1.0 起新增:如果没有提供回调且在支持 Promise 的环境中,则返回一个 Promise。请注意 Vue 不自带 Promise 的 polyfill,所以如果你的目标浏览器不原生支持 Promise (IE:你们都看我干嘛),你得自己提供 polyfill。
从上面的官方介绍中可以看到,nextTick
的主要功能就是更新数据后让回调函数作用于更新后的DOM
。看到这句话,你可能第一反应是:呸!说了等于没说,还是不理解。那么请看下面这个例子:
<template> <div id="example">{{message}}</div> </template> <script> var vm = new Vue({ el: '#example', data: { message: '123' } }) vm.message = 'new message' // 更改数据 console.log(vm.$el.innerHTML) // '123' Vue.nextTick(function () { console.log(vm.$el.innerHTML) // 'new message' }) </script>
在上面例子中,当我们更新了message
的数据后,立即获取vm.$el.innerHTML
,发现此时获取到的还是更新之前的数据:123。但是当我们使用nextTick
来获取vm.$el.innerHTML
时,此时就可以获取到更新后的数据了。这是为什么呢?
这里就涉及到Vue
中对DOM
的更新策略了,Vue
在更新 DOM
时是异步执行的。只要侦听到数据变化,Vue
将开启一个事件队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher
被多次触发,只会被推入到事件队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM
操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue
刷新事件队列并执行实际 (已去重的) 工作。
在上面这个例子中,当我们通过 vm.message = ‘new message‘
更新数据时,此时该组件不会立即重新渲染。当刷新事件队列时,组件会在下一个事件循环“tick”中重新渲染。所以当我们更新完数据后,此时又想基于更新后的 DOM
状态来做点什么,此时我们就需要使用Vue.nextTick(callback)
,把基于更新后的DOM
状态所需要的操作放入回调函数callback
中,这样回调函数将在 DOM
更新完成后被调用。
OK,现在大家应该对nextTick
是什么、为什么要有nextTick
以及怎么使用nextTick
有个大概的了解了。那么问题又来了,Vue
为什么要这么设计?为什么要异步更新DOM
?这就涉及到另外一个知识:JS
的运行机制。
3. 前置知识:JS的运行机制
我们知道 JS
执行是单线程的,它是基于事件循环的。事件循环大致分为以下几个步骤:
- 所有同步任务都在主线程上执行,形成一个执行栈(
execution context stack
)。 - 主线程之外,还存在一个"任务队列"(
task queue
)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。 - 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
主线程的执行过程就是一个 tick
,而所有的异步结果都是通过 “任务队列” 来调度。 消息队列中存放的是一个个的任务(task
)。 规范中规定 task
分为两大类,分别是宏任务(macro task
) 和微任务(micro task
),并且每执行完一个个宏任务(macro task
)后,都要去清空该宏任务所对应的微任务队列中所有的微任务(micro task
),他们的执行顺序如下所示:
for (macroTask of macroTaskQueue) { // 1. 处理当前的宏任务 handleMacroTask(); // 2. 处理对应的所有微任务 for (microTask of microTaskQueue) { handleMicroTask(microTask); } }
在浏览器环境中,常见的
- 宏任务(
macro task
) 有setTimeout
、MessageChannel
、postMessage
、setImmediate
; - 微任务(
micro task
)有MutationObsever
和Promise.then
。
OK,有了这个概念之后,接下来我们就进入本篇文章的正菜:从Vue
源码角度来分析nextTick
的实现原理。
4. nextTick源码分析
nextTick
的源码位于src/core/util/next-tick.js
,总计118行。
nextTick
源码主要分为两块:
- 能力检测
- 根据能力检测以不同方式执行回调队列
4.1 能力检测
Vue
在内部对异步队列尝试使用原生的 Promise.then
、MutationObserver
和 setImmediate
,如果执行环境不支持,则会采用 setTimeout(fn, 0)
代替。
宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,使用宏任务;但是,各种宏任务之间也有效率的不同,需要根据浏览器的支持情况,使用不同的宏任务。
这一部分的源码如下:
let microTimerFunc let macroTimerFunc let useMacroTask = false /* 对于宏任务(macro task) */ // 检测是否支持原生 setImmediate(高版本 IE 和 Edge 支持) if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) } } // 检测是否支持原生的 MessageChannel 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) } } // 都不支持的情况下,使用setTimeout else { macroTimerFunc = () => { setTimeout(flushCallbacks, 0) } } /* 对于微任务(micro task) */ // 检测浏览器是否原生支持 Promise if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { p.then(flushCallbacks) } } // 不支持的话直接指向 macro task 的实现。 else { // fallback to macro microTimerFunc = macroTimerFunc }
首先声明了两个变量: microTimerFunc
和 macroTimerFunc
,它们分别对应的是 micro task
的函数和 macro task
的函数。对于 macro task
的实现,优先检测是否支持原生 setImmediate
,这是一个高版本 IE
和Edge
才支持的特性,不支持的话再去检测是否支持原生的 MessageChannel
,如果也不支持的话就会降级为 setTimeout 0
;而对于 micro task
的实现,则检测浏览器是否原生支持 Promise
,不支持的话直接指向 macro task
的实现。
4.2 执行回调队列
接下来就进入了核心函数nextTick
中,如下:
const callbacks = [] // 回调队列 let pending = false // 异步锁 // 执行队列中的每一个回调 function flushCallbacks () { pending = false // 重置异步锁 // 防止出现nextTick中包含nextTick时出现问题,在执行回调函数队列前,提前复制备份并清空回调函数队列 const copies = callbacks.slice(0) callbacks.length = 0 // 执行回调函数队列 for (let i = 0; i < copies.length; i++) { copies[i]() } } 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() } } // 如果没有提供回调,并且支持Promise,返回一个Promise if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) } }
首先,先来看 nextTick
函数,该函数的主要逻辑是:先把传入的回调函数 cb
推入 回调队列callbacks
数组,同时在接收第一个回调函数时,执行能力检测中对应的异步方法(异步方法中调用了回调函数队列)。最后一次性地根据 useMacroTask
条件执行 macroTimerFunc
或者是 microTimerFunc
,而它们都会在下一个 tick 执行 flushCallbacks
,flushCallbacks
的逻辑非常简单,对 callbacks
遍历,然后执行相应的回调函数。
nextTick
函数最后还有一段逻辑:
if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) }
这是当 nextTick
不传 cb
参数的时候,提供一个 Promise 化的调用,比如:
nextTick().then(() => {})
当 _resolve
函数执行,就会跳到 then
的逻辑中。
这里有两个问题需要注意:
-
如何保证只在接收第一个回调函数时执行异步方法?
nextTick
源码中使用了一个异步锁的概念,即接收第一个回调函数时,先关上锁,执行异步方法。此时,浏览器处于等待执行完同步代码就执行异步代码的情况。 -
执行
flushCallbacks
函数时为什么需要备份回调函数队列?执行的也是备份的回调函数队列?因为,会出现这么一种情况:
nextTick
的回调函数中还使用nextTick
。如果flushCallbacks
不做特殊处理,直接循环执行回调函数,会导致里面nextTick
中的回调函数会进入回调队列。
5. 总结
以上就是对 nextTick
的源码分析,我们了解到数据的变化到 DOM
的重新渲染是一个异步过程,发生在下一个 tick。当我们在实际开发中,比如从服务端接口去获取数据的时候,数据做了修改,如果我们的某些方法去依赖了数据修改后的 DOM 变化,我们就必须在 nextTick
后执行。如下:
getData(res).then(()=>{ this.xxx = res.data this.$nextTick(() => { // 这里我们可以获取变化后的 DOM }) })