【源码&库】Vue3 中的 nextTick 魔法背后的原理

简介: 【源码&库】Vue3 中的 nextTick 魔法背后的原理

在使用Vue的时候,最让人着迷的莫过于nextTick了,它可以让我们在下一次DOM更新循环结束之后执行延迟回调。


所以我们想要拿到更新的后的DOM就上nextTick,想要在DOM更新之后再执行某些操作还上nextTick,不知道页面什么时候挂载完成依然上nextTick


虽然我不懂Vue的内部实现,但是我知道有问题上nextTick就对了,你天天上nextTick,那么nextTick为什么可以让你这么爽你就不好奇吗?


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的方式引入。


运行结果如下:

image.png

在我这个示例里面,点击增加按钮,会对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);
});

上面的代码执行的结果大家可以自己在浏览器中执行一下,就会发现和我们的预期是一致的。


具体流程可以看下面的图:

image.png


上面一个同步的宏任务就执行完成了,接下来就是微任务队列了,流程如下:

image.png


这样第二波任务也结束了,这一次的任务主要是刷新任务队列,这里执行的nextTick其实是上一个任务的tick(现在明白官网上说的直到下一个“tick”才一起执行是什么意思了吧)。


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

image.png


结束了,没错,这次的任务就是执行nextTick返回的promisethen回调函数;


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


这次的任务结束,就又下一个tick了,流程如下:

image.png


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


这次的任务结束,就是最后一个tick了,流程如下:

image.png


至此流程结束,过程很烧脑,但是理解了之后,发现非常的巧妙,对自己的思维能力有了很大的提升,同时也对异步的理解有了很大的提升。


总结


这篇文章主要是对Vue3nextTick的实现原理进行了分析,通过分析源码,我们发现nextTick的实现原理非常的巧妙。


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


如果在有任务排队的情况下注册nextTick,那么nextTick的回调函数会在任务队列中的任务执行完毕之后执行。


这里使用的思路非常简单,就是利用了Promise的可链式调用的特性,平时开发可能大家都用过,但是没想到可以这样用,真的是非常的巧妙。


这次就到这里了,感谢大家的阅读,如果有不对的地方,欢迎大家指正。


目录
相关文章
|
24天前
|
缓存 JavaScript UED
Vue3中v-model在处理自定义组件双向数据绑定时有哪些注意事项?
在使用`v-model`处理自定义组件双向数据绑定时,要仔细考虑各种因素,确保数据的准确传递和更新,同时提供良好的用户体验和代码可维护性。通过合理的设计和注意事项的遵循,能够更好地发挥`v-model`的优势,实现高效的双向数据绑定效果。
126 64
|
24天前
|
JavaScript 前端开发 API
Vue 3 中 v-model 与 Vue 2 中 v-model 的区别是什么?
总的来说,Vue 3 中的 `v-model` 在灵活性、与组合式 API 的结合、对自定义组件的支持等方面都有了明显的提升和改进,使其更适应现代前端开发的需求和趋势。但需要注意的是,在迁移过程中可能需要对一些代码进行调整和适配。
104 60
|
24天前
|
前端开发 JavaScript 测试技术
Vue3中v-model在处理自定义组件双向数据绑定时,如何避免循环引用?
Web 组件化是一种有效的开发方法,可以提高项目的质量、效率和可维护性。在实际项目中,要结合项目的具体情况,合理应用 Web 组件化的理念和技术,实现项目的成功实施和交付。通过不断地探索和实践,将 Web 组件化的优势充分发挥出来,为前端开发领域的发展做出贡献。
29 8
|
23天前
|
存储 JavaScript 数据管理
除了provide/inject,Vue3中还有哪些方式可以避免v-model的循环引用?
需要注意的是,在实际开发中,应根据具体的项目需求和组件结构来选择合适的方式来避免`v-model`的循环引用。同时,要综合考虑代码的可读性、可维护性和性能等因素,以确保系统的稳定和高效运行。
24 1
|
23天前
|
JavaScript
Vue3中使用provide/inject来避免v-model的循环引用
`provide`和`inject`是 Vue 3 中非常有用的特性,在处理一些复杂的组件间通信问题时,可以提供一种灵活的解决方案。通过合理使用它们,可以帮助我们更好地避免`v-model`的循环引用问题,提高代码的质量和可维护性。
33 1
|
24天前
|
JavaScript
在 Vue 3 中,如何使用 v-model 来处理自定义组件的双向数据绑定?
需要注意的是,在实际开发中,根据具体的业务需求和组件设计,可能需要对上述步骤进行适当的调整和优化,以确保双向数据绑定的正确性和稳定性。同时,深入理解 Vue 3 的响应式机制和组件通信原理,将有助于更好地运用 `v-model` 实现自定义组件的双向数据绑定。
|
27天前
|
JavaScript 前端开发 API
介绍一下Vue中的响应式原理
介绍一下Vue中的响应式原理
30 1
|
1月前
|
监控 JavaScript 算法
深度剖析 Vue.js 响应式原理:从数据劫持到视图更新的全流程详解
本文深入解析Vue.js的响应式机制,从数据劫持到视图更新的全过程,详细讲解了其实现原理和运作流程。
|
27天前
|
JavaScript 前端开发 API
从Vue 2到Vue 3的演进
从Vue 2到Vue 3的演进
39 0
|
27天前
|
JavaScript 前端开发 API
Vue.js响应式原理深度解析:从Vue 2到Vue 3的演进
Vue.js响应式原理深度解析:从Vue 2到Vue 3的演进
54 0