🧊

Solidの特徴と、その裏側

https://www.solidjs.com

ずっと調べたいものリストにはあったやつで、Astro熱がちょっと収まったのでこのタイミング。

めちゃめちゃ雑にまとめると、React + MobX feat. Svelteって感じのUIフレームワークです。(つまり好みは分かれるであろう)

Solidとは

  • 基本はReactライクな書き味
    • JSXでコンポーネントを書く、Propsでデータを単方向に流す
    • もちろん細かい挙動に差異はあるし、互換性はない
    • `Suspense`とか`Portal`とか`ErrorBoundary`とかもある
  • MobXライクなリアクティブの仕組みがビルトインされてる
    • Propsが自動でProxyされてるイメージ
    • 依存配列なしに、状態を読み取ったコンポーネント・関数だけが更新できたり
    • ストア(ちょっと大きめの状態用)関連のコードもある
  • Svelteと同様に、コンパイルして最適化されたコードがランタイムで動く
    • つまりVDOMではなく、カリカリのVanillaJSくらい速い
    • 最小サイズのコードベースだと、Svelteのほうが小さいらしいけど
  • JSXな分、SvelteよりもTS対応もアツめ
  • Svelteみたいに、テンプレで使える便利記法もある
    • `classList`で`string: boolean`なオブジェクト渡せる
    • `use:xxx`でその要素に対するディレクティブ作れる

というのが概要のまとめで、後発なだけあっていろんなやつのいいとこ取りをしてるな〜って感じ。

コード例とかそういうのは、DocsにいっぱいあるしTutorialもあるし、割愛。気になった人はぜひぜひ公式Docsへ。

実際にコードを書いてみて

社内案件の小さいアプリをPreactからSolidにリライトしてみて思ったこと。

  • Reactのメンタルモデルに毒されてると、微妙な差にいちいち戸惑う
  • 思ったよりも多機能で、SimpleっていうよりEasyなAPIセット
    • そういう意味で対抗馬はやっぱSvelteなのかも
  • Props(というか`Signal.Accessor`)がプレーンなJSではないというクセ
    • やっててよかったMobX
  • 雑に書いても再描画の範囲が抑えられるのはよい

個人的には割と好感触ではある。が、案件という意味での使い所はどうかな〜って感じ。

PreactとSvelteを足して2で割ったみたいな、立ち位置が中途半端なところなのもあって、もう少し使ってみないとなんとも。

ただエコシステムまわりはまだまだ比ではないので、未だPreactすら選べないリアルワールドでは中々に厳しいかもしれない。それでも、そのうちReact枠の代わりになったらいいなーって期待してる。

solidjs/solid リポジトリの概観

というわけで、ここから先はリポジトリ構成とかそういうコード的な裏側を見ていく。ちなみに、今の最新バージョンは`v1.4.6`だった。

https://github.com/solidjs/solid

これがメインのリポジトリでモノレポ、置いてあるパッケージは次の通り。

  • solid
  • babel-preset-solid
  • solid-ssr
  • solid-element

`solid-element`はWebComponents用のサポート層なので、一旦無視する。

`solid-ssr`はSSRって名前はついてるけど実体は`renderStatic()`っていうSSG用のAPIしか公開してなくて、いわゆるSSR用のAPIたちは、`solid`本体のほうにある。よってこれも無視。

babel-preset-solid

https://github.com/solidjs/solid/tree/main/packages/babel-preset-solid

概要でも書いたとおり、SolidではテンプレにJSXを使うので、そのコンパイルをやる。処理自体はBabelを使うようになってて、そのプラグインがコレってわけ。

ちなみに、Viteのプラグインもあるけどそっちでも同じようにBabelを呼んでる。

https://github.com/solidjs/vite-plugin-solid

このプラグインを通すと、JSXで書かれたマークアップコンポーネントが、生DOMのAPIである`createElement()`とか`cloneNode()`とかを駆使したコードに置き換えられる。

実際にそれをやってるのは`dom-expressions`っていう別のライブラリで、これもSolidの作者であるRyan氏の謹製。

https://github.com/ryansolid/dom-expressions

READMEからコード例を借りてくるとこういう具合。

// これが
const view = ({ item }) => {
  const itemId = item.id;
  return <tr class={itemId === selected() ? "danger" : ""}>
    <td class="col-md-1">{itemId}</td>
    <td class="col-md-4">
      <a onclick={e => select(item, e)}>{item.label}</a>
    </td>
    <td class="col-md-1">
      <a onclick={e => del(item, e)}>
        <span class="glyphicon glyphicon-remove" aria-hidden="true"></span>
      </a>
    </td>
    <td class="col-md-6"></td>
  </tr>;
};


// こうなる
import { insert as _$insert } from "dom";
import { wrap as _$wrap } from "dom";

const _tmpl$ = document.createElement("template");
_tmpl$.innerHTML = `<tr><td class="col-md-1"></td><td class="col-md-4"><a></a></td><td class="col-md-1"><a><span class="glyphicon glyphicon-remove" aria-hidden="true"></span></a></td><td class="col-md-6"></td></tr>`;

const view = ({ item }) => {
  const itemId = item.id;
  return (function() {
    const _el$ = _tmpl$.content.firstChild.cloneNode(true),
      _el$2 = _el$.firstChild,
      _el$3 = _el$2.nextSibling,
      _el$4 = _el$3.firstChild,
      _el$5 = _el$3.nextSibling,
      _el$6 = _el$5.firstChild;
    _$wrap(() => (_el$.className = itemId === selected() ? "danger" : ""));
    _$insert(_el$2, itemId);
    _el$4.onclick = e => select(item, e);
    _$insert(_el$4, () => item.label);
    _el$6.onclick = e => del(item, e);
    return _el$;
  })();
};

このライブラリ自体は汎用的な用途を目指していて、ここでいう`dom`っていう参照先を自分たちのレンダラに向けて使ってねって書いてあった。(`babel-plugin-transform-rename-import`で`import`先をすげ替えるとのこと)

最初のプラグインでは、それ以外の`For`とか`Show`とかのSolidでだけ使える条件分岐用のコンポーネントや、`Suspense`みたいな独自コンポーネントホワイトリストも列挙されてて、まとめてトランスパイルされる。

// これが
import { render } from "solid-js/web";
import { createSignal, Show } from "solid-js";

function Counter() {
  const [count, setCount] = createSignal(0);
  const increment = () => setCount(count() + 1);

  return (
    <div>
      <button type="button" onClick={increment}>
        {count()}
      </button>
      <Show when={count() % 2 === 0} fallback="ODD!">EVEN!</Show>
    </div>
  );
}

render(() => <Counter />, document.getElementById("app")!);


// こうなる
import { render, createComponent, delegateEvents, insert, template } from 'solid-js/web';
import { createSignal, Show } from 'solid-js';

const _tmpl$ = /*#__PURE__*/template(`<div><button type="button"></button></div>`, 4);

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

  const increment = () => setCount(count() + 1);

  return (() => {
    const _el$ = _tmpl$.cloneNode(true),
          _el$2 = _el$.firstChild;

    _el$2.$$click = increment;

    insert(_el$2, count);

    insert(_el$, createComponent(Show, {
      get when() {
        return count() % 2 === 0;
      },

      fallback: "ODD!",
      children: "EVEN!"
    }), null);

    return _el$;
  })();
}

render(() => createComponent(Counter, {}), document.getElementById("app"));

delegateEvents(["click"]);

というような様子は、Playgroundで見られるので要チェック。

Solid Playground

というように、JSXの変換処理から自作したりしてるがゆえに、Solidではこれほどのパフォーマンスが出るとのこと。

solid-js/*

続いて、本体から公開されてるAPIたち。exportされてるネームスペースとしては、次の通り。

  • `solid-js`
  • `solid-js/store`
  • `solid-js/web`
  • `solid-js/h`
  • `solid-js/html`
  • `solid-js/universal`

簡単そうなやつから見ていく。

solid-js/universal

https://github.com/solidjs/solid/tree/main/packages/solid/universal

いわゆるカスタムレンダラを利用するための層。`dom-expressions`がカバーするプリミティブなDOMのAPIに対応するコードを書けば、レンダラを自作できるよっていう。

もうこんなとこまで用意してるのすごいなって思ったけど、なんか利用例はあるんかな?

solid-js/html, solid-js/h

https://github.com/solidjs/solid/tree/main/packages/solid/html
https://github.com/solidjs/solid/tree/main/packages/solid/h

宗教上の利用でJSXが使えない人のための、Tagged Template LiteralとHyperScriptでもテンプレが書けるよっていうやつ。

JSXではないので、シンタックスに多少の制約はあるものの、ビルドなしで動かせるのは使いどころがあるかもしれない。

ま〜書くの大変やけども。

solid-js

https://github.com/solidjs/solid/tree/main/packages/solid/src

`createSignal()`とか`onMount()`とか、`Show`コンポーネントとか、つまりはメインのAPIたち。コアな中身を読むのはまたの機会に譲るとして、気になったやつだけピックアップ。

APIの一覧という意味では、やっぱりDocsがわかりやすくて眺めるのおすすめ。

https://www.solidjs.com/docs/latest/api

createResource()

一言でいうなら、ReactでSWRを使ったコードみたいなのが書けるヘルパー。

import { createResource } from "solid-js";

const [data, { mutate, refetch }] = createResource(fetcher);

// read value
data();

// check if loading
data.loading;
// check if errored
data.error;

// directly set value without creating promise
mutate(optimisticValue);
// refetch the last request explicitly
refetch();

というだけでもう便利ではあるが、さらには`Suspense`コンポーネントを作用させるきっかけにもできる。`Suspense`には`lazy()`かコレかって感じ。

untrack() / batch()

どっちもMobXで見たやつやん!!(懐かしくてつい)

どっちもリアクティブなコンテキストで使うもので、`untrack()`はその引数に与えたコールバック内での値へのアクセスを無視させるやつで、`batch()`は状態の更新をまとめてコミットするようにできる。

普段使いするものではない。

mergeProps()

これもMobX的なコンテキストで思い出すと、コンポーネントのPropsをDestructuringしてはいけない都合上、Propsの初期値を与えるのが不便・・・っていうときに使えるやつ。

export default function Greeting(props) {
  const merged = mergeProps({ greeting: "Hi", name: "John" }, props);
  return <h3>{merged.greeting} {merged.name}</h3>
}

どうしてもDestructuringしたい場合、いちおうライブラリでごまかせるらしい。

Switch / Match

見たらわかる、便利なやつやん!

<Switch fallback={<div>Not Found</div>}>
  <Match when={state.route === "home"}>
    <Home />
  </Match>
  <Match when={state.route === "settings"}>
    <Settings />
  </Match>
</Switch>

Solidでは、JSXあるあるの`Array#map()`や三項演算子の代わりに、それ用のコンポーネントがちゃんと用意されてて、そっちを使うほうがパフォーマンスも良くなる。役割的にはMobXでいう`Observer`コンポーネントみたいな再描画領域を区切るイメージかも。

solid-js/web

https://github.com/solidjs/solid/tree/main/packages/solid/web

クライアント・サーバーあわせてWebで使う専用のAPIたち。

具体的には、`Portal`と`Dynamic`のコンポーネントおよび、`render()`や`hydrate()`といったエントリーのコード。

`renderToString()`みたいなSSR用のコードがexportされてるのもココではあるけど、その実体は某`dom-expressions`にある。(SSR用のコードどこ?ってめっちゃ探してしまった)

solid-js/store

https://github.com/solidjs/solid/tree/main/packages/solid/store

いわゆるStoreのためのコード群。個人的に興味深いと思ったのは、状態を更新するときのコード。

import { createStore } from "solid-js/store";

const [state, setState] = createStore({
  firstName: "John",
  lastName: "Miller",
});

// 存在しないキーは上書き
setState({ firstName: "Johnny", middleName: "Lee" });
// ({ firstName: 'Johnny', middleName: 'Lee', lastName: 'Miller' })

// 更新されないものは消えない
setState((state) => ({ preferredName: state.firstName, lastName: "Milner" }));
// ({ firstName: 'Johnny', preferredName: 'Johnny', middleName: 'Lee', lastName: 'Milner' })


const [state, setState] = createStore({
  counter: 2,
  list: [
    { id: 23, title: 'Birds' }
    { id: 27, title: 'Fish' }
  ]
});

// キー指定で更新
setState('counter', c => c + 1);
setState('list', l => [...l, {id: 43, title: 'Marsupials'}]);
// list[2].read = true ってこと
setState('list', 2, 'read', true);
// {
//   counter: 3,
//   list: [
//     { id: 23, title: 'Birds' }
//     { id: 27, title: 'Fish' }
//     { id: 43, title: 'Marsupials', read: true }
//   ]
// }


const [state, setState] = createStore({
  todos: [
    { task: 'Finish work', completed: false }
    { task: 'Go grocery shopping', completed: false }
    { task: 'Make dinner', completed: false }
  ]
});

// 対象を選べる
setState('todos', [0, 2], 'completed', true);
// {
//   todos: [
//     { task: 'Finish work', completed: true }
//     { task: 'Go grocery shopping', completed: false }
//     { task: 'Make dinner', completed: true }
//   ]
// }

// レンジ指定もできる
setState('todos', { from: 0, to: 1 }, 'completed', c => !c);
// {
//   todos: [
//     { task: 'Finish work', completed: false }
//     { task: 'Go grocery shopping', completed: true }
//     { task: 'Make dinner', completed: true }
//   ]
// }

// フィルターにもなる
setState('todos', todo => todo.completed, 'task', t => t + '!')
// {
//   todos: [
//     { task: 'Finish work', completed: false }
//     { task: 'Go grocery shopping!', completed: true }
//     { task: 'Make dinner!', completed: true }
//   ]
// }

setState('todos', {}, todo => ({ marked: true, completed: !todo.completed }))
// {
//   todos: [
//     { task: 'Finish work', completed: true, marked: true }
//     { task: 'Go grocery shopping!', completed: false, marked: true }
//     { task: 'Make dinner!', completed: false, marked: true }
//   ]
// }

なにこれおしゃれ・・・!って感じやったけど、さらには`immer`ライクなアップデート方法まで用意されてる。

import { produce } from "solid-js/store";

setState(
  produce((draft) => {
    draft.user.name = "Frank";
    draft.list.push("Pencil Crayon");
  })
);

これだけでいいのでは説ある。

まとめ

いやー趣味があいますね!っていうのが主な感想。Preact+MobX推しのツボが。

さすがに、コードの少なさやオールインワンの手軽さを求めるならSvelteでいいけど、それ以外の場面ではもう少し使っていこうかなって。

いわゆるNext.jsやSvelteKit枠については、`solid-start`っていうのが一応あるけど、まだまだ比較対象にはならなそう。ドキュメントもないし。

https://github.com/solidjs/solid-start

ただRyan氏はeBay在籍時にMarkoにも関わってたりと、SSR方面にも確固たる信念を持ってるタイプの稀有な人材なので、そのあたりも何か隠し玉があるのかもしれない。

最近やっと世間的にも名前を聞くようになってきたかな?って感じもあるし、これからに期待。