🐱

yarn workspacesを使ったReact Nativeの環境構築

14 min read

はじめに

React Native の環境構築についてのお話です。
Git での開発戦略においては、フロントエンドとバックエンドは別々のリポジトリを作成して運用するように、
1 つのリポジトリで 1 つのパッケージを運用する方法がメジャーどころだと思います。

lerna が登場したあたりから、複数のパッケージを 1 つのリポジトリで管理するモノリシックリポジトリ(以下、モノレポ)運用も活発になってきました。
昨今の開発において、モノレポ運用をする場合は

  • lerna
  • yarn workspaces

が選択肢に上がります。

どちらもモノレポ運用をするにあたって使うツールという意味では同じですが、
依存モジュールが hoisting されるという点において違いがあります。

この記事では、モノレポ運用や hoisting のメリットなどについては、他の記事に解説を譲ることにします。

今回作成したものはここに置いてますので、参考にしてみてください。

yarn workspaces と React Native の問題

yarn workspaces は依存モジュールがルートの node_modules に hoisting されるという性質が、React Native では色々と問題を起こします。
実際に環境構築をしようとすると、React Native 本体や Metro は勿論のこと、CocoaPods や Xcode などでエラーが起きます。
なんなら、react-native-cli のテンプレートの作成からいきなりエラーが起きて失敗します。

これは、react-native-cli が生成するテンプレートが、コマンドを実行したカレントディレクトリーを基準に作成されるからです。
また、React Native に関わるモジュール郡も、基本的に同一階層の node_modules に全ている前提で動くようになっています。
yarn workspaces 環境下で react-native-cli を実行した場合も、依存モジュールは hoisting されます。
つまり、依存モジュールは全てルートの node_modules にインストールされます(例外あり)。
そのため、node_modules の参照先が合わずにエラーが起きるというカラクリです。

たまたまうまくいく場合もある

この問題を更にややこしくするのが、必ずしもエラーが起きるわけではないということです。
先ほど、依存モジュールは全てルートの node_modules にインストールされると書きました。
既にモノレポ運用でパッケージを作成済みの場合、そのプロジェクト全体の依存解決の兼ね合いによっては、packages 配下の node_modules に React Native や Metro がインストールされることがあります。

この場合は、エラーが起きずにテンプレートの作成ができます。
ただし、これでは yarn workspaces で環境構築した意味がなくなってしまうので注意しましょう。
開発を進めていく中で他のモジュールを追加したり、バージョンを上げたりしたタイミングで hoisting されると動かなくなるので、危ないです。

イチから環境構築する

それでは、まっさらな環境から yarn workspaces を使ったモノレポ運用で環境構築をしていきましょう。

  • web React を使ったウェブアプリ
  • mobile React Native を使ったモバイルアプリ
  • modules web と mobile で使う共通のモジュール

今回、React を使ったウェブアプリと React Native を使ったモバイルアプリを 1 つのリポジトリで管理することを想定しています。
また、共通の関数や定数などを管理するモジュールのパッケージも作って読み込めるようにしていきましょう。

ルートの pakcage.json の作成

モノレポ運用する適当なフォルダを作成し、無邪気に npm init を実行してテンプレートを作成しましょう。

npm init

package.json が作成されたら、下記のように package.json を編集します。

package.json
{
  "name": "monorepo",
  "version": "0.0.1",
  "license": "ISC",
  "description": "",
  "private": true,
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "workspaces": ["packages/*"],
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^4.26.0",
    "@typescript-eslint/parser": "^4.26.0",
    "eslint": "^7.27.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-react": "^7.24.0",
    "eslint-plugin-react-hooks": "^4.2.0",
    "eslint-plugin-react-native": "^3.11.0",
    "prettier": "^2.3.0",
    "typescript": "^4.3.2"
  }
}

ESLint や TypeScript は共通で利用するので、ルートの package.json に追加します。
yarn workspaces は package.json 内に

"workspaces": [
  "packages/*"
],

workspaces プロパティに配列で記述します。
packages/*と書くことで、packages フォルダ配下が workspaces の対象となります。
TypeScript のプロジェクトなので tsconfig.json も作成します。

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "commonjs",
    "lib": ["es2020"],
    "jsx": "react",
    "strict": true,
    "esModuleInterop": true
  }
}

設定はお好みで。
ルートの設定はこれにて一旦完了です。

web パッケージの作成

React を使ったウェブアプリの環境を構築します。

mkdir packages
cd ./packages
mkdir web
cd ./web
npm init

packages フォルダを作成し、web フォルダを作成して npm init を唱えます。
package.json の内容はまぁ、こんな感じでしょう!!(適当)

packages/web/package.json
{
  "name": "@monorepo/web",
  "version": "0.0.1",
  "license": "ISC",
  "description": "",
  "scripts": {
    "start": "webpack serve --mode development",
    "build": "NODE_ENV=production webpack"
  },
  "dependencies": {
    "react": "^17.0.1",
    "react-dom": "^17.0.1"
  },
  "devDependencies": {
    "@types/react": "^17.0.8",
    "@types/react-dom": "^17.0.5",
    "fork-ts-checker-webpack-plugin": "^6.2.10",
    "terser-webpack-plugin": "^5.1.3",
    "ts-loader": "^9.1.2",
    "typescript": "^4.3.2",
    "webpack": "^5.38.1",
    "webpack-cli": "^4.7.0",
    "webpack-dev-server": "^3.11.2"
  }
}

ここで一旦、依存モジュールをインストールしましょう。

yarn install

yaen workspaces 内では、どの階層で yarn install をしても大丈夫です。

mobile パッケージの作成

さて、ここからが本題です。
react-native-cli を使って React Native の環境構築を行います。
環境構築はReact Native Setting up the development environmentをベースに行います。
ここでは Xcode や Android Studio のインストールは終わってるものとして話を進めます。
先ずは packages に移動して cli でテンプレートを作成します。

cd packages
npx react-native init mobile --template react-native-template-typescript

これで packages 直下に mobile フォルダが作成され、テンプレートの構築が始まります。
おそらく、CocoaPods のインストールで早速下記のようなエラーが発生します。

✔ Downloading template
✔ Copying template
✔ Processing template
✖ Installing CocoaPods dependencies (this may take a few minutes)
✖ Installing CocoaPods dependencies (this may take a few minutes)
error Error: Failed to install CocoaPods dependencies for iOS project, which is required by this template.
Please try again manually: "cd ./mobile/ios && pod install".
CocoaPods documentation: https://cocoapods.org/

hoisting が正しく行われていた場合は、react-native-cli でインストールされた依存モジュールはルートの node_modules に入っています。
そのため、CocoaPods が参照しようとしている mobile フォルダ配下の node_modules には何もないため、エラーが発生します。
ios フォルダが作成されていると思いますので、ios フォルダ配下の Podfile を下記のように編集します。

packages/mobile/ios/Podfile
require_relative '../../../node_modules/react-native/scripts/react_native_pods'
require_relative '../../../node_modules/@react-native-community/cli-platform-ios/native_modules'

platform :ios, '10.0'

def rn_pods
  rn_path = '../../../node_modules/react-native'
  use_native_modules!
  use_react_native!(:path => rn_path)
end

target 'mobile' do
  rn_pods

  target 'mobileTests' do
    inherit! :complete
    # Pods for testing
  end

  # Enables Flipper.
  #
  # Note that if you have use_frameworks! enabled, Flipper will not work and
  # you should disable the next line.
  use_flipper!('Flipper' => '0.75.1', 'Flipper-Folly' => '2.5.3', 'Flipper-RSocket' => '1.3.1')

  post_install do |installer|
    react_native_post_install(installer)
  end
end

テンプレートが生成した react-native と@react-native-community の参照先が mobile 直下の node_modules を指しているので、
これをルートの node_modules を参照するように編集を行います。

use_filpper について

もし、2021 年 6 月時点で最新の XCode12.5 を使用している場合は、use_flipper も修正する必要があります。

use_flipper!('Flipper' => '0.75.1', 'Flipper-Folly' => '2.5.3', 'Flipper-RSocket' => '1.3.1')

さて、これで再度 pod install を実行すると正常にインストールが完了します。

こちらのissueで原因と対応策は詳しく説明されています。
使用する React Native のバージョンによっても変わるので、特定のバージョンで構築したい場合はこちらをご参照ください。
この記事では React Native は 0.64.1 を使用しています。
執筆時点では issue はまだ close しておらず、この修正を入れています。

node_modules の参照先を全て変更する

続いて、XCode と Android Studio が参照する node_modules のパスを変更します。
変更するファイルは全部で 4 つです。

  • ios フォルダ
    • mobile.xcodeproj/project.pbxproj
  • android フォルダ
    • build.gradle
    • settings.gradle
    • app/build.gradle

いずれのファイルのパスも../../と 2 つ階層を昇るように変更するのみで大丈夫です。
エディターやターミナルで node_modules で検索して置換しましょう。

metro の設定を変更する

metor バンドラーも同様に node_modules の参照先を変更します。
また、blockList を設定して参照する必要のない packages は除外するようにしましょう。
この設定をしていないと、packages が増えるにつれてどんどん遅くなってしまいます。

packages/mobile/metro.config.js
const path = require('path');

const watchFolders = [
  path.resolve(__dirname, '../../node_modules'),
];
const blockList = [/web/];

module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
  resolver: {
    resolverMainFields: ['react-native', 'browser', 'module', 'main'],
    blockList,
  },
  watchFolders,
};

起動してみる

ここまでの設定が完了したら、一度シュミレーターを起動してみましょう。

yarn ios
or
yarn android

でシュミレーターを立ち上げようとすると...

Error: EISDIR: illegal operation on a directory, read
    at Object.readSync (fs.js:592:3)
    at tryReadSync (fs.js:366:20)
    at Object.readFileSync (fs.js:403:19)

このようなエラーがターミナルとシュミレーターに出力されるはずです。
エラーの全文は長いので省いていますが、内容を見てみると Metro バンドラーがエラーを起こしていることがわかります。
続いて

yarn why metro

で metro のバージョンが何でどこで使われているか確認します。

[1/4] 🤔  Why do we have the module "metro"...?
[2/4] 🚚  Initialising dependency graph...
[3/4] 🔍  Finding dependency...
[4/4] 🚡  Calculating file sizes...
=> Found "metro@0.64.0"
info Reasons this module exists
   - "_project_#mobile#react-native#@react-native-community#cli" depends on it
   - Hoisted from "_project_#mobile#react-native#@react-native-community#cli#metro"
   - Hoisted from "_project_#mobile#react-native#@react-native-community#cli#metro-config#metro"
   - Hoisted from "_project_#mobile#react-native#@react-native-community#cli#metro#metro-transform-worker#metro"
info Disk size without dependencies: "2.77MB"
info Disk size with unique dependencies: "17.04MB"
info Disk size with transitive dependencies: "49.39MB"
info Number of shared dependencies: 185

v0.64.0 がインストールされているのがわかります。
上記エラーは metro の v0.64.0 で発生しているバグになります。

2021 年 6 月時点では react-native-cli でテンプレートの生成を行うと metro の 0.64.0 がインストールされ、このバグが発生します。
最新の 0.66.0 にあげるか、パッチを当ててあげることでこれを回避することができます。

metro バンドラーにパッチを当てる

パッチを当てるために先ずは patch-package というのをインストールします。

yarn add -D -W patch-package

次に、パッチファイルを作成します。
プロジェクトのルートに patches というフォルダを作成し metro+0.64.0.patch というファイルを作成し、下記コードをコピペします。

patches/metro+0.64.0.patch
diff --git a/node_modules/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/node_modules/metro/src/node-haste/DependencyGraph/ModuleResolution.js
index 5f32fc5..2b80fda 100644
--- a/node_modules/metro/src/node-haste/DependencyGraph/ModuleResolution.js
+++ b/node_modules/metro/src/node-haste/DependencyGraph/ModuleResolution.js
@@ -346,7 +346,7 @@ class UnableToResolveError extends Error {
     try {
       file = fs.readFileSync(this.originModulePath, "utf8");
     } catch (error) {
-      if (error.code === "ENOENT") {
+      if (error.code === "ENOENT" || error.code === 'EISDIR') {
         // We're probably dealing with a virtualised file system where
         // `this.originModulePath` doesn't actually exist on disk.
         // We can't show a code frame, but there's no need to let this I/O

このパッチが yarn install 時に適用されるようにしたいので、postinstall を行います。

package.json
"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
+    "postinstall": "patch-package"
  },

scripts に postinstall を記述すると、yarn install 後に自動で指定されたコマンドを実行してくれるようになります。
これで再度 yarn install するとパッチが適用されて metro バンドラーが正しく動くようになります。

metro の設定ファイルをルートにも作成する

これが最後の作業になります。
今までの設定で Xcode や metro バンドラーがルートの node_modules を参照するようになって動くようにはなっています。
しかし、これまでの設定でエントリーポイントもルートを指すようになっているため、本来見てほしい packages/mobile/index.js が見つけられずにいます。

一応、ルートに index.js を移動して、そこで mobile 直下の App.tsx を import しても動くのは動きます。
ですが、ここは metro バンドラーがちゃんと探せるようにしてあげましょう。
ルートに metro.config.js を作成し、下記コードをコピペします。

metro.config.js
const path = require("path");

const watchFolders = [
  path.resolve(__dirname, "./packages/modules"),
  path.resolve(__dirname, "node_modules"),
];
const blockList = [/packages\web/, /node_modules\/@monorepo\/web/];

module.exports = {
  projectRoot: path.join(__dirname, "packages/mobile"),
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: false,
      },
    }),
  },
  resolver: {
    resolverMainFields: ["react-native", "browser", "module", "main"],
    blockList,
  },
  watchFolders,
};

mobile 直下の metro とほとんど同じですが、パスが違うのでその部分が逆転しています。
唯一違うのは projectRoot というプロパティです。

projectRoot: path.join(__dirname, "packages/mobile"),

ここで package/mobile を見るように metro バンドラーに教えてあげることでエントリーポイントが正しく設定されます。
mobile フォルダに移動して yarn ios or yarn android をすると、Welcome to React の画面が表示されると思います。
以上で yarn workspaces で React Native が起動できるようになるまでの環境構築になります。

いやー結構骨が折れますね...

modules パッケージの作成

折角のモノレポなので、web と mobile で使い回す共通のモジュールを置くパッケージも作っておきましょう。
packages 配下に modules フォルダを作って npm init をしましょう。
package.json の name フィールドは@monorepo/modulesとしておきます。

src フォルダに下記のような定数ファイルでも作っておきましょう。

packages/modules/src/color.ts
export const BLACK = "#000000";
export const WHITE = "#FFFFFF";
packages/modules/src/index.ts
export * from './color.ts'

web で modules を読み込めるようにする

web で読み込むようにするのは簡単で、package.json に

packages/web/package.json
"dependencies": {
+  "@monorepo/modules": "*",
  "react": "17.0.1",
  "react-native": "0.64.1"
},

このように name フィールドと同じ名前を書いて*を指定するだけです。

import { BLACK, WHITE } from "@monorepo/modules";

使う時はこのように import するだけです。

mobile で modules を読み込めるようにする

web とは違い、mobile は少し面倒です。
package.json を編集するのは同じですが、追加でルートとパッケージ配下の metro.config.js も変更する必要があります。
metro.config.js に watchFolders という変数を定義しましたが、そこに modules の参照を追加します。

// metro.config.js
const watchFolders = [
+  path.resolve(__dirname, "./packages/modules"),
  path.resolve(__dirname, "node_modules"),
];

// packages/mobile/metro.config.js
const watchFolders = [
+  path.resolve(__dirname, '../modules'),
  path.resolve(__dirname, '../../node_modules'),
];

こうすることで、web と同様に参照できるようになり、modules 配下を変更しても即座に変更を検知して反映されるようになります。

終わりに

yarn workspaces と React Native をやろうとすると、最初の環境構築でやることが多くて詰まることが多いです。
また、開発を進めていくうちに依存関係が壊れて hoisting しなくなって動かなくなることも実は起こったりします。
そんな時は package.json の resolutions という便利なフィールドがあり、ここでバージョンを固定することができます。

色々問題が起きることが少なくはないので、これが誰かの参考になればなと思います。

GitHubで編集を提案

Discussion

ログインするとコメントできます