⚗️

pnpm + Turborepo + Expo で環境構築

2023/02/25に公開

はじめに

筆者は最近 TypeScript + モノレポ(というか Turborepo)の構成にハマっており、モノレポにする必要があるかまだ分からないプロジェクトでもとりあえず Turborepo でモノレポ構成で作り始めるようにしています。

今回、サイドプロジェクトで「モバイルアプリはマスト。できれば Web アプリもほしい」という要件が降ってきたのでどんな感じで作るか考えていたのですが、ふと「Turborepo + Expo + Next.js という技術スタックで作れば Web アプリとモバイルアプリ(React Native)でコンポーネント共有したりできて生産性最強なんじゃね!?」と思ったので開発環境の構築をしてみたログです。

この記事のゴールは Expo Go 上でモバイルアプリを立ち上げてみるところまでですので、実際に開発を進めていくとさらにいろいろ調整が必要になる可能性が高いですが、そこまではカバーしていません。あらかじめご了承ください。

手順

大まかな手順は次の通りです:

  1. pnpm + create-turbo で Turborepo のセットアップをする
  2. create-expo-app でモバイルアプリのパッケージを作成する
  3. Metro Bundler の設定を調整する
  4. モバイルアプリのエントリポイントを変更する
  5. pnpm の依存関係巻き上げ設定を調整する

以下の Turborepo と Expo のドキュメントを総合した感じになります:
https://turbo.build/repo/docs/getting-started/create-new
https://docs.expo.dev/guides/monorepos/

pnpm + create-turbo で Turborepo のセットアップをする

Turborepo + Next.js の構成については Trurborepo の公式サイトにある create-turbo というツールを使うことで難なくできます。

プロジェクトのディレクトリを置きたい場所で npx create-turbo@latest を実行し、プロンプトに適当に答えれば、2つの Next.js アプリと共有パッケージのサンプル + ESList 等が設定されたプロジェクトが作成されます。

https://turbo.build/repo/docs/getting-started/create-new

詳しい手順は上記の記事を参照してください。

.
├── .eslintrc.js
├── README.md
├── apps
│   ├── docs
│   ├── mobile
│   └── web
├── package.json
├── packages
│   ├── eslint-config-custom
│   ├── tsconfig
│   └── ui
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json

現時点で上記のようなディレクトリ構造になっていると思います。

create-expo-app でモバイルアプリのパッケージを作成する

create-expo-app コマンドを使って apps ディレクトリ内に Expo アプリのパッケージを作成します。

手順は以下の公式サイトに従います:

https://docs.expo.dev/get-started/create-a-new-app/

公式サイトでは npx が使用されていますが pnpm dlx を使用することもできるので、ここでは pnpm dlx を使ってみます:

pnpm dlx create-expo-app apps/mobile

引数にパスを渡すことで指定したディレクトリにアプリを作成することができるので、今回は apps/mobile にしています。

Metro Bundler の設定を調整する

このまま pnpm start して Expo アプリを立ち上げたいところですが、立ち上げても node_modules の依存関係が解決できないというエラーが発生します。

https://docs.expo.dev/guides/monorepos/#modify-the-metro-config

このエラーとその解決方法については上記のドキュメントに記載されており、どうやら Metro Bundler という React Native 用のバンドラー(正直よく分かっていません)がデフォルトでは monorepo に対応していないため、ワークスペースルートの node_modules とプロジェクトルートの node_modules それぞれにインストールされたライブラリをうまく見つけることができず起きるようです。

よって、Metro Bundler がプロジェクトルートとワークスペースルートそれぞれの node_modules にインストールされているパッケージを解決できるように設定をカスタムする必要があります。

手順は簡単で apps/mobilemetro.config.js という設定ファイルを作成し、先掲のページに載っている設定例をまるごとコピーします。

apps/mobile/metro.config.js
// Learn more https://docs.expo.dev/guides/monorepos
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');

// Find the project and workspace directories
const projectRoot = __dirname;
// This can be replaced with `find-yarn-workspace-root`
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages and in what order
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;

module.exports = config;

ここで設定していることととしては以下の3点のようです:

  1. ワークスペース全体のファイルの変更を監視する
  2. プロジェクトルート (apps/mobile/node_modules) とワークスペースルート (node_modules) それぞれのディレクトリから依存関係を解決する
  3. パッケージ毎にインストールされた依存パッケージのバージョンが異なる場合でも、ワークスペースルートの依存関係を使わないようにする

それぞれの詳細は先掲のページに詳しく解説されていますので、必要に応じて確認してください。

モバイルアプリのエントリポイントを変更する

これで起動できるかと思いきや、今度は「エントリポイントが見つからない」というエラーが発生します。
apps/mobile/package.json を見てみると、デフォルトのエントリポイントは node_modules/expo/AppEntry.js となっています。
なぜこうなっているのかはよく分かりませんが、monorepo ではこのエントリポイントがうまく解決できないようですので apps/mobile ディレクトリ内に手動で作成して変更します。

多分名前は何でも良いと思いますが、上記ドキュメントに従い apps/mobile/index.js というベーシックな名前で作成しました。
例によって中身は Expo のドキュメント から丸コピです:

apps/mobile/index.js
import { registerRootComponent } from "expo";

import App from "./App";

// custom entrypoint for monorepo
// see: https://docs.expo.dev/guides/monorepos/#change-default-entrypoint

// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

pnpm の依存関係巻き上げ設定を調整する

さて、今度こそ!と思うのですが、最後に1つ pnpm 特有の設定変更が必要です。
pnpm はデフォルトで依存関係の実体を node_modules/.pnpm ディレクトリ内に配置し、各ワークスペース内の node_modules には実体へのシンボリックリンクを配置することでデータ量を削減したり、プロジェクトの package.json にない依存パッケージへのアクセスを禁止したりできるという革新的な仕組みになっています。

が、しかし、、、残念なことに React Native はそのようなディレクトリ構造をサポートしていないらしく、pnpm の動作を npm と同じものに変更する必要があります。せっかく pnpm なのに...という気持ちはありますが、とりあえず動かしたいので涙をのんで以下のコマンドを実行します:

# in the workspace root
pnpm config set node-linker hoisted

上記コマンドは .npmrc という設定ファイルに設定を書き込むもので、手作業で .npmrc ファイルを以下の内容で作成するのと同じです:

node-linker=hoisted

node-linker というオプションの詳細は pnpm のドキュメントを参照してください。この設定により、すべての依存関係がワークスペースルートに巻き上げられるみたいです。

最後に node_modules を再構築するため、以下を実行します:

pnpm install

Expo を起動させてみる

ちょっと長い道のりでしたが、これでやっと Expo アプリを起動できます 🎉
apps/mobile ディレクトリ内で pnpm start を実行します。
問題なければ Metro が起動し、QR コードが表示されると思います。

iOS デバイスにインストールした Expo Go アプリ内で mobile アプリを起動させ App.js のテキストを編集してみます:

Expo Go アプリで立ち上がっているアプリのスクリーンショット
\ジャーン/

ホットリロードも問題なく動いているようなので、本記事のゴールは達成です!やったね!!

まとめ

この後は Tailwind を使って Next.js と モバイルアプリ間でコンポーネントのスタイルごと共有したいとかさらなる野望もありますが、ちょっと調べた感じそれはまた大変そうです。おとなしくモバイルアプリは Flutter とかで独立させて開発した方がかえって簡単だったりするかもしれませんが、まあ、とりあえずやってみます...

Expo というか React Native は Next.js ほどモノレポ対応に注力していない(特に pnpm を使う場合は)ようで少しいろいろ対応することがあったのですが、ネット上には日本語の情報があまりなかったので、本記事がどなたかの参考になれば幸いです!

Discussion