ホームページ  >  記事  >  ウェブフロントエンド  >  反応性における可変導出

反応性における可変導出

Linda Hamilton
Linda Hamiltonオリジナル
2024-10-24 08:22:02815ブラウズ

スケジューリングと非同期についてのこのような調査を通じて、反応性について私たちがまだどれほど理解していないのかを認識しました。研究の多くは、回路やその他のリアルタイム システムのモデリングから始まりました。関数型プログラミングのパラダイムにおいても、かなりの量の探求が行われてきました。これが、反応性に対する私たちの現代的な見方を大きく形作ってきたように感じます。

私が初めて Svelte 3 を見たとき、その後 React コンパイラーを見たとき、人々はこれらのフレームワークのレンダリングがきめ細かいことに異議を唱えました。そして正直に言うと、それらは多くの同じ特徴を共有しています。シグナルとこれまで見てきた派生プリミティブで話を終わらせるなら、これらのシステムがその反応性を UI コンポーネントの外で存続させることを許可していない点を除いて、同等であると主張することができます。

しかし、Solid がこれを実現するためにコンパイラを必要としなかったのには理由があります。そして、なぜ今日に至るまでそれがより最適であるのか。実装の詳細ではありません。それは建築的です。これは UI コンポーネントからの事後的な独立性に関連していますが、それ以上のものです。


可変対不変

定義では、変化する能力と変化しない能力。しかし、私たちが言いたいのはそういうことではありません。何も変わらなければ、非常に退屈なソフトウェアになるでしょう。プログラミングでは、値を変更できるかどうかが重要です。値を変更できない場合、変数の値を変更する唯一の方法は、変数を再割り当てすることです。

その観点からすると、シグナルは設計上不変です。何かが変更されたかどうかを知る唯一の方法は、新しい値が割り当てられたときにインターセプトすることです。誰かが自分の値を独立して変更したとしても、反応的なことは何も起こりません。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

私たちのリアクティブ システムは、不変ノードの接続されたグラフです。データを取得すると、次の値を返します。以前の値を使用して計算される場合もあります。

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

しかし、シグナルをシグナルに、エフェクトをエフェクトに配置すると、興味深いことが起こります。

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

ユーザーを変更するだけでなく、ユーザー名も変更できるようになりました。さらに重要なのは、名前のみが変更される場合、不要な作業を省略できることです。外側のエフェクトは再実行しません。この動作は、状態が宣言される場所ではなく、状態が使用される場所の結果です。

これは信じられないほど強力ですが、私たちのシステムが不変であるとは言いがたいです。はい、個々の原子はそうですが、それらを入れ子にすることで、突然変異に最適化された構造を作成しました。何かを変更すると、実行する必要があるコードの正確な部分のみが実行されます。

ネストしなくても同じ結果を得ることができますが、追加のコードを実行して diff を実行します。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

ここにはかなり明確なトレードオフがあります。私たちのネストされたバージョンでは、ネストされたシグナルを生成するために入ってくるデータをマッピングし、その後基本的に 2 回目にマッピングして、独立したエフェクトでのデータへのアクセス方法を分割する必要がありました。私たちの diff バージョンではプレーン データを使用できますが、変更があった場合はすべてのコードを再実行し、すべての値を diff して何が変更されたかを判断する必要があります。

差分分析はかなりパフォーマンスが高く、特に深くネストされたデータのマッピングは面倒であることを考慮すると、一般に後者を選択します。 Reactは基本的にこれです。ただし、データとそのデータに関連する作業が増加するにつれて、差分処理のコストはさらに高くなるだけです。

一度差分を放棄したら、それは避けられません。私たちは情報を失います。上記の例でそれを見ることができます。最初の例で名前を「Janet」に設定したとき、名前 user().setName("Janet") を更新するようにプログラムに指示しています。 2 番目の更新では、まったく新しいユーザーを設定しており、プログラムはユーザーが消費されるすべての場所で何が変更されたかを把握する必要があります。

ネストは面倒ですが、不要なコードが実行されることはありません。私が Solid を作成するきっかけとなったのは、ネストされたリアクティビティのマッピングに関する最大の問題がプロキシを使用すれば解決できることに気づいたことです。そしてリアクティブストアが誕生しました:

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

かなり良くなりました。

これが機能する理由は、setUser(user => user.name = "Janet") のときに名前が更新されることがわかっているためです。 name プロパティのセッターがヒットします。データのマッピングや差分を行わずに、このきめ細かな更新を実現します。

これがなぜ重要なのでしょうか?代わりにユーザーのリストがある場合を想像してください。不変の変更を考えてみましょう:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

名前が更新された新しいオブジェクトを除く、すべての既存のユーザー オブジェクトを含む新しい配列を取得します。この時点でフレームワークが知っているのは、リストが変更されたことだけです。リスト全体を反復処理して、移動、追加、削除が必要な行があるかどうか、または変更された行があるかどうかを判断する必要があります。 変更されている場合は、map 関数が再実行され、現在 DOM 内にあるものと置き換えられる/差分される出力が生成されます。

変更可能な変更について考えてみましょう:

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

私たちは何も返しません。代わりに、そのユーザーの名前である 1 つのシグナルが更新され、名前を表示する場所を更新する特定のエフェクトが実行されます。リストの再作成はありません。リストの差分はありません。列レクリエーションはありません。 DOM の差分はありません。

可変反応性を第一級市民として扱うことにより、不変状態と同様のオーサリング エクスペリエンスが得られますが、最も賢いコンパイラーでも達成できない機能が備わっています。しかし、今日私たちはリアクティブ ストアについて正確に話すためにここにいるわけではありません。これは派生とどのような関係がありますか?


派生を再考する

私たちが知っている派生値は不変です。実行されるたびに次の状態を返す関数があります。

状態 = fn(状態)

入力が変更されると再実行され、次の値が取得されます。これらは、リアクティブ グラフでいくつかの重要な役割を果たします。

まず、メモ化ポイントとして機能します。入力が変更されていないことが認識できれば、高価な計算や非同期計算の作業を節約できます。値を再計算せずに複数回使用できます。

2 番目に、それらはコンバージェンス ノードとして機能します。それらはグラフの「結合」です。これらは、複数の異なるソースを結び付けて、その関係を定義します。これは、物事を一緒に更新するための鍵ですが、ソースの数が有限で、それらの間の依存関係が増え続けると、最終的にはすべてが複雑になってしまうという理由にもなります。

それはとても理にかなっています。派生された不変データ構造では、「フォーク」ではなく「結合」のみが可能です。複雑さが増すにつれて、マージすることになります。興味深いことに、リアクティブな「ストア」にはこのプロパティがありません。個々のパーツは個別に更新されます。では、この考え方を導出にどのように適用すればよいでしょうか?

形状に従う

Mutable Derivations in Reactivity

Andre Staltz は数年前に、あらゆる種類のリアクティブ/反復可能なプリミティブを 1 つの連続体にリンクした素晴らしい記事を発表しました。プッシュ/プルはすべて 1 つのモデルに統一されています。

私はアンドレがこの記事で適用した体系的な考え方に長い間インスピレーションを受けてきました。そして、私はこのシリーズで取り上げてきたテーマについて長い間悩んできました。設計空間が存在することを理解するだけで、適切な探索が可能になる場合があります。最初に理解する必要があるのは、解の形状だけである場合もあります。


たとえば、特定の状態更新の同期を回避したい場合は、書き込み可能な状態を取得する方法が必要であることに私はずっと前に気づきました。それは数年間頭の片隅に残っていましたが、最終的には書き込み可能な派生を提案しました。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"


そのアイデアは、ソースから常にリセット可能ですが、次にソースが変更されるまで、短期間の更新を上に適用できるということです。なぜエフェクトではなくこれを使用するのですか?

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"


なぜなら、このシリーズのパート 1 で詳しく説明したように、ここでのシグナルは props.field に依存していることを知ることができないからです。依存関係を追跡できないため、グラフの一貫性が崩れてしまいます。同じプリミティブ内に読み取りを配置すると、その機能が解放されることが直感的にわかりました。実際、createWritable は今日のユーザーランドで完全に実装可能です。

<script> // Detect dark theme var iframe = document.getElementById('tweet-1252839841630314497-983'); if (document.body.className.includes('dark-theme')) { iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=1252839841630314497&theme=dark" } </script>
const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

それは単なる高次のシグナルです。信号の中の信号、またはアンドレが呼んだように、「ゲッターゲッター」と「ゲッターセッター」の組み合わせです。渡された fn が実行されると、外部導出 (createMemo) がそれを追跡し、Signal を作成します。これらの依存関係が変更されるたびに、新しいシグナルが作成されます。ただし、置き換えられるまで、そのシグナルはアクティブであり、返されたゲッター関数をリッスンするものはすべて、派生と依存関係チェーンを維持するシグナルの両方にサブスクライブします。

私たちがここにたどり着いたのは、解決策の形に従っていたからです。そして、その形状をたどる時間が経つにつれて、前回の記事の最後に示したように、この可変派生プリミティブは書き込み可能な派生ではなく、派生信号であると私は今では信じています。

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

しかし、私たちはまだ不変プリミティブを検討しています。はい、これは書き込み可能な派生ですが、値の変更と通知は依然として大規模に発生します。


差分の問題

Mutable Derivations in Reactivity

直感的にギャップがあることがわかります。解決したいものの例は見つかりましたが、その空間を処理する単一のプリミティブを見つけることができませんでした。問題の一部は形状にあることに気づきました。

一方では、派生した値をストアに入れることができます。

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

しかし、これはどうやって動的に形を変えることができるのでしょうか?

一方、ストアから動的な形状を派生させることはできますが、その出力はストアではありません。

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

すべての派生値が、次の値を返すラッパー関数を渡すことによって作成されている場合、変更をどのように分離できるでしょうか?せいぜい、新しい結果と以前の結果を比較し、細かい更新を外部に適用することができます。しかし、それは私たちが常に差分を望んでいることを前提としていました。

Solid の For コンポーネントのようなものを一般的な方法で実装するインクリメンタル コンピューテッドについての Signia チームの記事を読みました。しかし、ロジックがそれほど単純ではないことに私は気づきました:

  • これは単一の不変シグナルです。ネストされた変更を独立してトリガーすることはできません。

  • チェーン内のすべてのノードが参加する必要があります。それぞれがソース diff を適用して更新された値を実現し、エンド ノードを除いて、渡す diff を生成する必要があります。

不変データを扱う場合。参照は失われます。差分はこの情報を取り戻すのに役立ちますが、チェーン全体でコストを支払うことになります。また、サーバーからの新しいデータなどの場合には、安定した参照が存在しません。モデルを「キー設定」する必要がありますが、例で使用されている Immer にはそれが存在しません。 React にはこの機能があります。

そのとき、このライブラリが React 用に構築されたものであることに気づきました。さらに差分が発生するだろうという想定はすでに固まっていました。一度差分を受け入れることをやめると、差分はさらなる差分を生みます。それは避けられない真実です。彼らは、システム全体にわたって増分コストを押し出すことで、重労働を回避するシステムを構築していました。

Mutable Derivations in Reactivity

賢くなりすぎているように感じました。持続可能ではないものの、「悪い」アプローチの方がパフォーマンスが高いのは間違いありません。


(きめの細かい) 反応性の大統一理論

Mutable Derivations in Reactivity

ものを不変にモデリングすることに何も問題はありません。しかし、ギャップがあります。

それでは、形状に従ってみましょう:

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

明らかになったのは、派生関数が Signal setter 関数と同じ形状であるということです。どちらの場合も、前の値を渡して新しい値を返します。

ストアでこれをやってみませんか?

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

派生ソースを取り込むこともできます:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

ここには対称性があります。不変の変更は常に次の状態を構築し、可変の変更は現在の状態を次の状態に変更します。差分も行いません。不変の派生 (メモ) で優先順位が変更されると、参照全体が置き換えられ、すべての副作用が実行されます。可変導出 (射影) で優先順位が変更された場合、優先順位をリッスンするもののみが具体的に更新されます。


予測の探索

不変の変更は、何が変更されたとしても、次の状態を構築するだけで済むため、その操作において一貫性があります。 Mutable は変更に応じて異なる操作を行う場合があります。不変の変更には常に変更されていない以前の状態があり、可変の変更にはそれがありません。これは期待に影響を与えます。

これについては、前のセクションの例で reconcile を使用する必要があることがわかります。まったく新しいオブジェクトがプロジェクションを使用して渡される場合、すべてを置き換えるだけでは満足できません。変更を段階的に適用する必要があります。更新内容に応じて、さまざまな方法で変更する必要がある場合があります。すべての変更を毎回適用し、ストアの内部同等性チェックを利用できます:

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

しかし、これは浅くしか機能しないため、すぐに法外なレベルになります。調整 (相違) は常にオプションです。しかし、多くの場合、私たちがやりたいことは、必要なものだけを適用することです。これによりコードはより複雑になりますが、効率は大幅に向上します。

Solid の Trello クローンからの抜粋を変更すると、プロジェクションを使用して各オプティミスティック アップデートを個別に適用したり、ボードをサーバーからの最新のアップデートに調整したりできます。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

これは、UI 内の参照を保持して詳細な更新のみが行われるだけでなく、複製や差分を行わずに変更 (楽観的な更新) を段階的に適用するため、強力です。そのため、コンポーネントを再実行する必要がないだけでなく、変更を加えるたびに、ボードの状態全体を繰り返し再構築して、ほとんど変更されていないことを確認する必要もありません。そして最後に、差分が必要な場合、サーバーが最終的に新しいデータを返すと、その更新された投影との差分が行われます。参照は保持されるため、再レンダリングする必要はありません。

このアプローチは将来、リアルタイムおよびローカルファーストのシステムにとって大きな勝利となると信じていますが、私たちはおそらく気付かないうちに、すでにプロジェクションを今日使用しています。インデックスのシグナルを含むリアクティブ マップ関数を検討してください:

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

インデックスは、リアクティブ プロパティとしてインデックスを含まない行のリストに投影されます。このプリミティブは非常にベースラインなので、おそらく createProjection を使用して実装することはありませんが、これが完全に 1 つであることを理解することが重要です。

もう 1 つの例は、Solid の不明瞭な createSelector API です。これにより、選択状態を効率的な方法で行のリストに投影できるため、選択内容を変更してもすべての行が更新されなくなります。形式化された投影プリミティブのおかげで、特別なプリミティブはもう必要ありません:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

これにより、ID で検索するマップが作成されますが、選択された行のみが存在します。これはサブスクリプションの有効期間のプロキシであるため、存在しないプロパティを追跡し、更新されたときに通知することができます。 selectionId を変更すると、すでに選択されている行と選択されている新しい行の最大 2 行が更新されます。 O(n) 演算を O(2) に変換します。

このプリミティブをさらにいじってみると、これが直接可変派生を実現するだけでなく、反応性を動的に通過させるために使用できることがわかりました。

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

このプロジェクションはユーザーの ID と名前のみを公開しますが、privateValue にはアクセスできないようにします。これは名前にゲッターを適用するという点で興味深いことを行います。したがって、ユーザー全体を置き換えると投影が再実行されますが、ユーザー名を更新するだけで投影を再実行せずにエフェクトを実行できます。

これらの使用例はほんの一部であり、理解するにはもう少し時間がかかることは認めます。しかし、私は投影がシグナルの物語の欠けている部分であると感じています。


結論

Mutable Derivations in Reactivity

過去数年間にわたる反応性の導出の探求を通じて、私は多くのことを学びました。リアクティブに対する私の見方全体が変わりました。変化は必要悪として見られるのではなく、私にとって反応性の明確な柱に成長しました。粗粒度のアプローチやコンパイラによってエミュレートされないもの。

これは、不変反応性と可変反応性の根本的な違いを示唆する強力なステートメントです。 Signal と Store は同じものではありません。メモや投影も同様です。 API を統合できるかもしれませんが、そうすべきではないかもしれません。

私は Solid の API 形状に従ってこれらの認識に到達しましたが、他のソリューションにはシグナル用に異なる API があります。それで、彼らも同じ結論に達するのだろうかと思います。公平を期すために言うと、プロジェクションの実装には課題があり、ここでの話はまだ終わっていません。しかし、当面の思考探求はこれで終了と思います。私にはこれからたくさんの仕事が待っています。

この旅にご参加いただきありがとうございます。

以上が反応性における可変導出の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。