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
的回调函数是在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 方法中执行 flushJobscurrentFlushPromise = 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 也使用 resolvedPromisecurrentFlushPromise = 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 设置为一个 Mapif ((process.env.NODE_ENV <img src="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 {// 重置 flushIndexflushIndex = 0;// 快速清空队列,直接给 数组的 length属性 赋值为 0 就可以清空数组queue.length = 0;// 刷新生命周期的回调flushPostFlushCbs(seen);// 将 isFlushing 设置为 false,表示当前刷新结束isFlushing = false;// 将 currentFlushPromise 设置为 null,表示当前没有任务需要刷新了currentFlushPromise = null;// pendingPostFlushCbs 存放的是生命周期的回调,// 所以可能在刷新的过程中又有新的任务需要刷新// 所以这里需要判断一下,如果有新添加的任务,就需要再次刷新if (queue.length || pendingPostFlushCbs.length) {flushJobs(seen);}" style="margin: auto" />
}
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 的 gettercomputed?: 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 before
render 1
promise before
callback after
promise 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;
// 第一次注册 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
返回的promise
的then
回调函数;
因为nextTick
返回的promise
和currentFlushPromise
不是同一个promise
,nextTick
返回的promise
的then
是单独一个任务,并且优先级是高于currentFlushPromise
的。
这次的任务结束,就又下一个tick
了,流程如下:
graph TD
A[currentFlushPromise then] -->|因为是后注册的,所以相对于上面的后执行| B[nextTick callback after]
这次的任务就是执行currentFlushPromise
的then
回调函数,同时也是调用flushJobs
,由flushJobs
将resolvedPromise
返回的Promise
赋值给currentFlushPromise
。
这次的任务结束,就是最后一个tick
了,流程如下:
graph TD
A[nextTick promise after] -->|最后一个| B[nextTick promise after]
至此流程结束,过程很烧脑,但是理解了之后,发现非常的巧妙,对自己的思维能力有了很大的提升,同时也对异步的理解有了很大的提升。
总结
这篇文章主要是对Vue3
中nextTick
的实现原理进行了分析,通过分析源码,我们发现nextTick
的实现原理非常的巧妙。
nextTick
的实现原理是通过Promise
来实现的,nextTick
会返回一个Promise
,并且nextTick
的回调函数会被放到微任务队列中,等待执行。
如果在有任务排队的情况下注册nextTick
,那么nextTick
的回调函数会在任务队列中的任务执行完毕之后执行。
这里使用的思路非常简单,就是利用了Promise
的可链式调用的特性,平时开发可能大家都用过,但是没想到可以这样用,真的是非常的巧妙。
最后
整理了75个JS高频面试题,并给出了答案和解析,基本上可以保证你能应付面试官关于JS的提问。
文章来源:https://www.toymoban.com/news/detail-773310.html
有需要的小伙伴,可以点击下方卡片领取,无偿分享文章来源地址https://www.toymoban.com/news/detail-773310.html
到了这里,关于【源码&库】Vue3 中的 nextTick 魔法背后的原理的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!