Vue.js的nextTick实现
前言
nextTick 是 Vue 的一个核心实现,在介绍 Vue 的 nextTick 之前,我们先结合上一节的例子来回顾下 nextTick 用法。
操作 DOM
在使用 vue.js 的时候,有时候因为一些特定的业务场景,不得不去操作 DOM,比如这样:
1 | <template> |
1 | export default { |
在上面例子中,当我们更新了 test 的数据后,立即获取 innerHTML,发现此时获取到的还是更新之前的数据:start。但是当我们使用 nextTick 来获取时,此时就可以获取到更新后的数据了。这是为什么呢?
这里就涉及到 Vue 中对 DOM 的更新策略了,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个事件队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到事件队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新事件队列并执行实际 (已去重的) 工作。
在上面这个例子中,当我们通过 this.test = ‘end’ 更新数据时,此时该组件不会立即重新渲染。当刷新事件队列时,组件会在下一个事件循环“tick”中重新渲染。所以当我们更新完数据后,此时又想基于更新后的 DOM 状态来做点什么,此时我们就需要使用 Vue.nextTick(callback),把基于更新后的 DOM 状态所需要的操作放入回调函数 callback 中,这样回调函数将在 DOM 更新完成后被调用。
那么问题又来了,Vue 为什么要这么设计?为什么要异步更新 DOM?这就涉及到另外一个知识:JS 的运行机制。
JS 的运行机制
JS 执行是单线程的,它是基于事件循环的。事件循环大致分为以下几个步骤:
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
- 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
主线程的执行过程就是一个 tick,而所有的异步结果都是通过 “任务队列” 来调度。 任务队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是宏任务(macro task) 和微任务(micro task),并且每执行完一个个宏任务(macro task)后,都要去清空该宏任务所对应的微任务队列中所有的微任务(micro task),他们的执行顺序如下所示:
1 | for (macroTask of macroTaskQueue) { |
在浏览器环境中,常见的
- 宏任务(macro task) 有 setTimeout、MessageChannel、postMessage、setImmediate;
- 微任务(micro task)有 MutationObsever 和 Promise.then。
OK,有了这个概念之后,接下来我们就进入正菜:从 Vue 源码角度来分析 nextTick 的实现原理。
Vue 中的实现
nextTick 的定义位于源码的 src/core/util/next-tick.js 中,其大概可分为两大部分:
- 能力检测
- 根据能力检测以不同方式执行回调队列
1 | export let isUsingMicroTask = false; |
这里解释一下,Vue2.6.11 版本,在任何地方都使用微任务,一共有 Promise、MutationObserver、setImmediate 以及 setTimeout 四种尝试得到 timerFunc 的方法
优先使用 Promise,在 Promise 不存在的情况下使用 MutationObserver,这两个方法都会在 microtask 中执行,会比 setTimeout 更早执行,所以优先使用。
如果上述两种方法都不支持的环境则会使用 setImmediate,从技术上讲,它利用了(宏)任务队列,如果都不支持才会使用 setTimeout,在 task 尾部推入这个函数,等待调用执行。
next-tick.js 对外一个函数 nextTick,这就是我们在上一节执行 nextTick(flushSchedulerQueue) 所用到的函数。它的逻辑也很简单,把传入的回调函数 cb 压入 callbacks 数组,最后一次性地执行 timerFunc,而它们都会在下一个 tick 执行 flushCallbacks,flushCallbacks 的逻辑非常简单,对 callbacks 遍历,然后执行相应的回调函数。
nextTick 函数最后还有一段逻辑:
1 | if (!cb && typeof Promise !== "undefined") { |
这是当 nextTick 不传 cb 参数的时候,提供一个 Promise 化的调用,比如:
1 | nextTick().then(() => {}); |
当 _resolve 函数执行,就会跳到 then 的逻辑中。
这里有两个问题需要注意:
- 如何保证只在接收第一个回调函数时执行异步方法?
nextTick 源码中使用了一个异步锁的概念,即接收第一个回调函数时,先关上锁,执行异步方法。此时,浏览器处于等待执行完同步代码就执行异步代码的情况。 - 执行 flushCallbacks 函数时为什么需要备份回调函数队列?执行的也是备份的回调函数队列?
因为,会出现这么一种情况:nextTick 的回调函数中还使用 nextTick。如果 flushCallbacks 不做特殊处理,直接循环执行回调函数,会导致里面 nextTick 中的回调函数会进入回调队列。 - 为什么这里使用 callbacks 而不是直接在 nextTick 中执行回调函数?
原因是保证在同一个 tick 内多次执行 nextTick,不会开启多个异步任务,而把这些异步任务都压成一个同步任务,在下一个 tick 执行完毕。
以上就是对 nextTick 的源码分析,我们了解到数据的变化到 DOM 的重新渲染是一个异步过程,发生在下一个 tick。当我们在实际开发中,比如从服务端接口去获取数据的时候,数据做了修改,如果我们的某些方法去依赖了数据修改后的 DOM 变化,我们就必须在 nextTick 后执行。
总结
通过这一节对 nextTick 的分析,并前面对 setter 的分析,我们了解到数据的变化到 DOM 的重新渲染是一个异步过程,发生在下一个 tick。这就是我们平时在开发的过程中,比如从服务端接口去获取数据的时候,数据做了修改,如果我们的某些方法去依赖了数据修改后的 DOM 变化,我们就必须在 nextTick 后执行。比如下面的伪代码:
1 | getData(res).then(() => { |
Vue.js 提供了 2 种调用 nextTick 的方式,一种是全局 API Vue.nextTick,一种是实例上的方法 vm.$nextTick,无论我们使用哪一种,最后都是调用 next-tick.js 中实现的 nextTick 方法。