KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

React + esbuildの開発環境にSASSを導入する

image

こんにちは!カケハシにて薬局と患者の関係性を向上させるためのツールである 患者リスト というWEB業務アプリケーションを開発している小室と申します。

本プロダクトのフロントエンドの開発環境としては、React + esbuildを採用しており、採用の経緯や実践している環境構築方法などは以下の通り、TechPlayQiitaなどに記事を投稿してきました。

しかしながら、esbuildは標準でsassに対応しておらず、今までの環境ではCSSを利用していたのですが、プロダクトの成長と共にCSSで書き続けることが苦痛になってきました。

そこで、本記事では React + esbuildの爆速開発環境に、さらに sass を導入していく方法を検討していきたいと思います!

検討

React + esbuild環境にsassを利用するには2つの方法が考えられます。

  1. esbuildのpluginを作成し、esbuildのコンパイルプロセスの中でsassをビルドする
  2. 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つのファイルにしています。

graph LR
React --import--> css["main.css"]
sass --"@use"--> hoge.scss & fuge.scss
sass["main.scss"] --compile--> css

続いてコンパイルに関して、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