前言

从2018年开始用Vue开发项目,到如今也有几个年头,这期间也看过很多讲解Vue的书籍,对于Vue这门技术也有了自己的理解。Vue 作为目前最为主流的前端 MVVM 框架之一,在熟练使用的基础上,去深入理解其实现原理是非常有意义的一件事情。

​阅读 Vue 源码就是一个很好的学习方式,不仅可以让我们帮助我们更快解决工作中遇到的问题,也能借鉴优秀源码的经验,学习高手发现问题、思考问题、解决问题的思路,学习怎么写出规范又好维护的高质量代码。

异步更新队列

Vue使用数据驱动来更新视图,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 ​​Watcher​​ 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。

Vue 在内部对异步队列尝试使用原生的 ​​Promise.then​​​、​​MutationObserver​​ 和 ​​setImmediate​​,如果执行环境不支持,则会采用 ​​setTimeout(fn, 0)​​ 代替。

例如,当你设置 ​vm.someData = 'new value'​​,该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环 “tick” 中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。

​虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。例如:

<div id="example">{{message}}</div>
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
});
vm.message = 'new message'; // 更改数据
vm.$el.textContent === 'new message'; // false
Vue.nextTick(function () {
vm.$el.textContent === 'new message'; // true
});

在组件内使用 vm.$nextTick() 实例方法特别方便,因为它不需要全局 Vue,并且回调函数中的 this 将自动绑定到当前的 Vue 实例上:

Vue.component('example', {
template: '<span>{{ message }}</span>',
data: function () {
return {
message: '未更新'
};
},
methods: {
updateMessage: function () {
this.message = '已更新';
console.log(this.$el.textContent); // => '未更新'
this.$nextTick(function () {
console.log(this.$el.textContent); // => '已更新'
});
}
}
});

因为 $nextTick() 返回一个 Promise 对象,所以你可以使用新的 ES2017 async/await 语法完成相同的事情:

methods: {
updateMessage: async function () {
this.message = '已更新';
console.log(this.$el.textContent); // => '未更新'
await this.$nextTick();
console.log(this.$el.textContent); // => '已更新'
}
}

​nextTick​接收一个回调函数作为参数,并将这个回调函数延迟到DOM更新后才执行;​nextTick​ 是在下次 DOM 更新循环结束之后执行延迟回调,在修改数据之后使用nextTick,则可以在回调中获取更新后的 DOM。

使用场景:想要操作 基于最新数据的生成DOM 时,就将这个操作放在 nextTick​ 的回调中;

基础知识​

​​​nextTick​​ 函数的作用可以理解为异步执行传入的函数,这里先介绍一下什么是异步执行,从 JS 运行机制说起。

JS运行机制

JS 的执行是单线程的,所谓的单线程就是事件任务要排队执行,前一个任务结束,才会执行后一个任务,这就是同步任务,为了避免前一个任务执行了很长时间还没结束,那下一个任务就不能执行的情况,引入了异步任务的概念。JS 运行机制简单来说可以按以下几个步骤。

  • 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  • 主线程之外,还存在一个任务队列(task queue)。只要异步任务有了运行结果,会把其回调函数作为一个任务添加到任务队列中。
  • 一旦执行栈中的所有同步任务执行完毕,就会读取任务队列,看看里面有那些任务,将其添加到执行栈,开始执行。
  • 主线程不断重复上面的第三步。也就是常说的事件循环(Event Loop)。


异步任务的类型

​nextTick​​ 函数异步执行传入的函数,是一个异步任务。异步任务分为两种类型。

主线程的执行过程就是一个 ​​tick​​,而所有的异步任务都是通过任务队列来一一执行。任务队列中存放的是一个个的任务(task)。规范中规定 task 分为两大类,分别是宏任务(macro task)和微任务 (micro task),并且每个 ​​macro task​​ 结束后,都要清空所有的 ​​micro task​​。

用一段代码形象介绍 task的执行顺序。

for (macroTask of macroTaskQueue) {
handleMacroTask();
for (microTask of microTaskQueue) {
handleMicroTask(microTask);
}
}

在浏览器环境中, 常见的创建 ​​macro task​​ 的方法有

  • ​setTimeout​​、​​setInterval​​、​​postMessage​​、​​MessageChannel​​(队列优先于​​setTimeiout​​执行)
  • ​网络请求IO​
  • 页面交互:DOM、鼠标、键盘、滚动事件
  • 页面渲染

常见的创建 ​​micro task​​ 的方法

  • ​Promise.then​
  • ​MutationObserve​
  • ​process.nexttick​

在 ​​nextTick​​​ 函数要利用这些方法把通过参数 ​​cb​​ 传入的函数处理成异步任务。


nextTick 实现原理

将传入的回调函数包装成异步任务,异步任务又分微任务和宏任务,为了尽快执行所以优先选择微任务;

​nextTick​ 提供了四种异步方法 ​Promise.then​​MutationObserver​​setImmediate​​setTimeOut(fn,0)​


Vue.nextTick 内部逻辑

在执行 ​initGlobalAPI(Vue)​ 初始化 Vue 全局 API 中,这么定义

​Vue.nextTick​ :

function initGlobalAPI(Vue) {
//...
Vue.nextTick = nextTick;
}

可以看出是直接把 nextTick 函数赋值给 Vue.nextTick,就可以了,非常简单。

vm.$nextTick 内部逻辑
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
};

可以看出是​vm.$nextTick​内部也是调用 ​nextTick​ 函数。

 源码解读

​nextTick​​ 的源码位于 ​​src/core/util/next-tick.js​

nextTick​源码主要分为两块:

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

// 上面三行与核心代码关系不大,了解即可
// noop 表示一个无操作空函数,用作函数默认值,防止传入 undefined 导致报错
// handleError 错误处理函数
// isIE, isIOS, isNative 环境判断函数,
// isNative 判断是否原生支持,如果通过第三方实现支持也会返回 false

export let isUsingMicroTask = false // nextTick 最终是否以微任务执行

const callbacks = [] // 存放调用 nextTick 时传入的回调函数
let pending = false // 标识当前是否有 nextTick 在执行,同一时间只能有一个执行


// 声明 nextTick 函数,接收一个回调函数和一个执行上下文作为参数
export function nextTick(cb?: Function, ctx?: Object) {
let _resolve
// 将传入的回调函数存放到数组中,后面会遍历执行其中的回调
callbacks.push(() => {
if (cb) { // 对传入的回调进行 try catch 错误捕获
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})

// 如果当前没有在 pending 的回调,就执行 timeFunc 函数选择当前环境优先支持的异步方法
if (!pending) {
pending = true
timerFunc()
}

// 如果没有传入回调,并且当前环境支持 promise,就返回一个 promise
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}

可以看到在 ​​nextTick​​​ 函数中把通过参数 cb 传入的函数,做一下包装然后 push 到 ​​callbacks​​ 数组中。

然后用变量 pending 来保证执行一个事件循环中只执行一次 timerFunc()。

最后执行 ​​if (!cb && typeof Promise !== 'undefined')​​,判断参数 ​​cb​​不存在且浏览器支持 Promise,则返回一个 Promise 类实例化对象。例如 ​​nextTick().then(() => {})​​,当 ​​_resolve​​ 函数执行,就会执行 then 的逻辑中。

来看一下 ​​timerFunc​​​ 函数的定义,先只看用 Promise 创建一个异步执行的 ​​timerFunc​​ 函数 。

// 判断当前环境优先支持的异步方法,优先选择微任务
// 优先级:Promise---> MutationObserver---> setImmediate---> setTimeout
// setTimeOut 最小延迟也要4ms,而 setImmediate 会在主线程执行完后立刻执行
// setImmediate 在 IE10 和 node 中支持

// 多次调用 nextTick 时 ,timerFunc 只会执行一次

let timerFunc
// 判断当前环境是否支持 promise
if (typeof Promise !== 'undefined' && isNative(Promise)) { // 支持 promise
const p = Promise.resolve()
timerFunc = () => {
// 用 promise.then 把 flushCallbacks 函数包裹成一个异步微任务
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
// 标记当前 nextTick 使用的微任务
isUsingMicroTask = true


// 如果不支持 promise,就判断是否支持 MutationObserver
// 不是IE环境,并且原生支持 MutationObserver,那也是一个微任务
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
let counter = 1
// new 一个 MutationObserver 类
const observer = new MutationObserver(flushCallbacks)
// 创建一个文本节点
const textNode = document.createTextNode(String(counter))
// 监听这个文本节点,当数据发生变化就执行 flushCallbacks
observer.observe(textNode, { characterData: true })
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter) // 数据更新
}
isUsingMicroTask = true // 标记当前 nextTick 使用的微任务


// 判断当前环境是否原生支持 setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => { setImmediate(flushCallbacks) }
} else {

// 以上三种都不支持就选择 setTimeout
timerFunc = () => { setTimeout(flushCallbacks, 0) }
}

其中 ​isNative​ 方法是如何定义,代码如下。

function isNative(Ctor) {
return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
}

在其中发现 ​​timerFunc​​​ 函数就是用各种异步执行的方法调用 ​​flushCallbacks​​ 函数。

来看一下 ​​flushCallbacks​​ 函数

// 如果多次调用 nextTick,会依次执行上面的方法,将 nextTick 的回调放在 callbacks 数组中
// 最后通过 flushCallbacks 函数遍历 callbacks 数组的拷贝并执行其中的回调
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0) // 拷贝一份
callbacks.length = 0 // 清空 callbacks
for (let i = 0; i < copies.length; i++) { // 遍历执行传入的回调
copies[i]()
}
}

// 为什么要拷贝一份 callbacks

// callbacks.slice(0) 将 callbacks 拷贝出来一份,
// 是因为考虑到 nextTick 回调中可能还会调用 nextTick 的情况,
// 如果 nextTick 回调中又调用了一次 nextTick,则又会向 callbacks 中添加回调,
// nextTick 回调中的 nextTick 应该放在下一轮执行,
// 如果不将 callbacks 复制一份就可能一直循环

执行 ​pending = false​ 使下个事件循环中能 ​nextTick​ 函数中调用 ​timerFunc​ 函数。

执行 ​var copies = callbacks.slice(0);callbacks.length = 0​; 把要异步执行的函数集合 ​callbacks​克隆到常量 ​copies​,然后把 ​callbacks​ 清空。

然后遍历 ​copies​ 执行每一项函数。回到 ​nextTick​ 中是把通过参数 ​cb​ 传入的函数包装后 push 到 ​callbacks​ 集合中。来看一下怎么包装的。

function() {
if (cb) {
try {
cb.call(ctx);
} catch (e) {
handleError(e, ctx, 'nextTick');
}
} else if (_resolve) {
_resolve(ctx);
}
}

逻辑很简单。若参数 cb 有值。在 try 语句中执行 ​​cb.call(ctx)​​​ ,参数 ctx 是传入函数的参数。 如果执行失败执行 ​​handleError(e, ctx, 'nextTick')​​。

若参数 cb 没有值。执行 ​​_resolve(ctx)​​,因为在 ​​nextTick​​ 函数中如何参数 cb 没有值,会返回一个 Promise 类实例化对象,那么执行 ​​_resolve(ctx)​​,就会执行 then 的逻辑中。

到这里 ​​nextTick​​ 函数的主线逻辑就很清楚了。定义一个变量 ​​callbacks​​,把通过参数 cb 传入的函数用一个函数包装一下,在这个中会执行传入的函数,及处理执行失败和参数 cb 不存在的场景,然后 添加到 callbacks。

调用 ​​timerFunc​​ 函数,在其中遍历 ​​callbacks​​ 执行每个函数,因为 ​​timerFunc​​ 是一个异步执行的函数,且定义一个变量 ​​pending​​ 来保证一个事件循环中只调用一次 ​​timerFunc​​ 函数。这样就实现了 ​​nextTick​​ 函数异步执行传入的函数的作用了。

那么其中的关键还是怎么定义 ​​timerFunc​​ 函数。因为在各浏览器下对创建异步执行函数的方法各不相同,要做兼容处理,下面来介绍一下各种方法。


为什么优先使用微任务:

按照上面事件循环的执行顺序,执行下一次宏任务之前会执行一次 UI 渲染,等待时长比微任务要多很多。所以在能使用微任务的时候优先使用微任务,不能使用微任务的时候才使用宏任务,优雅降级。*