SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

Rails+ReactプロジェクトでWebpackからViteに乗り換えたら、開発が劇的に快適になった話

はじめに

こんにちは!スマートキャンプ開発エンジニアの林(ぱずー)です。

BOXIL SaaSのフロントエンドは歴史的経緯からjQuery、CoffeeScript、Vue、Reactが混在した環境で開発しています。 今回はReactで作られているページにViteを導入したので、経緯やハマったことについて書こうと思います。

なぜViteに移行したか

webpackはビルドが遅く、本番環境のビルドもファイル数が少ないのにも関わらず、3〜4分ほどかかっていたり、開発環境でもコードを変更するごとに30秒ほど待たされるためスピード感を持って開発できる状態ではありませんでした。

また、移行先としてTurbopackも検討しましたが、Railsと合わせて使う場合においては、 ドキュメントの充実度や日本語の情報も多いことから、Viteの方が移行しやすいと感じたため、選択しました。

ちなみに過去に導入を検討したこともあったのですが、当時はInternet Explorer対応が必須だったため見送りました。 ようやくInternet Explorerがサポート外となったので(とはいっても去年ですが)、晴れて導入できるようになりました。

導入方針

  1. 開発環境に導入
  2. ビルドの設定
  3. Staging、Pre環境へのデプロイ検証
  4. リリース

開発環境に導入

BOXIL SaaSのReactはRails上のhtml上でReactを読み込んでいるため、デフォルト設定では動きません。 そのため、Backend Integrationに従って導入しました。

vite側の作業

この場合、読み込む必要があるファイルをオブジェクト形式でrollupOptions.inputに記述していくのですが、そのままどんどん追記していくとかなり設定ファイルの見通しが悪くなってしまいます。 そこで、glob.syncを使用して、手動で読み込むファイルを追加する手間を解消したいと考えました。

webpack時代はこんなコードが何行もあり、ファイルを追加するごとに書き足していくやり方でした...。

const entries = {
  hoge: "./hoge.tsx",
  ...この後何行にもわたるエントリーポイントの記述
}

ただ、現状すべてのファイルがルートディレクトリ直下にフラットに配置されており、glob.syncで自動で取得するには無視するディレクトリをignoreオプションで設定しなければなりませんでした。

const srcFileKeys = glob.sync("**/*.+(js|ts|tsx)", {
  cwd: srcDir,
  ignore: ['.eslintrc.js', 'vite.config.ts']
});

それを解消するために、entriesディレクトリにすべてのエントリーポイントを集約することでglob.syncを手軽に使用できるようにしました。

const convertToEntryKey = (key: string, ext: ".js" | ".css") => {
  return key.replace(/^entries\//, "").replace(/\.[^/.]+$/, ext);
};

const entries: { [key: string]: string } = {};
const srcDir = "./";

const srcFileKeys = glob.sync("entries/**/*.+(js|ts|tsx)", {
  cwd: srcDir,
});

const srcStyleKeys = glob.sync("styles/**/*.+(scss|css)", {
  cwd: srcDir,
});

for (const key of srcFileKeys) {
  const srcFilepath = path.join(srcDir, key);
  this.entries[convertToEntryKey(key, ".js")] = srcFilepath;
}

for (const key of srcStyleKeys) {
  const srcFilepath = path.join(srcDir, key);
  this.entries[convertToEntryKey(key, ".css")] = srcFilepath;
}

export default defineConfig({
  build: {
    rollupOptions: {
      input: entries,
      ...other
    },
  },
});

これでrailsのhtml側でファイル名を指定するとjsを読み込めるようにしています。

例:

vite_javascript_tag('hoge')

詰まったところ

vite自体に付属するmanifestオプションを使用すると、manifest.jsonの形式が大幅に変わってしまう

解決策として、rollup-plugin-output-manifestを導入し、今までと変わらないやり方でmanifest.jsonを出力できるようにしました。

import outputManifest from "rollup-plugin-output-manifest";

export default defineConfig({
  appType: "custom",
  plugins: [
    outputManifest({
      nameWithExt: false,
    }),
    ...other
  ],
})

before:

  "_hoge-4dc41c9a.js": {
    "file": "javascripts/hoge-4dc41c9a.js",
    "imports": [
      "_styled-components.browser.esm-0475c222.js",
      "_colors-52758e30.js",
      "entries/hoge.tsx"
    ]
  },

after:

{
  "hoge.js": "/bundles/javascripts/hoge-0403de2a.js",
}

同じスタイルを複数のエントリーポイントで読み込むとファイル名が変わってしまう

swiperを複数のページで使用する必要があったのですが、 読み込んだ回数によってmanifest.jsonに吐き出されるファイル名が変わってしまうことが判明しました。

1回だけ読み込んだ場合: スタイルを読み込んだ場所のファイル名で吐き出される。

"hoge.css": "http://localhost:3000//bundles/stylesheets/hoge.css"

2回以上読み込んだ場合: なぜかswiper.cssとして吐き出される。

"swiper.css": "http://localhost:3000//bundles/stylesheets/swiper.css"

この状態だと、rails側でスタイルを読み込むときに指定するファイルが読み込む回数によって変わってしまいます。

解決策としてoutputManifestのmapオプションで出力するmanifest.jsonのキーを手動でマッピングしてあげることで、glob.syncで読み込んだファイルのみ出力するように変更しました。

これで、manifest.jsonに記載するファイルをglob.syncで読み込んだファイルに限定できるので、読み込み回数によってファイル名が変わることはなくなりました。

import outputManifest from "rollup-plugin-output-manifest";

const getEntryName = (name: string | undefined) => {
  if (!name) return "";
  const entryPaths = Object.keys(entries);
  const entryPath = entryPaths.find((entryPath) => entryPath.includes(name));

  if (!entryPath) {
    throw new Error(
      `entryPath is not found \ntarget: ${name} \nentries: \n${entryPaths.join(
        "\n"
      )} `
    );
  }

  return entryPath;
}

export default defineConfig({
  appType: "custom",
  plugins: [
    outputManifest({
      nameWithExt: false,
      map: (chunk: Bundle) => {
        const name = getEntryName(chunk.name);
        return {
          ...chunk,
          name,
        };
      }
    }),
    ...other
  ],
})

Rails側の作業

Rails側でも上記のmanifest.jsonを読み込むための作業をしました。 viteをRuby上で動かすにあたってVite Rubyを使用することも検討しましたが、 ライブラリを導入することによる依存の発生や、vite以外の選択肢が今後出てきたときに乗り換えやすいようにVite Rubyの実装を参考に必要な部分だけ使用する形で独自実装することにしました。

すべてのコードの記載は割愛しますが、webpack時代の処理に比べてScriptタグをmoduleで読み込む点と、開発サーバーと接続する設定が入っている点が大きく異なってます。

  def vite_react_refresh_tag
    react_refresh_preamble&.html_safe if dev_server_running?
  end

  def vite_client_tag(crossorigin: 'anonymous', **options)
    javascript_include_tag(vite_dev_host('@vite/client'), type: 'module', extname: false, crossorigin: crossorigin, **options) if dev_server_running?
  end

  def vite_javascript_tag(entry,
                          type: 'module',
                          skip_preload_tags: false,
                          crossorigin: 'anonymous',
                          **options)

    # MEMO: 開発中の場合は開発サーバからjsを取得する
    return javascript_include_tag(vite_dev_host("entries/#{entry}"), crossorigin: crossorigin, type: type, extname: false, **options) if dev_server_running?

    files = vite_manifest.fetch("#{entry}.js")
    tags = javascript_include_tag(*files, crossorigin: crossorigin, type: type, extname: false, defer: true, **options)
    # modulepreloadタグを生成してモジュールを先読みする
    tags << vite_preload_tag(*files, crossorigin: crossorigin, **options) unless skip_preload_tags
    tags
  end

ビルドの設定

本番環境ではCIでビルドされたファイルをS3に配置しているため、manifest.jsonで参照されるURLをS3に向けてあげる必要があります。 そこでrollup-plugin-output-manifestにpublicPathのオプションを追加し、環境に合わせてURLを変更できるようにしました。

export default defineConfig({
  plugins: [
    outputManifest({
      publicPath: `${
        process.env.PRECOMPILED_ASSETS_HOST
          ? process.env.PRECOMPILED_ASSETS_HOST
          : ""
      }/bundles/react/`,
      nameWithExt: false,
    }),
  ],
  ...otherConfig
});

こうすることで、以下のようにURL付きのmanifest.jsonが生成されるので、読み込む先を変更できます。

  "hoge.js": "https://domain/bundles/react/javascripts/hoge.js-350806ce.js",

詰まったところとして、swiper(7.4.1)を使用しているページがビルドがうまくいかない問題が発生したので、aliasを書いてあげることで解消できました。

エラー文:

[commonjs--resolver] Missing "./swiper-bundle.min.css" specifier in "swiper" package
error during build:
Error: Missing "./swiper-bundle.min.css" specifier in "swiper" package
export default defineConfig({
  resolve: {
    alias: {
      swiper: path.resolve(__dirname, "node_modules/swiper"),
    },
  },
  ...otherConfig
});

現時点ではswiperでしか発生していませんが、他のライブラリでも発生する可能性もあるので、注意が必要です。

その他、CicleCIでメモリの問題で頻繁に落ちてしまう問題が発生したため、ジョブを分けることで回避しました。(ちなみにビルドに20秒ってだいぶ早いですね)

Staging、Pre環境へのデプロイ検証

試行錯誤しつつも、開発環境での動作は問題ない状態になったので、staging環境にデプロイしたところ、JavaScriptの取得リクエストで404エラーが大量発生しました。 ドキュメントを追ってみると、デフォルトではbuild.modulePreloadオプションが有効となっており、自動でpreloadするためのjsを読み込んでいた事が原因でした。 preloadタグの生成はRails側で行なっているため、設定を無効化することで解消できました。

export default defineConfig({
  ...otherConfig
  build: {
    modulePreload: false, // デフォルトではpolyfill: trueとなっていて、preloadするためのpollyfillが自動注入されてしまう
  },
});

リリース

現状、残念ながらフロントエンドのテストがほとんど書かれていないため、開発チームで手作業で頑張りました。

具体的には、BOXIL SaaSの主要ページシナリオを作成し、Vite導入に影響する箇所を確認していくことで、リリース後のバグが少しでも発生しないように努めました。

結果、幸いにも本番リリース後も特に大きな問題は起こらなかったので、無事に完遂できました。

結果

ローカル環境でのコード反映:

30秒 → 1秒未満

これは例えば、10行のコードを変更した場合、webpackだと反映に300秒かかっていたのが、Viteだったら10秒で反映できることになります。

ちなみに、webpack時代はコードの反映が遅すぎて、コードの保存を極力まとめて行なっていたのですが、Vite導入によって臆することなく、コード保存するショートカットキーを多用できるようになったので個人的には数字以上の開発体験の向上している実感があります。

ビルド時間:

49秒 → 7秒

現状コード量が少ないことや、構成上、Railsのアプリケーションのビルドが速くならないと結果的にデプロイ速度は向上しないため、そこまで重視していない項目でしたが、こちらも大幅に改善していました。 今後BOXIL SaaSにおいてReactのコードがメインになってくる(していく)ことを考えると、 ビルド速度はボトルネックになりうる部分なので、早めに改善できてよかったです。

今後

冒頭にも説明したとおり、歴史的経緯からBOXIL SaaSのフロントエンドはカオスな状態なのですが、

ここ一年でyarn workspaces(v3)導入によるモノレポ化と、今回の記事で取り上げたVite導入をおこなってきました。

直近あったプロジェクトではReactを使用してスピード感を持って開発できているので、 これまでの地道な改善による効果が一定数あったのかなと感じています。

今後の個人的な展望としては直近のVitest導入に合わせて、継続的にテストを書いていける環境・方針の策定を進めて更なる開発体験の向上を推進していくことや、

統一されておらずページごとにバラバラになっているコンポーネントをデザイナーさんと協力して整理していきたいと考えています。

最後に

長い歴史を持つプロダクトの負を改善していくにあたって、工数の兼ね合いから諦めなければいけない部分も出てくる事がありますが、 少ない工数で劇的な変化をもたらすこともできることをViteを導入してみて感じました。

今後もこのような改善を継続的に行なって開発効率を上げて、さらなるプロダクトの価値向上に貢献していきたいです!