banner
沈青川

旧巷馆子

愿我如长风,渡君行万里。
twitter
jike

❗多画像警告 · Vue はどのように Glitching avoidance を解決するのか?

グリッチ回避とは?#

今日では、Reactivity メカニズムに基づいてデータの変化を監視し処理する Web フロントエンドライブラリが多数存在します。リアクティブシステムには注意すべき問題があります。さっそく、以下のコードを見てみましょう:

const var1 = ref(1)
const var2 = computed(() => var1.value * 2)
const var3 = computed(() => var1.value + var2.value)

// それぞれ異なる effect 関数を使って実行し、コンソール出力を確認
effect(() => {
  console.log('@vue/reactiviy effect: var3 =', var3.value)
})
watchEffect(() => {
  console.log('@vue/runtime-core watchEffect: var3 = ', var3.value)
})

// それから var1 を変更します
var1.value = 2

上記のコードをオンラインの Playground で試してみました:

https://stackblitz.com/edit/typescript-y4kx5e?file=index.ts

出力を見てみましょう:

@vue/reactiviy effect: var3 = 3
@vue/reactiviy effect: var3 = 4
@vue/reactiviy effect: var3 = 6

@vue/runtime-core watchEffect: var3 = 3
@vue/runtime-core watchEffect: var3 = 6

effectwatchEffect はどちらも最初に一度実行されて関連する依存関係を取得することがわかります。したがって、結論としては:effect は 2 回実行され、watchEffect は 1 回だけ実行されました。

なぜ effect が 1 回多いのでしょうか?

これは、var3var1var2 の 2 つの依存関係を持っているためです。これらの依存関係のいずれかが変化すると、effect のコールバックが再実行されます。また、var2var1 の変化によって更新されます。

しかし、これらの 2 回の実行時の var1var2 の実際の値を見てみましょう:

  • var1 = 2, var2 = 2 (旧値) この実行は var1 が更新されたことによってトリガーされました
  • var1 = 2, var2 = 4 (新値) この実行は var2 が更新されたことによってトリガーされました

明らかに、この var2 が旧値のままであるのは予期しない結果です。この中間状態のプロセスは「フリッカー」(Glitching)と呼ばれます。

ページを作成しているときに、ある要素が初期状態に制御され、状態が開始時にすぐに更新され、その結果フリッカーが見られたことを思い出しましたか?

しかし、watchEffect を使用するとこの問題は解決されます。

watchEffecteffect に比べて何を追加で行っているのでしょうか?

ソースコードデバッグの旅#

デバッグ前に準備すること...#

優れたライブラリのコードをソースコードデバッグする最良の方法は、その単体テスト部分を見つけて、環境を整えたら楽しくテストを行うことです!

私は Vue リポジトリの runtime-coreapiWatch.spec.ts を開いて、単体テストを追加しました。

左側のテストパネルはどのようにして持っているのでしょうか?テストケースのタイトルに基づいて検索もできるなんて!素晴らしい!実際に使用しているのは Vitest の公式プラグイン ZixuanChen.vitest-explorer

image

私たちがデバッグして確認したいのは var1.value = 2 の「値が更新された」後の一連の副作用がトリガーされるプロセスですので、適切な位置にブレークポイントを設定する必要があります。(上の図のように)

ブレークポイントを設定したら、左側で デバッグを開始 をクリックできます:

image

第 1 章・副作用のトリガー#

デバッグを始めると、var1 の副作用がトリガーされることがわかります:

図 1 :Ref の値の更新が set によってインターセプトされ、その ref に依存する副作用をトリガーする

図 2 :ref に含まれるデータから依存関係 dep を読み出し、これらの依存関係をトリガーする

図 3 :最終的に副作用がトリガーされる位置

ここまで来たら、少し立ち止まって分析しましょう:

ここでトリガーされるのは「var1 を購読している」すべての副作用、つまり var2var3 の 2 つの computed の計算関数です。では、この for ループ内でどの effect なのかをどうやって特定するのでしょうか?まずは ReactiveEffect の定義を見てみましょう:

image

ReactiveEffect には public プロパティ fn があり、これは実際には対応する副作用関数です。デバッグコンソールで effect.fn を出力すると、この関数の元の文字列が表示され、その内容から誰に属するかを判断できます:

image

これは var3 の計算関数であることがわかります。つまり、今まさにこの計算関数を再実行して var3 の値を更新しようとしているのです。

分析はここまでにしましょう!🚄 旅を続けましょう!

image

正式に triggerEffect をトリガーする際、var3 という computed をトリガーしているため、ソースコードには「副作用に scheduler がある場合は、優先的にそれを呼び出す必要がある」と書かれています。

中に入ると、Computed は作成時に自分にバインドされた副作用にこのような scheduler を追加していることがわかります:

image

このスケジューラが行うことは、現在の計算プロパティが「汚れている」とマークすること😜、そして計算プロパティ自体もリアクティブ変数であり、他の場所からも購読される可能性があるため、「再計算」この更新プロセスもその副作用をトリガーします。(上の図のコード 47 行を参照)

間違いなく、私たちの単体テストコードの中で var3 を購読しているのは watchEffect の中の関数だけです。したがって、スケジューラに従って triggerRefValue を実行すると、再び多くの前述のステップを経ることになりますので、詳細は省略します。

重要な「チェックポイント」は triggerEffects の 2 つの for ループのところです。ここでは 配列 形式の副作用リストを見ることができます。

image

この図には多くの情報がありますので、まとめてみましょう:

  • ソースコードをデバッグする際は、左側の関数呼び出しスタックフレームを見ることを学ぶ必要があります。多くの複雑なライブラリの実行中に、一部のロジックが再帰的または繰り返し実行されることがあるため、その時に自分がどこにいるのか、現在の実行が何のために行われているのかを知っておく必要があります。方向を見失わないようにしましょう。現在、私たちはまだ var1.value = 2 の「set プロセスで副作用をトリガーする」同期タスクにいます。
  • 再度 effect.fn を出力して、私たちが単体テストの watchEffect に書いた関数であるか確認しようとしたところ、こんなに大きな塊が出てきました。これは何でしょうか?ざっと見たところ、エラーハンドリングを伴う call... にラップされているようです。この疑問を持ったまま、さらに進んでいきましょう。
  • 2 番目の for ループに入ると、今回の effect は computed ではないことを意味します。実際、これは watchEffect の中の関数であることを間接的に証明しています。

第 2 章・watchEffect を理解する#

triggerEffect に入ると、再び この副作用にはスケジューラがある ことに気づきます。スケジューラに入ると、doWatch という関数に入ります:

doWatch 関数の全体像

ここで私たちはこの旅の 2 回目のブレーキをかける必要があります!この関数はかなり複雑で、多くのロジックがあるように見えますが、心配しないでください。

🫵 ソースコードを読む際には、主幹をつかむ必要があります。上の図では、私たちの研究にとって重要でない部分を折りたたんで、現在デバッグが実行されている行までのスクリーンショットを撮りました。私たちが主に注目する必要があるのは、310 行から始まるこの job です。

345 - 347 行の部分では、ソースコードのコメントが明確に watchEffect がこのパスをたどることを示しています。

さらに、画像の長さを制御するために、上の図の 230 行を特に展開する必要があります。209 - 252 行のこの if-else 文が判断する source は、文字通り watch のターゲット、つまり私たちが watchEffect に渡した関数です。したがって、入るのは isFunction の分岐です:

展開すると、目が覚めるような光景が広がります 👀🌟:

image

この getter のコードは見覚えがありますね?これは私たちが先ほど effect.fn を出力したときにどこから来たのかわからなかったコードではありませんか!

doWatch 関数の 367 行(上記の「doWatch 関数の全体像」のすぐ下)で副作用が作成されているのが見えます。構造体の 2 つのパラメータは、先ほどの getter と、デバッグ実行中にハイライトされた行で作成された scheduler です:

image

ReactiveEffect の構造体の 2 つのパラメータはそれぞれ:

  1. 副作用関数そのもの
  2. 副作用のスケジューラ

ここまで来ると、まだ混乱しているかもしれません。私たちはこれだけの情報を得たのに、watchEffect との関係が理解できないようです。むしろ、心の中の疑問が増えているかもしれません... 焦らないで、深呼吸して、この複雑な doWatchwatchEffect の関係を見てみましょう:

image

実は doWatchwatchEffect の実装なのです!ただし、apiWatch.ts というファイルでは、doWatch はここだけでなく、私たちがよく知っている watch という API も実装しています。区別するのは、watchEffect はコールバック cb を必要としないことです(上の図のように、2 番目のパラメータには null が渡されています)。

したがって、整理すると、私たちが出力した effect.fn が私たちが渡した関数そのものではない理由は、doWatch でいくつかの追加処理がラップされているからです。この疑問は解決しました!旅を続けましょう〜 🏄🏻‍♂️

第 3 章・明るい展望#

今、私たちは watchEffect に渡された副作用関数が queueJob にプッシュされているようだと推測できます。では、見てみましょう:

image

このソースコードをじっくり読むと、次の結論が得られます:キューが空であるか、またはキューに現在追加しようとしている job が含まれていない場合、キューにプッシュされます。

次に、実行される queueFlush() を見てみましょう:

image

ここには、どこで定義されたのかわからない変数がたくさんあり、上の queue もそうです。ファイルの上部をめくると、これらはすべてトップレベルで直接定義されていることがわかります:

image

プロセスをより核心に集中させるために、これらの変数の役割を一つ一つ探求する必要はありません。主幹を手がかりにしなければなりません。さもなければ、脱線してしまいます 😱!

現在実行中の 105 行は非常に興味深い操作で、flushJobs という関数を Promise.then に置いています。

イベントループの原理に慣れている方は、これはこの関数を マイクロタスクキュー に置くことを意味することが理解できるでしょう。現在の Tick で各マクロタスクが完了した後、マイクロタスクキュー内のタスクが実行されます。この部分の知識に疑問がある場合は、まずここで基礎を補完してください:https://zh.javascript.info/event-loop。 Vue がこのように設計した目的は、リスニングオブジェクトによって引き起こされる副作用が主なレンダリングプロセスをブロックしないようにするためです。

flushJobs の内容は Vue のソースコードで自分で調べることができますが、特に言うことはありません。キュー内の関数を順番に実行するだけですので、ここでは詳しく説明しません。

ここまで来ると、「var1 の変化が var3 の再計算を引き起こす」この副作用はキューにプッシュされましたが、現在のユーザーコードの同期プロセスではまだ実行されていません。次に見るべきは「var1 の変化が var2 の再計算を引き起こす」ことです。

第 2 の effect:var1 の変化が var2 の再計算を引き起こす

その後のプロセスは、私たちが先ほど見たステップを繰り返します:Computed が「汚れている」とマークされ、自身の副作用がトリガーされます。var2var3 に依存しているため、次に進むと var3scheduler をトリガーします:

image

しかし、var3 はすでに「汚れている」とマークされているため、var3 の関連する副作用関数は再度トリガーされません。そして、実行を続けると、関数スタックフレームが上昇し始め、var1 を 2 に更新することによって引き起こされたこの一連の副作用トリガーのプロセスが終了します。

テストでブラウザのイベントループをシミュレートするために、現在の Tick のマクロタスクが終了して次の Tick のマクロタスクに移る間のプロセス(つまり、マイクロタスクキューを実行するため)に、Vue の単体テストでは大量の await nextTick() が使用されています。私たちも同様に実行します:

image

マイクロタスクキューの実行に入ると、関数スタックフレームは Promise.then で区切られており、以前の同期タスクの各スタックフレーム内の変数値を確認することはできなくなります:

image

この時点で、私たちのキューには 1 つの job しかないため、最初の実行を加えると、watchEffect の副作用は合計で 2 回しか実行されず、3 回ではありません。

したがって、結論は次のとおりです:Vue は副作用の応答による値のフリッカーを回避する方法として、スケジューラを追加し、副作用関数をキューに収集し、実行をマイクロタスク段階に移します。

さらに...#

実際、「フリッカー回避」はプッシュベースのリアクティブデータシステムが直面し、解決する必要がある問題です。この部分のリアクティブデータシステムに関するより深い知識に興味がある場合、以下の文献を参考にしてください:

計算プロパティのこの遅延効果 + レイジー計算により、グリッチ回避が実現されます。なぜ両者の組み合わせがグリッチ回避を実現できるのでしょうか?

それは、フロントエンドのリアクティブフレームワークには、リアクティブデータに対する 2 種類のリスニングしか存在しないからです:

  1. 反応、つまり副作用の実行。
  2. 派生、つまり新しいリアクティブデータの生成。

このうち、反応同士は互いに依存してはならず、反応が遅延実行されると、すべての派生が「更新が必要」とマークされます。派生値を再計算する際には、依存ツリーに沿って上流の最新値を計算し終えれば、常に最新の上流値を取得でき、グリッチ回避が実現されます。

計算プロパティのレイジー実行は、プルモデルを参考にしており、最終的に必要なときに「引き出す」ことができると理解できます。

私たちの例に戻ると、実際には var3 = 6var2 = 4watchEffect の副作用関数が 2 回目に実行されるときに、下の図の self.effect.run() によって再計算されます。ComputedRefImplget value() の実装を見てみましょう:

image

以上がグリッチ回避に関するすべての内容です。ここまで読んでいただきありがとうございます。もし何か得るものがあったなら、ぜひ小さな手を動かしていいねを押してください!

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。