Closed1

【TypeScript】Preactを使用して軽量なコンポーネントを作る方法

NanaoNanao

Preact を使うメリット

Preact は React とほぼ同じコードで React よりも軽量なコンポーネントが作成できる Javascript ライブラリです。
抽象化を抑えて DOM 本来の API を利用することで 3KB という極小サイズと高速な動作を実現しています。

Preact は Google のJason Miller 氏によって開発されました。
余談ですが同氏はIslands Architectureという記事でアイランドアーキテクチャの発展にも貢献しています。

Preact のメリット:

  • React とほぼ同等の API で軽量なコンポーネントが開発できる
  • DOM に近い実装のためバンドルサイズが小さく消費メモリも少ない
  • バンドルサイズが小さいため通信量を節約できる
  • 本番環境向けの SSR やルーティングなども充実している

環境

バージョンは執筆時点のものです。

  • Preact: 10.11.2
  • TypeScript: 4.6.4
  • Vite: 3.2.0

Preact のインストール

公式ドキュメントでは Preact CLI によるインストールを推奨していますが、今回は入門のため Vite を使用します。
Vite は Preact をサポートしているため以下のコマンドで対話的にインストールできます。

npm create vite@latest

いくつか質問されるので以下のように回答します。

  • プロジェクト名: preact-example
  • フレームワーク: Preact
  • プログラミング言語: TypeScript

回答が終わると必要なファイルがダウンロードされます。
続いてプロジェクトディレクトリへ移動して npm install を実行すれば完了です。

cd preact-example
npm install

実行コマンド

以下のコマンドを実行するとlocalhost:5173で開発サーバーが起動します。
開発サーバーの起動中はファイルを変更するとホットリロードされます。

npm run dev

以下のコマンドを実行すると production 向けのファイル一式が dist ディレクトリに出力されます。

npm run build

基本的な書き方

以下は簡単な Counter アプリのサンプルです。
import している型は異なりますがほぼ React のコードですね。

このように Preact は React とほぼ同様の API を使用しているのでプロダクトによっては楽に移行できます。

import { useState } from "preact/hooks";

type Props = {
  defaultCount?: number;
};

export const Counter: preact.FunctionComponent<Props> = ({
  defaultCount = 0,
}) => {
  const [count, setCount] = useState(defaultCount);

  return (
    <button onClick={() => setCount((v) => v + 1)}>{`Count: ${count}`}</button>
  );
};

Props の使用

コンポーネント間で値をやり取りするには Props を使用します。
Preact には preact.FunctionComponent という関数コンポーネント用の型が用意されています。
(React で言うところの React.FC や React.VFC ですね)

Props を使用する場合はこの型の型引数に Props を指定します。
子要素である children は組み込み済みなので型定義しなくても使用できます。

/src/components/Some.tsx

type Props = {
    message: string;
    status: number;
};

export const Some: preact.FunctionComponent<Props> = (props) => {
    return (
        <>
            <div>Status: {props.status}</div>
            <div>Message: {props.message}</div>
            {props.children}
        </>
    );
};

State の使用

State の宣言は React と同様に setState()で行います。
戻り値は以下のように Getter と Setter のタプルです。

const [count, setCount] = useState(0);

以下は State を使用したコンポーネントの例です。
ボタンに応じて like の値が変化し、like の数に応じてテキストが変化します。

/src/components/Like.tsx
import { useState } from "preact/hooks";

export const Like: preact.FunctionComponent = () => {
    const [like, setLike] = useState(0);

    return (
        <>

            <div>
                <button onClick={() => setLike(v => v + 1)}>いいね!!</button>
                <button onClick={() => setLike(0)}>リセット</button>
            </div>

            <div>
                {like > 0 && <span>{`${like} いいねを獲得しました`}</span>}
                {like < 1 && <span>いいねはまだありません</span>}
            </div>

        </>
    );
};

Setter は setLike(0)のように新しい値をセットできる他に
setLike(v => v + 1) のように記述すると現在の like の値を使用して新しい値をセットできます。

useEffect の使用

ある State が変更された時に別の State を更新したい時は useEffect()を使用します。
useState()の第 2 引数は依存配列となっており、指定したオブジェクトが変更されると第 1 引数に指定した関数がトリガーされます。
以下の例では isPrivate が変更される度に useEffect()がトリガーされ、その値に応じて hint を変更しています。

/src/components/OptionButton.tsx
import { useEffect, useState } from "preact/hooks";

type Props = {
    defaultIsPrivate: boolean;
};

export const OptionButton: preact.FunctionComponent<Props> = ({ defaultIsPrivate }) => {
    const [isPrivate, setIsPrivate] = useState(defaultIsPrivate);
    const [hint, setHint] = useState('');

    // isPrivateが変更される度にuseEffect()が呼び出される
    useEffect(() => {
        const newHint = isPrivate
            ? '現在この投稿はあなたにしか見えません'
            : '現在この投稿は誰でも閲覧できます';
        setHint(newHint);
    }, [isPrivate]);

    return (
        <>

            <div>
                <button onClick={() => setIsPrivate(v => !v)}>
                    {isPrivate ? '非公開中' : '公開中'}
                </button>
            </div>

            <div>{hint}</div>

        </>
    );
}

Reducer の使用

useReducer()は Action と State の状態に応じた処理を一箇所でまとめて記述できる Hook です。
戻り値は State と Dispatcher (Action をトリガーする関数)のタプルです。

useReducer()を使用すると State とそれに関連する処理をコンポーネントから分離できるのでコードの見通しがよくなります。
特に複雑なオブジェクトを useState()で更新する処理は煩雑になりがちなので useReducer()を使用してコンポーネントから分離するなどの使い方がおすすめです。

useState()とそれに関連する処理は useReducer()に置き換え可能です。
以下のコードは先程の Like コンポーネントを useReducer()で書き換えたものです。

/src/components/LikeWithReducer.tsx
import { Reducer, useReducer } from "preact/hooks";

// Stateの型
export type LikeReducerState = {
    count: number;
    text: string;
};

// Actionの型
export type LikeReducerAction = 'increment' | 'reset';

// Reducerを定義する
export const likeReducer: Reducer<LikeReducerState, LikeReducerAction> = (state, action) => {
    let newCount = 0;

    // actionの値に応じて処理を分岐する
    switch (action) {
        case 'increment':
            newCount = state.count + 1;
            break;
        case 'reset':
            newCount = 0;
            break;
    }

    // stateを更新する。「state.count = 0」のように個々のプロパティを更新しても反映されないので注意。
    state = {
        count: newCount,
        text: newCount > 0
            ? `${newCount} いいねを獲得しました`
            : 'いいねはまだありません',
    };
    return state;
};

export const LikeWithReducer: preact.FunctionComponent = () => {
    const [like, dispatcher] = useReducer(likeReducer, { count: 0, text: '', });

    return (
        <>

            <div>
                <button onClick={() => dispatcher("increment")}>いいね!!</button>
                <button onClick={() => dispatcher("reset")}>リセット</button>
            </div>

            <div>{like.text}</div>

        </>

    );
};

Context の使用

コンポーネント間で値をやりとりするには Props を使うのが基本です。
しかし深いコンポーネント階層では Props を直接使用しない中間コンポーネントにも中継する必要があるため Props への依存が大きくなってしまいます。
Props を使うことでコンポーネント間の依存関係が明確になるというメリットがありますが、習性時のファイル数が多くなってしまうというデメリットもあります。

それを解決するのが Context です。
Context は値を提供する上位コンポーネントと値を使用する下位コンポーネントでのみやり取りするため使用するとコードの見通しが良くなります。

  • createContext()でコンテキストの初期値を定義します
  • 値を提供する上位コンポーネントでは MyContext.Provider コンポーネントを使用してコンテキストのスコープを定義します
  • 値を使用する下位コンポーネントでは useContext()を使用してコンテキストの値を取り出せます

先述の Like コンポーネントを Context で書き直した例は以下の通りです。

import { createContext } from "preact";
import { useContext, useState } from "preact/hooks";

// コンテキストの値の型
export type LikeValue = {
  like: number;
  increment: () => void;
  reset: () => void;
};

// 初期値を指定してコンテキストを作成する
export const LikeContext = createContext<LikeValue>({
  like: 0,
  increment() {},
  reset() {},
});

// 値を提供する上位コンポーネント
export const LikeProvider: preact.FunctionComponent = () => {
  // コンテキストの値に使用するState
  const [like, setLike] = useState(0);

  // コンテキストの値を実装する。型はコンテキストの初期値と一致させる必要がある。
  const value: LikeValue = {
    like,
    increment: () => setLike((v) => v + 1),
    reset: () => setLike(0),
  };

  // MyContext.Providerコンポーネントでラップすることでコンテキストのスコープを定義できる
  // さらにvalueに値を渡すことでコンテキストの値を上書きできる
  return (
    <LikeContext.Provider value={value}>
      <SomeComponent>
        <LikeConsumer />
      </SomeComponent>
    </LikeContext.Provider>
  );
};

// 値をやり取りしない中間コンポーネント
export const SomeComponent: preact.FunctionComponent = ({ children }) => {
  return <div>{children}</div>;
};

// 値を使用する下位コンポーネント
export const LikeConsumer: preact.FunctionComponent = () => {
  // コンテキストから値を取り出す
  const context = useContext(LikeContext);

  return (
    <>
      <div>
        <button onClick={() => context.increment()}>いいね!!</button>
        <button onClick={() => context.reset()}>リセット</button>
      </div>

      <div>
        {context.like > 0
          ? `${context.like} いいねを獲得しました`
          : `いいねはまだありません`}
      </div>
    </>
  );
};

コンポーネント階層は LikeProvider→SomeComponent→LikeConsumer となっています。
直接値をやり取りしない SomeComponent は children 以外の Props や Context に一切依存していないことが分かります。
このようにコンポーネント階層が深くなっても値を提供する側と値を使用する側でやり取りが完結するためコードの見通しが良くなります。

ただしコンテキストの値は 1 つのため、一部のプロパティのみ更新するといったことができません。
例えば上記のコードで increment 関数の内容のみを更新したいと思っても value 全体を更新する必要があります。
コンテキストの値を個別に更新したい場合は Context を分割するか後述する Signals に置き換えます。

Signals という考え方

React や Preact のアプリケーションの規模が大きくなるとメモ化や Context を使用したパフォーマンスの最適化に直面します。
しかし最適化はいくつもの課題を抱えています。

※ Preact Signals が作られた背景は公式ドキュメントのIntroducing Signalで詳しく解説されています。

パフォーマンス最適化の主な課題:

  • 組み込みの機能ではパフォーマンスに限界があるためカスタムフックを作成したり外部ライブラリに依存する必要がある
  • 状態管理ライブラリを使用するとバンドルサイズが大きくなってしまう
  • コンポーネント階層が深くなると useMemo()による再レンダリングの抑制が必要になる
  • Context は 1 つの値しか持てないため複数の Context へ依存することになりコンポーネントが肥大化する

最適化するとコードの見通しはどうしても悪くなってしまいます。
これらの課題を解決するのが Signals です。

Signals は値を持ったオブジェクトです。
値が更新されても Signals 自体は更新されないため DOM 差分による再レンダリングをスキップできます。
そして Signals の値にアクセスするコンポーネントのみが再レンダリングされます。

Signals は簡単に使用できる上にフレームワークが効率的に最適化を行ってくれます。
開発者が特にパフォーマンスを意識しなくても高速に動作してくれるので最適化にかけるリソースを節約できます。

Signals のインストール

Signals の使用はオプションなので以下のコマンドでインストールする必要があります。

npm install @preact/signals

(※ yarn や pnpm の場合は適宜書き換えてください)

Signals の使用

Signals の使用はびっくりするほど簡単です。

import { signal } from "@preact/signals";

// 初期値を指定してSignalsを作成する
const count = signal(0);

// Signalsの値を変更する
count.value = 1;

// Signalsの値を参照する
console.log(count).value;

// JSXでは.valueを省略しても参照できる
console.log(count);

effect()を使用すると Signals の値の変更を追跡して別の Signals の値を変更できます。
(State で言うところの useEffect()ですね)

import { effect, signal } from "@preact/signals";

const count = signal(0);

// 第1引数のコールバックの中で使用したSignalsの値が追跡されるようになる
// Signalsの値が変更される度に第1引数のコールバックが呼び出される
// 戻り値であるunSubscribe()を呼び出すとそれ以降は追跡されなくなる
const unSubscribe = effect(() => {
  console.log(count);
});

以下は Like コンポーネントを Signals で書き直した例です。

/src/components/LikeWithSignals.tsx
import { effect, signal } from "@preact/signals";

// 初期値を指定してSignalsを作成
const like = signal(0);
const text = signal('');

// コールバックの中のSignalsの値が変更される度にコールバックが呼び出される
effect(() => {
    text.value = like.value > 0
        ? `${like.value} いいねを獲得しました`
        : `いいねはまだありません`;
});

export const LikeWithSignals: preact.FunctionComponent = () => {
    return (
        <>

            <div>
                <button onClick={() => like.value += 1}>いいね!!</button>
                <button onClick={() => like.value = 0}>リセット</button>
            </div>

            <div>{text}</div>

        </>
    );
};

State を使うくらい簡単に Signals を使えることが分かりますね。
これだけ簡単に使える上にいくつもの最適化を自動的に行なってくれるのは魅力的ですね。

その他にも computed()や batch()など便利な機能が用意されています。

https://github.com/preactjs/signals

このスクラップは2022/10/31にクローズされました