Closed1

【TypeScript】SolidJSで高速で軽量なWEBサイトを作る方法

NanaoNanao

今回のサンプルコード全体は僕の Github にあります。
https://github.com/7oh2020/solidjs-example-app

SolidJS を使うメリット

SolidJS はシンプルさと高いパフォーマンスが特徴の Javascript ライブラリです。
洗練されたシンプルな記述で高速に動作する宣言的な UI が作れます。

公式ドキュメントには「がんばらなくても高速になる」という旨の説明があります。

個人的にもその手軽さと軽量なところが気に入っています。
また、Astroとの相性も良いので最近話題のアイランドアーキテクチャも実現できそうです。

SolidJS の特徴:

  • ReactKnockout の哲学をリスペクトしつつも仮想 DOM を持たない別のアプローチを採用している
  • ビルド時に最適化されるためバンドルサイズが小さくて高速に動作する
  • JSX または TSX 形式で記述できるので React や Vue 経験者にとっては学習ハードルが低い
  • For each や Switch など独自の便利なコンポーネントが用意されているので開発体験が良い
  • 状態管理とデータフェッチ機能が標準で用意されているので外部ライブラリへの依存が少ない

環境

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

  • SolidJS: 1.5.1
  • TypeScript: 4.8.2
  • Vite: 3.0.9

インストール

SolidJS 単体でもインストールできますが、ありがたいことに公式で SolidJS + Vite + TypeScript の組み合わせのテンプレートが用意されています。
MyApp という名前の最小プロジェクトを作るコマンドは以下の通りです。
(※ yarn や pnpm の場合は適宜書き換えてください)

npx degit solidjs/templates/ts-minimal MyApp
cd MyApp
npm install

他にも便利なテンプレートが用意されているので README をご一読することをおすすめします。
https://github.com/solidjs/templates/

実行コマンド

開発サーバーを起動するコマンドは以下の通りです。
WEB ブラウザからlocalhost:3000でアクセス可能です。

npm run dev

また、下記のコマンドを実行すると dist ディレクトリに production 向けのファイルが生成されます。
(インストール直後とはいえビルドされた JS ファイルがたったの 7 キロバイトしかなくて驚きました)

npm run build

SolidJS のコンポーネントについて

SolidJS のコンポーネントは JSX または TSX ファイルで記述された関数です。
ただの関数なので別ファイルから import したり 1 ファイルに複数コンポーネントを定義したりもできます。

src/app.tsx
import type { Component } from 'solid-js';
import Comp from './Comp';

const App: Component = () => {
  return (
    <>
      <h1>Hello world!!!!</h1>
      <Comp />
    </>
  );
};

export default App;

そして index.tsx を見てみると以下の 1 行でルートコンポーネントと DOM の関連付けが行われていることが分かります。
全てのコンポーネント関数はこの render 関数の中で呼び出されます。

render(() => <App />, document.getElementById("root") as HTMLElement);

公式ドキュメントによるとコンポーネントは 1 回呼び出された後に破棄されるようです。
全てのコンポーネントは 1 回だけ呼び出され、その後は後述する signal や store 等が変化する度に部分的に画面が更新されます。

Props を使う

React 等でお馴染みの props ももちろん扱えます。
コンポーネントで props を受け取るには Component<{ ... }> のように型引数を指定します。
さらに子コンポーネント(children)を受け取りたい場合は Component<ParentProps<{ ... }>> のように props の型をラップします。

src/app.tsx
import type { Component, ParentProps } from 'solid-js';

// propsありのコンポーネント
export const Profile: Component<{
  name: string;
  job: string;
}> = (props) => {
  return (
    <div>
      <h3>{props.name}</h3>
      <p>{props.job}</p>
    </div>
  );
};

// propsとchildrenありのコンポーネント
// 子要素はprops.childrenで取得できます
export const ProfileWithChildren: Component<ParentProps<{
  name: string;
  job: string;
}>> = (props) => {
  return (
    <div>
      <h3>{props.name}</h3>
      <p>{props.job}</p>
      {props.children}
    </div>
  );
};

export const App: Component = () => {
  return (
    <main>

      <article>
        <h2>Profile</h2>
        <Profile name={'Example User'} job={'Developer'} />
      </article>

      <hr />

      <article>
        <h2>Profile with children</h2>
        <ProfileWithChildren name={'Test User'} job={'Designer'}>
          <div>profile description</div>
        </ProfileWithChildren>
      </article>

    </main>
  );
};

その他、公式ドキュメントには props にデフォルト値を設定する方法なども掲載されています。
https://www.solidjs.com/tutorial/props_defaults

createSignal を使って状態を更新する

signal は React でいうところの state です。
宣言方法も useState のように好きな名前の Getter と Setter のタプルとして受け取れます。

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

この Getter と Setter はどちらも関数なので count()や setCount()のように呼び出します。
count のように()を付け忘れると値が取得できないので注意が必要です。

// signalの値を取得する
count();

// signalの値をvalueに変更する
setCount(value);

さらに signal はコンポーネントの外に宣言できるため同じファイルのコンポーネント間で値を共有できます。
以下の例ではコンポーネントの外で signal を宣言して 2 つのコンポーネントで共有しています。

src/Counter.tsx
import { Component, createEffect, createSignal } from "solid-js";

// signalをグローバルに宣言する
const [count, setCount] = createSignal(0);

export const Counter: Component = () => {
    return (
        <div>
            <button onClick={() => setCount(v => v + 1)}>Click here!</button>
        </div>
    );
};

export const CountDisplay: Component = () => {
    return (
        <div>Count: {count}</div>
    );
};

createEffect を使って依存オブジェクトの変化を追跡する

createEffect 関数は React でいうところの useEffect です。
関数内で呼び出した依存オブジェクト(signal や後述する store)の変化を追跡してくれます。

以下の例では createEffect 関数が like に依存しているためボタンを押下する度に createEffect 関数の中の console.log()が呼び出されます。

src/Like.tsx
import { Component, createEffect, createSignal } from "solid-js";

export const Like: Component = () => {

    // 追跡対象のsignal
    const [like, setLike] = createSignal(0);

    // signalの値を監視して変化があった時に処理を実行する
    createEffect(() => {
        console.log(`Like: ${like()}`);
    });

    return (
        <div>
            <button onClick={() => setLike(v => v + 1)}>{like} Like</button>
        </div>
    );
};

createStore を使って複雑なオブジェクトを部分的に更新する

signal はシンプルで便利ですがリストや複雑なオブジェクトの変化を追跡するにはちょっと不向きです。
SolidJS にはリストやオブジェクトの全体または一部だけを更新するのに最適な store という機能が用意されています。

createStore 関数を使用すると store が作成されます。宣言方法は createSignal 関数とよく似ていますね。

初期値の各プロパティはプロキシオブジェクトというオブジェクトでラップされ、それぞれの値の変更が追跡可能になります。
もちろん大きなリストやオブジェクトでは大量のプロキシオブジェクトが作成されますが、必要になったタイミングでのみ作成されるためパフォーマンス的に問題はなさそうです。

以下の例では good や bad がプロキシオブジェクトにラップされ、それぞれの変化が追跡可能となります。

const [vote, setVote] = createStore({
  good: 0,
  bad: 0,
});

宣言後は以下のように store の一部だけを取得更新できます。

// 値を取得する
vote.good;

// 値をvalueに変更する
setVote("good", value);

さらに store の Setter はリストやオブジェクト向けの便利なオプションが用意されています。
例えばリストから ID が一致する要素を 1 件に絞り込んでその要素のプロパティの値を更新するといった処理が 1 行で書けます。

詳細は公式ドキュメントをご確認ください。

createContext を使って子孫コンポーネントに store や関数を提供する

親子関係のあるコンポーネント間で値を受け渡すには通常は props を使います。
しかしコンポーネント階層が深くなると値を使用しない中間コンポーネントにも値を受け渡す必要があるためコード修正時にとても苦労します。

こういったバケツリレーを回避するために context という仕組みがあります。
context は値を提供するコンポーネントと値を使うコンポーネントでのみやり取りするため、中間のコンポーネントへの受け渡しが不要になります。

createContext 関数を使用すると context を作成できます。
context の値は signal でも store でも関数でも渡すことができます。
そして createContext 関数に渡した初期値の型が推論されて context の構造が確定します。

const RatingContext = createContext({
  store: {
    rate: 3,
  },
  increment() {},
  decrement() {},
});

今回の例では context を提供する側の Rating コンポーネントと context を使用する側の RatingInput コンポーネントの 2 つを定義します。

Rating コンポーネントは context の値を実装して値を提供します。
この時に依存性の注入(Dependency injection)が行われるため、context の初期値と構造が異なる実装はコンパイルエラーになります。
今回の例の RatingContext では store と increment 関数と decrement 関数の実装が必須です。

src/Rating.tsx
// 機能を提供する上位コンポーネント
export const Rating: Component = () => {

    // 値を格納するためのstore
    const [store, setStore] = createStore({ rate: 3 });

    // 子孫コンポーネントに提供する機能の実装。型はRatingContextの初期値と一致させる必要がある
    const value = {
        store,
        increment: () => {
            if (5 <= store.rate) {
                return;
            }
            setStore("rate", (rate) => rate + 1);
        },
        decrement: () => {
            if (store.rate <= 1) {
                return;
            }
            setStore('rate', (rate) => rate - 1);
        },
    };

    // コンテキストプロバイダに値を渡す。valueがない場合はcontextの初期値が使用される
    return (
        <RatingContext.Provider value={value} >
            <RatingInput />
        </RatingContext.Provider >
    );
};

次に context を使用する側の RatingInput コンポーネントを定義します。

RatingInput コンポーネントは RatingContext.Provider にラップされているため context のスコープ内です。
なので useContext 関数を使って実装済みの RatingContext を受け取ることができます。

const { store, increment, decrement } = useContext(RatingContext);

RatingInput のコード全体は以下のようになります。

src/Rating.tsx
// 機能を使用する子孫コンポーネント
// contextのスコープ内であればもっと深いコンポーネント階層の場合でも同じように使用できる
export const RatingInput: Component = () => {
    // コンテキストからstoreと関数を取得する
    const { store, increment, decrement } = useContext(RatingContext);

    return (
        <div>
            <div>{store.rate}</div>
            <button onClick={increment}>+ 1</button>
            <button onClick={decrement}>- 1</button>
        </div>
    );
};

もし RatingInput コンポーネントが Rating コンポーネントを経由せずに直接呼び出された場合、RatingContext の初期値が使用されます。
経由するコンポーネントによって実装を切り替えることができるのでまさにコンテキスト(文脈)ですね。

createResource を使って API に非同期リクエストする

データフェッチといえば SWRTanStack Query(React Query)等が有名ですが、SolidJS には独自のデータフェッチ機能が標準で組み込まれています。
createResource 関数を使うと通常のデータフェッチの他にも signal が変化する度にデータフェッチを行うといったこともできます。

例として TODO データをフィルタリングするコンポーネントを作ってみます。

リクエストには Fetch API を使用するのでもちろん別サーバーの API へリクエストもできますが、今回はサンプルのためローカルの JSON をフェッチしてみます。
フェッチ対象の JSON ファイルは以下のように全件の TODO データと完了済みの TODO データを持つ 2 つです。

public/all_items.json
[{
    "title": "本を10冊読む"
}, {
    "title": "朝の5時からジョギングをする"
}, {
    "title": "6時間以上の睡眠"
}, {
    "title": "記事を5本書く"
}]
public/completed_items.json
[{
    "title": "本を10冊読む"
}, {
    "title": "6時間以上の睡眠"
}]

データフェッチには fetcher 関数が必要です。
fetcher 関数は fetch してレスポンスを取得する非同期関数です。

fetcher 関数の第 1 引数には関連付けられた signal の変更時の値が渡されます。
今回の例では FilterString 型の signal と連動させたいので引数の型も FilterString 型とします。

src/FilterableList.tsx
// フィルタ条件の型
type FilterString = 'all' | 'completed';

// JSONデータの型
type Item = {
    title: string;
};

// シグナルの変更時にフィルタ条件を受け取るfetcher関数
const fetchData = async (filter: FilterString) => {
    // 受け取ったフィルタ条件に応じたAPIリクエストを行う
    const url = filter == "completed"
        ? '/completed_items.json'
        : '/all_items.json';
    return await fetch(url).then(resp => resp.json()).catch(err => {
        console.error(err.message);
        throw err;
    });

次にデータフェッチのトリガーにしたい signal を定義して fetcher 関数と関連付けます。
以下の例では filter が変更される度に fetchData 関数が呼び出されるように関連付けています。

// フィルタ条件用のシグナル
const [filter, setFilter] = createSignal<FilterString>("all");

// シグナルが変更される度にfetcher関数が実行される
const [data] = createResource<Item[], FilterString>(filter, fetchData);

あとは取得した data を表示するだけです。
データフェッチを呼び出す側のコンポーネント全体は以下の通りです。

また、ErrorBoundary コンポーネントを使うとデータフェッチのエラー処理がスマートに書けるのでおすすめです。
今回の例では fetchData 関数の中で throw された場合に FetchBoundary の fallback に指定した要素が表示されます。

他にも For や Switch など SolidJS には便利なコンポーネントが多数用意されています。

src/FilterableList.tsx
export const FilterableList: Component = () => {

    // フィルタ条件用のシグナル
    const [filter, setFilter] = createSignal<FilterString>('all');

    // シグナルが変更される度にfetcher関数が実行される
    const [data,] = createResource<Item[], FilterString>(filter, fetchData);

    // ErrorBoundaryでラップするとエラー処理がスマートに書ける
    return (
        <ErrorBoundary fallback={err => <p>Error: {err.message}</p>}>
            <div>
                <button onClick={() => setFilter('all')}>All</button>
                <button onClick={() => setFilter('completed')}>Completed</button>
                {data.loading && <p>Loading...</p>}
            </div>
            <For each={data()}>
                {(item, i) => (
                    <div>{i() + 1} / {item.title}</div>
                )}
            </For>
        </ErrorBoundary>
    );
};

WEB ブラウザで確認してみると 'all' が初期値のため最初は全件表示されます。
completed ボタンを押下すると signal の値が'completed'に変化し、関連付けられている fetchData 関数に'completed'が渡されます。
データフェッチの完了後は data に関連する部分のみが更新されます。

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