基本使用
nextTick函数接收一个回调函数作为参数,它的作用是将回调延迟到DOM更新并渲染到页面上之后执行。当更新了状态(数据)后需要对新DOM做一些操作,但是这时我们其实获取不到更新后的DOM(具体原因后面会讲)。
<template>
<div>
<div ref="next">{{ message }}</div>
<button @click="change">按钮</button>
</div>
</template>
<script>
export default {
name: "NextTick",
data() {
return {
message: "你好",
}
},
methods: {
change() {
this.message = "changed"; //更新和渲染DOM的回调被推送到微任务队列中
console.log(this.$refs.next.innerHTML); // 你好 // 同步代码
this.$nextTick(() => { // 回调被推送到微任务队列中
// 对更新后的DOM进行操作
console.log(this.$refs.next.innerHTML); // changed
});
console.log(this.$refs.next.innerHTML); // 你好 // 同步代码
}
}
}
</script>
在 Vue 中,当状态发生变化时,watcher 会得到通知,然后触发 虚拟 DOM 的渲染流程。而 watcher 触发渲染这个操作并不是同步的, 而是异步的。Vue 中有一个队列,每当需要渲染时,会将 watcher 推送到这个队列中,在下一次事件循环中再让 watcher 触发渲染的 流程。
为什么 Vue 使用异步更新队列?
从 Vue 2.0开始使用虚拟DOM进行渲染,变化侦测的通知只发送到组件,组件内用到的所有状态的变化都会通知到同一个 watcher ,然后虚拟DOM会对整个组件进行“比对(diff)”并 更改DOM。也就是说,如果在同一轮事件循环中有两个数据发生了变化,那么组件的 watcher 会收到两份通知,从而进行两次渲 染。事实上,并不需要渲染两次,虚拟DOM会对整个组件进行渲染,所以只需要等所有状态都修改完毕后,一次性将整个组件的 DOM渲染到最新即可。 要解决这个问题,Vue 的实现方式是将收到通知的watcher 实例添加到队列中缓存起来,并且在添加到队列之前检查其中是否已经存在相同的watcher ,只有不存在时,才将watcher 实例添加到队列中。然后在下一次事件循环(event loop)中, Vue.js会让队列中的watcher 触发渲染流程并清空队列。这样就可以保证即便在同一事件循环中有两个状态发生改变, watcher 最后也只执行一次渲染流程。
“下次DOM更新周期”的意思其实是下次微任务执行时更新DOM。而 vm.nextTick 来获取更新后的DOM,则需要注意顺序的问题。因为不论是更新DOM的回调还是使用 vm.$nextTick 注册的回调,都是向微任务队列中添加任务,所以哪个任务先添加到队列中,就先执行哪个任务。
注意:事实上,更新DOM的回调也是使用vm.$nextTick 来注册到微任务中的
$nextTick 实现原理
import { nextTick } from '../util/index'
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
}
由于vm.nextTick ,Vue.js并不 会反复将回调添加到任务队列中,只会向任务队列中添加一个任 务。此外,Vue.js内部有一个列表用来存储vm.nextTick 只会向 任务队列添加一个任务,多次使用vm.$nextTick 只会将回调 添加到回调列表中缓存起来。当任务触发时,依次执行列表中的 所有回调并清空列表。其代码如下:
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
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
}
export function nextTick (cb, ctx) {
callbacks.push(() => {
if (cb) {
cb.call(ctx)
}
})
if (!pending) {
pending = true
microTimerFunc()
}
}
// 测试一下
nextTick(function () {
console.log(this.name) // Berwin 34
}, {name: 'Berwin'})
在上面代码中,我们通过数组callbacks 来存储用户注册的回调,声明了变量pending 来标记是否已经向任务队列中添加了一个任务。每当向任务队列中插入任务时,将pending 设置为 true ,每当任务被执行时将pending 设置为false ,这样就可以通过pending 的值来判断是否需要向任务队列中添加任务。 上面我们还声明了函数flushCallbacks ,它就是我们所说的被注册的那个任务。当这个函数被触发时,会将callbacks 中的所有函数依次执行,然后清空callbacks ,并将pending 设置为false 。也就是说,一轮事件循环中flushCallbacks只会执行一次。接下来声明了microTimerFunc 函数,它的作用是使用 Promise.then 将flushCallbacks 添加到微任务队列中。上面的准备工作完成后,当我们执行nextTick 函数注册回调时,首先将回调函数添加到callbacks 中,然后使用pending 判断是否需要向任务队列中新增任务。下面我们从执行的角度回顾nextTick 的流程。首先,当 nextTick 被调用时,会将回调函数添加到callbacks 中。如果此时是本轮事件循环第一次使用nextTick ,那么需要向任务队列中添加任务。因此,我们使用microTimerFunc 函数封装 Promise.then 的作用就是将任务添加到微任务队列中。如果 不是本轮事件循环中第一次调用nextTick ,也就是说,此时任务队列中已经被添加了一个执行回调列表的任务,那么我们就不需要执行microTimerFunc 向任务队列中添加重复的任务,因为被添加到任务队列中的任务只需要执行一次,就可以将本轮事件循环中使用nextTick 方法注册的回调都依次执行一遍。下图给出了nextTick 的内部注册流程和执行流程。
在Vue 2.4版本之前,nextTick 方法在任何地方都使用微任务,但是微任务的优先级太高,在某些场景下可能会出现问题。所以Vue.js提供了在特殊场合下可以强制使用宏任务的方法。具体实现如下:
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 = function () {...}
// 新增代码
let useMacroTask = false
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
}
// 新增代码
export function withMacroTask (fn) {
return fn._withTask || (fn._withTask = function () {
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
}
export function nextTick (cb, ctx) {
callbacks.push(() => {
if (cb) {
cb.call(ctx)
}
})
if (!pending) {
pending = true
// 修改代码
if (useMacroTask) {
macroTimerFunc()
} else {
microTimerFunc()
}
}
}
在上述代码中,新增了withMacroTask 函数,它的作用是给回调函数做一层包装,保证在整个回调函数执行过程中,如果修改了状态(数据),那么更新DOM的操作会被推到宏任务队列中。也就是说,更新DOM的执行时间会晚于回调函数的执行时间。下面用点击事件举例。假设点击事件的回调使用了 withMacroTask 进行包装,那么在点击事件被触发时,如果回调中修改了数据,那么这个修改数据的操作所触发的更新DOM的操 作会被添加到宏任务队列中。因为我们在nextTick 中新增了判断语句,当useMacroTask 为true 时,则使用 macroTimerFunc 注册事件。因此,withMacroTask 的实现逻辑很简单,先将变量 useMacroTask 设置为true ,然后执行回调,如果这时候回调中修改了数据(触发了更新DOM的操作),而useMacroTask 是true ,那么更新DOM的操作会被推送到宏任务队列中。当回调执行完毕后,将useMacroTask 恢复为false 。
说明:更新DOM的回调也是使用nextTick 将任务添加到任 务队列中。
简单来说就是,被withMacroTask 包裹的函数所使用的所有 vm.nextTick 注册的回调等。 接下来,我们将介绍macroTimerFunc 是如何将回调添加到宏任务队列中的。前面我们介绍过几种属于宏任务的事件,Vue.js优先使用 setImmediate ,但是它存在兼容性问题,只能在IE中使用,所以使用MessageChannel 作为备选方案。如果浏览器也不支持MessageChannel ,那么最后会使用setTimeout 来将回调 添加到宏任务队列中。实现方式如下:
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (typeof MessageChannel !== 'undefined'
&& (isNative(MessageChannel)
|| MessageChannel.toString() === '[object MessageChannelConstructor]')) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
可以看到,macroTimerFunc 被执行时,会将 flushCallbacks 添加到宏任务队列中。前面提到microTimerFunc 的实现原理是使用Promise.then ,但并不是所有浏览器都支持Promise ,当不支持时,会降级 成macroTimerFunc 。其实现方式如下:
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
}
} else {
microTimerFunc = macroTimerFunc
}
首先判断浏览器是否支持Promise ,然后进行相应的处理即 可。官方文档中有这样一句话:如果没有提供回调且在支持Promise 的环境中,则返回一个Promise 。也就是说,可以这样使用 vm.$nextTick :
this.$nextTick().then(function () { // DOM更新了 })
要实现这个功能,我们只需要在nextTick 中进行判断,如果没有提供回调且当前环境支持Promise ,那么返回Promise ,并且在callbacks 中添加一个函数,当这个函数执行时,执行 Promise 的resolve 即可,代码如下:
export function nextTick (cb, ctx) {
// 新增代码
let _resolve
callbacks.push(() => {
if (cb) {
cb.call(ctx)
} 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
})
}
}
在上面的代码中,先在函数作用域中声明了变量 _resolve ,然后进行相应的处理。 最终完整的代码如下:
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)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (
typeof MessageChannel !== 'undefined' &&
(isNative(MessageChannel) ||
MessageChannel.toString() === '[object MessageChannelConstructor]')
) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
}
} else {
microTimerFunc = macroTimerFunc
}
export function withMacroTask(fn) {
return (
fn._withTask ||
(fn._withTask = function () {
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
)
}
export function nextTick(cb, ctx) {
let _resolve
callbacks.push(() => {
if (cb) {
cb.call(ctx)
} 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
})
}
}
注意
修改数据的操作要在nextTick之前进行,因为更新渲染DOM和nextTick的回调都是被放到微任务队列中,而微任务队列中的任务是先进先执行,如果this.$nextTick在修改数据之前,nextTick的回调先进入微任务队列中,那么回调中获取的还是更新前的DOM。