谈一谈你对 nextTick 的理解?


什么是nextTick呢?

vue实现响应式并不是数据发生变化后dom立即变化,而是按照一定的策略来进行dom更新。

官方文档:

在下次DOM更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的DOM。

nextTick,我可以理解为next是下一个的意思,在事件循环中,每进行一次循环操作称为 tick

就是下一个事件循环操作,也就是下一个宏任务

nextTick实现原理

异步更新

  我们发现上述两个问题的发生,不管子组件还是父组件,都是在给data中赋值后立马去查看数据导致的。由于“查看数据”这个动作是同步操作的,而且都是在赋值之后;因此我们猜测一下,给数据赋值操作是一个异步操作,并没有马上执行,Vue官网对数据操作是这么描述的:

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

  也就是说我们在设置this.msg = ‘xiaoming’的时候,Vue并没有马上去更新DOM数据,而是将这个操作放进一个队列中;如果我们重复执行的话,队列还会进行去重操作;等待同一事件循环中的所有数据变化完成之后,会将队列中的事件拿出来处理。
  这样做主要是为了提升性能,因为如果在主线程中更新DOM,循环100次就要更新100次DOM;但是如果等事件循环完成之后更新DOM,只需要更新1次。

直接上代码看效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<div class="views__home">
<div ref="xiaoming">{{message}}</div>
</div>
</template>

<script>
export default {
data() {
return {
message: 'test'
};
},
mounted() {
this.message = 'xiaoming';

console.log('first', this.$refs.xiaoming.innerHTML);

this.$nextTick(() => {
console.log('second', this.$refs.xiaoming.innerHTML);
});
}
};
</script>

如图

为什么会这样呢?

当你执行this.message = ‘xiaoming’的时候,只是把xiaoming赋值给了一个变量,dom实际上还没有更新呢

那么为什么在this.$nextTick里面却可以拿到dom更新后的值呢?

一般来说nextTick,就是在dom渲染完了之后才会执行

我们要知道vue里面有一个虚拟dom,页面上所有的操作都是先改变虚拟dom,最后再把虚拟dom更新到实际的页面dom上,这个时候就会涉及到到一个问题,什么时候才是最后?

这个时候就会涉及到JS事件执行机制中的宏任务和微任务

1、执行完当前宏任务script,立即执行微任务

2、微任务执行完毕后进行页面渲染

3、页面渲染完毕,开始执行$nextTick

为什么用Vue.nextTick()

首先来了解一下JS的运行机制。

JS运行机制(Event Loop)

JS执行是单线程的,它是基于事件循环的。

  1. 所有同步任务都在主线程上执行,形成一个执行栈。
  2. 主线程之外,会存在一个任务队列,只要异步任务有了结果,就在任务队列中放置一个事件。
  3. 当执行栈中的所有同步任务执行完后,就会读取任务队列。那些对应的异步任务,会结束等待状态,进入执行栈。
    主线程不断重复第三步。
  4. 这里主线程的执行过程就是一个tick,而所有的异步结果都是通过任务队列来调度。Event Loop 分为宏任务和微任务,无论是执行宏任务还是微任务,完成后都会进入到一下tick,并在两个tick之间进行UI渲染。

由于Vue DOM更新是异步执行的,即修改数据时,视图不会立即更新,而是会监听数据变化,并缓存在同一事件循环中,等同一数据循环中的所有数据变化完成之后,再统一进行视图更新。为了确保得到更新后的DOM,所以设置了 Vue.nextTick()方法。

Vue 使用了 nextTick 进行统一更新

你应该知道,即使在 Vue 中多么频繁地修改数据,最后 Vue 页面只会更新一次

这是 Vue 和 nextTick 合作产生的结果,但又并不只是 nextTick 起作用

根据响应式原理,你我都知道

响应式原理

比如

数据 name 被 页面引用,name 会收集到 页面的 watcher

name 被修改时,会通知所有收集到的 watcher 进行更新(watcher.update)

1
2
3
4
5
this.name = 2

this.name = 3

this.name = 4

如果

name 一时间被修改三次时,按道理应该会通知三次 watcher 更新,那么页面会更新三次

但是最后只会更新一次

就是因为他们的合作

设置 nextTick 回调 + 过滤 watcher

当数据变化后,把 watcher.update 函数存放进 nextTick 的 回调数组中,并且会做过滤。

通过 watcher.id 来判断 回调数组 中是否已经存在这个 watcher 的更新函数

不存在,才 push

之后 nextTick 遍历回调数组,便会执行了更新

所以

当三次修改数据的时候,会准备 push进 回调数组 三个 watcher.update,但是只有第一次是 push 成功的,其他的会被过滤掉

所以,不管你修改多少次数据,nextTick 的回调数组中只存在唯一一个 watcher.update,从而页面只会更新一次

nextTick源码分析

参考文章

  Vue把nextTick的源码单独抽到一个文件中,/src/core/util/next-tick.js,删掉注释也就大概六七十行的样子,让我们逐段来分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const callbacks = []
let pending = false
let timerFunc

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()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}

 我们首先找到nextTick这个函数定义的地方,看看它具体做了什么操作;看到它在外层定义了三个变量,有一个变量看名字就很熟悉:callbacks,就是我们上面说的队列;在nextTick的外层定义变量就形成了一个闭包,所以我们每次调用$nextTick的过程其实就是在向callbacks新增回调函数的过程。
  callbacks新增回调函数后又执行了timerFunc函数,pending用来标识同一个时间只能执行一次。那么这个timerFunc函数是做什么用的呢,我们继续来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
export let isUsingMicroTask = false
if (typeof Promise !== 'undefined' && isNative(Promise)) {
//判断1:是否原生支持Promise
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
//判断2:是否原生支持MutationObserver
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)) {
//判断3:是否原生支持setImmediate
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
//判断4:上面都不行,直接用setTimeout
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}

这里出现了好几个isNative函数,这是用来判断所传参数是否在当前环境原生就支持;例如某些浏览器不支持Promise,虽然我们使用了垫片(polify),但是isNative(Promise)还是会返回false。
  可以看出这边代码其实是做了四个判断,对当前环境进行不断的降级处理,尝试使用原生的Promise.then、MutationObserver和setImmediate,上述三个都不支持最后使用setTimeout;降级处理的目的都是将flushCallbacks函数放入微任务(判断1和判断2)或者宏任务(判断3和判断4),等待下一次事件循环时来执行。MutationObserver是Html5的一个新特性,用来监听目标DOM结构是否改变,也就是代码中新建的textNode;如果改变了就执行MutationObserver构造函数中的回调函数,不过是它是在微任务中执行的。
  那么最终我们顺藤摸瓜找到了最终的大boss:flushCallbacks;nextTick不顾一切的要把它放入微任务或者宏任务中去执行,它究竟是何方神圣呢?让我们来一睹它的真容:

1
2
3
4
5
6
7
  pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}

  本来以为有多复杂的flushCallbacks,居然不过短短的8行。它所做的事情也非常的简单,把callbacks数组复制一份,然后把callbacks置为空,最后把复制出来的数组中的每个函数依次执行一遍;所以它的作用仅仅是用来执行callbacks中的回调函数。

总结

  到这里,整体nextTick的代码都分析完毕了,总结一下它的流程就是:

  1. 把回调函数放入callbacks等待执行
  2. 将执行函数放到微任务或者宏任务中
  3. 事件循环到了微任务或者宏任务,执行函数依次执行callbacks中的回调

  再回到我们开头说的setTimeout,可以看出来nextTick是对setTimeout进行了多种兼容性的处理,宽泛的也可以理解为将回调函数放入setTimeout中执行;不过nextTick优先放入微任务执行,而setTimeout是宏任务,因此nextTick一般情况下总是先于setTimeout执行,我们可以在浏览器中尝试一下:

1
2
3
4
5
6
7
8
9
10
setTimeout(()=>{
console.log(1)
}, 0)
this.$nextTick(()=>{
console.log(2)
})
this.$nextTick(()=>{
console.log(3)
})
//运行结果 2 3 1

 最后验证猜想,当前宏任务执行完成后,优先执行两个微任务,最后再执行宏任务。

参考文章

知乎
Vue中$nextTick源码解析