前回のおさらい
前回の記事では、Reactに有利なベンチマークでUIライブラリに競ってもらいました。
こういうベンチマークに対しては、「実務では〜」みたいな反応が一定数出てくるのが自然の摂理です。
書きやすさランキング
そこで、シリーズのまとめとして、より実務に近い指標として「書きやすさ」で競ってもらおうと思います。ただし、今回は筆者の独断と偏見によるランキングとなります。せっかく6つのライブラリで同じアプリケーションを書いたので、感想を記事にして残しておきたいという意図です。筆者と同じくReact脳の方にとっては参考になるかもしれません。
なお、前の記事を読んだ方はお分かりの通り、今回書いたアプリケーションはコンポーネントが何個かのものであり、React以外の知識は公式ドキュメントを一通り読んだ程度です。したがって、今回のランキングはコンポーネントの書きやすさに着目しています。大規模開発のノウハウなどは考慮されていないのでそこはご理解ください。
ではさっそくランキングを見ていきましょう。
1位: React
React脳なので当然1位はReactです。なお、Preactもアーキテクチャが同じなので同率1位です。
Reactのアーキテクチャはこのランキングに登場するライブラリの中でも特殊で、コンポーネントはpropsやstateからUI (ブラウザの場合はDOM) を出力する純粋関数であるというのが基本です。他のライブラリはコンポーネントを表すスクリプトが初期化時の1回しか実行されなかったりしますが、Reactの関数コンポーネントはレンダリングのたびに実行されます。
それゆえに、(JSXをJavaScriptの一部として認めれば)「ただのJavaScript」でコンポーネントが出力するUIを定義できるのがReactの大きな魅力です。これは、他のライブラリでv-if
などのディレクティブを使わないといけないのと対照的で、筆者がReactを特に好む理由のひとつです。Early returnを使うなどJavaScriptのテクニックも自由に使うことができ、UIを出力するロジックを書くという点においてもっとも長けていると思います。
if (!item) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.id}>{item.id}</div>
<div>{nameMarked}</div>
<div>{item.ja}</div>
</div>
);
ただ、ReactではuseEffect
に代表されるフックのAPIを用いてコンポーネントに副作用などのロジックを添加できるのが特徴的です。フックのルールによりearly returnしたあとにフックを使ってはいけないなどの制約があり、これはやや不便と言わざるを得ません(筆者もたまに間違えます)。
if (!item) {
return null;
}
// ここでフックを使うのはだめ
const { prefix, marked, suffix } = useMemo(() => {
// ...
}, [item]);
return (
<div className={classes.wrapper}>
<div className={classes.id}>{item.id}</div>
<div>...</div>
<div>{item.ja}</div>
</div>
);
この制約により、フックを使うパートを関数コンポーネント内の先にまとめて、UIの出力を最後で行うのが常套手段となります。もっとも、Reactではフックの中(useMemo
など)でもJSXを組み立てられるのでこの制約はあまり苦になることがありません。
関数コンポーネント時代におけるReactの特徴は、コンポーネントの実体(インスタンス)が我々から見えないところで管理されているということです。クラスコンポーネントの場合はクラスのインスタンスがコンポーネントの実体として、そのコンポーネントのライフサイクルの間生存しており、我々はthis
などを通じてそれに触れることができました。一方、関数コンポーネントの場合はその実体に直接触れることはできず、useState
やuseRef
などのフックを用いることで、その実体が持つ記憶領域に断片的なアクセスをすることができます。これが、関数コンポーネントがレンダリングされるたびに呼び出されるにもかかわらずコンポーネントがステートなどを持つことができるからくりです。
このように、フックはコンポーネントの実体とコンポーネントのコードの間に抽象化レイヤーを挟むためのAPIだと見ることができます。その利点はより高い凝集度のロジックを書けることです。
以上のことから、Reactは「関数ひとつでロジックを全部書けて、JavaScriptのフルパワーを使うことができる」点が特徴であり、筆者がReactが好きな理由です。
2位: Svelte
これ以降のライブラリは、いわゆるリアクティブなアーキテクチャを採用しています。つまり、コンポーネントのコアはミュータブルなロジックであり、それに対する変更を検知してUIが更新されるというものです。筆者がそのようなアーキテクチャのライブラリを評価するにあたって重視する点は、いかにコアロジックを分かりやすく書けるかです。
Svelteは、JavaScriptの構文を少し濫用して驚きはありつつも、少ないルールでリアクティブにコアロジックが書ける点を評価して2位としました。
Svelteのシングルファイルコンポーネントは次の形です。
<script lang="ts">
// ここがコアロジック
</script>
<!-- ここがテンプレート -->
<div>
...
</div>
<!-- スタイルも書ける -->
<style>
/* ... */
</style>
詳しくはSvelteのドキュメントを参照していただきたいのですが、<script>
の中を書くために覚える必要があるルールは、独特であるものの少数です。
/**
* ID of item
*/
export let id: string;
/**
* Search query, in lowercase
*/
export let searchQuery: string;
$: item = itemMap.get(id);
$: nameMarked = (() => {
if (!item) {
return undefined;
}
// ...
})();
まず、コンポーネントが受け取るpropsはexport let
で宣言されます。export
が外からの入力を表すのがあまりに独特であり、ES Modulesのセマンティクス的にもexport let
で宣言された変数がモジュール外から書き換えられることはあり得ないので、ここはJavaScriptから逸脱しておりやや気に入らない点です。
そうなると、変数id
やquery
が勝手に書き換わることがあります。そこで、トップレベルで$:
でラベル付けされた文は、依存する変数が書き換わった場合に自動的に再実行されます。例えば $: item = itemMap.get(id);
は、itemMap
かid
に再代入された場合に再実行され、item
の内容が更新されます。
item
やnameMarked
は一見すると宣言されていない変数を使っていてTypeScriptが怒りそうですが、Svelteでは不思議な力によってこれらの変数はlet
で宣言されていたことになり、TypeScriptもご満悦です。
$:
は代入以外の副作用にも使うことができます。このように$:
をいろいろな用途に使うことができるシンプルさが好印象です。
テンプレート部分はこんな書き味です。スクリプト部分のトップレベルで宣言された変数がテンプレートに露出され、使用できます。変数が更新された際はテンプレートの対応部分も更新されるようになっていて、その更新は最適化されています。
{#if item && nameMarked}
<div class="wrapper">
<div class="id">{item.id}</div>
<div>
<span class="name" class:unmatchedName={nameMarked.unmatched}>
{nameMarked.prefix}{#if nameMarked.mark}<mark>{nameMarked.mark}</mark
>{/if}{nameMarked.suffix}
</span>
</div>
<div>{item.ja}</div>
</div>
{/if}
Svelteのテンプレート構文はあまり特筆することがありませんが、テンプレート内でローカル変数を宣言できる{@const}
を備えているのが類似のライブラリより一枚上手に感じられました。
Svelteのリアクティブシステムは「代入」が更新を引き起こす鍵となっていて、例えば配列へのpushなどは更新のトリガーにならないと説明されています。不便に感じるかもしれませんが、筆者としてはProxyなどを使った魔法をされるよりも、このように構文的に判断できる仕組みのほうが好印象です。前回の記事のベンチマークでもSvelteは安定して他のライブラリを出し抜く速さを見せていましたが、このように構文的に判断してコンパイラで最適化できる部分を増やしているのがその理由かもしれません。
他にちょっとぎょっとした点としては、ストアの概念が組み込まれており、ストアが入った変数名の前に$
をつけるとストアの中身へのアクセスとなる機能があることです。例えばstore
がストアオブジェクトだとすると、$store
でストアの中身を取得でき、代入すれば書き換えもできます。ちょっと不気味ですがgetter/setterを持つ変数が自動的に宣言されると考えればぎりぎり許容できるかな? そんなSvelteが第2位です。
3位: Vue
VueもSvelteと同様、コンポーネントをシングルファイルコンポーネントが主流(多分)です。なお、Vueは歴史的経緯からなのかコンポーネントロジックの書き方がいくつかありますが、ここではcomposition API (+ <script setup>
) を評価対象としています。
.vue
ファイルの書き方はこんな具合です。Svelteと似ていますが、テンプレート部分は<template>
で囲むことと、<style>
がscopedと非scopedを使い分けられる点が特徴的です。
<script setup lang="ts">
// ...
</script>
<template>
<!-- ... -->
</template>
<style scoped>
/* ... */
</style>
<script setup>
に書いた内容がコンポーネントのコアロジックとなり、トップレベルで宣言された変数がテンプレートに露出されるのもSvelteと同様です。propsに関してはdefineProps
というマクロ(Vueコンパイラに解釈され解決される関数)によって宣言できます。
<script setup lang="ts">
const props = defineProps<{
/**
* ID of item
*/
id: string;
/**
* Search query, in lowercase
*/
searchQuery: string;
}>();
const item = computed(() => itemMap.get(props.id));
const nameMarked = computed(() => {
const i = item.value;
if (!i) {
return undefined;
}
// ...
});
</script>
Vueのリアクティビティのコアとなるのがrefオブジェクトとreactive proxyオブジェクトであり、データ間の依存関係は、あるデータの計算(あるいはテンプレートのレンダリング)時にrefやreactiveオブジェクトの中身にアクセスがあったかどうかによって判断されます。
例えば上の例ではitem
というcomputed refが定義されていますが、これは計算時にprops.id
を参照していることから、item
はprops.id
に依存していると判断されます。よって、props.id
が変わった場合にitem
が再計算されます。また、nameMarked
もitem.value
に依存しているので、nameMarked
も再計算されます。
VueではこのようなトラッキングをProxyオブジェクトを通じてランタイムに行なっています。これはコンパイル不要なモードを備えるVueならではですが、そのためかどうしてもref
, computed
, reactive
など記述上のオーバーヘッドが多い傾向が見られます。明示的に書けることはよいことですが、ref関連のutilがあまりに多く、2位のSvelteに感じると複雑さで差を感じました。
また、プロパティアクセスが依存関係発生のトリガーになる関係から、分割代入などを自由に行えない点は筆者としてはあまり好きではない点です。Svelteでも分割代入するところは適切に$:
をつけたりする必要がありますが、見た目の分かりやすさからSvelteに分がある気がします。
ただし、Vueでは依存関係のトラッキングがProxyを通してランタイムに行われることから、条件分岐によって依存したりしなかったりケースにはVueのほうが強いと思われます。
テンプレート部分の構文についてはそこまで特筆すべき点はありません。ifなどのディレクティブが独自の構文ではなくタグの属性扱いになっているのが特徴的です。これは、Vueが(SFCではなく)生のHTMLにもテンプレートを書けるようになっている(色々HTMLの仕様に由来する制限がありますが)ことによるものでしょう。
<template>
<div class="wrapper" v-if="item && nameMarked">
<div class="id">{{ item.id }}</div>
<div>
<span :class="['name', {unmatchedName: nameMarked.unmatched}]">
{{ nameMarked.prefix }}<mark v-if="nameMarked.mark">{{ nameMarked.mark }}</mark>{{ nameMarked.suffix }}
</span>
</div>
<div>{{ item.ja }}</div>
</div>
</template>
ちなみに、Proxyを使っているという点に関しては、現在experimentalのReactivity Transformによって解消される見込みもありそうです。そうしたら筆者からのVueの評価が上がるかもしれません。そんな将来性も併せ持つVueが第3位です。
4位: SolidJS
SolidJSはJSXを採用しているからReact使いとの親和性が高いという噂がありますが、使ってみたところそんなことはないと思いました。
SolidJSのアーキテクチャとしてはリアクティビティを中心に添えていることから、ReactではなくVueやSvelteの仲間です。SolidJSは専用のシングルファイルコンポーネントのフォーマットを持たず、ただの.tsx
ファイルで書けるのが特徴であり、そのためにJSXが採用されています。
SolidJSにおけるコンポーネント定義は、Reactと同様に「propsを受け取りJSX.Element
を返す関数」として定義します。次がその実例です。
export const Item: Component<Props> = (props) => {
const item = () => itemMap.get(props.id);
const nameMarked = createMemo(() => {
const i = item();
if (!i) {
return undefined;
}
// ...
});
return (
<Show when={item()}>
{(item) => (
<Show when={nameMarked()}>
{(nameMarked) => (
<div classList={{ [classes.wrapper]: true }}>
<div classList={{ [classes.id]: true }}>{item.id}</div>
<div>
<span
classList={{
[classes.name]: true,
[classes.unmatchedName]: nameMarked.unmatched,
}}
>
{nameMarked.prefix}
<Show when={nameMarked.mark}>
<mark>{nameMarked.mark}</mark>
</Show>
{nameMarked.suffix}
</span>
</div>
<div>{item.ja}</div>
</div>
)}
</Show>
)}
</Show>
);
};
ただし、Reactとは異なり、この関数が実際に実行されるのはコンポーネントのライフサイクルのはじめに1回だけです。つまり、この関数の中身はVueで言う<script setup>
に相当するものを記述する場所であり、返り値が<template>
を記述する場所であるということです。この関数の中では、Vueのref
に相当するcreateSignal
や、Vueのcomputed
に相当するcreateMemo
などを使用できます。これらの返り値はsignal(Vueのrefに相当するもの)です。
JSX内での条件分岐やループに関してもReactとは異なり、JavaScriptの式やArray#mapなどを使うのではなく、制御フロー用に用意されたコンポーネント(Show
, For
, Index
, Switch
, Match
など)を使うべきだとされています。
SolidJSでは、シグナルに対するアクセスはsignal()
のように関数呼び出しの形で行われます。ただし、propsに関してはVueと同様にprops.id
のようなプロパティアクセスがリアクティビティのトリガーとなります。
驚くべきことに、レンダリング内容がリアクティブに反応するようにするためには、シグナルに対するアクセスをJSXの中で行う必要があります。逆に言えば、シグナルへのアクセスをJSX(というよりコンポーネントのreturn文の外)に出すと、正しく反応できません。
function Counter() {
const [count, setCount] = createSignal(0);
setInterval(() => setCount(count() + 1), 1000);
return <div>Count: {count()}</div>;
}
function Counter() {
const [count, setCount] = createSignal(0);
setInterval(() => setCount(count() + 1), 1000);
const c = count(); // count()の呼び出しをreturn文の外に出したら動かなくなった!
return <div>Count: {c}</div>;
}
個人的にはこれが致命的な問題です。関数呼び出しの結果を変数に入れておいただけで挙動が変わるというのはあまりにJavaScriptのセマンティクスから逸脱しており、筆者の許容範囲を超えています。SolidJSでは我々はJavaScriptではなく、言わばSolid DSLを書いているのだということを思い知らされます。筆者のSolidJSに対する評価はこれにより大きく下がっています。Vueなどで「プロパティアクセスをどこで行なったかによって挙動が変わる」というのもやや微妙でしたが、それはまだJavaScriptのセマンティクスの範囲内で理解できるものでした。
Show
による型の絞り込みがうまく効くなどJSXの良さが活かされている場面もあるものの、Reactの関数コンポーネントのガワだけを真似るのに固執したせいなのかはいざ知らず、セマンティクスに大きな問題を抱えるSolidJSが第4位です。
番外: Angular
すみません、Angularもドキュメントを一通り読んで動くサンプルは作れたのですが、自分の実力不足によりベストプラクティスが全然理解できなくて理念や本質が読み取れなかったのでランキングに入れませんでした。他のライブラリはチュートリアルを一通り読めば小さいアプリなら理想的と思われる書き方ができたのですが、Angularはなんとか動くものができるまでにドキュメントを右往左往しました。「わからん! 5位!」はさすがに申し訳ないので番外としました。
リアクティブ系全体のテンプレートについての感想
Svelte, Vue, SolidJSをまとめてリアクティブ系と呼ぶことにしますが、これらに共通する特徴として、テンプレート部分(いわゆる UI = f(state) で言うfに相当する部分)に含まれるロジックを、JavaScriptではなく独自のテンプレート構文({#if}
, v-if
, <Show>
)を使って書くことが挙げられます。
React脳としては、ここでJavaScriptのフルパワーを使えないのがReactに比べると残念だなあと思っており、これらのライブラリよりもReactが上位に来る一因となっています。特に、スクリプト側とテンプレート側で同じ条件分岐を書かないといけなかったりすると、凝集度が下がるのでとても良くありません。
それでも、テンプレートはテンプレートなりに頑張っています。例えば、TypeScriptは条件分岐による型の絞り込みがとても重要ですが、実はSvelte, Vue, SolidJSでのifに相当する構文はいずれも型の絞り込みをサポートしています。SvelteとVueは拡張機能で頑張っている一方、SolidJSはJSXなのでShow
の型定義で頑張ればいいため、そこはSolidJSがリードしている点です。
そのようなことを考えると、テンプレートだからだめと頭ごなしに言えるわけでもないのですが、個人的にテンプレートがいけてないと思ったのはslotにpropsを渡すパターンです。Vueのドキュメントから引用すると:
<!-- <MyComponent> template -->
<div>
<slot :text="greetingMessage" :count="1"></slot>
</div>
このように、<MyComponent>
のテンプレートの中で<slot>
を使うことで、<MyComponent>
の子要素として渡されたものを表示できます。Reactで言うところのprops.children
です。
このとき、:text
や:count
というのが、MyComponent
側から子要素に対してpropsを送り返しているものです。
これは使用者側でこのようにv-slot
ディレクティブを使って受け取ることができます。
<MyComponent v-slot="slotProps">
{{ slotProps.text }} {{ slotProps.count }}
</MyComponent>
また、Svelteにも同じような機能があります。今度はSvelteのドキュメントから引用します。
<!-- FancyList.svelte -->
<ul>
{#each items as item}
<li class="fancy">
<slot prop={item}></slot>
</li>
{/each}
</ul>
<!-- App.svelte -->
<FancyList {items} let:prop={thing}>
<div>{thing.text}</div>
</FancyList>
このように、<slot>
にpropsを渡せるのは同じで、使用者側ではlet:prop
によりslotに渡されたものを受け取ることができます。
以上のように、Reactで言うところのHoCやrender props的なことをしたい場合にそれ用の構文をいちいちテンプレートに足さないといけないのが、非JavaScriptであるがゆえの限界を見ているような気がしてやや残念です。React(およびSolidJS)だとただの関数でOKです。
まとめ
今回は、React大好きな筆者の視点からUIライブラリたちをランキング形式で評価してみました。
筆者としては、Svelte, Vue, SolidJSのようなリアクティブなシステムも嫌いというわけではありません。筆者は最近ReactアプリケーションでもRecoilを使ってデータフローグラフを作ることにはまっていますが、これも一種のリアクティブシステムです。つまり、リアクティブシステムもうまく使えばとても有用な道具だし、Reactからも使い道があります。それをコアに組み込んだのがSvelteなどのライブラリです。
Svelteなどは、ある種のDSLを用いてリアクティブなコアをとても簡潔に書けるのがとても評価できます。さすがにビルドシステムが大きくなりそうですがそこだけReactにも欲しいくらいです。
React使いのみなさんが他のライブラリに手を出したいときの参考になれば幸いです。