React から Svelte へのアプリ書き換えで何が起きるか
Published: 2021-04-08
最近、React で書かれた中規模アプリ(React コンポーネントが約 250 個)を Svelte に書き換えました。結果として、アプリはより効率的になり、保守が簡単になり、コードもずっと読みやすくなりました。とはいえ、いくつか問題や、Svelte コードへ素直には変換できない場面もありました。この記事では、そのような状況の一部を紹介します。
これは React プログラマー向けの Svelte チュートリアルではありません。まだ Svelte を知らない場合は、まず公式の Svelte tutorial から始めるのがよいでしょう。この記事では、Svelte の原理を説明する代わりに、Svelte のチュートリアルへのリンクを多く含めています。
CSS
Svelte は CSS をとてもきれいで分かりやすい形で直接サポートしています。React アプリでは styled components を使っていました。
React には多くの CSS ライブラリがありますが、私の知る限りどれも CSS-in-JS パラダイムを使っています。一方 Svelte では、かなり素直な CSS を使います。
次のスニペットでは、ツールバーのボタンの簡略実装を、React(styled components 使用)と Svelte で示します。
ツールバーボタンには 1 つのプロパティ(disabled)があり、これが hover の機能を無効にします。また、テーマからいくつかの変数を利用します。
// React component ToolbarButton({disabled, toolBarHeight})
const ToolbarButton = styled.div`
padding: 5px 15px;
height: ${props.toolBarHeight}px;
${(props) =>
!props.disabled &&
`
&:hover {
background-color: gray;
}
`}
`;
<!-- Svelte component -->
<script>
export let disabled;
</script>
<div class="button" class:disabled />
<style>
.button {
padding: 5px 15px;
height: var(--theme-toolbar-height); /* prop を直接は使えないので、CSS 変数を使う必要がある */
}
.button.disabled {
background-color: gray;
}
</style>
Svelte は CSS 内での変数をサポートしていません。そのため、Svelte コンポーネントにプロパティを渡して、それを CSS で直接使うことはできません。これを行う唯一の方法は、CSS クラスを ON/OFF で切り替えることです。disabled が true に設定されているときに、button.disabled セレクタが使われます。
CSS で何らかの値(例えば寸法)を使いたい場合は、CSS 変数を利用できます。
React hooks、特に useEffect
React hooks は非常に強力な機能です。Svelte にはこれに相当するものはありませんが、React hooks の多くは Svelte ではそもそも必要になりません。
- useState - 状態は let 文で定義します
- useMemo - 内部状態の再計算にはリアクティブ文を使えます
- useCallback - 関数式は一度だけ評価されるので、あまり意味がありません
- useContext - Svelte にはとてもシンプルで分かりやすいコンテキスト API(getContext(), setContext())があります
- useReducer - Svelte 版をストアを使って自作する必要があります(とても簡単 にできます)
- useRef - bind:this(またはインスタンス変数用の let 変数)を代わりに使えます
- useEffect - これは少し複雑です…
useEffect - 単純な使い方
私は React では useEffect をよく使いますが、Svelte で同じロジックをどう実装するかは考える必要があります。
最も単純な使い方は、マウント時とアンマウント時にコードを実行することです。
// React
React.useEffect(() => {
console.log('MOUNT');
return () => console.log('UNMOUNT');
}, []);
このパターンは Svelte の onMount と onDestroy メソッドでカバーされます。
// Svelte
onMount(() => {
console.log('MOUNT');
return () => console.log('UNMOUNT 1');
});
onDestroy(() => {
console.log('UNMOUNT 2');
});
見ての通り、アンマウント時にコードを実行する方法は 2 通りあります。onDestroy 関数を使う方法と、onMount 関数の戻り値として返す方法です。
useEffect - リアクティブ文
ある式が変化したときにコードを実行したい場合、Svelte の reactive statement を使えます。
// React
React.useEffect(() => {
console.log('HEIGHT changed, new value:', height);
}, [height]);
// Svelte
$: console.log('HEIGHT changed, new value:', height);
Svelte では、リアクティブ文は、その文の依存関係のいずれかが代入されたときに実行されます。したがって、依存関係が変化したときだけ何かをしたい場合は、自分で値をチェックする必要があります。
React.useEffect の第 2 引数のように、依存関係を明示的に列挙したい場合は、次のパターンを使えます。
// Svelte
$: {
height;
width;
handleChanged();
}
これは height または width 変数に代入が行われた後に呼び出されます。handleChanged 関数内で他の変数を使うこともできますが、それらの依存関係はこのリアクティブ文をトリガーしません。
useEffect - クリーンアップ付きリアクティブ文
React の useEffect は、プロパティ値に依存するリソースを確保するためにも使えます。次の例では、コンポーネントは SQL データベース内のテーブル一覧を表示しています。データベース構造の変更を監視しています。connectionId プロパティが変わったときには、古いソケットから切断し、新しいソケットに接続し直す必要があります。
// React
function SqlTableList({ connectionId }) {
React.useEffect(() => {
socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
return () => {
socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
};
}, [connectionId]);
}
Svelte はこのシナリオを React ほど素直な形ではサポートしていません。しかし、簡単なトリックを使えば、この挙動を実現できます。
// Svelte
const useEffect = subscribe => ({ subscribe });
$: effect = useEffect(() => {
socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
return () => {
socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
};
});
$: $effect;
このトリックがどう動くかを理解するには、ストア、特にカスタムストアについて知っておく必要があります(tutorial)。
useEffects 関数は subscribe メソッドを持つカスタムストアを作成します。ストアの subscribe メソッドは、購読が不要になったときに呼ばれる unsubscribe メソッドを返さなければなりません。
最後の行( $: $effect )は、自動ストア購読(tutorial)を行います。これにより、connectionId の値が変わった(あるいは Svelte で再代入された)ときに、socket.on が正しく呼ばれ、その後に socket.off が呼ばれます。React 版とほぼ同じ動作になります。
なぜ Svelte の props に物足りなさを感じるのか
もちろん、Svelte にも props はあります。React と似たような動きをします。しかし React では、コンポーネントの外部インターフェースは props だけです。Svelte では、コンポーネントの振る舞いを管理する仕組みがいくつかあります。
- props - React と同様に動作
- events - 特別な構文を使い、イベントはすべての props を含む $$props オブジェクトの一部ではない
- actions(use:action 構文) - HTML 要素に紐づくロジックを再利用する仕組み
- slots - 目的は React の children プロパティと同じだが、いくつか拡張がある
これらの仕組みはどれも非常に便利ですが、React の props のように統一されたアクセス手段ではありません。以下では、私が遭遇したいくつかの問題について述べます。
イベントのフォワーディング
より複雑なコンポーネント階層を作ると、親コンポーネントからデータを単にフォワードするだけのコンポーネントが出てきます。
// React - イベントは props の一部なので、すべてフォワードできる
function Outer(props) {
return <Inner {...props} />;
}
<!-- Svelte - 明示的に名前を挙げた click と keydown イベントだけがフォワードされる -->
<Inner {...$$props} on:click on:keydown />;
}
Svelte では、すべてのイベントをフォワードすることはできず、列挙したイベントだけをフォワードできます。どうしてもすべてのイベントをフォワードしたい場合は、on:click の代わりに onClick のようなコールバック関数を使えば、onClick は $$props の一部になります。
TabControl の実装
React では、次のように使える TabControl コンポーネントを実装するのはかなり簡単です。
// React
<TabControl>
<TabPage label='Page 1'>
Page 1 content
</TabPage>
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
実装では children 配列を列挙し、必要な情報(ページタイトルと内容)を簡単に取り出せます。このアプローチは Svelte では動きません。Svelte には children に相当するものがないからです。代わりに Svelte のフラグメントを使うことができ、使用例は次のようになります。
<!-- Svelte -->
<TabControl tabs={[
{ label: 'Page 1', slot: 1},
{ label: 'Page 2', slot: 2},
]}>
<svelte:fragment slot='1'>
Page 1 content
</svelte:fragment>
<svelte:fragment slot='2'>
Page 2 content
</svelte:fragment>
</TabControl>
タブは配列で定義され、タブのレイアウト(children)はフラグメントで定義され、それが TabControl コンポーネントにスロットとして渡されます。React ほど直感的ではありませんが、動作はします。唯一の、かなり大きな落とし穴は、TabControl の実装 を見ると分かります。
// TabControl.svelte
<script>
export let tabs = []
</script>
<div>
{#each _.compact(tabs) as tab, index}
<div class="container" class:isInline class:tabVisible={index == value}>
{#if tab.slot == 0}<slot name="0" />
{:else if tab.slot == 1}<slot name="1" />
{:else if tab.slot == 2}<slot name="2" />
{:else if tab.slot == 3}<slot name="3" />
{:else if tab.slot == 4}<slot name="4" />
{:else if tab.slot == 5}<slot name="5" />
{:else if tab.slot == 6}<slot name="6" />
{:else if tab.slot == 7}<slot name="7" />
{/if}
</div>
{/each}
</div>
スロット名は静的な文字列でなければならないため、これを動かすにはこのようなことをしなければなりません。
動的コンポーネントの使用
別のアプローチとして、タブページごとにコンポーネントを定義する方法がありますが、その場合は小さなファイルが大量にできてしまいます。Svelte では各コンポーネントを別ファイルで定義する必要があるからです。
<!-- Svelte -->
<TabControl tabs={[
{ label: 'Page 1', component: Tab1},
{ label: 'Page 2', component: Tab2},
]}/>
// Tab1.svelte
Page 1 content
// Tab2.svelte
Page 2 content
実装では、svelte:component を使って適切なタブをインスタンス化します。
コンテキストの使用
これは最も分かりにくい方法です。しかし実際には、React と同じ構文を実現できます。
<!-- Svelte -->
<TabControl>
<TabPage label='Page 1'>
Page 1 content
</TabPage>
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
TabControl コンポーネント側では、「集約ポイント」となるもの、例えば子タブの配列を定義する必要があります。
<!-- TabControl.svelte -->
<script>
const tabs = [];
setContext('tabs', tabs);
</script>
タブページ側では、親の配列にタブを登録するだけです。
<!-- TabPage.svelte -->
<script>
export let label;
const tabs = getContext('tabs');
tabs.push({ label });
</script>
この方法の唯一の落とし穴は、タブページが条件付きでレンダリングされる場合に、定義した順序が保たれないことです。
<TabControl>
{#if condition_will_be_true_later}
<TabPage label='Page 1'>
Page 1 content
</TabPage>
{/if}
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
これは、まず Page 2 が表示され、その後に Page 1 が表示されることになり、おそらく望ましい結果ではありません。
bind:clientHeight と bind:clientWidth 使用時の注意
寸法のバインディング(tutorial)は素晴らしい機能です。ただし、使用には注意が必要です。これは隠れた iframe を使って実装されています(この REPL で確認できます)。私の経験では、より複雑な状況では Firefox でうまく動かないことがありました。場合によっては、ResizeObserver を使う方が安全です(action の実装 を参照)。
エラーバウンダリ
React にはエラーバウンダリという素晴らしい概念があります(コンポーネント用の「try-catch」のようなもの)。関数コンポーネントではサポートされていませんが(実際、私の React アプリケーションでの唯一のクラスコンポーネントが ErrorBoundary でした)、関数コンポーネント中心の React アプリでもこのクラスコンポーネントを使うのは問題ありませんでした。
// React
<ErrorBoundary>
{(null).read()}
</ErrorBoundary>
React アプリでこのコードをエラーバウンダリなしで書くと、アプリ全体が落ちてしまいます。エラーバウンダリがあれば、その内側だけが落ち、アプリの他の部分は通常通りレンダリングされます。
残念ながら、Svelte にはこれに相当するものがありません。svelte-error-boundary という NPM パッケージはありますが、実際には問題のごく一部しか解決せず、Svelte アプリのエラーの大半は依然としてアプリクラッシュを引き起こします。
Svelte には独特のクラッシュの仕方があります。Svelte コンポーネントがリアクティブでなくなり、アプリがフリーズしたように見えるのです。
できることは、この状況を検出し、ユーザーにページのリロードを促すことだけです。
<!-- Svelte -->
<script>
let counter = 0;
$: counterCopy = counter;
const onunhandledrejection = async e => {
console.log('Unhandler error, checking whether crashed', e);
const oldCounter = counter;
counter++;
window.setTimeout(() => {
if (counterCopy <= oldCounter) {
console.log('CRASH DETECTED!!!');
if (window.confirm('Sorry, App has crashed.\nReload page?')) {
window.location.reload();
}
}
}, 500);
};
</script>
<svelte:window on:unhandledrejection={onunhandledrejection} />
検出には unhandledrejection イベントを使っています。このイベントはさまざまな状況で発生し、そのすべてが Svelte のクラッシュを引き起こすわけではありません。そこで counter と counterCopy という変数を使っています。リアクティブ文が動作しない場合、それは Svelte 全体がクラッシュしていることを意味し、復旧する唯一の方法はページのリロードです。
完全な ErrorHandler コンポーネントも参照できます。
結論
これらの問題があるにもかかわらず、Svelte は素晴らしいフレームワークであり、React から Svelte へのアプリの移行結果にはとても満足しています。現在 Svelte で実装されているいくつかの機能は、React ではほぼ不可能だっただろうと思います。
もちろん、React にしか存在しないサードパーティライブラリを大量に使っている場合は、大きな問題になり得ます。しかし私の場合はそうではなく、React への依存は最小限でした。このようなケースでは、Svelte への書き換えも比較的簡単かつ迅速に行えると思います。