こんにちは!カケハシにて薬局と患者の関係性を向上させるためのツールである 患者リスト
というWEB業務アプリケーションを開発している小室と申します。
本プロダクトのフロントエンドの開発環境としては、React + esbuild
を採用しており、採用の経緯や実践している環境構築方法などは以下の通り、TechPlay
やQiita
などに記事を投稿してきました。
しかしながら、esbuild
は標準でsass
に対応しておらず、今までの環境ではCSSを利用していたのですが、プロダクトの成長と共にCSSで書き続けることが苦痛になってきました。
そこで、本記事では React + esbuild
の爆速開発環境に、さらに sass
を導入していく方法を検討していきたいと思います!
検討
React + esbuild
環境にsass
を利用するには2つの方法が考えられます。
esbuild
のpluginを作成し、esbuild
のコンパイルプロセスの中でsass
をビルドするesbuild
のビルドプロセスとは別で、sass
をコンパイルする
2つの方法にはpros/consがあるため本記事では両方の方法を紹介したいと思いますが、結論として私のチームでは2の方法を採用いたしました。
1. esbuild
のpluginを利用する方法
esbuild sass
などで検索すると、pluginを利用する方法がいくつか見つかります。esbuild-sass-plugin のようなレポジトリも見つかるのですが、まだ定評があるいうほどのStar数にはなっていない印象です。
そこで、esbuildのプラグイン機能を直接試して見ようとおもいます。 下記のように、意外と簡単に実装することができます。
import * as sass from 'sass'; const pluginName = 'esbuild-plugin-sass'; const sassPlugin: Plugin = { name: pluginName, setup(build) { // esbuildが .ts|.tsx ファイルのimportを処理する時に呼ぶので、 // 読み込むべきファイルのパスを返却する build.onResolve({ filter: /\.(sass|scss)$/ }, args => ({ // ファイルの絶対パス path: path.resolve(args.resolveDir, args.path), // 下記の `build.onLoad` のところでここで指定した namespace 毎に処理される namespace: pluginName })); // esbuildが実際にファイルを読み込む時に呼ばれるので、 // cssにコンパイルした、sassを返却してあげる build.onLoad({ filter: /.*/, namespace: pluginName }, args => { return new Promise(resolve => { // sassの本家を使ってコンパイル sass .compileAsync(args.path) .then(result => { resolve({ contents: result?.css, loader: 'css', // これを指定しないと、scssファイルを修正した際に // esbuildがwatchモードでもrebuildしてくれない watchFiles: [args.path], }); }) .catch(error => { // エラーした場合はエラー内容を通知する resolve({ pluginName, errors: [ { text: error.message, pluginName, location: { file: args.path, namespace: pluginName, }, }, ], }); }); }); }); }, }; build({ ..., //その他の設定 plugins: [sassPlugin], })
処理に関してはコメントの通りです。
このプラグインを利用することで、.ts|.tsx
から.css
と同様に.scss
ファイルもimportできるようになります。
この方法の利点は、既存のコードやビルド設定にはほとんど手を加えることがなく、pluginを指すだけでsass
のimportができるようになるため、シンプルになる点があります。
一方、問題点としてesbuild
をwatchモードで起動している時にscss
のみを更新しても、jsバンドルとcssバンドルの両方が再生成されてしまうのですが、
Qiita: esbuild + React(TS) で実現する超軽量な開発環境 でご紹介している環境で困る点があります。
esbuild
でビルドした内容をbrowser-sync
を利用して即座にbrowserにプッシュすることで画面にauto reloadをかけて、効率的に開発を行なっているのですが、
スタイルだけを変更しても.js
バンドルが再生成されて画面全体のリロードが走ってしまいます。
2. esbuild
とは別プロセスでsass
をコンパイルする
そもそもesbuild
はビルドに特化したツールであり、Webpackのようにdev serverを起動したりというようなエコシステムはないので、
実際のWebアプリケーション開発ではいくつかのツールと組み合わせて環境構築することになると思います。
弊チームの環境では、concurrently
を利用して、esbuild
, express(node)
, browser-sync
の3つを実行することで効率的な開発環境を実現しています。
この流れで、sass
のビルドを第4のプロセスとして同時に実行する事を検討しました。
まずは、.css
=> .scss
とした時にtsx
ファイルから.scss
ファイルをimportするはできないので、コンポーネントから.css
のimportを削除します。
続いて、sass
のビルド用のスクリプトを用意します。
buildStyle.ts
import * as sass from 'sass'; import * as path from 'path'; import * as fs from 'fs'; import glob from 'glob'; // 出力先などを設定 const ASSETS_DIR = path.resolve(__dirname, 'src/assets'); const PUBLIC_DIR = path.resolve(__dirname, 'public'); const OUT_FILE_NAME = 'main'; const OUT_PATH = path.resolve(PUBLIC_DIR, `${OUT_FILE_NAME}.css`); const MAP_OUT_PATH = path.resolve(PUBLIC_DIR, `${OUT_FILE_NAME}.css.map`); // 用途は後述 const getComponentStyleFilePaths = async () => { return await new Promise<string[]>(resolve => { glob(path.resolve(__dirname, 'src/components/**/*.@(css|scss)'), (err, files) => { if (err) throw new Error('CSSファイルの探索に失敗しました。'); resolve(files); }); }); }; /** * .scssファイルをコンパイルする * `sass.compileString` でコンパイルするため、 * `@use` のimport先は `sass.FileImporter` を利用して解決する */ const compile = (sassCodeString: string) => { const importer: sass.FileImporter = { findFileUrl: url => { if (url === 'variables') { return new URL(`file://${ASSETS_DIR}/_variables.scss`); } return new URL(`file://${url}`); }, }; return sass.compileString(sassCodeString, { sourceMap: true, importer, }); }; /** * 各コンポーネントのStyleのpathを読み込んで @use文を追加する * * イメージ * @use '/frontend/components/atoms/hoge/index.scss' as atoms_Hoge; * @use '/frontend/components/atoms/fuga/index.scss' as atoms_Fuga; */ const addImportStatementOfComponentStyles = async (mainFile: Buffer) => { const paths = await getComponentStyleFilePaths(); return ( paths .map(p => { const splits = p.split('/'); const fileName = splits.slice(-2)[0].split('.')[0]; const dir = splits.slice(-3)[0]; // namespaceを指定しないと、ファイル名が同じだと同じだと被ってしまう const namespaceName = `${dir}_${fileName}`; return `@use '${p}' as ${namespaceName};`; }) .join('\n') + '\n' + mainFile ); }; const main = async () => { // 1. メインファイルを読み込む const mainFile = fs.readFileSync(path.resolve(ASSETS_DIR, 'main.scss')); // 2. コンポーネントのスタイルを @use に追加する const mainFileWithAtUse = await addImportStatementOfComponentStyles(mainFile); // 3. コンパイルを実行 const result = compile(mainFileWithAtUse); // 4. ファイル出力する fs.writeFileSync(OUT_PATH, result.css); fs.writeFileSync(MAP_OUT_PATH, JSON.stringify(result.sourceMap)); }; (async () => { await main(); console.log('スタイルをコンパイルしました。'); })().catch(console.error);
まず考慮が必要な点として、sass
はコンパイル機能のみでバンドルすることはできませんが、cssファイルが分割されるとimportが大変なので1つのファイルにまとめられるとbetterです。
そこで、2.
部分で今回はentrypointとなるassets/main.scss
のソースコードの冒頭に、各コンポーネントのスタイルのインポートを@use
文で無理やりくっつけてから、main.scss
をビルドすることで、1つのファイルにしています。
続いてコンパイルに関して、2.
で生成したソースコード文字列からcss
を生成するので、sass.compileString()
を利用しますが、@use
文が指す実ファイルのパスはsass.FileImporter
で解決してあげる必要があります。
最後に4.
でファイル出力します。
あとは、ビルド作業をconcurrently
に追加してあげればOKです。
スクリプトは以下のようになります。
package.json
{ "scripts": { "build:style": "node -r esbuild-register buildStyle.ts", "build:style:watch": "nodemon --watch src --ext scss --exec \"node -r esbuild-register buildStyle.ts\"" } }
watchモードでは、nodemon
を利用してscssファイルに変更があれば自動的にビルドが再実行されるようになっています。
この方法のメリットは、SCSSファイルを変更した時にcssバンドルのみが再生成されることです。
これによって、スタイルの修正時にbrowser-sync
がcssのみを更新してくれるので、画面のリロードなしにスタイルが適用され、(HMDのような)快適なスタイリングが可能になります。
まとめ
正直、プラグインを利用した方法の方がビルドの流れが綺麗かつシンプルになりますが、 私のチームでは実装時のDeveloperExperienceを優先としているので、少々ダーティですがsassのビルドを独立したプロセスにする方法を選定しました。 ただ、プラグインの機能は非常にシンプルで使いやすかったので、他の用途での活用はいろいろと検討していきたいと思いました! (ex. 不要なパッケージをBanするプラグイン など)
今後もesbuild
を利用したエコシステムは今後ますます発展していくことと思いますので、また有用な環境構築方法があれば整理していきたいと思っております。
最後に、KKHSでは日本の医療体験にプロダクトで価値を提供するエンジニアを積極的に募集しております。 興味がある方は是非弊社の 採用ページ からご応募頂けますと幸いです!
末筆ながら、読んでいただきありがとうございました!m