关于vue.js:源码库Vue3-中的-nextTick-魔法背后的原理

5次阅读

共计 11197 个字符,预计需要花费 28 分钟才能阅读完成。

在应用 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 before
render 1
promise before
callback after
promise 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 before
render 1
promise before
callback after
promise 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;

// 第一次注册 nextTick
resolvedPromise.then(() => {console.log('callback before', count);
}).then(() => {console.log('promise before', count);
});

// 执行 this.count++
// 这里会触发 queueJob 办法,将工作增加到工作队列中
const currentFlushPromise = resolvedPromise.then(() => {
    count++;
    console.log('render', count);
});

// 第二次注册 nextTick
currentFlushPromise.then(() => {console.log('callback after', count);
}).then(() => {console.log('promise after', count);
});

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

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

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

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

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

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

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

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

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

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

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

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

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

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

总结

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

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

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

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

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

正文完
 0