Vue的nextTick实现原理

85 阅读6分钟

一、异步更新队列

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.thenMutationObserver 和 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); // => '已更新'
        }
    }

二、Vue.nextTick实现原理

将传入的回调函数包装成异步任务,异步任务又分微任务和宏任务,为了尽快执行所以优先选择微任务; nextTick 提供了四种异步方法 Promise.thenMutationObserversetImmediatesetTimeOut(fn,0)

2.1分析源码

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) // 调用call方法如果传入第二个参数,则接收的回调函数(箭头函数内部this指向上一层)内部this指向传入的第二个参数,
            } 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) }
}

在其中发现 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 函数的主线逻辑就很清楚了。定义一个变量 callbacks,把通过参数 cb 传入的函数用一个函数包装一下,在这个中会执行传入的函数,及处理执行失败和参数 cb 不存在的场景,然后 添加到 callbacks。

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

本文部分文字及代码片段主要来自于 Vue官方文档 和 稀土掘金社区,引用参考文献出处均在文章末尾显著标注。如有侵权,请联系删除。

三、参考

深入响应式原理 — Vue.js

Vue 之 nextTick 原理

🚩Vue源码——nextTick实现原理