見出し画像

noteのSvelte componentを事前ビルドして配布するようにしました

この記事はnote株式会社 Advent Calendar 2023の5日目の記事です。

僕はnoteで働いているエンジニアで、主にABテストで改善を繰り返す業務をやりつつ、フロントエンドのリアーキテクチャにも取り組んでいます。
趣味はコーヒーとエレキギターで、どちらも物欲が尽きることはありませんが、最近は新しい焙煎機とエスプレッソマシーンを買いたくてあーでもないこーでもないと悩みながら商品検索を繰り返しています。

Svelteを使っている背景

さて、noteではSvelteを使って一部のコンポーネントを実装しています。Svelte自体の詳しい解説は省きますが、開発体験がよくコンパクトなコード
にトランスパイルできるUIライブラリです。

現在2023年12月時点でもnoteのフロントエンドの大部分はNuxtで実装されていますが、様々な問題があるためアーキテクチャそのものから見直し、主にNextを使って移行を進めている最中です。そこでVue・Reactという異なる実装の中に共通したコンポーネントを設置する手段としてSvelteを利用しています。

このあたりの話は以前にも記事にしていますので興味のある方はそちらもご覧ください。

実際Reactで書き直したコードはかなり増えてきていて、本丸の部分もReact化が進んでいます。

SvelteをNuxt/Nextで動かす方法

Nuxt/NextというかWebpackでVue/Reactの中に埋め込む方法という方が正確かもしれませんが、割と普通にsvelte-loaderを使い、Vue/Reactのライフサイクルに合わせたアダプターを実装するだけで動きます。noteでも最近までこの方法でSvelteを利用していました。

なぜ事前ビルドするのか

一言で説明するとこうしないと周辺パッケージがアップデートできないからです。
例えばNuxt バージョン2系ではTypeScript 4.2までしか対応されていないですが、Nextは最新を使っているので5系も対応しているとなると、一番低いバージョンに合わせるしかなく悲しい状態になってしまいました。

それならいっそのこと事前にビルドされていればランタイムに影響されないコンポーネントが実装できるよねということです。

ちなみにビルドにはrollupを使用しています。

事前ビルドする際のハードル

しかしここでいくつかのハードルがありました。

  1. SSR/CSR両方に対応できなければいけない

  2. 利用ライブラリのTree shakingが完璧にできていないといけない

  3. Dynamic Importに対応しなければいけない

  4. 型が定義されていると嬉しい

SSR/CSRに対応できなければいけない

これが最も難しい問題でした。
Svelteではサーバーサイドで初期化する場合は

ExampleComponent.render(props)

という関数を使うのですが、CSRの時は

new ExampleComponent({ target, props })

というようにconstructorの実行に変わります。つまりビルドされるコードが全然変わってくるということです。
しかしimportのパスはどちらも同じにしたいという気持ちがありました。
なぜなら

const SvelteComponent = typeof window === 'undefined'
  ? require('./ExampleComponent.server')
  : require('./ExampleComponent.client')

というコードはきついですからね。

これを解決するためにrollupのビルド設定でserver/client両方のコードを生成しつつ、両方の環境から参照するエントリーポイントのJSファイルも生成するようにしました。

components
├── ExampleComponent.svelte.client.js <-- ESM
├── ExampleComponent.svelte.js <-- エントリーポイント
└── ExampleComponent.svelte.server.js <-- CJS

importのパスにはエントリーポイントを書くだけですが、Webpack loaderを自作してSSR/CSRそれぞれの環境で返すJSコードを切り替えるようにしています。

利用ライブラリのTree shakingが完璧にできていないといけない

Svelteを採用した理由はビルトコードがコンパクトにできるという理由だったので、なるべく小さい容量にしたかったのですが事前ビルドとDynamic Importが相性が悪くicon系のimport等で依存ライブラリがまるごと含まれるようになってしまっていました。

これはStaticな方式に変更することで簡単に回避できました。

// このようにしていたコードをすべて
const icon = await import(`icons-lib/${iconName}.svg`)

// staticなimportに
import icon from 'icons-lib/home.svg'

Dynamic Importに対応しなければいけない

コンポーネントに含まれるパーツが全て一つのファイルに収まってしまうと、いくらTree shakingなどをやっていても容量が大きくなりがちです。例えばユーザーがクリックするまで表示されないメニューはその時までimportを遅延させるようにしていたのですが、事前ビルドで一つのファイルにまとまってしまうと当然一度に全部を読み込むようになってしまいパフォーマンスへの影響が懸念されます。

しかしこれも対応的には簡単で、rollupのoutput.fileオプションではなくoutput.dirを指定することで、いい感じにファイルを分割してくれるようになりました。

ただこれの影響で別の問題が発生し、importしているアプリ側でパスが一階層ずれるので、ビルドされたコードの内容を書き換える必要がありました。
例えばこのようにファイルが分割されていた場合、

components
├── ExampleComponent.svelte.js <-- エントリーポイント
└── ExampleComponent.svelte.client
    ├── ExampleComponent.js <-- 実際に読み込まれるコード
    └── SubComponent.js

ExampleComponent.jsからSubComponentを参照するパスは以下ではなく

// ExampleComponent.svelte.client/ExampleComponent.js
import('./SubComponent')

以下のようにしないといけません

// ExampleComponent.svelte.client/ExampleComponent.js
import('./ExampleComponent.svelte.client/SubComponent')

これは実際にはエントリーポイントは一階層上なのに、コードとして読み込まれる内容はひとつ下の階層になっているからです。つまりエントリーポイントを基準にパスを解決しないといけなかったです。

こう書くとシンプルな問題なのですが、importが失敗する時はここまで親切にエラーが出るわけではないので手探り状態で原因を特定する状況に難しさがありました。

型が定義されていると嬉しい

SvelteコンポーネントのpropsがReact側でもサジェストされるとかなり開発体験が良いと思ったのですが、結局これはまだ実現できていません。
というのも svelte2tsx というライブラリにemitDts関数が定義されているので使っているのですが、これはエディタの支援用という位置づけで.d.tsファイルをいい感じに出力するには物足りない感じです。

https://github.com/sveltejs/language-tools/tree/master/packages/svelte2tsx

まとめ

作業に紆余曲折ありつつ最終的にはやりたかったことが実現できまして、本番でも事前ビルドしたコンポーネントが稼働しています。

あらためてSvelteとrollupを触ってみてそれぞれのツールのパワフルさに驚きつつ、Svelteを事前にビルドする難しさもかなり感じました。SSRを抜きにしたらかなり簡単なんですけどね。

もの凄く地味な努力と成果でしたが、これで本来やりたかったポータブルなSvelteコンポーネントが実現できたように思います。
今後Svelte 5がリリースされてもアプリ側の依存ライブラリを気にせずバージョンアップできるという安心感があるのでとてもいいですね。

▼さらにnoteの技術記事が読みたい方はこちらhttps://engineerteam.note.jp/