はじめに
こんにちは。ニフティ株式会社の山田です。
今回は、フロントエンドフレームワークとして最近SolidJSを試してみたので、その内容を紹介いたします。
そもそもSolidJSとは?
SolidJSとは、仮想DOMを使用しないJSX系宣言的UIフレームワークです。
宣言的UIフレームワークとしてはReactやVue.jsが有名ですが、この2つは差分レンダリングに仮想DOMを利用しています。
これは仮想DOMに一度書き込んだあと、差分を計算して実際のDOMに反映させるという動作を基本としています。プログラミングモデルが単純化される一方で、仕組みとしては重く、JavaScriptのサイズを肥大化させる原因ともなっています。仮想DOMへの書き込みを減らす仕組みも入っていますが、根本的な重さからは逃れられません。

一方で最近登場したのが、イベント駆動とトランスパイルを組み合わせるフレームワークです。
これらのフレームワークは、変数を「値変化イベントを発火させるオブジェクト」として取り扱うことで、その変数に関連するコンポーネントだけを再レンダリングするように動作します。
普通であればコーディングが複雑化しますが、ビルド時変換を多用することで極力簡単に書けるようになっています。

有名なフレームワークとしてSvelteが挙げられますが、SvelteはVue.jsに近いHTML/CSS/JavaScriptを1ファイルに書く独自文法を採用しています。
一方で、React同様にJSXを採用しているのがSolidJSです。
SolidJSは値変化の検出にsignalという仕組みを採用しています。この仕組みはknockout.jsで開発されたものですが、宣言的UIフレームワークに採用したのはSolidJSが初であり、その後PreactやVue.js、Svelte 5などでも採用が広がっています。

SolidJSのメリット
- 独自文法を採用せず、JSXベースのためセットアップが容易
- SvelteはLinterなどのセットアップが辛い…
- Astroに部分的に組み込む〜 などがやりやすい
- 読みにくい挙動部分が少ない
- この辺りもReactの思想に近い
- Svelteのように Easy >>> Simple な思想とは真逆
- 仮想DOMを使用せず、動作速度・JSサイズともに軽量
デメリット
- 利用者がまだ少ない…
- React > Vue.js > Svelte >>>>> SolidJS くらいの肌感覚
- 周辺ツールのサポートも少ない
- Vitest Browser Mode 非対応なのが痛い
- SSRフレームワーク(SolidStart)の歴史が浅い
- 2024年4月にようやく1.0になったばかり
使い心地
基本的なコンポーネント
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import { createEffect, createSignal, onCleanup } from "solid-js"; const CountingComponent = () => { const [count, setCount] = createSignal(0); const interval = setInterval( () => setCount(c => c + 1), 1000 ); onCleanup(() => clearInterval(interval)); createEffect(() => { console.log(`count: ${count()}`); }); return <div>Count value is {count()}</div>; }; |
Reactに慣れている人であれば、
- createSignal → useState
- createEffect → useEffect
のように読み替えて理解ができるくらいにはそっくりです。
Reactでは状態変数をstateと呼びますが、Solidではsignalと呼びます。値の変化に応じて値変化イベントを発火させるオブジェクトになっています。
Reactとの違いとしては、以下となります。
- コンポーネントは初期化時の1回しか実行されないこと
- Reactのように再レンダリングの度に実行される、というモデルではない
- signalが変化した場合、signalに関連する部分のコードだけを再実行する
- 定数宣言してしまうと初期化時に値が固定されてしまう
- signalに依存する別の変数を扱う場合は、関数として扱います
1 2 3 4 5 6 |
const [base, setBase] = createSignal<number>(1); // Bad: これは初期化時に値固定され、以降変わらなくなる const multiply = base * base; // Good: これはbaseの変化に応じて変化する const multiply = () => base * base; |
- returnは必ず1つでなければならない
- 複数あったとしても、使われるのは初期化時に使われた1つだけ
- 変化する値は関数を通してアクセスする
- 例えばcreateSignal()で返るのは定数ではなく、関数になっている
- TypeScript的にはAccessor<T>という型になっている
- 例えばcreateSignal()で返るのは定数ではなく、関数になっている
1 2 3 |
type Signal<T> = [get: Accessor<T>, set: Setter<T>]; type Accessor<T> = () => T; type Setter<T> = (v: T | ((prev?: T) => T)) => T; |
- createEffectに依存配列は必要ない
- ReactのuseEffectとは明確に異なる
- createEfffectの中で使われているsignalが変化したとき、自動で再実行される
props
1 2 3 4 5 6 7 8 9 10 |
import { type JSX } from 'solid-js'; type Props = { titile: string; url: string; }; const Component = (props: Props): JSX.Element => { return <a href={props.url}>{props.title}</a> }; |
propsもReact同様…ですが、オブジェクトの展開(destructuring)を行ってはいけないです。
(以下のように書くのはアウトとなります)
1 2 3 4 5 6 7 8 |
const Component = ({title, url}: Props): JSX.Element => { ... }; // これはは以下の構文と等しい // つまり初回実行時の値をconstで取り出して固定化しているので、値の変化に追従しなくなる // const Component = (props: Props): JSX.Element => { // const { title, props } = props; // ... // }; |
制御構文
「変数(signal)の変化に応じて再レンダリングする」という構造上、コンポーネントの出し分けに三項演算子やmap()を使用してしまうと無駄な再レンダリングを引き起こしてしまう可能性が高いです。(条件文の結果が変わらなくても、条件判定に使用するsignalが変わっていれば再レンダリングされるため)
というわけで、コンポーネントの出し分けには独自のコンポーネントが必要になります。
Show
1 2 3 4 5 6 |
<Show when={/* 条件文 */} fallback={/* else相当のコンポーネント */} > {/* コンポーネント */} </Show> |
if文や三項演算子の代わりになるのがShow。whenに条件文を渡して制御します。else相当のコンポーネントはfallbackに渡します。
残念な仕様として、whenによるtype narrowingが効かないです。このためasなどを使用せざるを得ません。
1 2 3 4 5 6 7 8 |
const data: Accessor<string | string[]>; <Show when={!Array.isArray(data)} > {/* whenと↓のコンポーネントは文法上の紐づけがないので、!isArray()による型の絞り込みがされない */} <SomeComponent data={data() as string}> </Show> |
一応、子コンポーネントを「whenの値を渡す形の関数」として記述でき、null・undefined判定だけならこれで解決できます。
1 2 3 4 5 6 7 |
const data: Accessor<string | undefined>; <Show when={data} > {(data) => <SomeComponent data={data() /* string型 */}> } </Show> |
For / Index
forやmap()の代わりはForとなります。
1 2 3 4 5 |
<For each={/* forで回せるデータ */} > {(item) => <SomeComponent data={item()}>} </For> |
同じ文法でIndexというものもあります。それぞれの使い分けはドキュメントに記載されていますのでそちらを参照してみてください。
createResource
データの非同期取得のために、createResouceが標準で用意されています。
ReactのSWRやTanstack Queryのような別ライブラリを必要とせずにデータフェッチを記載できます。
1 2 3 4 5 6 7 8 9 |
const fetchUser = async (userId) => (await fetch('https://....')).json(); const userId = createSignal<string>(""); // 第一引数に与えたsignalに応じて自動的に第二引数の非同期関数を実行する const [user] = createResource(userId, fetchUser); // refetchなどを行いたい場合は追加の戻り値もある const [user, { mutate, fetch }] = createResource(userId, fetchUser); |
まとめ
Good Point
- 挙動が理解しやすい
- Svelteのような、どう動くのか理解不能な局面が少ない
- 軽い
- Reactで書くと数百KBになるものが数KBで済む
- 運用が軽い
- 破壊的変更が非常に少ない
- Svelteは周辺ツールもふくめてそれなりに壊してくる & Svelte 5で超大規模変更が…
- JSXなので大体のツールが標準対応しており、余計なパーサーなどの設定が不要
- 破壊的変更が非常に少ない
- 罠はあるが、入門ドキュメントで覚えた程度で事足りる
- 公式ドキュメントに日本語あり
Bad Point
- Showが辛い
- ここだけ型安全性が崩れる
- SolidStartの歴史が流石に浅い
- まだ半年程度…
- ベースになっているvinxiがまだ0.4でドキュメントも貧弱
- Astro + Solidのような組み合わせが現状は安牌
今回はSolidJSを試してみた所感を紹介しました。SolidJSや他フレームワークの比較をされている際に参考になれば幸いです。