React NativeでのPackage Exportsのサポート
React Native 0.72のリリースにより、JavaScriptビルドツールであるMetroに、package.jsonの"exports"フィールドのベータサポートが追加されました。有効化すると、以下の機能が追加されます。
- React Nativeプロジェクトが、より多くのnpmパッケージをすぐに使えるようになります。
- パッケージがAPIを定義し、React Nativeをターゲットにするための新しい機能。
- パッケージ解決におけるいくつかの破壊的変更(エッジケースにおいて)。
この記事では、Package Exportsがどのように機能するのか、そしてこれらの変更がReact Nativeアプリ開発者やパッケージメンテナーにとって何を意味するのかを解説します。
Package Exportsとは?
Node.js 12.7.0で導入されたPackage Exportsは、npmパッケージがエントリーポイント — 外部からインポートできるパッケージのサブパスのマッピングと、それらがどのファイルに解決されるべきかを指定する現代的なアプローチです。
"exports"をサポートすることで、React Nativeプロジェクトがより広範なJavaScriptエコシステム(現在約16.6kのパッケージで使用されています)と連携する方法が改善され、パッケージ作者はマルチプラットフォームパッケージがReact Nativeをターゲットにするための標準化された機能セットを得られます。
"exports"は、package.jsonファイル内で"main"と併用することも、その代わりに使用することもできます。
{
"name": "@storybook/addon-actions",
"main": "./dist/index.js",
...
"exports": {
".": {
"node": "./dist/index.js",
"import": "./dist/index.mjs",
"default": "./dist/index.js"
},
"./preview": {
"import": "./dist/preview.mjs",
"default": "./dist/preview.js"
},
...
"./package.json": "./package.json"
}
}
以下は、@storybook/addon-actionsの異なるサブパスをインポートすることで、上記のパッケージを使用するアプリのコードです。
import {action} from '@storybook/addon-actions';
// -> '@storybook/addon-actions/dist/index.js'
import {action} from '@storybook/addon-actions/preview';
// -> '@storybook/addon-actions/dist/preview.js'
import helpers from '@storybook/addon-actions/src/preset/addArgsHelpers';
// Inaccessible - not listed in "exports"!
Package Exportsの主な機能は次のとおりです。
- パッケージカプセル化:
"exports"で定義されたサブパスのみがパッケージ外部からインポートできます。これにより、パッケージは自身の公開APIを制御できます。 - サブパスエイリアス:パッケージは、異なるファイルロケーションにマッピングするカスタムサブパスを定義できます(サブパスパターン経由も含む)。これにより、公開APIを維持したままファイルの再配置が可能です。
- 条件付きエクスポート:サブパスは、環境に応じて異なる下層ファイルに解決される場合があります。例えば、
"node"、"browser"、または"react-native"ランタイムをターゲットにする場合などです。これは"browser"フィールドの仕様を置き換えます。
"exports"の全機能は、Node.js Package Entry Pointsの仕様に詳しく記載されています。
これらの機能は既存のReact Nativeの概念(プラットフォーム固有の拡張機能など)と重複するため、また"exports"はnpmエコシステムでしばらく前から利用可能であったため、私たちはReact Nativeコミュニティに連絡を取り、私たちの実装が開発者のニーズを満たすことを確認しました(PR、最終RFC)。
アプリ開発者向け
Package Exportsは、本日よりベータ版で有効にできます。
- Package Exports機能に依存するパッケージ(FirebaseやStorybookなど)に対するインポートは、設計どおりに機能するはずです。
- Metroを使用するReact Native for Webプロジェクトは、
"browser"の条件付きエクスポートを使用できるようになり、回避策が不要になります。
Package Exportsを有効にすると、特定のプロジェクトに影響を与える可能性のあるいくつかのエッジケースの破壊的変更が生じますが、これらは本日テストできます。
将来のReact Nativeリリースでは、Package Exportsがデフォルトで有効になります。鶏と卵のような状況で、React Nativeアプリはこれまで一部のパッケージが"exports"に移行するのを待っていました。あるいは、私たちの"react-native"ルートフィールドの緊急脱出策を使用していました。Metroでこれらの機能をサポートすることで、エコシステムが前進できるようになります。
Package Exportsの有効化 (ベータ版)
Package Exportsは、アプリのmetro.config.jsファイルで、resolver.unstable_enablePackageExportsオプションを通じて有効にできます。
const config = {
// ...
resolver: {
unstable_enablePackageExports: true,
},
};
Metroは、条件付きエクスポートの動作を設定する2つの追加のレゾルバオプションを公開しています。
unstable_conditionNames— 条件付きエクスポートを解決する際にアサートする条件名のセットです。デフォルトでは、['require', 'import', 'react-native']に一致します。unstable_conditionsByPlatform— 指定されたプラットフォームターゲットに対して解決する際にアサートする追加の条件名です。デフォルトでは、プラットフォームが'web'の場合に'browser'に一致します。
React NativeのJestプリセットを忘れずに使用してください! JestはPackage Exportsをデフォルトでサポートしています。テストでは、testEnvironmentOptionsオプションを使用して、解決されるcustomExportConditionsをオーバーライドできます。
TypeScriptを使用している場合、プロジェクトのtsconfig.json内でmoduleResolution: 'bundler'とresolvePackageJsonImports: falseを設定することで、解決動作を一致させることができます。
プロジェクトの変更を検証する
既存のプロジェクトでは、早期採用者はunstable_enablePackageExportsを有効にした後に解決の変更が発生するかどうかを確認するために、以下の手順に従うことを推奨します。これは1回限りのプロセスです。変更は全くない可能性が高いですが、開発者には確実な選択をしていただきたいと考えています。
💡 プロジェクトの変更を検証する
Yarnを使用していない場合は、yarnをnpx(またはプロジェクトで使用されている関連ツール)に置き換えてください。
-
すべての解決済み依存関係を取得する(変更前)
# Replace index.js with your entry file if needed, such as App.js
yarn metro get-dependencies index.js --platform android --output before.txt- Expo CLI: プロジェクトにまだ
metro.config.jsファイルがない場合は、npx expo customize metro.config.jsを実行します。 - 完全なカバレッジを得るには、
--platform androidをアプリで使用している他のプラットフォーム(例:ios、web)に置き換えてください。
- Expo CLI: プロジェクトにまだ
-
metro.config.jsでresolver.unstable_enablePackageExportsを有効にします。 -
すべての解決済み依存関係を取得する(変更後)
yarn metro get-dependencies index.js --platform android --output after.txt -
比較!
diff before.txt after.txt
破壊的変更
私たちは、MetroでPackage Exportsの仕様に準拠した実装(いくつかの破壊的変更を必要とする)を決定しましたが、それ以外は後方互換性があり(既存のインポートを持つアプリが段階的に移行できるように支援する)、また、後方互換性があります。
主な破壊的変更は、パッケージによって"exports"が提供された場合、それが最初に考慮され(他のpackage.jsonフィールドよりも前)、一致したサブパスターゲットが直接使用されることです。
- Metroは、インポート指定子に対して
sourceExtsを展開しません。 - Metroは、ターゲットファイルに対してプラットフォーム固有の拡張機能を解決しません。
詳細については、Metroドキュメントのすべての破壊的変更を参照してください。
パッケージのカプセル化は寛容です
Metroが"exports"にリストされていないサブパスに遭遇した場合、レガシー解決にフォールバックします。これは、既存のReact Nativeプロジェクトで以前に許可されていたインポートに対するユーザーの摩擦を減らすことを目的とした互換性機能です。
エラーをスローする代わりに、Metroは警告をログに記録します。
warn: You have imported the module "foo/private/fn.js" which is not listed in
the "exports" of "foo". Consider updating your call site or asking the package
maintainer(s) to expose this API.
将来的には、Nodeのデフォルトの動作に合わせるために、パッケージカプセル化の厳格モードを実装する予定です。したがって、これらの警告がユーザーによって発生した場合は、すべての開発者にそれらに対処することを推奨します。
パッケージメンテナー向け(プレビュー)
私たちの展開計画に従い、Package Exportsは今年の後半にリリースされる次期React Nativeリリース(0.73)で、ほとんどのプロジェクトで有効になる予定です。
私たちは、"main"フィールドおよびその他の現在のパッケージ解決機能のサポートをすぐに削除する予定はありません。
Package Exportsは、パッケージの内部へのアクセスを制限する機能を提供し、ライブラリがReact NativeおよびReact Native for Webをターゲットにするためのより予測可能な機能を提供します。
現在"exports"を使用している場合
もしあなたのパッケージが現在の"react-native"ルートフィールドとともに"exports"を使用している場合、上記のユーザーに対する破壊的変更にご留意ください。Metroでこの機能を有効にしているユーザーの場合、モジュール解決の際に"exports"が最初に考慮されるようになります。
実際には、ユーザーにとっての主な変更点は、"exports"パッケージのカプセル化を尊重することで、アプリ内のアクセス不能なサブパスが(警告を通じて)強制されることだと予想されます。
"exports"への移行
パッケージに"exports"フィールドを追加することは完全に任意です。"exports"を使用しないパッケージの場合、既存のパッケージ解決機能は同じように動作し、この動作を削除する予定はありません。
私たちは、"exports"の新しい機能がReact Nativeパッケージメンテナーにとって魅力的な機能セットを提供すると信じています。
- パッケージAPIを厳密にする:これは、パッケージのモジュールAPIを見直す絶好の機会です。エクスポートされたサブパスエイリアスを通じて正式に定義できるようになりました。これにより、ユーザーが内部APIにアクセスすることを防ぎ、バグの発生領域を減らすことができます。
- 条件付きエクスポート:パッケージがReact Native for Web(つまり
"react-native"と"browser")をターゲットにしている場合、これらの条件の解決順序をパッケージが制御できるようになりました(次の見出しを参照)。
"exports"の導入を決定する場合は、これを破壊的変更として行うことを推奨します。プラットフォーム固有の拡張機能などの機能を置き換える方法を含む移行ガイドをMetroドキュメントに用意しました。
Metroの実装の寛容な動作に頼らないでください。 Metroは後方互換性がありますが、パッケージは仕様に記載されている"exports"の動作に従い、他のツールによって厳密に実装されるべきです。
新しい"react-native"条件
私たちは、コミュニティ条件(条件付きエクスポートで使用)として"react-native"を導入しました。これは、フレームワークとしてのReact Nativeを表し、"node"や"deno"などの他の認識されたランタイムと並んでいます(RFC)。
React Nativeフレームワーク(すべてのプラットフォーム)によって一致します。React Native for Webをターゲットにするには、この条件の前に「browser」を指定する必要があります。
これは以前の"react-native"ルートフィールドを置き換えるものです。以前の解決の優先順位はプロジェクトによって決定されていましたが、これによりReact Native for Webを使用する際に曖昧さが生じていました。"exports"の下では、パッケージが条件付きエントリーポイントの解決順序を具体的に定義することで、この曖昧さが解消されます。
"exports": {
"browser": "./dist/index-browser.js",
"react-native": "./dist/index-react-native.js",
"default": "./dist/index.js"
}
"android"および"ios"条件を導入しないことを選択しました。これは、他の既存のプラットフォーム選択方法が普及していることと、この動作がフレームワーク全体でどのように機能するかの複雑さによるものです。代わりにPlatform.select() APIを使用してください。
将来:安定版"exports"、デフォルトで有効
次期React Nativeリリースでは、この機能のunstable_プレフィックスを削除し(計画されたパフォーマンス作業とバグに対処済み)、Package Exportsの解決をデフォルトで有効にすることを目指しています。
すべての人に"exports"が有効になることで、React Nativeコミュニティを前進させることができます。例えば、React Nativeのコアパッケージは、公開モジュールと内部モジュールをより良く分離するように更新できるでしょう。

謝辞
RFCにフィードバックを提供してくださったReact Nativeコミュニティのメンバー、@SimenB、@tido64、@byCedric、@thymikeeに感謝します。
