🎃

yarnからpnpmへ移行する

2023/03/01に公開

yarnからpnpmへ移行する理由

yarnをv1からberryに移行した当初も pnpm の存在は認知していましたが、シンボリックリンクを使う関係で動かないパッケージがいくつかあったので、当時は berry を使う選択をしていました。

しかし、最近利用者が増えてきてシンボリックリンクに対応したパッケージが増えてきたのと、
Next.jsを開発しているVercelもpnpmを支援しているというのもあり、この度乗り換えることにしてみました。

pnpm のセットアップ

インストール方法は色々ありますが、Node.jsのv16.9.0以降とv14.19.0以降には corepack が同梱されているため、管理のしやすさから corepack を使ってインストールします。
まず、package.json に以下の記述を追加します。

{
  ...
  "packageManager": "pnpm@7.28.0",
  ...
}

次に、Node.jsをインストール後に以下のコマンドを実行します。

corepack enable

これで pnpm コマンドが使えるようになりました。

yarn独自の記述を置き換える

yarn には npm とは名称の異なる依存関係解決記述があります。
代表的なものは resolutions です。
pnpm は npm と同じく resolutions に該当する overrides という機能があるので、 resolutions を使っている場合はこちらに置き換えます。

・yarnでの記述

{
  "resolutions": {
    "react": "18.2.0"
  }
}

・pnpmでの記述

{
  "pnpm": {
    "overrides": {
      "react": "18.2.0"
    }
  }
}

また、patch の機能を利用している場合は、 patchedDependencies を使って以下のように置き換えます。

・yarnでの記述

{
  "dependencies": {
    "react-native": "patch:react-native@npm:0.70.7#.yarn/patches/react-native-npm-0.70.7-21f8a0d0a2.patch",
  }
}

・pnpmでの記述

{
  "pnpm": {
    "patchedDependencies": {
      "react-native@0.70.7": "patches/react-native-npm-0.70.7-21f8a0d0a2.patch"
    }
  }
}

yarn patch はデフォルトだと resolutions に patch の依存関係記述を自動的に追加するのですが、
少なくとも私の環境では resolutions にある状態では yarn install でパッチがまともに動いたことがありませんでした。
そのため、dependencies や devDependencies に resolutions へ追加されたパッチ記述を移すことで対応していました。

pnpm の patchedDependencies にはそのような問題はないので安心して利用ができます。
パッチを当てる際は dependencies や devDependencies へも依存関係のバージョン記述が必要です。
つまり、以下のようになっている必要があります。

{
  "pnpm": {
    "patchedDependencies": {
      "react-native@0.70.7": "patches/react-native-npm-0.70.7-21f8a0d0a2.patch"
    }
  }
  ...
  "dependencies": {
    "react-native": "0.70.7"
  }
}

ちなみに、パッチを当てる依存関係には ^0.70.7 のようなバージョン指定はできません。必ず明示的なバージョン番号のみの指定である必要があります。

以上で、patch の移行もできました。

コンテナビルドの移行

Dockerやpodmanといったコンテナビルドも移行します。
以下、Next.jsプロジェクトでの yarn を利用している際のコンテナビルドを pnpm 記述へ移行します。

・yarnでの記述

# ------------------------------
# Builder stage
# ------------------------------
FROM node:19.7.0-buster as builder

WORKDIR /var/opt/app
COPY package.json yarn.lock ./
COPY . .
RUN yarn --immutable --immutable-cache \
  && yarn build \
  && yarn workspaces focus --all --production

# ------------------------------
# Execution image
# ------------------------------
FROM node:19.7.0-buster-slim

WORKDIR /var/opt/app
COPY --from=builder /var/opt/app/node_modules ./node_modules
COPY --from=builder /var/opt/app/package.json ./
COPY --from=builder /var/opt/app/.yarn ./.yarn
COPY --from=builder /var/opt/app/.yarnrc.yml ./
COPY --from=builder /var/opt/app/yarn.lock ./
COPY --from=builder /var/opt/app/next.config.js ./
COPY --from=builder /var/opt/app/.next ./.next
COPY --from=builder /var/opt/app/dist ./dist
COPY --from=builder /var/opt/app/public ./public

ENTRYPOINT ["./docker-entrypoint.sh"]

・pnpmでの記述

# ------------------------------
# Builder stage
# ------------------------------
FROM node:19.7.0-buster as builder

WORKDIR /var/opt/app
COPY package.json pnpm-lock.yaml ./
COPY . .
RUN pnpm i \
  && pnpm build \
  && pnpm i --production

# ------------------------------
# Execution image
# ------------------------------
FROM node:19.7.0-buster-slim

WORKDIR /var/opt/app
COPY --from=builder /var/opt/app/node_modules ./node_modules
COPY --from=builder /var/opt/app/package.json ./
COPY --from=builder /var/opt/app/pnpm-lock.yaml ./
COPY --from=builder /var/opt/app/next.config.js ./
COPY --from=builder /var/opt/app/.next ./.next
COPY --from=builder /var/opt/app/dist ./dist
COPY --from=builder /var/opt/app/public ./public

CMD ["./docker-entrypoint.sh"]

yarnの場合は .yarn ディレクトリ、.yarnrc.yml、yarn.lockをコピーする必要がありましたが、pnpm では pnpm-lock.yaml をコピーするくらいなので簡潔です。また、 npm で使える npm i --production が pnpm でも使えるので、yarnのように別途プラグインを追加する必要はありません。(上記の yarn の例は workspace-tools プラグインを導入した上で実行できるコマンドとなっている)

以上で、コンテナビルドも pnpm へ移行できました。

インストール速度について

実際に移行した後のCIにかかった時間を比較すると、もちろん各プロジェクトで利用している依存関係によって変わりますが、
約7分かかっていた処理が約4分にまで短縮できたケースもありました。
そのため、node_modules のインストール時間が占める割合が多いプロジェクトほど pnpm は有効であるということがわかります。(あくまで一例なので、環境によってはそこまで改善しない場合もあります)

pnpm における注意点

yarnと比べてインストール速度も短縮できて良いことだらけかというと、pnpm にはいくつか注意点があります。
例として、私が react-native プロジェクトを pnpm 対応したときにいくつか問題があったため参考までに紹介します。

1. yarnやnpmと異なりインストールされない依存関係がある

yarnのときは package.json に記述しなくてもインストールされていたパッケージが、pnpmでは明示的に package.json へ追加しないとインストールされないことがあります。
いくつかのプロジェクトを pnpm へ移行して特に多いと感じたのは、tslib と Babel 関連のプラグインになります。
別途インストールする必要があるパッケージを判断するためには、アプリケーション起動時に Cannot find module というエラーが出て判断できるので、もし足りていない場合はそのエラーメッセージを元に判断して、パッケージを追加インストールする必要があります。

参考までに、react-native プロジェクト(v0.70.7の場合)では yarn 利用時には明示的にインストールが不要だった以下のパッケージを明示的にインストールする必要がありました。

pnpm i tslib
pnpm i -D @react-native-community/cli-platform-ios @react-native-community/cli-platform-android 
pnpm i -D @babel/plugin-proposal-nullish-coalescing-operator @babel/plugin-proposal-optional-chaining @babel/plugin-transform-arrow-functions @babel/plugin-transform-shorthand-properties @babel/plugin-transform-template-literals @babel/preset-typescript

2. シンボリックリンクを解釈できないパッケージがある

pnpmで作られた node_modules はシンボリックリンクにて同一のモジュールを参照するようにリンクされた構成になっています。
それ故に余計なモジュールをダウンロードせず、短い時間でインストールが完了して全体のファイルサイズとしても小さいというのが pnpm の特徴となるのですが、一方で利用するパッケージによってはこのシンボリックリンクをうまく解釈できない実装になっているケースがあります。
その代表的な例としても、react-native が挙げられます。
react-native は yarn での利用が前提となっており、pnpm で利用するためには別途対応が必要となります。
参考までに、react-native プロジェクトで pnpm に対応する例を紹介します。

react-native を pnpm へ対応する手順

react-native でJSバンドルを行う Metro Bundler がpnpmのシンボリックリンク構成に対応していません。
ただ、幸いにも react-native ではMicrosoftが提供している rnx-kit のパッケージを利用すればこの問題を解決できます。
以下に導入方法を記載します。

# シンボリックリンクに対応するために、以下の依存関係を追加インストールする
pnpm i -D @rnx-kit/metro-config @rnx-kit/metro-resolver-symlinks

続いて、 metro.config.js を開き、 makeMetroConfig と MetroSymlinksResolver の記述を追加します。

metro.config.js
const { makeMetroConfig } = require('@rnx-kit/metro-config');
const MetroSymlinksResolver = require('@rnx-kit/metro-resolver-symlinks');

module.exports = makeMetroConfig({
  ...
  resolver: {
    resolveRequest: MetroSymlinksResolver(),
  },
  ...
});

これで、react-native でも pnpm の利用ができるようになります。

このように、pnpm を使う上では利用するパッケージが pnpm に対応できているかどうかを予め確認しておく必要があります。
場合によっては react-native のように別途パッケージを必要とする場合があります。

【追記】
実は react-native では上記設定を入れても monorepo 構成のプロジェクトだと動かないケースがあります。
これは pnpm というより Metro Bundler の問題が主要因なのですが、こちらもついでなので紹介します。

  1. シンボリックリンクを使わないで node_modules を作成する

pnpm ではどうしてもシンボリックリンクを使った時の対処方法がない!というケースへの対処法が存在します。
.npmrc ファイルを追加し、以下の設定を記述してください。

.npmrc
node-linker=hoisted

この設定を行うと、シンボリックリンクを使わずに node_modules を作成します。
それなら最初からこの設定でも良いのでは?と思うかもしれませんが、
速度やサイズ面でのメリットも当然受けられなくなるため、あくまで最終手段として利用する必要があります。

  1. Metro Bundlerの設定で最上位の node_modules のみ参照させる

Metro Bundlerはプロジェクトルート配下以外のディレクトリからパッケージを参照しようとすると
デフォルト設定ではファイルがあっても認識しようとしてくれません。
以下のように metro.config.js を書き換えます。

・metro.config.js

const path = require('path');

module.exports = {
  ...
  resolver: {
    nodeModulesPaths: [
      path.resolve(__dirname, 'node_modules'),
    ],
  },
  watchFolders: [
    path.resolve(__dirname, '..', 'proto'),
  ],
  ...
};

上記の resolver にある設定によって、プロジェクト上位の node_modules のみを参照するようになり、
かつ watchFolders の設定によって別パッケージのディレクトリを認識できるようになります。

この方法と rnx-kit を使う方法を合わせることも一見できそうな気がするのですが、その場合は node_modules 内のモジュールの参照が上手く出来なくなってしまうようで、そのために node-linker=hoisted の設定が必要になってくるという感じです。
react-native を使っている人は参考にしてみてください。

3. npm-run-all 利用時に package.json に "config" 項目があるとエラーになる

具体的には以下の issue です。
https://github.com/mysticatea/npm-run-all/issues/201

要は config の項目が package.json にあると実行エラーが発生するというものです。
そのため、config を削除すれば発生しなくなりますが、かといって config を利用するパッケージが代替手段を提供していなければ削除できないケースもあるかと思います。
npm-run-all は未だに利用しているユーザーが多いようですが、更新が4年以上止まっているのと、fork版である npm-run-all2 も同様の問題が残っているため、個人的には pnpm を利用する場合は run-z などの pnpm に対応した別のタスク実行ツールへの移行をおすすめします。

まとめ

というわけで、yarn から pnpm へ移行するために行った手順について紹介しました。
利用しているパッケージのシンボリックリンク対応可否によっては全てのプロジェクトにおいて pnpm 対応が出来るというわけではありませんが、冒頭でも挙げたようにNext.jsを開発しているVercelも推奨していることから、今後についても期待が出来るパッケージマネージャーです。
古いパッケージでもない限りは大抵のケースにおいて代替手段があるので、依存関係のインストール速度やファイルサイズに課題を感じている場合は是非移行することをお勧めします。

Discussion