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 への書き換えも比較的簡単かつ迅速に行えると思います。