在应用Vue
的时候,最让人着迷的莫过于nextTick
了,它能够让咱们在下一次DOM
更新循环完结之后执行提早回调。
所以咱们想要拿到更新的后的DOM
就上nextTick
,想要在DOM
更新之后再执行某些操作还上nextTick
,不晓得页面什么时候挂载实现仍然上nextTick
。
尽管我不懂Vue
的外部实现,然而我晓得有问题上nextTick
就对了,你天天上nextTick
,那么nextTick
为什么能够让你这么爽你就不好奇吗?
大家好,这里是田八的【源码&库】系列,
Vue3
的源码浏览打算,Vue3
的源码浏览打算不出意外每周一更,欢送大家关注。如果想一起交换的话,能够点击这里一起独特交换成长
系列章节:
- 【源码&库】跟着 Vue3 学习前端模块化
- 【源码&库】在调用 createApp 时,Vue 为咱们做了那些工作?
- 【源码&库】细数 Vue3 的实例办法和属性背地的故事
首发在掘金,无任何引流的意思。
nextTick 简介
依据官网的简略介绍,nextTick
是期待下一次 DOM 更新刷新的工具办法。
类型定义如下:
而后再依据官网的具体介绍,咱们能够晓得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
进行加一操作,这个办法外面能够分为三个局部:
- 应用
nextTick
,并应用回调函数和Promise
的混合应用 - 对
count
进行加一操作 - 应用
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
的回调函数是在Promise
的then
办法中执行的
当初回到咱们之前的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
的回调函数是在Promise
的then
办法中执行的
而依据Promise
的个性,咱们晓得Promise
是能够链式调用的,所以咱们能够这样写:
Promise.resolve().then(() => { // ...}).then(() => { // ...}).then(() => { // ...});
而且依据Promise
的个性,每次返回的Promise
都是一个新的Promise
;
同时咱们也晓得Promise
的then
办法是异步执行的,所以下面的代码的执行程序也就有了肯定的猜想,然而当初不下结论,咱们持续深挖。
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(() => { // ...});
其实就是利用Promise
的then
办法能够注册多个回调函数的个性,将须要刷新的工作都注册到同一个Promise
的then
办法中,这样就能够保障这些工作的执行程序,就是一个队列。
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
办法通常寄存的是生命周期的回调,比方mounted
、updated
等。
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
是一个函数,也就是咱们须要刷新的工作,然而这个函数会拓展一些属性,比方id
、pre
、active
等。
在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
中是否曾经存在雷同的工作,如果存在雷同的工作,那么就不须要再次增加了。
这里次要是解决递归调用的问题,因为这里寄存的工作大多数都是咱们在批改数据的时候触发的;
而批改数据的时候用到了数组的办法,例如forEach
、map
等,这些办法在执行的时候,会触发getter
,而getter
中又会触发queueJob
办法,这样就会导致递归调用。
所以这里会判断isFlushing
,如果是正在刷新,那么就会将flushIndex
设置为+1
;
flushIndex
是以后正在刷新的工作的索引,+1
之后就从下一个工作开始搜寻,这样就不会反复的往里面增加同一个工作导致递归调用。
而watch
的回调是能够递归调用的,因为这个是用户管制的,所以这里就多了一个allowRecurse
属性,如果是watch
的回调,那么就会将allowRecurse
设置为true
。
这样就能够防止递归调用的问题,是一个十分奇妙的设计。
queueJob
最初是被导出的,这个用于其余模块增加工作,比方watchEffect
、watch
等。
flushPostFlushCbs
flushPostFlushCbs
办法是用来执行生命周期的回调的,比方mounted
、updated
等。
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
,所以咱们返回的promise
中then
回调函数也会被放到微工作队列中,然而肯定会落后于nextTick
中的回调函数。
接着咱们再执行this.count++
,这外面的外部实现逻辑咱们还没接触到,只须要晓得他会触发queueJob
办法,将工作增加到工作队列中即可。
最初咱们又执行了一次nextTick
,这个时候queue
中曾经有了工作,所以会调用flushJobs
办法,将工作队列中的工作顺次执行。
划重点:并且这个时候currentFlushPromise
有值了,值是resolvedPromise
执行结束之后,返回的Promise
。
和第一次不同的是,第一次执行nextTick
的时候,currentFlushPromise
是undefined
,应用的是resolvedPromise
;
能够了解为第一次执行nextTick
的时候,是和flushJobs
办法注册的工作应用的是同一个Promise
。
第二次执行nextTick
的时候,应用的是currentFlushPromise
,这个Promise
和flushJobs
办法注册的工作不是同一个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]
下面一个同步的宏工作就执行实现了,接下来就是微工作队列了,流程如下:
这样第二波工作也完结了,这一次的工作次要是刷新工作队列,这里执行的nextTick
其实是上一个工作的tick
(当初明确官网上说的直到下一个“tick”才一起执行
是什么意思了吧)。
接着就执行下一个tick
(是这么个意思吧,手动狗头),流程如下:
完结了,没错,这次的工作就是执行nextTick
返回的promise
的then
回调函数;
因为nextTick
返回的promise
和currentFlushPromise
不是同一个promise
,nextTick
返回的promise
的then
是独自一个工作,并且优先级是高于currentFlushPromise
的。
这次的工作完结,就又下一个tick
了,流程如下:
这次的工作就是执行currentFlushPromise
的then
回调函数,同时也是调用flushJobs
,由flushJobs
将resolvedPromise
返回的Promise
赋值给currentFlushPromise
。
这次的工作完结,就是最初一个tick
了,流程如下:
至此流程完结,过程很烧脑,然而了解了之后,发现十分的奇妙,对本人的思维能力有了很大的晋升,同时也对异步的了解有了很大的晋升。
总结
这篇文章次要是对Vue3
中nextTick
的实现原理进行了剖析,通过剖析源码,咱们发现nextTick
的实现原理十分的奇妙。
nextTick
的实现原理是通过Promise
来实现的,nextTick
会返回一个Promise
,并且nextTick
的回调函数会被放到微工作队列中,期待执行。
如果在有工作排队的状况下注册nextTick
,那么nextTick
的回调函数会在工作队列中的工作执行结束之后执行。
这里应用的思路非常简单,就是利用了Promise
的可链式调用的个性,平时开发可能大家都用过,然而没想到能够这样用,真的是十分的奇妙。
这次就到这里了,感激大家的浏览,如果有不对的中央,欢送大家斧正。