✌🏻

React & ReactNativeのmonorepo環境にViteを導入する

2022/06/08に公開

はじめに

こんにちは、Unlaceを運営している株式会社Unlaceの岩下です。

Unlaceでは、ユーザー向けにカウンセリング機能を提供するUnlaceのほか、登録カウンセラーの方が利用するUnlace for counselor、企業が従業員に対してUnlaceの利用料金を負担する仕組みを提供するUnlace for businessがあり、全部で3つのサービスを提供しています。
この内、UnlaceとUnlace for counselorはWebのほかiOS/Android向けのアプリでも提供しています。

全サービス共通でWebはReact、アプリはReactNativeを使用しており、react-native-webを用いて実装の大部分を共通化しています。
また、共通部分の実装作業を効率化するため、これら全てを一つのリポジトリで管理する、いわゆるmonorepo構成となっています。
今回は、このmonorepo構成においてWeb部分の開発サーバーをViteで構築した事例について紹介します。

背景

もともとWeb部分にはcreate-react-appを使用していましたが、次の様な課題があり他のビルドツールへの移行を検討していました。

  • コードベースの増加に伴ってdevサーバーの起動に時間がかかるようになった。
  • ReactNativeと実装の共通化を図るにあたって、create-react-app特有の制限に躓くことが増えた。
    • @babel/plugin-proposal-class-propertiesがnode_modules以下に効かないとか。
      • ES2022に入ったようなので、最新のCRAでは使えるのかもしれませんが...
  • Webを前提に作られたパッケージはReactNativeで利用できない事が多いため、どうしてもパッケージを選定する際の軸足はReactNative側にとなってしまい、そういったときにWeb側で色々と吸収できる自由度が欲しくなった。

これら全てを解消してくれるビルドツールは見つかりませんでしたが、開発の活発さや将来性を加味してViteへ移行することにしました。

前提

  • monorepoの管理にはyarn workspacesを使用
  • 可能な限りWebとAppの実装を共通化するため、共有コードはパッケージとして切り出し、それぞれのプロジェクトからimportする形を取る
  • 高速化のため、共通コードのバンドル処理を避け、typescriptのまま扱う

環境

  • yarn@3.2.1
  • vite@2.9.9
  • typescript

最終的な構成

以降の説明はこちらのディレクトリ構造を例に進めます。

$ tree .
.
├── node_modules
├── package.json
├── unlace-app # ReactNativeプロジェクト
│   ├── android
│   ├── app.json
│   ├── babel.config.js
│   ├── index.js
│   ├── ios
│   ├── metro.config.js
│   ├── node_modules
│   │   └── unlace-lib # ./unlace-libへのシンボリックリンク
│   ├── package.json
│   ├── src
│   ├── tsconfig.json
│   └── tsconfig.json
├── unlace-web # Webプロジェクト
│   ├── index.html
│   ├── node_modules
│   │   └── unlace-lib # ./unlace-libへのシンボリックリンク
│   ├── package.json
│   ├── public
│   ├── src
│   ├── tsconfig.json
│   └── vite.config.ts
├── unlace-web-server # Webサーバー
│   ├── build
│   ├── node_modules
│   ├── package.json
│   ├── src
│   ├── tsconfig.json
│   └── webpack.config.ts
├── unlace-lib # WebとReactNativeで共通利用するパッケージ
│   ├── node_modules
│   ├── package.json
│   ├── src
│   └── yarn-error.log
└── yarn.lock
unlace-web/vite.config.ts
vite.config.ts
import * as fs from 'fs';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import requireTransform from 'vite-plugin-require-transform';
import viteSentry from 'vite-plugin-sentry';

export default defineConfig(({ command, mode }) => {
  const env = loadEnv(mode, process.cwd(), 'REACT_APP_');
  const release = fs.readFileSync('../.version').toString();
  const packageJson: { dependencies: Record<string, string> } = JSON.parse(
          fs.readFileSync('package.json').toString()
  );

  return {
    server: {
      port: 3000,
    },
    build: {
      outDir: '../unlace-web-server/build',
      emptyOutDir: true,
      sourcemap: command === 'build' && env.REACT_APP_ENV === 'production' ? 'hidden' : true,
    },
    envPrefix: 'REACT_APP_',
    plugins: [
      (() => {
        const plugin = requireTransform();
        return {
          ...plugin,
          async transform(code: string, id: string) {
            const { code: newCode } = await plugin.transform(code, id);
            return {
              code: newCode,
              map: null,
            };
          },
        };
      })(),
      react({
        exclude: /\.stories\.tsx?$/,
        babel: {
          parserOpts: {
            plugins: ['decorators-legacy', 'classProperties'],
          },
        },
      }),
      !!process.env.SENTRY_AUTH_TOKEN &&
      command === 'build' &&
      viteSentry({
        debug: true,
        skipEnvironmentCheck: true,
        release: release,
        authToken: process.env.SENTRY_AUTH_TOKEN,
        org: XXX,
        project: XXX,
        deploy: {
          env: env.REACT_APP_ENV,
        },
        sourceMaps: {
          include: ['../unlace-web-server/build/assets'],
          ignore: ['node_modules'],
        },
      }),
    ],
    resolve: {
      dedupe: Object.keys(packageJson.dependencies),
      alias: [
        { find: 'src/', replacement: `${__dirname}/src/` },
        { find: 'lib/', replacement: `${__dirname}/node_modules/unlace-lib/src/` },
        { find: /^react-native$/, replacement: 'react-native-web' },
        {
          find: 'entities/maps/entities.json',
          replacement: `${__dirname}/node_modules/entities/lib/maps/entities.json`,
        },
        {
          find: 'entities/maps/legacy.json',
          replacement: `${__dirname}/node_modules/entities/lib/maps/legacy.json`,
        },
        { find: 'entities/maps/xml.json', replacement: `${__dirname}/node_modules/entities/lib/maps/xml.json` },
      ],
      extensions: ['.web.js', '.js', '.ts', '.jsx', '.tsx'],
    },
    optimizeDeps: {
      esbuildOptions: {
        resolveExtensions: ['.web.js', '.js', '.ts', '.jsx', '.tsx'],
      },
    },
    define: {
      __DEV__: command === 'serve',
      __SENTRY_RELEASE__: JSON.stringify(release),
      'import.meta.env.variables': JSON.stringify(env),
    },
  };
});

unlace-web/tsconfig.json
tsconfig.json
{
    "compilerOptions": {
        "target": "ESNext",
        "useDefineForClassFields": true,
        "lib": ["DOM", "DOM.Iterable", "ESNext"],
        "allowJs": false,
        "skipLibCheck": true,
        "esModuleInterop": false,
        "allowSyntheticDefaultImports": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "module": "ESNext",
        "moduleResolution": "Node",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "react-jsx",
        "baseUrl": ".",
        "experimentalDecorators": true,
        "paths": {
            "src/*": ["src/*"],
            "lib/*": ["../unlace-lib/src/*"],
        }
    },
    "include": ["src"]
}

ワークスペースの準備

unlace-libへlinkを設定します。
これでunlace-libaddしたときにシンボリックリンクが貼られるようになります。

$ yarn link --private --relative ./unlace-lib

次に、unlace-webからunlace-libを参照します。

$ yarn workspace unlace-web add unlace-lib

Viteの導入作業

パッケージをインストールする

Vite本体の他に、React用の公式のプラグインがあるためこちらも併せてインストールします。

$ yarn workspace unlace-web add vite @vitejs/plugin-react

vite.config.tsを作成する

$ touch unlace-web/vite.config.ts
vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig(({ command, mode }) => {
    return {};
});

npm scriptsの起動コマンドを変更する

package.json
"scripts": {
+    "dev": "vite dev",
},

index.htmlをプロジェクトルートへ移動する

$ mv ./unlace-web/public/index.html ./unlace-web/index.html

craではindex.htmlpublic/にありましたが、Viteではプロジェクトルートがデフォルトなのでこちらへ移動します。public/自体はViteでもそのまま利用可能なので、favicon等はそのまま残しておきます。

index.htmlを編集する

public/に置いたファイルはルートの下に直接生えるので、craであった%PUBLIC_URL%は不要になります。
例は一部ですが他にも記述があるところは全て削除します。

index.html
- <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+ <link rel="icon" href="/favicon.ico" />

また、Viteではmoduleのエントリポイントが必要となるためこちらもbodyへ追加します。

index.html
  <body>
    <div id="root"></div>
+   <script type="module" src="/src/index.tsx"></script>
  </body>

@vitejs/plugin-reactを設定する

babelに追加したいものがあればここで一緒に指定できます。

vite.config.ts
...
plugins: [
  ...
  react({
      exclude: /\.stories\.tsx?$/,
      babel: {
          parserOpts: {
              plugins: ['decorators-legacy', 'classProperties'],
          },
      },
  }),
  ...
],
...

環境変数にアクセスできる状態を作る

craではREACT_APPというprefixを付けた環境変数にアクセスすることができましたが、Viteでは同様の仕組みをVITE_というprefixで提供しています。
ですが、今回はApp側になるべく変更を加えたくないので、prefixをREACT_APPに変更し、変更箇所を最小限に抑えています。

vite.config.ts
...
envPrefix: 'REACT_APP_',
...

定義した環境変数へはimport.meta.envというオブジェクトを通してアクセスすることが出来ます。
しかし、ガイドによると

プロダクションでは、これらの環境変数は、静的に置換されます。

ということで、indexerを用いてimport.meta.env['REACT_APP_ENV']といった形で参照することは出来ないようです。

これまでは、環境変数の参照方法をWeb/Appで共通化する為ラッパーを挟んだ上でindexerを用いてprocess.env[key]という形でアクセスしていましたが、この形が使えないとなると他の手段を取るほかありません。
少々力技ですが、defineも静的に置換されることを利用してビルド時に環境変数をオブジェクトにして置いてやることにしました。

vite.config.ts
const env = loadEnv(mode, process.cwd(), 'REACT_APP_');
...
define: {
    'import.meta.env.variables': JSON.stringify(env),
},

これをアプリケーションコードで

const env: any = import.meta.env.variables;

のように宣言しておくと、

const env: any = {
    REACT_APP_ENV: "development",
    REACT_APP_BASE_URL: "https://...",
    ...
};

といった形に展開してくれます。

共通パッケージを読み込む

まずTypeScriptから参照できるようにtsconfig.jsonpathsを追加します。

tsconfig.json
...
"paths": {
    "src/*": ["src/*"],
    "lib/*": ["../unlace-lib/src*"],
}
...

次にViteでビルドする際のエイリアスを設定します。

vite.config.ts
...
resolve: {
  alias: [
    { find: 'src/', replacement: `${__dirname}/src/` },
    { find: 'lib/', replacement: `${__dirname}/node_modules/unlace-lib/src/` },
...

これでimport MyComponent from 'lib/component/MyComponent'; という風に共通パッケージを参照できるようになりました。

Web用のコードを読み込む

ReactNative向けに提供されているパッケージには、react-native-gesture-handlerのようにreact-native-web向けのコードが同梱されていることがあります。
create-react-appは標準でこういったコードを読み込んでくれますが、Viteにはそういった仕組みは無いため、resolve.extensionsに追加しweb向けコードがある場合は優先的に読むようします。
また、resolve.extensionsに追加するだけではvite dev時に読み込んでくれないため、optimizeDeps.esbuildOptions.resolveExtensionsにも同様の指定を追加しています。

vite.config.ts
...
resolve: {
    ...
    extensions: ['.web.js', '.js', '.ts', '.jsx', '.tsx'],
},
...
optimizeDeps: {
    esbuildOptions: {
        resolveExtensions: ['.web.js', '.js', '.ts', '.jsx', '.tsx'],
    },
},
...

画像を読み込む為にrequireを書き換える

ReactNativeでは次のようにrequireを使用して画像を読み込みます。

<Image source={require('icon.png')}/>

一方、Viteは静的アセットの取り扱いにあるようにimportを使って最終的な画像のURLを得る仕組みになっています。
この違いを吸収するため、ビルド時にrequireimportに書き換えます。

書き換えにはこちらのプラグインを使用しました。
https://github.com/WarrenJones/vite-plugin-require-transform#readme

pluginsに追加します。

vite.config.ts
import requireTransform from 'vite-plugin-require-transform';
...
plugins: [
  (() => {
      const plugin = requireTransform();
      return {
          ...plugin,
          async transform(code: string, id: string) {
              const { code: newCode } = await plugin.transform(code, id);
              return {
                  code: newCode,
                  // https://rollupjs.org/guide/en/#thisgetcombinedsourcemap
                  map: null,
              };
          },
      };
  })(),
  ...
]
...

こちらのプラグインをそのまま使用するとSourceMapの出力時に次のエラーが発生したため、ワークアラウンドとしてmap: nullを追加しています。

Sourcemap is likely to be incorrect: a plugin (vite_plugin_require_transform) was used to transform files, but
didn't generate a sourcemap for the transformation. Consult the plugin documentation for help

パッケージの重複を解消する

こちらで言及されているように、reactreact-domにはアプリケーション全体でインスタンスは1つ
という制限があります。 同様の制限は他のライブラリにも存在し、Unlaceでは状態管理に使用しているrecoilなどがこれに該当しました。
この問題はnode_modulesに同じパッケージが複数箇所でインストールされてしまうことで発生しますが、発生の条件は様々で一概には言えません。
今回Unlaceではyarn linkによってunlace-web/node_modulesunlace-libへのシンボリックリンクを置いた事でこちらの問題にあたりました。

unlace-libunlace-webの依存パッケージであると同時に、ワークスペース全体で見るとルート直下に存在する一つのパッケージでもあります。
そのためyarn installlすると、

  • unlace-web/node_modules/recoil
  • unlace-lib/node_modules/recoil

の2つがインストールされ、unlace-web/node_modules/unlace-lib/src/.+に置いたコードからはunlace-lib/node_modules/recoilの方が参照されてしまい、
冒頭で述べた問題が生じてしまいます。

こちらの問題の対応として、resolve.dedupeに重複を許可しないパッケージを指定しました。

vite.config.ts
...
const packageJson: { dependencies: Record<string, string> } = JSON.parse(
    fs.readFileSync('package.json').toString()
);
...
resolve: {
...
  dedupe: Object.keys(packageJson.dependencies),
...
},

一つ一つ調べるのが面倒だったので、明示的にインストールしてあるパッケージは全て突っ込んでいます...

細かい部分は省略しましたが、概ねこちらの流れで移行が完了しました。

その他にハマったところ

Unlaceではカウンセリングのチャット部分にstreamを使用しているのですが、こちらのSDKが依存するentities
というパッケージがビルドの際に次のエラーを吐いていました。

Uncaught TypeError: Failed to resolve module specifier "entities/maps/entities.json?commonjs-external". Relative
references must start with either "/", "./", or "../".

entitiesは中でjsonをインポートしているのですが、これがViteのcjsの変換処理と相性が悪いようでした。
こちらはresolve.aliasで読み込み先を絶対パスに変更することで回避できました。

vite.config.js
...
resolve: {
  ...
  alias: [
    {
      find: 'entities/maps/entities.json',
      replacement: `${__dirname}/node_modules/entities/lib/maps/entities.json`,
    },
    ...
  ],
},
...

性能比較

移行を終えて、devサーバーの起動に掛かる時間がどのように変化したのか計測しました。

create-react-app Vite
初回起動後ページが表示されるまで 32.44秒 16.98秒
HMR 2.76秒 一瞬(!)

Viteは開発サーバーの立ち上げは1秒程度完了しますが、ブラウザ側でESModuleの読み込みが完了してページが表示されるまでに少し時間が掛かります。 それでもcraの約半分の時間で済むので大分速くなりました。
また、HMRに関してはViteが圧倒的で、体感では保存した瞬間に書き換わっています。craではよっこらせという感じだったのですごい。

GitHubで編集を提案

Discussion