什么是nextTick
?
定义: 在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM
nextTick 用法
看例子,比如当 DOM 内容改变后,我们需要获取最新的高度
<template>
<div>{{ message }}</div>
</template>
<script>
export default {
data() {
return {
message: "123"
}
},
mounted() {
console.log(this.$el.innerHTML) // '123'
this.message = "new message"
console.log(this.$el.clientHeight) // '123'
this.$nextTick(() => {
console.log(this.$el.clientHeight) // 'new message'
});
}
};
</script>
在上面例子中,当我们更新了message
的数据后,立即获取vm.$el.innerHTML
,发现此时获取到的还是更新之前的数据:123。但是当我们使用nextTick
来获取vm.$el.innerHTML
时,此时就可以获取到更新后的数据了。这是为什么呢?
原理分析
在上面例子中,当我们更新了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
的运行机制。
JS 执行机制
我们都知道 JS 是单线程语言,即指某一时间内只能干一件事,即为同步。
而JS为什么是单线程的呢?这就要提及JS的主要用途了。JS自诞生之日起,其主要用途是与用户互动和DOM操作,如果同一时间,一个添加了 DOM
,一个删除了 DOM
, 这个时候语言就不知道是该添还是该删了,所以从应用场景来看 JS
只能是单线程,否则会带来复杂的同步问题。
单线程就意味着所有的任务都需要排队,后面的任务需要等前面的任务执行完才能执行,如果前面的任务耗时过长,后面的任务就需要一直等,一些从用户角度上不需要等待的任务就会一直等待,这个从体验角度上来讲是不可接受的,所以JS
中就出现了异步的概念。
概念
同步任务
:指排队在主线程上依次执行的任务
异步任务
:不进入主线程,而进入任务队列的任务,又分为宏任务和微任务
宏任务
: 渲染事件、请求、script、setTimeout、setInterval、Node中的setImmediate 等
微任务
: Promise.then、MutationObserver(监听DOM)、Node 中的 Process.nextTick等
执行机制
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。
当执行栈中的同步任务执行完后,就会去任务队列中拿一个宏任务放到执行栈中执行,执行完该宏任务中的所有微任务,再到任务队列中拿宏任务,即一个宏任务、所有微任务、渲染、一个宏任务、所有微任务、渲染…(不是所有微任务之后都会执行渲染),如此形成循环,即事件循环(EventLoop)
。
nextTick
就是创建一个异步任务,那么它自然要等到同步任务执行完成后才执行。
Vue2中nextTick的实现原理
在执行 vm.message = 'new message'
的时候,就会触发 Watcher
更新,watcher 会把自己放到一个队列
用队列的原因是比如多个数据变更就更新视图多次的话,性能上就不好了,所以对视图更新做一个异步更新的队列,避免重复计算和不必要的DOM操作,在下一轮事件循环的时候刷新队列,并执行已去重的任务(nextTick的回调函数),更新视图
然后调用 nextTick()
,响应式派发更新的源码在这一块是这样的,地址:src/core/observer/scheduler.js - 164行
export function queueWatcher (watcher: Watcher) {
...
// 因为每次派发更新都会引起渲染,所以把所有 watcher 都放到 nextTick 里调用
nextTick(flushSchedulerQueue)
}
这里参数 flushSchedulerQueue
方法就会被放入事件循环,主线程任务的行完后就会执行这个函数,对 watcher 队列排序、遍历、执行 watcher 对应的 run 方法,然后 render,更新视图
也就是说 this.message = “new message”的时候,任务队列可以简单理解成这样
[flushSchedulerQueue]`
然后下一行 console.log(...)
,由于会更新视图的任务 flushSchedulerQueue
在任务队列里没有执行,所以无法拿到更新后的视图
然后执行到 this.$nextTick(fn)
的时候,添加一个异步任务,这时的任务队列可以简单理解成这样 [flushSchedulerQueue, fn]
然后同步任务就执行完了,接着按顺序执行任务队列里的任务,第一个任务执行就会更新视图,后面自然能得到更新后的视图了
nextTick 源码剖析
源码版本:2.6.14
,源码地址:src/core/util/next-tick.js
这里整个源码分为两部分:
- 一是判断当前环境能使用的最合适的
API
并保存异步函数 - 二是调用异步函数 执行回调队列
环境判断
主要是判断用哪个宏任务或微任务,因为宏任务耗费的时间是大于微任务的,所以成先使用微任务,判断顺序如下
Promise
MutationObserver
setImmediate
setTimeout
export let isUsingMicroTask = false // 是否启用微任务开关
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]()
}
}
let timerFunc // 用来保存调用异步任务方法
// 判断当前环境是否支持原生 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
// 保存一个异步任务
const p = Promise.resolve()
timerFunc = () => {
// 执行回调函数
p.then(flushCallbacks)
// ios 中可能会出现一个回调被推入微任务队列,但是队列没有刷新的情况
// 所以用一个空的计时器来强制刷新任务队列
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
// 不支持 Promise 的话,在支持MutationObserver的非 IE 环境下
// 如 PhantomJS, iOS7, Android 4.4
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// 使用setImmediate,虽然也是宏任务,但是比setTimeout更好
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// 以上都不支持的情况下,使用 setTimeout
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
环境判断结束就会得到一个延迟回调函数 timerFunc
然后进入核心的 nextTick
nextTick()
我们用 Vue.nextTick()
或者 this.$nextTick()
都是调用 nextTick()
这个方法
这里代码不多,主要逻辑就是:
- 把传入的回调函数放进回调队列
callbacks
- 执行保存的异步任务
timeFunc
,就会遍历callbacks
执行相应的回调函数了
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
timerFunc()
}
// 如果没有提供回调,并且支持 Promise,就返回一个 Promise
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
可以看到最后有返回一个 Promise
是可以让我们在不传参的时候用的,如下
this.$nextTick().then(()=>{ ... })
结语
nextTick
是vue
中的更新策略,也是性能优化手段,基于JS执行机制实现。vue 中我们改变数据时不会立即触发视图,如果需要实时获取到最新的DOM,可以手动调用 nextTick。