[Source Code & Library] The principle behind nextTick magic in Vue3

NextTick Introduction

According to the brief introduction on the official website, nextTickit is a tool method to wait for the next DOM update refresh.

The type definition is as follows:

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

Then according to the detailed introduction on the official website, we can know nextTickthe general implementation ideas and usage:

When you Vuechange reactive state in a , the final DOMupdates are not synchronized, but are Vuecached in a queue until the next "tick". This is to ensure that each component only performs one update regardless of how many state changes occur.

nextTick()Can be used immediately after a status change to wait for DOMthe update to complete. You can pass a callback function as argument, or awaitreturn it Promise.

The explanation on the official website is already very detailed, so I won’t over-interpret it. The next step is the analysis.

Some details and usage of nextTick

Usage of nextTick

First, according to the introduction on the official website, we can know that nextTickthere are two ways to use it:

  • Pass in callback function
nextTick(() => {// DOM 更新了
}) 
  • Return aPromise
nextTick().then(() => {// DOM 更新了
}) 

So can these two methods be mixed?

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

nextTick phenomenon

I wrote a very simple one demoand found that it can be mixed, and discovered an interesting phenomenon:

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"); 

I use it here for simplicity vue.global.js. The usage method is Vue3the same, but it is ESMintroduced without using it.

The running results are as follows:

In my example, clicking the add button will countperform an increment operation. This method can be divided into three parts:

1. Use nextTickand use Promisethe mixed use of the callback function and
2. countAdd one to the pair
3. Use nextTickand use Promisethe mixed use of the callback function and

The first registered one is executed nextTickbefore countadding one, and the second registered one is executed nextTickafter countadding one.

But the final result is very interesting:

callback before
render 1
promise before
callback after
promise after 

The first registered nextTickcallback function is renderexecuted before, Promisebut renderafter.

The second registered nextTick, callback function is renderexecuted after, Promisebut renderafter.

And both nextTickcallback functions take precedence over Promiseexecution.

How to explain this phenomenon? We will nextTickstart with the implementation of the analysis.

Implementation of nextTick

nextTickThere are only more than 200 lines of source code in packages/runtime-core/src/scheduler.tsthe file. If you are interested, you can go directly to tsthe source code of the version. We will still look at the packaged source code.

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;
} 

At first glance, people are dumbfounded. nextTickThere is only such a small amount of code? Taking a closer look, we find nextTickthat the implementation is actually an Promiseencapsulation.

Ignoring other things for the moment, just look at this code, we can know:

  • nextTickWhat is returned is aPromise
  • nextTickThe callback function is executed in the method Promiseofthen

Now going back to what we said before demo, we have actually found part of the answer:

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

this.count++; 

The final execution sequence above, expressed in code, is:

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++; 

From the disassembled code, what we can see is:

  • nextTickWhat is returned is aPromise
  • nextTickThe callback function is executed in the method Promiseofthen

According to Promisethe characteristics of , we know Promisethat it can be called in a chain, so we can write like this:

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

And according to the characteristics of , a new one is Promisereturned every time ;PromisePromise

At the same time, we also know Promisethat thenthe method is executed asynchronously, so we have some guesses about the execution order of the above code, but we can't draw a conclusion now, let's continue to dig deeper.

Implementation details of nextTick

Although the above source code is very short, there is a currentFlushPromisevariable in it, and this variable is letdeclared using. All variables are constdeclared using. This variable is letused to declare, and it must be in stock.

Through search, we can find where this variable is used and find that there are two methods of using this variable:

  • queueFlush: will currentFlushPromisebe set to aPromise
  • flushJobs: will be currentFlushPromiseset tonull

queueFlush

// 是否正在刷新
let isFlushing = false;

// 是否有任务需要刷新
let isFlushPending = false;

// 刷新任务队列
function queueFlush() {// 如果正在刷新,并且没有任务需要刷新if (!isFlushing && !isFlushPending) {// 将 isFlushPending 设置为 true,表示有任务需要刷新isFlushPending = true;// 将 currentFlushPromise 设置为一个 Promise, 并且在 Promise 的 then 方法中执行 flushJobscurrentFlushPromise = resolvedPromise.then(flushJobs);}
} 

In fact, these codes are easy to understand without writing comments. You can know the meaning by seeing the name. In fact, you can already get a glimpse of it here:

  • queueFlushIs a method used to refresh the task queue
  • isFlushingIndicates whether it is refreshing, but it is not used in this method.
  • isFlushPendingIndicates whether there are tasks that need to be refreshed, which are queued tasks.
  • currentFlushPromiseIndicates tasks that currently need to be refreshed

Now combined with the above nextTickimplementation, we will actually find a very interesting point. resolvedPromiseBoth of them are in use:

const resolvedPromise = Promise.resolve();
function nextTick(fn) {// nextTick 使用 resolvedPromise return resolvedPromise.then(fn);
}

function queueFlush() {// queueFlush 也使用 resolvedPromisecurrentFlushPromise = resolvedPromise.then(flushJobs);
} 

Simplifying the above code, it actually looks like this:

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

resolvedPromise.then(() => {// ...
}); 

In fact, the method is used Promiseto thenregister multiple callback functions, and all tasks that need to be refreshed are registered in the same Promisemethod then, so that the execution order of these tasks can be guaranteed, which is a queue.

flushJobs

In the above queueFlushmethod, we know that queueFlushit is a method used to refresh the task queue;

So what tasks should be refreshed? Anyway, the last thing passed in is a flushJobsmethod, and it is also used in this method currentFlushPromise. Isn’t this just a string? Let’s take a look:

// 任务队列
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" />
} 

flushJobsFirst, it will isFlushPendingbe set to false, the current batch of tasks has already started to be refreshed, so there is no need to wait, and then it will be isFlushingset to true, indicating that it is refreshing.

This queueFlushis exactly the opposite of the method, but their functions complement each other. queueFlushIt means that there is currently a task that requires attributes, and flushJobsit means that the task is currently being refreshed.

The execution of tasks is callWithErrorHandlingperformed through methods. The code inside is very simple, which is to execute the method and capture errors during the execution, and then hand over the errors to onErrorCapturedthe method for processing.

The refresh tasks are stored in queueattributes. This queueis the task queue we mentioned above. What is stored in this task queue is the task we need to refresh.

Finally, clear queueand execute flushPostFlushCbsthe method. flushPostFlushCbsThe method usually stores life cycle callbacks, such as mounted, updatedetc.

Task addition to queue

As mentioned above queue, queuehow do you add tasks?

Through search, we can locate queueJobthe method, which is used to add tasks:

// 添加任务,这个方法会在下面的 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();}
} 

Here jobis a function, which is the task we need to refresh, but this function will expand some attributes, such as id, pre, activeetc.

There is a pair of type definitions in tsthe source code 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
} 

queueJobThe method first determines queuewhether the same task already exists. If the same task exists, there is no need to add it again.

This mainly deals with the problem of recursive calls, because most of the tasks stored here are triggered when we modify the data;

When modifying data, array methods are used, such as forEach, mapetc. These methods will be triggered when executed, getterand the method getterwill be triggered in turn queueJob, which will lead to recursive calls.

So it will be judged here isFlushing, if it is refreshing, then it will be flushIndexset to +1;

flushIndexIt is the index of the task currently being refreshed, +1and then the search starts from the next task, so that the same task will not be added repeatedly and cause recursive calls.

The watchcallback can be called recursively, because this is controlled by the user, so there is an additional allowRecurseattribute here. If it is watcha callback, it will be allowRecurseset to true.

This can avoid the problem of recursive calls, which is a very clever design.

queueJobThe last one is exported, this is used for other modules to add tasks, such as watchEffect, watchetc.

flushPostFlushCbs

flushPostFlushCbsMethods are used to execute life cycle callbacks, such as mounted, updatedetc.

flushPostFlushCbsI won’t go into too much detail, the overall process is flushJobspretty much the same;

The difference is that flushPostFlushCbsthe tasks will be backed up and then executed sequentially, and exceptions will not be caught, but will be called directly.

Interested students can check the source code themselves.

The beginning of the problem

Going back to the original question, which is demothe example at the beginning of the article, let’s review demothe code first:

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

this.count++;

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

The printed result is:

callback before
render 1
promise before
callback after
promise after 

In fact, it is very clear by looking through the source code. nextTickWhen we registered the first one, queuethere was no task in it;

And the method nextTickwill not be called queueJob, nor will flushJobsthe method be called, so the task queue will not be refreshed at this time.

But resolvedPromiseit is a success promise, so nextTickthe callback function passed in will be placed in the microtask queue, waiting for execution.

nextTickOne will also be returned promise, so the callback function we return will also be placed in the microtask queue, but it will definitely lag behind promisethe callback function.thennextTick

Then we execute it this.count++. We haven’t touched the internal implementation logic here yet. We only need to know that it will trigger queueJobthe method and add the task to the task queue.

Finally, we executed it again nextTick. At this time, queuethere was already a task, so flushJobsthe method was called to execute the tasks in the task queue in sequence.

Emphasis: And currentFlushPromisethere is a value at this time. The value is resolvedPromisereturned after the execution is completed Promise.

The difference from the first time is that the first time it was executed nextTick, currentFlushPromiseit undefinedused resolvedPromise;

It can be understood that the first nextTicktime it is executed, the flushJobssame task as the method registration is used Promise.

When executed for the second time nextTick, the task used is not the same as currentFlushPromisethe one registered by the method .PromiseflushJobsPromise

This ensures that nextTickthe registered callback function will flushJobsbe executed after the method's registered callback function.

The specific process can be seen in the following code example:

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);
}); 

You can execute the result of the above code in your browser and you will find that it is consistent with our expectations.

The specific process can be seen in the figure below:

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] 

The above synchronized macro task has been executed, and the next step is the micro task queue. The process is as follows:

graph TD
A[resolvedPromise] -->|直接调用 then 里面注册的回调函数| B[then callbacks]
B -->|注册了多个,依次执行| C[nextTick callback before]
C -->|注册了多个,依次执行| D[value++] 

In this way, the second wave of tasks is also over. This time the task is mainly to refresh the task queue. What is executed here nextTickis actually the previous task tick(now you understand 直到下一个“tick”才一起执行what it means on the official website).

Then execute the next one tick(that’s what I mean, manual dog head), the process is as follows:

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

It’s over, yes, the task this time is to execute the nextTickreturned callback function;promisethen

Because nextTickthe returned sums promiseare currentFlushPromisenot the same promise, what is nextTickreturned is a single task, and the priority is higher .promisethencurrentFlushPromise

After this mission is over, there will be another one tick. The process is as follows:

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

This time the task is to execute currentFlushPromisethe thencallback function, and it is also a call flushJobs, which flushJobsis assigned resolvedPromisethe returned Promisevalue currentFlushPromise.

This mission is over and it is the last one tick. The process is as follows:

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

At this point, the process is over. The process is very brain-consuming, but after understanding it, I found it to be very clever. It has greatly improved my thinking ability and my understanding of asynchronous.

Summarize

This article mainly analyzes the implementation principles. By analyzing the source code, we found Vue3that the implementation principles are very clever.nextTicknextTick

nextTickThe implementation principle is Promiseimplemented by , nextTickwhich will return one Promise, and nextTickthe callback function will be placed in the microtask queue, waiting for execution.

If registered while there are tasks queued nextTick, nextTickthe callback function will be executed after the tasks in the task queue are executed.

The idea used here is very simple, which is to take advantage Promiseof the chainable call feature. Everyone may have used it in daily development, but I didn't expect it to be used in this way. It is really very clever.

at last

We have compiled 75 JS high-frequency interview questions, and provided answers and analysis. This can basically guarantee that you can cope with the interviewer's questions about JS.



Friends in need can click on the card below to receive it and share it for free

Guess you like

Origin blog.csdn.net/web2022050901/article/details/129406826