RellikaZZ

RellikaZZ

前端シグナルの紹介と原理

なぜ#

signal とは何かを説明する前に、ケーススタディを見てみましょう。

// useEffectとタイマーを使用してインクリメントを実現する、以下のDemoには何か問題がありますか?なぜそうなるのか、どうすればよいですか?
function Demo() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 500);
  }, []);

  useEffect(() => {
    setInterval(() => {
      console.log(count);
    }, 500);
  }, []);

  return <div>{count}</div>;
}

export default Demo;

このコードは、500ms ごとにインクリメントされた count を出力することを期待しています。
タイマーをクリアしないという問題を除けば、実際にはこのコードは常に 0 を出力し続けます。これは、useEffect の依存項目に count が含まれていないため、count は常に 0 のままだからです。
eslint-plugin-react-hooks と react-hooks/exhaustive-deps をインストールしている場合、より明確な警告が表示されます。

一般的な解決策は以下の 3 つです:

  1. ref を使用して state を保存する、これは多くの人が使用する一般的な方法です。
import * as React from 'react';

export default function App() {
  const countRef = React.useRef(0);
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    setInterval(() => {
      countRef.current = countRef.current + 1;
      // ページが変化に応じて反応するためには、setCountが必要です。
      setCount(countRef.current);
    }, 500);
  }, []);

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

これは ref の不変の原理を利用しています。

  1. useReducer を使用する。
import * as React from 'react';

const initialCount = 0;
const reducer = (count, action) => {
  switch (action.type) {
    case 'increment':
      return count + 1;
    default:
      throw new Error();
  }
};

export default function App() {
  const [count, dispatch] = React.useReducer(reducer, initialCount);
  React.useEffect(() => {
    setInterval(() => {
      dispatch({
        type: 'increment',
      });
    }, 500);
  }, [dispatch]);

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

これは dispatch の不変の原理を利用しています。
3. タイマーのコールバックを ref に保存する。

import * as React from 'react';

export default function App() {
  const savedCallback = React.useRef<() => void>();
  const [count, setCount] = React.useState(0);
  const callback = () => {
    setCount(count + 1);
  };
  React.useEffect(() => {
    savedCallback.current = callback;
  });
  React.useEffect(() => {
    setInterval(() => {
      savedCallback.current();
    }, 500);
  }, []);

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

少し簡潔になりましたが、依然として追加の ref を導入しています。
上記の解決策はすべて react-hooks/exhaustive-deps に準拠しており、useEffect 内で存在しない deps の変数を使用することはできません。(余談ですが、このルールを厳密に遵守すべきかどうかは以前に議論を呼びました)
これは React で一般的な手法です。そして、今回紹介したいのは signal という別の可能性です。

import * as React from 'react';
import { useSignal, useSignalEffect } from '@preact/signals-react';

export default function App() {
  const count = useSignal(0);
  useSignalEffect(() => {
    setInterval(() => {
      count.value++;
    }, 1000);
  });

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

何か#

7980d6da-cbdc-4521-ad12-106b7af7c72e.png
Qwik で使用されているのは signal による状態管理で、その作者(同時に Angular の作者でもある)は Signal がフロントエンドフレームワークの未来であると述べています。Vue、Solid.js、Preact、Svelte はすでに Signal を実装しています。
signal の普及は主に 2 つの問題を解決するためです:

  • より良い DX(開発者体験)、すなわち手動で effect の依存関係を維持する必要がなく、クロージャの罠を心配する必要がないこと。
  • より良い細粒度の更新性能。
    より良い DX は上記の例で感じられますが、より良い細粒度の更新性能はどこから来るのでしょうか?

細粒度の更新#

上記の例に戻りますが、signal を実装していないバージョンにいくつかのコードを追加すると、次のようになります。

// RefRenderDemo.tsx
import * as React from 'react';

function Child() {
  console.log('子をレンダリング');
  return <div>子</div>;
}

export default function App() {
  const countRef = React.useRef(0);
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    setInterval(() => {
      countRef.current = countRef.current + 1;
      setCount(countRef.current);
    }, 500);
  }, []);

  console.log('レンダリング中');

  return (
    <div>
      <h1>{count}</h1>
      <Child />
    </div>
  );
}

コンソールでは、count が更新されるたびに「レンダリング中」と表示され、Child コンポーネントも「子をレンダリング」と表示されます。
これは、> React の useState () は状態値のみを返すため、この状態値がどのように使用されているかを知らず、状態変更に応じてコンポーネントツリー全体を再レンダリングする必要があるからです。

signal に置き換えると、レンダリング中と子をレンダリングは一度だけ表示されます。

// SignalRenderDemo.tsx
import * as React from 'react';
import { useSignal, useSignalEffect } from '@preact/signals-react';

function Child() {
  console.log('子をレンダリング');
  return <div>子</div>;
}

export default function App() {
  const count = useSignal(0);
  useSignalEffect(() => {
    setInterval(() => {
      count.value++;
    }, 1000);
  });
  console.log('レンダリング中');

  return (
    <div>
      <h1>{count}</h1>
      <Child />
    </div>
  );
}

重要な違いは、signal が getter と setter を返すのに対し、useState は値と setter を返すことです。
上記のデモは構文糖衣のためにあまり明確ではないかもしれませんが、solidjs での signal の書き方を見てみましょう。

import { render } from "solid-js/web";
import { createSignal } from "solid-js";

function Counter() {
  const [count, setCount] = createSignal(0);
  setInterval(() => setCount(count() + 1), 1000);
  return <div>カウント: {count()}</div>;
}

count は実際には getter メソッドであり、呼び出すことで状態値を取得できます。

原理#

signal を使用する際、signal の状態値が変わると、signal 自体は変わらず、コンポーネントの再レンダリングを回避します。では、コンポーネントの更新をどのように通知するのでしょうか?実際には、公開購読モデルを通じて実現されています。

Signals はイベントエミッターであり、購読リストを保持します。値が変わるたびに購読者に通知します。

Vue のリアクティブ原理を理解している場合、signal は比較的理解しやすいでしょう。以下は createSignal と useSignal の簡易実装です。

// mySignal.ts
import React from 'react';

let currentListener = undefined;

export const createSignal = (initialValue) => {
  // 購読リスト
  const subscribers = new Set();
  // 状態値
  let value = initialValue;
  const getter = () => {
    if (currentListener !== undefined) {
      // 値を読み取るときに購読を収集
      subscribers.add(currentListener);
    }
    return value;
  };
  const setter = (newValue) => {
    value = newValue;
    subscribers.forEach((subscriber) => {
      // 値が変わるときに購読を実行
      subscriber();
    });
  };
  return [getter, setter];
};

export const createSignalEffect = (callback) => {
  // コールバックをcurrentListenerに保存
  currentListener = callback;
  // ここでのcallbackがsignalのgetterを呼び出すと、currentListenerがsubscribersに保存されます。
  callback();
  // currentListenerを元に戻す
  currentListener = undefined;
};

// フックとしてラップ
export const useSignal = (value) =>
  React.useMemo(() => createSignal(value), []);

使用時

// MySignalDemo.tsx
import * as React from 'react';
import { useSignal, createSignalEffect } from './mySignal';

export default function App() {
  const [count, setCount] = useSignal(0);
  const nodeRef = React.useRef();
  // preact/signalsのコンポーネントレンダリング時の購読行動を模倣
  React.useEffect(() => {
    createSignalEffect(() => {
      nodeRef.current.innerText = count();
    });
  }, []);

  return <div ref={nodeRef} onClick={() => setCount(count() + 1)} />;
}

【preact/signals のコンポーネントレンダリング時の購読行動を模倣】は、ソースコードのコメントで言及されているこの部分のロジックを指します。

image

現状#

私の個人的な見解では、signal には多くの利点がありますが、現時点では React プロジェクトで使用するのはあまりお勧めできません。
まず、React の公式は signal が良いパターンではないと考えています。dan の signal に関する見解を参考にしてください。
https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71 そして

image

次に、実装において、@preact/signals-react は属性__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIREDを使用しています。
https://github.com/preactjs/signals/blob/main/packages/react/src/index.ts#L22
この属性の日本語訳は:内部の神秘的な属性、乱用しないでください!さもなければ解雇されます!この属性に興味がある方は、この記事を参照してください。
https://zhuanlan.zhihu.com/p/540482955

まとめ#

signal はより良い DX とパフォーマンスを提供しますが、その原理は公開購読ですが、現時点では個人的には React に適用するのはあまり適していないと考えています。

デモのリンク#

https://stackblitz.com/edit/react-ts-k36uan?file=App.tsx

参考#

https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks/
https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71
https://preactjs.com/blog/introducing-signals/
https://www.builder.io/blog/usesignal-is-the-future-of-web-frameworks#code-use-ref-code-does-not-render

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