在应用Vue的时候,最让人着迷的莫过于nextTick了,它能够让咱们在下一次DOM更新循环完结之后执行提早回调。

所以咱们想要拿到更新的后的DOM就上nextTick,想要在DOM更新之后再执行某些操作还上nextTick,不晓得页面什么时候挂载实现仍然上nextTick

尽管我不懂Vue的外部实现,然而我晓得有问题上nextTick就对了,你天天上nextTick,那么nextTick为什么能够让你这么爽你就不好奇吗?

大家好,这里是田八的【源码&库】系列,Vue3的源码浏览打算,Vue3的源码浏览打算不出意外每周一更,欢送大家关注。

如果想一起交换的话,能够点击这里一起独特交换成长

系列章节:

  • 【源码&库】跟着 Vue3 学习前端模块化
  • 【源码&库】在调用 createApp 时,Vue 为咱们做了那些工作?
  • 【源码&库】细数 Vue3 的实例办法和属性背地的故事

首发在掘金,无任何引流的意思。

nextTick 简介

依据官网的简略介绍,nextTick是期待下一次 DOM 更新刷新的工具办法。

类型定义如下:

function nextTick(callback?: () => void): Promise<void> {}

而后再依据官网的具体介绍,咱们能够晓得nextTick的大体实现思路和用法:

当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步失效的,而是由 Vue 将它们缓存在一个队列中,直到下一个“tick”才一起执行。
这样是为了确保每个组件无论产生多少状态扭转,都仅执行一次更新。

nextTick()能够在状态扭转后立刻应用,以期待 DOM 更新实现。
你能够传递一个回调函数作为参数,或者 await 返回的 Promise

官网的解释曾经很具体了,我就不适度解读,接下来就是剖析环节了。

nextTick 的一些细节和用法

nextTick 的用法

首先依据官网的介绍,咱们能够晓得nextTick有两种用法:

  • 传入回调函数
nextTick(() => {  // DOM 更新了})
  • 返回一个Promise
nextTick().then(() => {  // DOM 更新了})

那么这两种办法能够混用吗?

nextTick(() => {  // DOM 更新了}).then(() => {  // DOM 更新了})

nextTick 的景象

写了一个很简略的demo,发现是能够混用的,并且发现一个有意思的景象:

const {createApp, h, nextTick} = Vue;const app = createApp({    data() {        return {            count: 0        };    },    methods: {        push() {            nextTick(() => {                console.log('callback before');            }).then(() => {                console.log('promise before');            });            this.count++;            nextTick(() => {                console.log('callback after');            }).then(() => {                console.log('promise after');            });        }    },    render() {        console.log('render', this.count);        const pushBtn = h("button", {            innerHTML: "减少",            onClick: this.push        });        const countText = h("p", {            innerHTML: this.count        });        return h("div", {}, [pushBtn, countText]);    }});app.mount("#app");
我这里为了简略应用的vue.global.js,应用形式和Vue3一样,只是没有应用ESM的形式引入。

运行后果如下:

在我这个示例外面,点击减少按钮,会对count进行加一操作,这个办法外面能够分为三个局部:

  1. 应用nextTick,并应用回调函数和Promise的混合应用
  2. count进行加一操作
  3. 应用nextTick,并应用回调函数和Promise的混合应用

第一个注册的nextTick,在count加一之前执行,第二个注册的nextTick,在count加一之后执行。

然而最初的后果却是十分的乏味:

callback beforerender 1promise beforecallback afterpromise after

第一个注册的nextTick,回调函数是在render之前执行的,而Promise是在render之后执行的。

第二个注册的nextTick,回调函数是在render之后执行的,而Promise是在render之后执行的。

并且两个nextTick的回调函数都是优先于Promise执行的。

如何解释这个景象呢?咱们将从nextTick的实现开始剖析。

nextTick 的实现

nextTick的源码在packages/runtime-core/src/scheduler.ts文件中,只有两百多行,感兴趣的能够间接去看ts版的源码,咱们还是看打包之后的源码。

const resolvedPromise = /*#__PURE__*/ Promise.resolve();let currentFlushPromise = null;function nextTick(fn) {    const p = currentFlushPromise || resolvedPromise;    return fn ? p.then(this ? fn.bind(this) : fn) : p;}

猛一看人都傻了,nextTick的代码竟然就这么一点?再认真看看,发现nextTick的实现其实是一个Promise的封装。

临时不思考别的货色,就看看这点代码,咱们能够晓得:

  • nextTick返回的是一个Promise
  • nextTick的回调函数是在Promisethen办法中执行的

当初回到咱们之前的demo,其实咱们曾经找到一部分的答案了:

nextTick(() => {    console.log('callback before');}).then(() => {    console.log('promise before');});this.count++;

下面最终执行的程序,用代码示意就是:

function nextTick(fn) {    // 2. 返回一个 Promise, 并且在 Promise 的 then 办法中执行回调函数    return Promise.resolve().then(fn);}// 1. 调用 nextTick,注册回调函数const p = nextTick(() => {    console.log('callback before');})// 3. 在 Promise 的 then 办法注册一个新的回调p.then(() => {    console.log('promise before');});// 4. 执行 count++this.count++;

从拆解进去的代码中,咱们能够看到的是:

  • nextTick返回的是一个Promise
  • nextTick的回调函数是在Promisethen办法中执行的

而依据Promise的个性,咱们晓得Promise是能够链式调用的,所以咱们能够这样写:

Promise.resolve().then(() => {    // ...}).then(() => {    // ...}).then(() => {    // ...});

而且依据Promise的个性,每次返回的Promise都是一个新的Promise

同时咱们也晓得Promisethen办法是异步执行的,所以下面的代码的执行程序也就有了肯定的猜想,然而当初不下结论,咱们持续深挖。

nextTick 的实现细节

下面的源码尽管很短,然而外面有一个currentFlushPromise变量,并且这个变量是应用let申明的,所有的变量都应用const申明,这个变量是用let来申明的,必定是有货的。

通过搜寻,咱们能够找到这个变量变量的应用中央,发现有两个办法在应用这个变量:

  • queueFlush:将currentFlushPromise设置为一个Promise
  • flushJobs:将currentFlushPromise设置为null

queueFlush

// 是否正在刷新let isFlushing = false;// 是否有工作须要刷新let isFlushPending = false;// 刷新工作队列function queueFlush() {    // 如果正在刷新,并且没有工作须要刷新    if (!isFlushing && !isFlushPending) {                // 将 isFlushPending 设置为 true,示意有工作须要刷新        isFlushPending = true;                // 将 currentFlushPromise 设置为一个 Promise, 并且在 Promise 的 then 办法中执行 flushJobs        currentFlushPromise = resolvedPromise.then(flushJobs);    }}

这些代码其实不必写正文也很看懂,见名知意,其实这里曾经能够初窥端倪了:

  • queueFlush是一个用来刷新工作队列的办法
  • isFlushing示意是否正在刷新,然而不是在这个办法外面应用的
  • isFlushPending示意是否有工作须要刷新,属于排队工作
  • currentFlushPromise示意以后就须要刷新的工作

当初联合下面的nextTick的实现,其实咱们会发现一个很乏味的中央,resolvedPromise他们两个都有在应用:

const resolvedPromise = Promise.resolve();function nextTick(fn) {    // nextTick 应用 resolvedPromise     return resolvedPromise.then(fn);}function queueFlush() {    // queueFlush 也应用 resolvedPromise    currentFlushPromise = resolvedPromise.then(flushJobs);}

下面代码再简化一下,其实是上面这样的:

const resolvedPromise = Promise.resolve();resolvedPromise.then(() => {    // ...});resolvedPromise.then(() => {    // ...});

其实就是利用Promisethen办法能够注册多个回调函数的个性,将须要刷新的工作都注册到同一个Promisethen办法中,这样就能够保障这些工作的执行程序,就是一个队列。

flushJobs

在下面的queueFlush办法中,咱们晓得了queueFlush是一个用来刷新工作队列的办法;

那么刷新什么工作呢?反正最初传入的是一个flushJobs办法,同时这个办法外面也应用到了currentFlushPromise,这不就串起来吗,连忙来看看:

// 工作队列const queue = [];// 以后正在刷新的工作队列的索引let flushIndex = 0;// 刷新工作function flushJobs(seen) {    // 将 isFlushPending 设置为 false,示意以后没有工作须要期待刷新了    isFlushPending = false;        // 将 isFlushing 设置为 true,示意正在刷新    isFlushing = true;        // 非生产环境下,将 seen 设置为一个 Map    if ((process.env.NODE_ENV !== 'production')) {        seen = seen || new Map();    }        // 刷新前,须要对工作队列进行排序    // 这样能够确保:    // 1. 组件的更新是从父组件到子组件的。    //    因为父组件总是在子组件之前创立,所以它的渲染优先级要低于子组件。    // 2. 如果父组件在更新的过程中卸载了子组件,那么子组件的更新能够被跳过。    queue.sort(comparator);        // 非生产环境下,查看是否有递归更新    // checkRecursiveUpdates 办法的应用必须在 try ... catch 代码块之外确定,    // 因为 Rollup 默认会在 try-catch 代码块中进行 treeshaking 优化。    // 这可能会导致所有正告代码都不会被 treeshaking 优化。    // 尽管它们最终会被像 terser 这样的压缩工具 treeshaking 优化,    // 但有些压缩工具会失败(例如:https://github.com/evanw/esbuild/issues/1610)    const check = (process.env.NODE_ENV !== 'production')        ? (job) => checkRecursiveUpdates(seen, job)        : NOOP;        // 检测递归调用是一个十分奇妙的操作,感兴趣的能够去看看源码,这里不做解说    try {        for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {            const job = queue[flushIndex];            if (job && job.active !== false) {                if ((process.env.NODE_ENV !== 'production') && check(job)) {                    continue;                }                                // 执行工作                callWithErrorHandling(job, null, 14 /* ErrorCodes.SCHEDULER */);            }        }    }    finally {        // 重置 flushIndex        flushIndex = 0;                // 疾速清空队列,间接给 数组的 length属性 赋值为 0 就能够清空数组        queue.length = 0;                // 刷新生命周期的回调        flushPostFlushCbs(seen);                // 将 isFlushing 设置为 false,示意以后刷新完结        isFlushing = false;                // 将 currentFlushPromise 设置为 null,示意以后没有工作须要刷新了        currentFlushPromise = null;                // pendingPostFlushCbs 寄存的是生命周期的回调,        // 所以可能在刷新的过程中又有新的工作须要刷新        // 所以这里须要判断一下,如果有新增加的工作,就须要再次刷新        if (queue.length || pendingPostFlushCbs.length) {            flushJobs(seen);        }    }}

flushJobs首先会将isFlushPending设置为false,以后批次的工作曾经开始刷新了,所以就不须要期待了,而后将isFlushing设置为true,示意正在刷新。

这一点和queueFlush办法正好相同,然而它们的性能是相互辉映的,queueFlush示意以后有工作须要属性,flushJobs示意以后正在刷新工作。

而工作的执行是通过callWithErrorHandling办法来执行的,外面的代码很简略,就是执行办法并捕捉执行过程中的谬误,而后将谬误交给onErrorCaptured办法来解决。

而刷新的工作都寄存在queue属性中,这个queue就是咱们下面说的工作队列,这个工作队列外面寄存的就是咱们须要刷新的工作。

最初清空queue而后执行flushPostFlushCbs办法,flushPostFlushCbs办法通常寄存的是生命周期的回调,比方mountedupdated等。

queue 的工作增加

下面提到了queue,那么queue是怎么增加工作的呢?

通过搜寻,咱们能够定位到queueJob办法,这个办法就是用来增加工作的:

// 增加工作,这个办法会在上面的 queueFlush 办法中被调用function queueJob(job) {    // 通过 Array.includes() 的 startIndex 参数来搜寻工作队列中是否曾经存在雷同的工作    // 默认状况下,搜寻的起始索引蕴含了以后正在执行的工作    // 所以它不能递归地再次触发本身    // 如果工作是一个 watch() 回调,那么搜寻的起始索引就是 +1,这样就能够递归调用了    // 然而这个递归调用是由用户来保障的,不能有限递归    if (!queue.length ||        !queue.includes(job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)) {        // 如果工作没有 id 属性,那么就将工作插入到工作队列中        if (job.id == null) {            queue.push(job);        }                // 如果工作有 id 属性,那么就将工作插入到工作队列的适合地位        else {            queue.splice(findInsertionIndex(job.id), 0, job);        }                // 刷新工作队列        queueFlush();    }}

这里的job是一个函数,也就是咱们须要刷新的工作,然而这个函数会拓展一些属性,比方idpreactive等。

ts版的源码中有对job的类型定义:

export interface SchedulerJob extends Function {    // id 就是排序的根据    id?: number    // 在 id 雷同的状况下,pre 为 true 的工作会先执行    // 这个在刷新工作队列的时候,在排序的时候会用到,本文没有解说这方面的内容    pre?: boolean    // 标识这个工作是否明确处于非活动状态,非活动状态的工作不会被刷新    active?: boolean    // 标识这个工作是否是 computed 的 getter    computed?: boolean    /**     * 示意 effect 是否容许在由 scheduler 治理时递归触发本身。     * 默认状况下,scheduler 不能触发本身,因为一些内置办法调用,例如 Array.prototype.push 实际上也会执行读取操作,这可能会导致令人困惑的有限循环。     * 容许的状况是组件更新函数和 watch 回调。     * 组件更新函数能够更新子组件属性,从而触发“pre”watch回调,该回调会扭转父组件依赖的状态。     * watch 回调不会跟踪它的依赖关系,因而如果它再次触发本身,那么很可能是无意的,这是用户的责任来执行递归状态变更,最终使状态稳固。     */    allowRecurse?: boolean    /**     * 在 renderer.ts 中附加到组件的渲染 effect 上用于在报告最大递归更新时获取组件信息。     * 仅限开发。     */    ownerInstance?: ComponentInternalInstance}

queueJob办法首先会判断queue中是否曾经存在雷同的工作,如果存在雷同的工作,那么就不须要再次增加了。

这里次要是解决递归调用的问题,因为这里寄存的工作大多数都是咱们在批改数据的时候触发的;

而批改数据的时候用到了数组的办法,例如forEachmap等,这些办法在执行的时候,会触发getter,而getter中又会触发queueJob办法,这样就会导致递归调用。

所以这里会判断isFlushing,如果是正在刷新,那么就会将flushIndex设置为+1

flushIndex是以后正在刷新的工作的索引,+1之后就从下一个工作开始搜寻,这样就不会反复的往里面增加同一个工作导致递归调用。

watch的回调是能够递归调用的,因为这个是用户管制的,所以这里就多了一个allowRecurse属性,如果是watch的回调,那么就会将allowRecurse设置为true

这样就能够防止递归调用的问题,是一个十分奇妙的设计。

queueJob最初是被导出的,这个用于其余模块增加工作,比方watchEffectwatch等。

flushPostFlushCbs

flushPostFlushCbs办法是用来执行生命周期的回调的,比方mountedupdated等。

flushPostFlushCbs就不多讲了,整体的流程和flushJobs差不多;

不同的是flushPostFlushCbs会把工作备份,而后顺次执行,并且不会捕捉异样,是间接调用的。

感兴趣的同学能够本人查看源码。

问题的开始

回到最开始的问题,就是文章最结尾的demo示例,先回顾一下demo的代码:

nextTick(() => {    console.log('callback before');}).then(() => {    console.log('promise before');});this.count++;nextTick(() => {    console.log('callback after');}).then(() => {    console.log('promise after');});

打印的后果是:

callback beforerender 1promise beforecallback afterpromise after

其实通过翻看源码曾经很明确了,咱们在注册第一个nextTick的时候,queue中并没有任何工作;

而且nextTick并不会调用queueJob办法,也不会调用flushJobs办法,所以这个时候工作队列是不会被刷新的。

然而resolvedPromise是一个胜利的promise,所以传入到nextTick外面的回调函数会被放到微工作队列中,期待执行。

nextTick还会返回一个promise,所以咱们返回的promisethen回调函数也会被放到微工作队列中,然而肯定会落后于nextTick中的回调函数。

接着咱们再执行this.count++,这外面的外部实现逻辑咱们还没接触到,只须要晓得他会触发queueJob办法,将工作增加到工作队列中即可。

最初咱们又执行了一次nextTick,这个时候queue中曾经有了工作,所以会调用flushJobs办法,将工作队列中的工作顺次执行。

划重点:并且这个时候currentFlushPromise有值了,值是resolvedPromise执行结束之后,返回的Promise

和第一次不同的是,第一次执行nextTick的时候,currentFlushPromiseundefined,应用的是resolvedPromise;

能够了解为第一次执行nextTick的时候,是和flushJobs办法注册的工作应用的是同一个Promise

第二次执行nextTick的时候,应用的是currentFlushPromise,这个PromiseflushJobs办法注册的工作不是同一个Promise

这样就就保障了nextTick注册的回调函数会在flushJobs办法注册的回调函数之后执行。

具体的流程能够能够看上面的代码示例:

const resolvedPromise = Promise.resolve();let count = 0;// 第一次注册 nextTickresolvedPromise.then(() => {    console.log('callback before', count);}).then(() => {    console.log('promise before', count);});// 执行 this.count++// 这里会触发 queueJob 办法,将工作增加到工作队列中const currentFlushPromise = resolvedPromise.then(() => {    count++;    console.log('render', count);});// 第二次注册 nextTickcurrentFlushPromise.then(() => {    console.log('callback after', count);}).then(() => {    console.log('promise after', count);});

下面的代码执行的后果大家能够本人在浏览器中执行一下,就会发现和咱们的预期是统一的。

具体流程能够看上面的图:

graph TDA[resolvedPromise] -->|注册 nextTick 回调| B[nextTick callback before]B -->|在 nextTick 返回的 promise 注册 then 的回调| C[nextTick promise then]A -->|执行 value++ 会触发 queueJob| D[value++]D -->|执行 flushJobs 会将 resolvedPromise 返回的 promise 赋值到 currentFlushPromise| E[currentFlushPromise]E -->|注册 nextTick 回调应用的是 currentFlushPromise| F[nextTick callback after]F -->|在 nextTick 返回的 promise 注册 then 的回调| G[nextTick promise after]

下面一个同步的宏工作就执行实现了,接下来就是微工作队列了,流程如下:

graph TDA[resolvedPromise] -->|间接调用 then 外面注册的回调函数| B[then callbacks]B -->|注册了多个,顺次执行| C[nextTick callback before]C -->|注册了多个,顺次执行| D[value++]

这样第二波工作也完结了,这一次的工作次要是刷新工作队列,这里执行的nextTick其实是上一个工作的tick(当初明确官网上说的直到下一个“tick”才一起执行是什么意思了吧)。

接着就执行下一个tick(是这么个意思吧,手动狗头),流程如下:

graph TDA[nextTick promise then] -->|因为是先注册的,所以先执行| B[nextTick promise before]

完结了,没错,这次的工作就是执行nextTick返回的promisethen回调函数;

因为nextTick返回的promisecurrentFlushPromise不是同一个promisenextTick返回的promisethen是独自一个工作,并且优先级是高于currentFlushPromise的。

这次的工作完结,就又下一个tick了,流程如下:

graph TDA[currentFlushPromise then] -->|因为是后注册的,所以绝对于下面的后执行| B[nextTick callback after]

这次的工作就是执行currentFlushPromisethen回调函数,同时也是调用flushJobs,由flushJobsresolvedPromise返回的Promise赋值给currentFlushPromise

这次的工作完结,就是最初一个tick了,流程如下:

graph TDA[nextTick promise after] -->|最初一个| B[nextTick promise after]

至此流程完结,过程很烧脑,然而了解了之后,发现十分的奇妙,对本人的思维能力有了很大的晋升,同时也对异步的了解有了很大的晋升。

总结

这篇文章次要是对Vue3nextTick的实现原理进行了剖析,通过剖析源码,咱们发现nextTick的实现原理十分的奇妙。

nextTick的实现原理是通过Promise来实现的,nextTick会返回一个Promise,并且nextTick的回调函数会被放到微工作队列中,期待执行。

如果在有工作排队的状况下注册nextTick,那么nextTick的回调函数会在工作队列中的工作执行结束之后执行。

这里应用的思路非常简单,就是利用了Promise的可链式调用的个性,平时开发可能大家都用过,然而没想到能够这样用,真的是十分的奇妙。

这次就到这里了,感激大家的浏览,如果有不对的中央,欢送大家斧正。