反応コンポーネントの正しいオブザーバーパターンを調査および実装します







そのため、オブザーバーパターンの開発を続けています。 以前の非常に単純なオブザーバーパターンからの記事では、小さなステップでmobxにアクセスし、そのミニバージョンを作成しました。 この記事では、不要な計算を回避するために、正しい順序で依存関係更新アルゴリズムを実装するmobxの完全版を作成します。 ハブに関するこのアルゴリズムを説明しようとする試みは、 ここここ 、およびここでのアトムに関するビンテージ同志の記事で以前に行われたと言わなければなりません。







そのため、前回の記事では、反応のコンポーネントがレンダリングするデータを自動的にサブスクライブし、変更時に必要なコンポーネントのみが呼び出されるように、オブザーバーパターンのこのような修正を思い付きました。







let CurrentObservables = null; class Observable { listeners = new Set(); constructor(value){ this.value = value } get(){ if(CurrentObservables) CurrentObservables.add(this); return this.value; } set(newValue){ if(newValue !== this.value){ this.notify(); } } subscribe(listener){ this.listeners.add(listener) } unsubscribe(listener){ this.listeners.delete(listener) } notify(){ for(const listener of this.listeners){ listener(); } } } function connect(target){ return class extends (React.Component.isPrototypeOf(target) ? target : React.Component) { stores = new Set(); listener = ()=> this.setState({}) render(){ this.stores.forEach(store=>store.unsubscribe(this.listener)); this.stores.clear(); const prevObservables = CurrentObservables; CurrentObservables = this.stores; cosnt rendered = target instanceof React.Component ? super.render() : target(this.props); this.stores = CurrentObservables; CurrentObservables = prevObservables; this.stores.forEach(store=>store.subscribe(this.listener)); return rendered; } componentWillUnmount(){ this.stores.forEach(store=>store.unsubscribe(this.listener)); } } }
      
      





少しリファクタリングしましょう-オブザーバ自体の内部でグローバル配列を設定するロジックを取り出します。 これは、たとえば、Google Doxのテーブルセルとして表すことができます-値を単に格納するセルがあり、値(キャッシュされる)だけでなく、その変換のための式(関数)も格納するセルがあります。 同時に、再計算関数の式に加えて、値を変更するときにコンポーネントでsetState({})を呼び出すなど、副作用を実行するための別の関数パラメーターを追加します。 その結果、そのようなクラスのセルを取得します







 let CurrentObserver = null class Cell { reactions = new Set(); dependencies = new Set(); tracked = false; constructor(value, fn = null, reactionFn = null) { this.value = value; this.fn = fn; this.reactionFn = reactionFn } get() { if (this.fn && !this.tracked) this.run(); if (CurrentObserver) { this.reactions.add(CurrentObserver); CurrentObserver.dependencies.add(this); } return this.value; } set(newValue) { if (newValue !== this.value) { this.value = newValue; for (const reaction of this.reactions) { reaction.run(); } return true; } else { return false; } } run() { if(!this.fn) return; const currentObserver = CurrentObserver; CurrentObserver = this; const oldDependencies = this.dependencies; this.dependencies = new Set(); const newValue = this.fn(); CurrentObserver = currentObserver; for(const dep of oldDependencies){ if(!this.dependencies.has(dep)){ dep.reactions.delete(this); } } const changed = this.set(newValue); if (changed && this.tracked && this.reactionFn){ const currentObserver = CurrentObserver; CurrentObserver = null; this.reactionFn(); CurrentObserver = currentObserver; } this.tracked = true; } unsubscribe(){ for(const dep of this.dependencies){ dep.reactions.delete(this); } this.tracked = false; } } function connect(target){ return class extends (React.Component.isPrototypeOf(target) ? target : React.Component) { constructor(...args){ super(...args); this._cell = new Cell(null, ()=>{ return React.Component.isPrototypeOf(target) ? super.render() : target(this.props); }, ()=>{ this.forceUpdate(); //  setState({})  forceUpdate()           }); } render(){ return this._cell.get(); } componentWillUnmount(){ this._cell.unsubscribe(); } } }
      
      





次に、サーバーの更新モードを確認しましょう。 上記の例では、すべてのアクティブなサーバーがまだあります。最初に.get



後、依存関係にサブスクライブし、何らかの依存関係の値が変更されるたびに呼び出されます。 このモードは、サブスクライブ先のデータが変更されるたびに更新する必要があるコンポーネントに便利ですが、そのような動作が望ましくないいわゆる「キャッシュ」または「メモ」機能があります。 たとえば、オブザーバconst fullName = new Cell(()=>firstName.get() + lastName.get())



あり、名前または姓が変更されたときにフルネームを計算する必要があります。 しかし、アプリケーションでfullNameに計算された後、特定の条件下で連絡する必要がない場合はどうでしょうか? 不要な計算を取得し、これを回避するために、コンポーネントがすぐに計算されないようにします。ただし、アドレス指定されていない場合のみ.get()



呼び出されます。







通常、追加の計算は、「テーブル内のセルと数式」モデルに基づいてライブラリを比較する際の重要なポイントです。 ダイアモンド型の依存関係図の場合(依存関係グラフにサイクルが存在する場合)、値が変更された後に呼び出す必要がある依存関係を決定するアルゴリズムが正しくない場合(上記の例のように)







この状況を見てみましょうfirstName



つのセルがありますfirstName



lastName



fullName



(フルネームを計算する)およびlabel



(名前が長い場合はフルネームを表示します)







 const firstName = new Cell("fff"); const lastName = new Cell("lll"); const fullName = new Cell("", ()=>firstName.get() + " " + lastName.get()); const label = new Cell("", ()=>firstName.get().length <= 3 ? fullName.get() : firstName.get()))
      
      





ここでfullName



依存関係の最も単純なバリアントは、 fullName



firstName



に依存し、 label



fullName



依存することですが、 label



firstName



にも依存し、ループのようになります。

プロセスでは、 fullName



値の再計算のみに関心があることを明確にする必要があります(たとえば、コンポーネントでそれをレンダリングする必要があります)。したがって、 label



fullName



値を突然計算する必要がない場合は、計算しないでください。







そして、ここに最初のバグがありfirstName



変更するときfirstName



実装で、ループ内のサブスクライバーを呼び出すとき、 label



コンポーネントは2回評価されfirstName



は直接署名されているためlabel



を呼び出し、2回目label



fullName



が値を変更するときにlabel



計算されます。 最初のlabel



計算には、一時データ(新しい名前と古いfullName



が含まれるため、必要ありません。 したがって、不必要な計算を取り除く必要があります。これを行うには、サブスクライバを正しい順序で呼び出す必要があります。最初にfullName



、次にlabel



呼び出します。







どうすればこれができますか? 考えてみると、いくつかのオプションがあります。







1つのオプションは「dirty-clean」メソッドです。これは、mobxがmobxデバイスについての講演( https://www.youtube.com/watch?v=TfxfRkNCnmk )で説明しています(mobx自体が嘘をついていないため、作者が実際に嘘をついたのは面白いことです)これはより正しいアルゴリズムですが、それについては後で詳しく説明します。







要するに、アルゴリズムは、依存関係グラフに沿って関数呼び出しを配布し、増分-減分カウンターを介して各依存関係の「深さ」値を特定し、深さの順に呼び出します。 名前を変更するときに、 firstName



セルがループ内のサブスクライバーをすぐに呼び出すのではなく、各リスナー内で値1を設定し、呼び出して、全員が自分のサブスクライバーの値を自分の値よりも1大きく設定するとします。 そして、再帰的に。 fullName



セルは値1を取得し、 fullName



は値2を取得します。これは、カウンターが最初にfirstName



セルによって、次にfullName



セルによって切断されたためです。 現在、再帰呼び出しが終了した後、 fistName



セルは逆の手順を呼び出します。サブスクライバー間でカウンターを再帰的に減らします。 そして今-カウンター減少コードが呼び出された後、値がゼロに戻ったかどうかを確認する必要があります。その場合のみ、セルを再計算する必要があります。 したがって、 label



カウンターは2から1に減少しますが(0ではないため計算されません)、 fullName



カウンターは1から0に減少し、 fullName



計算されます。計算後のfullName



によりlabel



カウンターが1から0にlabel



ため、 label



自体が計算されます。







したがって、すべての依存セルが更新され、現在の値になった後、 label



計算は1回だけ行われました。







別のオプション(実際は最初のバージョンの最適化バージョン)は、サブスクライバーに電話して深さを増やすことです。 セルの深さの下で、依存セルの最大深さ値+ 1を取得し、依存関係のない数式のないセルの深さは0になりますfirstName



lastName



の値は0、 fullName



の値は1、 label



の値は2になりますサブスクライバー( fullName



およびfirstName



)の場合は1で、+ 1を行うと2が得られます。







fistName



セルが値を更新するとき、深さの順にサブスクライバーを呼び出す必要があります-最初はfullName



、次にlabel



です。 配列は、呼び出されるたびにソートすることも、新しい依存関係が追加されたときに最適化してソート済み配列に貼り付けることもできます。







深度値は、新しいサブスクライバーが追加されるたびに、その値を現在のセル値と比較して更新する必要もあります。







したがって、サブスクライバーの呼び出しを正しい順序で受け取り、不要な計算を回避します。 ほぼ...







どちらの場合も、非常に目立たないバグが1つあります。 firstName



数式は、 firstName



fullName



依存するだけでなく、特定の条件下でそれらに依存します。 firstName.get().length <= 3



の値の場合はfullName



を表示しますが、値が3より大きい場合はfirstName



のみに依存しfirstName



firstName



値が4から3に変更されたときに何が起こるか考えてみましょうfirstName



セルは値を更新し、深さ順にサブスクライバーを呼び出す必要があります。まず、値を計算するfullName



呼び出しがあり、次に実際のfullName



値を持つ値を計算するlabel



呼び出しがあります。 一見、すべてが正しいようです。 しかし、考えてみると、ここではfullName



計算は実際には必要ありませんfistName



の値は3になるため、 label



最後に呼び出されたときに、ifブランチが失敗するため、 fullName.get()



を呼び出す必要はありません。 さらに、次回fullName



を呼び出す必要がある場合、lastNameはその間で何度でも更新できるためfullName



その値は無関係です。 ここに、余分な計算のバグがあります。 その結果、深さの順にサブスクライバを呼び出すというアルゴリズムは、一般的なケースでは機能しません。







したがって、非常に「正しい」アルゴリズムがあります。このアルゴリズムは、状況や複雑な依存関係のもとでは二重セル計算を引き起こしません。 始めに、組み合わせて、mobxのほぼ完全なバージョン(配列とデコレーターを除く)であるコードを提供します。コードは85行のみです。







 class Cell { reactions = new Set(); dependencies = new Set(); runned = false; constructor(value, fn = null, reactionFn = null, active = false) { this.value = value; this.fn = fn; this.reactionFn = reactionFn; this.state = fn ? "dirty" : "actual"; } get() { if (this.state !== "actual") this.actualize(); if (CurrentObserver) { this.reactions.add(CurrentObserver); CurrentObserver.dependencies.add(this); } return this.value; } set(newValue) { if (newValue !== this.value) { this.value = newValue; for (const reaction of this.reactions) { reaction.mark(true); } runPendingCells() return true; } else { return false; } } mark(dirty = false) { this.state = dirty ? "dirty" : "check"; for (const reaction of this.reactions) { if(reaction.state === "actual") reaction.mark(); } if (this.active) PendingCells.push(this); } actualize() { if (this.state === "check") { for (const dep of this.dependencies) { if(this.state === "dirty") break; dep.actualize(); } if(this.state === "dirty"){ this.run(); } else { this.state = "actual" } } else if(this.state === "dirty"){ this.run(); } } run() { if (!this.fn) return; const currentObserver = CurrentObserver; CurrentObserver = this; const oldDependencies = this.dependencies; this.dependencies = new Set(); const newValue = this.fn(); CurrentObserver = currentObserver; for (const dep of oldDependencies) { if (!this.dependencies.has(dep)) dep.reactions.delete(this); } const changed = this.set(newValue); this.state = "actual"; if (changed && this.reactionFn) { const currentObserver = CurrentObserver; CurrentObserver = null; this.reactionFn(!this.runned); if(!this.runned) this.runned = true; CurrentObserver = currentObserver; } } unsubscribe() { for (const dep of this.dependencies) { dep.reactions.delete(this); if(dep.reactions.size === 0) dep.unsubscribe(); } this.state = "dirty"; } } function runPendingCells() { for (const cell of PendingCells) { cell.actualize(); } }
      
      





そして今、説明:

セルに3つの状態を持たせます-「実際」(式の値が関連することを意味します)、「ダーティ」( get()



呼び出されるとすぐにセルが再計算されることを意味します)、および「チェック」。 これで、セルの値が変更されるとすぐに、サブスクライバーが任意の順序ですぐに計算されるのではなく、サブスクライバーが「ダーティ」としてマークされます。 そして、それらは順番に、サブスクライバーに「check」値のみをマークし、順番に、サブスクライバーにも「check」値をマークし、最後まで再帰的にマークします。 つまり、変更されたセルのサブスクライバーだけが「ダーティ」の値を持ち、ツリーの最後までのすべての値が「check」の値を持ちます。したがって、再帰呼び出し中にサイクルに入れないで、まだマークされていないセルのみに対して再帰を呼び出す必要があります(値が「実際の ")。







さらに、ツリーの最後に到達すると、つまり、サブスクライバーがもはや存在せず、「アクティブ」なセルは、そのようなセルをグローバルなPendingCells



配列に追加する必要があります。 「アクティブ」とは、メモ化された機能(現時点ではその値は必要ないかもしれません)ではなく、依存セルのいずれかが値を変更するたびに起動されるリアクション(リアクションのコンポーネントなど)を表すセルです。







その結果、セルの値が変更され、サブスクライバーのこの再帰プロセスが発生した場合、 PendingCells



グローバル配列にいくつかのPendingCells



がありますが、これらは依存関係はありませんが、直接または間接的に依存するか、または再カウントされます(すべての中間チェーン内のセルの値は変更されます)または変更されません(このチェーン内の誰かが再計算しても値が変更されない場合)







次に、第2段階に進みます。 サブスクライバーの再帰プロセスを変更して呼び出したセルは、特定のグローバル関数flush()



を呼び出します。この関数は、グローバルPendingCells



配列に蓄積されたセルをPendingCells



し、それらのactualize()



関数を呼び出します。 この関数は再帰的であり、これを行います-セル値が「ダーティ」の場合、その式を再計算します(そして、「ダーティ」の値は、変更されたセルの直接サブスクライバーであり、残りはすべてツリーの最後までであることを覚えています「チェック」の意味があります)。 値が「チェック」の場合、セルはその依存セルの更新を要求しactualize()



メソッドを呼び出します)、その後、値を再度チェックし、「チェック」の場合、値を「実際」に変更し、再計算を呼び出さない場合汚れている場合は、それに応じて再計算を呼び出す必要があります。 同時に、セルが「ダーティ」に設定されている場合、他のセルを更新しても意味がなく、すぐにサイクルを中断して再計算できるため、各依存セルで「actualize()」を呼び出した後に「ダーティ」をチェックする必要があります。 そして、他のセルが更新されていないという事実は重要ではありません。セルにアクセスして.get()



メソッドで式の値を取得する場合、セルはその値を確認する必要があり、「check」の場合、このactualize()



メソッドを呼び出す必要があるためです「ダーティ」の場合、再計算を適切に実行します。 以上でアルゴリズムの終わりです。







したがって、アルゴリズムは一見複雑に見えるかもしれませんが、非常に簡単です-セルの値が変わると、2段階しかありません-最初の段階は、ダーティとマークする再帰降下であり(最初のレベル)、他のすべての人をチェックし、2番目の段階は再帰的な上昇ですこれは値の実現です。







今、私はいくつかの明白でない点を明確にします。







まず、不必要な再計算によるそのバグの回避はどのように発生しますか? これは、変更されたセル内の依存セルの再計算を引き起こす厳密な条件がないためです。 従属セルはダーティとしてマークされ、それだけです-どこかで値を知る必要がある場合にのみ計算されます。 つまり、バグのある例では、 fullName



セルは単に「ダーティ」とマークされ、 label



で条件firstName.get().length === 3



満たされるため、その値を計算する必要はありませんfirstName.get().length === 3



で、 label



fullName



依存しなくなります。







2番目-なぜこのような奇妙なアクションactualize()



メソッド内actualize()



値が「check」の場合、依存セルでactualize()



を呼び出し、ループ中およびループ後に再び値を確認し、「dirty」でサイクルを中断し、再計算を引き起こし、「チェック」がサイクルの後に「実際」にリセットされ、何もしない場合 問題は、依存セルでactualize()



を呼び出すプロセスで、それらの一部が値「ダーティ」を持ち、私たちが知っているように、再計算を実行する必要があることです。 計算時には条件があります-セルの値が変更された場合、リスナーを「ダーティ」としてマークする必要があります。 したがって、以前に「チェック」されていたセルは、その依存セルを更新した後、セルのいずれかが変更されたときに値を変更できるため、条件を再度チェックする必要があります。 ただし、この場合にのみ、依存するセルの値が変更されていない場合、セル自体を計算する意味がないことを意味し、値を「check」から「actual」に変更します。







さて、この例でこのアルゴリズムの動作をバグでチェックできます。 fullName



label



ような「アクティブな」セルでfullName



なく、その値を単にメモし、アクセスされたときにのみ更新されるため、FirstNameの変更、 label



およびfullName



セルは「ダーティ」としてマークされ、 label



のみがPendingCells



グローバル配列に入ります。 値が「dirty」であるため、 label



はすぐに再計算されますが、 firstName.get().length === 3



以降、 fullName



値は必要ないため、不必要な再計算を回避できます。







正直なところ、アルゴリズムの説明は実装よりもはるかに多くのスペースを占有します。 このタイプスクリプトコードと、反応の例および計算を伴うバグのテストは、リポジトリ( https://github.com/bgnx/xmob )にあります








All Articles