[ソースコードとライブラリ] Vue3 の nextTick マジックの原理

NextTick の紹介

公式サイトの簡単な紹介によると、nextTick次のDOMアップデートの更新を待つツール方法です。

型定義は次のとおりです。

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

公式 Web サイトの詳細な紹介によると、nextTick一般的な実装アイデアと使用法を知ることができます。

Vueリアクティブ状態を変更すると、最終的なDOM更新は同期されませんが、Vue次の「ティック」までキューにキャッシュされます。これは、状態変化が何回発生したかに関係なく、各コンポーネントが確実に 1 回の更新のみを実行するようにするためです。

nextTick()ステータス変更直後に使用して、DOM更新が完了するまで待つことができます。コールバック関数を引数として渡すことも、awaitそれを返すこともできますPromise

公式サイトの説明が詳しく書かれているので深読みはせず、次は分析です。

nextTick の詳細と使用法

nextTickの使い方

nextTickまず、公式サイトの紹介によると、次の2つの使い方があることが分かります。

  • コールバック関数を渡す
nextTick(() => {// DOM 更新了
}) 
  • を返すPromise
nextTick().then(() => {// DOM 更新了
}) 

では、これら 2 つの方法を混合することはできるのでしょうか?

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

次へティック現象

非常に単純なものを書いたところ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増分操作が実行されます。このメソッドは 3 つの部分に分けることができます。

1.コールバック関数との混合使用nextTick2.ペアに 1 を追加3.コールバック関数との混合使用Promise
count
nextTickPromise

最初に登録されたものは1 を追加するnextTick前に実行されcount、2 番目に登録されたものは1 を追加したnextTick後に実行されますcount

しかし、最終的な結果は非常に興味深いものです。

callback before
render 1
promise before
callback after
promise after 

最初に登録されたnextTickコールバック関数はrender、その前に実行されますPromiserenderその後に実行されます。

2 番目に登録されたnextTickコールバック関数はrenderPromiseその後render

そして、どちらのnextTickコールバック関数も実行より優先されますPromise

この現象はどうやって説明すればいいのでしょうか?nextTickまずは分析の実装から始めます

nextTickの実装

nextTickファイルには 200 行を超えるソース コードしかありません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

同時に、メソッドが非同期で実行されることもわかっているため、上記のコードの実行順序についてはいくつかの推測がありますが、現時点では結論を出すことはできません。さらに深く掘り下げてみましょうPromisethen

nextTickの実装詳細

上記のソースコードは非常に短いですが、currentFlushPromiseその中に変数があり、この変数はletusing で宣言されています。すべての変数は using で宣言さconstれています。この変数は宣言letに使用されて、在庫がなければなりません。

検索を通じて、この変数が使用されている場所を見つけ、この変数を使用する方法が 2 つあることがわかります。

  • queueFlush:currentFlushPromiseに設定されますPromise
  • flushJobs:currentFlushPromiseに設定されますnull

キューフラッシュ

// 是否正在刷新
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ため、これらのタスクの実行順序が保証されます。これがキューです。

フラッシュジョブ

上記のメソッドでは、それがタスク キューをリフレッシュするために使用されるメソッドである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設定され、更新中であることを示します。falseisFlushingtrue

これはメソッドとはまったく逆ですが、属性を必要とするタスクが現在存在することを意味し、そのタスクが現在リフレッシュ中であることを意味するため、queueFlushこれらの機能は相互に補完します。queueFlushflushJobs

タスクの実行はメソッドを通じて実行され、内部のコードは非常に単純で、メソッドを実行して実行中にエラーをキャプチャし、そのエラーを処理のためにメソッドcallWithErrorHandlingに渡すというものです。onErrorCaptured

リフレッシュ タスクはqueue属性に格納されます。これがqueue上で説明したタスク キューです。このタスク キューに格納されているのは、リフレッシュする必要があるタスクです。

最後に、メソッドをクリアしqueueて実行します。メソッドには通常などのライフサイクル コールバックが保存されます。flushPostFlushCbsflushPostFlushCbsmountedupdated

キューへのタスクの追加

上で述べたようにqueuequeueタスクを追加するにはどうすればよいでしょうか?

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関数があります。これは更新する必要があるタスクですが、この関数は 、 などのいくつかの属性を展開idpreますactive

tsソース コードには1 対の型定義があります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の場合は、に設定されますwatchallowRecursetrue

これにより、再帰呼び出しの問題を回避できます。これは非常に賢い設計です。

queueJobwatchEffect最後のものはエクスポートされ、これは、watchなどのタスクを追加する他のモジュールに使用されます。

フラッシュポストフラッシュCBS

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呼び出されずqueueJobflushJobsメソッドも呼び出されないため、この時点ではタスク キューは更新されません。

しかし、resolvedPromiseこれは成功であるpromiseため、nextTick渡されたコールバック関数はマイクロタスク キューに配置され、実行を待機します。

nextTick1 つも返されるpromiseため、返されるコールバック関数もマイクロタスク キューに配置されますが、promiseコールバック関数thenよりも確実に遅れます。nextTick

次に、それを実行しますthis.count++。ここでは内部実装ロジックにはまだ触れていません。queueJobメソッドがトリガーされ、タスクがタスク キューに追加されることだけを知っておく必要があります。

最後に再度実行しましたがnextTick、この時点ではqueueすでにタスクが存在していたので、flushJobsタスクキュー内のタスクを順番に実行するメソッドが呼び出されています。

強調:currentFlushPromiseこの時点では値があり、その値はresolvedPromise実行完了後に返されますPromise

初回との違いは、初回実行時に;nextTick使用したことcurrentFlushPromiseです。undefinedresolvedPromise

nextTick初回実行時はflushJobsメソッド登録と同じタスクが使用されることが分かりますPromise

2 回目の実行ではnextTick、使用されるタスクはメソッドによって登録されたcurrentFlushPromiseタスクと同じではありませPromiseflushJobsPromise

これにより、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ため返されるのは単一のタスクであり、優先度が高くなりますcurrentFlushPromisepromisenextTickpromisethencurrentFlushPromise

このミッションが終了すると、次のミッションが行われますtick。プロセスは次のとおりです。

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

今回のタスクはコールバック関数を実行することでありcurrentFlushPromiseこれは戻り値が割り当てられるthen呼び出しでもありますflushJobsflushJobsresolvedPromisePromisecurrentFlushPromise

このミッションは終了し、最後のミッションですtick。プロセスは次のとおりです。

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

この時点でプロセスは終了です。このプロセスは非常に頭を使うものですが、理解した後は非常に賢いものであることがわかり、私の思考能力と非同期についての理解が大幅に向上しました。

要約する

この記事では主に実装原理を分析しますが、ソースコードを分析すると、実装原理が非常に巧妙であるVue3ことがわかりました。nextTicknextTick

nextTick実装原理はPromiseによって実装され、nextTickこれは 1 を返しPromisenextTickコールバック関数はマイクロタスク キューに配置され、実行を待機します。

タスクキューが存在する状態で登録した場合nextTicknextTickタスクキュー内のタスクが実行された後にコールバック関数が実行されます。

ここで使用されているアイデアは非常にシンプルで、Promise連鎖呼び出し機能を利用するというものです。誰もが日常の開発で使用したことがあるかもしれませんが、このような方法で使用されるとは予想していませんでした。これは本当に非常に賢いものです。

やっと

JS の面接でよく聞かれる 75 の質問をまとめ、その回答と分析を提供しているので、JS に関する面接官の質問に基本的に対処できることが保証されます。



必要な友達は下のカードをクリックして受け取り、無料で共有できます

おすすめ

転載: blog.csdn.net/web2022050901/article/details/129406826