グリッチ回避とは?#
今日では、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
effect
と watchEffect
はどちらも最初に一度実行されて関連する依存関係を取得することがわかります。したがって、結論としては:effect
は 2 回実行され、watchEffect
は 1 回だけ実行されました。
なぜ effect
が 1 回多いのでしょうか?
これは、var3
が var1
と var2
の 2 つの依存関係を持っているためです。これらの依存関係のいずれかが変化すると、effect
のコールバックが再実行されます。また、var2
も var1
の変化によって更新されます。
しかし、これらの 2 回の実行時の var1
と var2
の実際の値を見てみましょう:
var1
= 2,var2
= 2 (旧値) この実行は var1 が更新されたことによってトリガーされましたvar1
= 2,var2
= 4 (新値) この実行は var2 が更新されたことによってトリガーされました
明らかに、この var2
が旧値のままであるのは予期しない結果です。この中間状態のプロセスは「フリッカー」(Glitching)と呼ばれます。
ページを作成しているときに、ある要素が初期状態に制御され、状態が開始時にすぐに更新され、その結果フリッカーが見られたことを思い出しましたか?
しかし、watchEffect
を使用するとこの問題は解決されます。
watchEffect
は effect
に比べて何を追加で行っているのでしょうか?
ソースコードデバッグの旅#
デバッグ前に準備すること...#
優れたライブラリのコードをソースコードデバッグする最良の方法は、その単体テスト部分を見つけて、環境を整えたら楽しくテストを行うことです!
私は Vue リポジトリの runtime-core
の apiWatch.spec.ts
を開いて、単体テストを追加しました。
左側のテストパネルはどのようにして持っているのでしょうか?テストケースのタイトルに基づいて検索もできるなんて!素晴らしい!実際に使用しているのは Vitest の公式プラグイン ZixuanChen.vitest-explorer
私たちがデバッグして確認したいのは var1.value = 2
の「値が更新された」後の一連の副作用がトリガーされるプロセスですので、適切な位置にブレークポイントを設定する必要があります。(上の図のように)
ブレークポイントを設定したら、左側で デバッグを開始 をクリックできます:
第 1 章・副作用のトリガー#
デバッグを始めると、var1
の副作用がトリガーされることがわかります:
ここまで来たら、少し立ち止まって分析しましょう:
ここでトリガーされるのは「var1
を購読している」すべての副作用、つまり var2
と var3
の 2 つの computed の計算関数です。では、この for ループ内でどの effect なのかをどうやって特定するのでしょうか?まずは ReactiveEffect
の定義を見てみましょう:
ReactiveEffect
には public
プロパティ fn
があり、これは実際には対応する副作用関数です。デバッグコンソールで effect.fn
を出力すると、この関数の元の文字列が表示され、その内容から誰に属するかを判断できます:
これは var3
の計算関数であることがわかります。つまり、今まさにこの計算関数を再実行して var3
の値を更新しようとしているのです。
分析はここまでにしましょう!🚄 旅を続けましょう!
正式に triggerEffect
をトリガーする際、var3
という computed をトリガーしているため、ソースコードには「副作用に scheduler
がある場合は、優先的にそれを呼び出す必要がある」と書かれています。
中に入ると、Computed は作成時に自分にバインドされた副作用にこのような scheduler
を追加していることがわかります:
このスケジューラが行うことは、現在の計算プロパティが「汚れている」とマークすること😜、そして計算プロパティ自体もリアクティブ変数であり、他の場所からも購読される可能性があるため、「再計算」この更新プロセスもその副作用をトリガーします。(上の図のコード 47 行を参照)
間違いなく、私たちの単体テストコードの中で var3
を購読しているのは watchEffect
の中の関数だけです。したがって、スケジューラに従って triggerRefValue
を実行すると、再び多くの前述のステップを経ることになりますので、詳細は省略します。
重要な「チェックポイント」は triggerEffects
の 2 つの for ループのところです。ここでは 配列 形式の副作用リストを見ることができます。
この図には多くの情報がありますので、まとめてみましょう:
- ソースコードをデバッグする際は、左側の関数呼び出しスタックフレームを見ることを学ぶ必要があります。多くの複雑なライブラリの実行中に、一部のロジックが再帰的または繰り返し実行されることがあるため、その時に自分がどこにいるのか、現在の実行が何のために行われているのかを知っておく必要があります。方向を見失わないようにしましょう。現在、私たちはまだ
var1.value = 2
の「set プロセスで副作用をトリガーする」同期タスクにいます。 - 再度
effect.fn
を出力して、私たちが単体テストのwatchEffect
に書いた関数であるか確認しようとしたところ、こんなに大きな塊が出てきました。これは何でしょうか?ざっと見たところ、エラーハンドリングを伴うcall...
にラップされているようです。この疑問を持ったまま、さらに進んでいきましょう。 - 2 番目の for ループに入ると、今回の
effect
は computed ではないことを意味します。実際、これはwatchEffect
の中の関数であることを間接的に証明しています。
第 2 章・watchEffect を理解する#
triggerEffect
に入ると、再び この副作用にはスケジューラがある ことに気づきます。スケジューラに入ると、doWatch
という関数に入ります:
ここで私たちはこの旅の 2 回目のブレーキをかける必要があります!この関数はかなり複雑で、多くのロジックがあるように見えますが、心配しないでください。
🫵 ソースコードを読む際には、主幹をつかむ必要があります。上の図では、私たちの研究にとって重要でない部分を折りたたんで、現在デバッグが実行されている行までのスクリーンショットを撮りました。私たちが主に注目する必要があるのは、310 行から始まるこの job
です。
345 - 347 行の部分では、ソースコードのコメントが明確に watchEffect
がこのパスをたどることを示しています。
さらに、画像の長さを制御するために、上の図の 230 行を特に展開する必要があります。209 - 252 行のこの if-else
文が判断する source は、文字通り watch のターゲット、つまり私たちが watchEffect
に渡した関数です。したがって、入るのは isFunction
の分岐です:
展開すると、目が覚めるような光景が広がります 👀🌟:
この getter
のコードは見覚えがありますね?これは私たちが先ほど effect.fn
を出力したときにどこから来たのかわからなかったコードではありませんか!
doWatch
関数の 367 行(上記の「doWatch 関数の全体像」のすぐ下)で副作用が作成されているのが見えます。構造体の 2 つのパラメータは、先ほどの getter
と、デバッグ実行中にハイライトされた行で作成された scheduler
です:
ReactiveEffect の構造体の 2 つのパラメータはそれぞれ:
- 副作用関数そのもの
- 副作用のスケジューラ
ここまで来ると、まだ混乱しているかもしれません。私たちはこれだけの情報を得たのに、watchEffect
との関係が理解できないようです。むしろ、心の中の疑問が増えているかもしれません... 焦らないで、深呼吸して、この複雑な doWatch
と watchEffect
の関係を見てみましょう:
実は doWatch
は watchEffect
の実装なのです!ただし、apiWatch.ts
というファイルでは、doWatch
はここだけでなく、私たちがよく知っている watch
という API も実装しています。区別するのは、watchEffect
はコールバック cb
を必要としないことです(上の図のように、2 番目のパラメータには null
が渡されています)。
したがって、整理すると、私たちが出力した effect.fn
が私たちが渡した関数そのものではない理由は、doWatch
でいくつかの追加処理がラップされているからです。この疑問は解決しました!旅を続けましょう〜 🏄🏻♂️
第 3 章・明るい展望#
今、私たちは watchEffect
に渡された副作用関数が queueJob
にプッシュされているようだと推測できます。では、見てみましょう:
このソースコードをじっくり読むと、次の結論が得られます:キューが空であるか、またはキューに現在追加しようとしている job
が含まれていない場合、キューにプッシュされます。
次に、実行される queueFlush()
を見てみましょう:
ここには、どこで定義されたのかわからない変数がたくさんあり、上の queue
もそうです。ファイルの上部をめくると、これらはすべてトップレベルで直接定義されていることがわかります:
プロセスをより核心に集中させるために、これらの変数の役割を一つ一つ探求する必要はありません。主幹を手がかりにしなければなりません。さもなければ、脱線してしまいます 😱!
現在実行中の 105 行は非常に興味深い操作で、flushJobs
という関数を Promise.then
に置いています。
イベントループの原理に慣れている方は、これはこの関数を マイクロタスクキュー に置くことを意味することが理解できるでしょう。現在の Tick で各マクロタスクが完了した後、マイクロタスクキュー内のタスクが実行されます。この部分の知識に疑問がある場合は、まずここで基礎を補完してください:https://zh.javascript.info/event-loop。 Vue がこのように設計した目的は、リスニングオブジェクトによって引き起こされる副作用が主なレンダリングプロセスをブロックしないようにするためです。
flushJobs
の内容は Vue のソースコードで自分で調べることができますが、特に言うことはありません。キュー内の関数を順番に実行するだけですので、ここでは詳しく説明しません。
ここまで来ると、「var1
の変化が var3
の再計算を引き起こす」この副作用はキューにプッシュされましたが、現在のユーザーコードの同期プロセスではまだ実行されていません。次に見るべきは「var1
の変化が var2
の再計算を引き起こす」ことです。
その後のプロセスは、私たちが先ほど見たステップを繰り返します:Computed が「汚れている」とマークされ、自身の副作用がトリガーされます。var2
も var3
に依存しているため、次に進むと var3
の scheduler
をトリガーします:
しかし、var3
はすでに「汚れている」とマークされているため、var3
の関連する副作用関数は再度トリガーされません。そして、実行を続けると、関数スタックフレームが上昇し始め、var1
を 2 に更新することによって引き起こされたこの一連の副作用トリガーのプロセスが終了します。
テストでブラウザのイベントループをシミュレートするために、現在の Tick のマクロタスクが終了して次の Tick のマクロタスクに移る間のプロセス(つまり、マイクロタスクキューを実行するため)に、Vue の単体テストでは大量の await nextTick()
が使用されています。私たちも同様に実行します:
マイクロタスクキューの実行に入ると、関数スタックフレームは Promise.then
で区切られており、以前の同期タスクの各スタックフレーム内の変数値を確認することはできなくなります:
この時点で、私たちのキューには 1 つの job
しかないため、最初の実行を加えると、watchEffect
の副作用は合計で 2 回しか実行されず、3 回ではありません。
したがって、結論は次のとおりです:Vue は副作用の応答による値のフリッカーを回避する方法として、スケジューラを追加し、副作用関数をキューに収集し、実行をマイクロタスク段階に移します。
さらに...#
実際、「フリッカー回避」はプッシュベースのリアクティブデータシステムが直面し、解決する必要がある問題です。この部分のリアクティブデータシステムに関するより深い知識に興味がある場合、以下の文献を参考にしてください:
- リアクティブプログラミングに関する調査 - https://blog.acolyer.org/2015/12/08/a-survey-on-reactive-programming
- リアクティブプログラミングのバックプレッシャーの紹介 - Pull と Push モードの組み合わせ - https://juejin.cn/post/7138241196744736799
計算プロパティのこの遅延効果 + レイジー計算により、グリッチ回避が実現されます。なぜ両者の組み合わせがグリッチ回避を実現できるのでしょうか?
それは、フロントエンドのリアクティブフレームワークには、リアクティブデータに対する 2 種類のリスニングしか存在しないからです:
- 反応、つまり副作用の実行。
- 派生、つまり新しいリアクティブデータの生成。
このうち、反応同士は互いに依存してはならず、反応が遅延実行されると、すべての派生が「更新が必要」とマークされます。派生値を再計算する際には、依存ツリーに沿って上流の最新値を計算し終えれば、常に最新の上流値を取得でき、グリッチ回避が実現されます。
計算プロパティのレイジー実行は、プルモデルを参考にしており、最終的に必要なときに「引き出す」ことができると理解できます。
私たちの例に戻ると、実際には var3 = 6
、var2 = 4
は watchEffect
の副作用関数が 2 回目に実行されるときに、下の図の self.effect.run()
によって再計算されます。ComputedRefImpl
の get value()
の実装を見てみましょう:
以上がグリッチ回避に関するすべての内容です。ここまで読んでいただきありがとうございます。もし何か得るものがあったなら、ぜひ小さな手を動かしていいねを押してください!