Package Exports Support in React Native(React NativeにおけるPackage Exportsサポート)
React Native 0.72のリリースに伴い、Metro(JavaScriptビルドツール)は package.json の "exports" フィールドのベータサポートを含むようになりました。有効にすると、次の機能が追加されます。
- React Nativeプロジェクトがより多くのnpmパッケージとそのまま動作するようになる
- パッケージがReact Nativeをターゲットに自身のAPIを定義するための新しい機能
- パッケージ解決に関するいくつかの破壊的変更(エッジケース)
本記事ではPackage Exportsの仕組みと、React Nativeアプリ開発者やパッケージメンテナにとってこれらの変更が何を意味するかを解説します。
Package Exportsとは?
Node.js 12.7.0で導入されたPackage Exportsは、npmパッケージがエントリポイント(外部からimport可能なパッケージのサブパスと、それが解決されるファイル)を指定するためのモダンな方法です。
"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"
}
}
上のパッケージをアプリ側で異なるサブパスとして利用する例:
import { action } from '@storybook/addon-actions';
import { action } from '@storybook/addon-actions/preview';
import helpers from '@storybook/addon-actions/src/preset/addArgsHelpers';
Package Exportsの主な特徴は次の通りです。
- パッケージのカプセル化:"exports"に定義されたサブパスのみが外部からimport可能になり、パッケージが公開APIをコントロールできます。
- サブパスのエイリアス:パッケージはカスタムサブパスを定義して別のファイル位置にマッピングできます(サブパスパターンも可)。これにより内部ファイルを移動しても公開APIを維持できます。
- 条件付きエクスポート:サブパスは環境に応じて異なるファイルに解決できます。たとえば "node"、"browser"、"react-native" のランタイムをターゲットにすることができ、従来の "browser" フィールドの仕様を置き換えます。
注意: "exports" の詳細な機能は Node.js Package Entry Points spec に記載されています。
これらの機能は既存のReact Nativeの概念(プラットフォーム固有の拡張など)と重複する部分があり、また "exports" はnpmエコシステム上である程度定着しているため、実装が開発者のニーズを満たすかどうかを確認するためにコミュニティと協議を行いました(PR、最終RFC参照)。
アプリ開発者向け
Package Exportsは現在ベータで有効にできます。FirebaseやStorybookのようにPackage Exportsの機能に依存するパッケージへのimportは、これで設計どおりに動作するはずです。Metroを使うReact Native for Webプロジェクトは、"browser" 条件付きエクスポートをそのまま使えるようになり、従来のワークアラウンドが不要になります。
Package Exportsを有効にすると、特定のプロジェクトに影響するいくつかのエッジケースな破壊的変更が導入されます。これらは今日テストできます。将来のReact Nativeのリリースでは、Package Exportsがデフォルトで有効になる予定です。
これまでの経緯では、React Nativeアプリ側が一部パッケージの "exports" への移行の足かせになっていました(あるいは我々の "react-native" ルートフィールドの逃げ道を使っていました)。Metroがこれらの機能をサポートすることでエコシステムの前進が可能になります。
Package Exportsを有効にする(ベータ)
アプリの metro.config.js で resolver.unstable_enablePackageExports オプションを有効にすることでPackage Exportsを利用できます。
const config = {
resolver: {
unstable_enablePackageExports: true,
},
};
Metroは条件付きエクスポートの解決方法を構成するための2つの追加resolverオプションを公開しています:
- unstable_conditionNames — 条件付きエクスポートを解決するときに主張する条件名のセット。デフォルトは ['require', 'import', 'react-native'] です。
- unstable_conditionsByPlatform — 指定されたプラットフォームターゲットで解決する際に追加で主張する条件名。デフォルトではプラットフォームが 'web' のときに 'browser' をマッチさせます。
ヒント: React NativeのJestプリセットを使うことを忘れないでください!
JestはデフォルトでPackage Exportsをサポートしています。テスト内では testEnvironmentOptions を使ってどの customExportConditions を解決するかを上書きできます。
TypeScriptを使用している場合は、プロジェクトの tsconfig.json に次を設定することで解決挙動を合わせられます:
{
"compilerOptions": {
"moduleResolution": "bundler",
"resolvePackageJsonImports": false
}
}
プロジェクトでの変更を検証する
既存プロジェクトでは、unstable_enablePackageExports を有効にした後に解決変更が発生するかどうかを確認するため、早期導入者には以下の手順を推奨します。これは一回限りのプロセスです。多くの場合、変更は発生しませんが、確実にオプトインするために検証をお願いします。
注意: Yarnを使用していない場合は、yarn の代わりに npx(またはプロジェクトで使っている該当ツール)を使用してください。
-
変更前の全ての解決済み依存関係を取得します(必要に応じて index.js を App.js などエントリファイルに置き換えてください)。
yarn metro get-dependencies index.js --platform android --output before.txt
(Expo CLI: プロジェクトにまだ metro.config.js がない場合は npx expo customize metro.config.js を実行)
対応範囲を広げるため、アプリで使っている他のプラットフォーム(例: ios, web)についても --platform を置換してください。
-
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を仕様準拠で実装しました(そのためいくつかの破壊的変更が必要になりました)が、既存のimportを持つアプリが段階的に移行できるよう互換性も確保しています。主な破壊的変更は次の通りです。
- パッケージが "exports" を提供している場合、他の package.json フィールドよりも先に "exports" が参照され、マッチしたサブパスのターゲットが直接使われます。
- Metroは import 指定子に対して sourceExts を展開しません。
- Metroはターゲットファイルに対してプラットフォーム固有の拡張を解決しません。
詳細は Metroのドキュメントにあるすべての破壊的変更を参照してください。
パッケージカプセル化は寛容に扱われる
Metroが "exports" に列挙されていないサブパスに遭遇した場合、互換性を保つためにレガシー解決へフォールバックします。これは既存のReact Nativeプロジェクトで以前許容されていたimportによるユーザーの摩擦を減らすための互換性機能です。
エラーを投げる代わりに、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" を使っている場合
パッケージが既に "exports" を "react-native" ルートフィールドと併用している場合、上で述べたユーザーに対する破壊的変更を考慮してください。Metroでこの機能を有効にしているユーザーにとっては、モジュール解決時に "exports" が最初に評価されます。
実務上の主な違いは、アプリ内でアクセス不能なサブパス("exports" に含まれていない)がある場合に警告が出る点になると予想しています。
"exports" への移行
packageに "exports" を追加するのは完全に任意です。"exports" を使っていないパッケージについては既存の解決機能は同じように動作し続け、現在の挙動を削除する計画はありません。とはいえ、"exports" の新機能はReact Nativeパッケージメンテナにとって有用な機能群を提供します。
- パッケージAPIの厳格化:公開サブパスでモジュールAPIを正式に定義する良い機会です。これにより内部APIへのアクセスが防がれ、バグの表面積が減ります。
- 条件付きエクスポート:パッケージがReact Native for Web(つまり "react-native" と "browser")をターゲットにする場合、これらの条件の解決順序を制御できます(次の見出し参照)。
"exports" を導入する場合、破壊的変更としてリリースすることを推奨します。プラットフォーム固有の拡張の置き換え方法などを含む移行ガイドはMetroのドキュメントに用意しています。
注意: Metroの実装の寛容な振る舞いに依存しないでください。Metroは後方互換性を持ちますが、パッケージは仕様で文書化され他のツールで厳密に実装されているように "exports" を扱うべきです。
新しい "react-native" 条件
条件付きエクスポートで使うためのコミュニティ条件として "react-native" を導入しました。これは "node" や "deno" のような既存のランタイムと並んで、React Nativeフレームワークを表します(RFC参照)。
- Community Conditions Definitions — "react-native"
- React Nativeフレームワーク(すべてのプラットフォーム)でマッチします。
- React Native for Web をターゲットにするには、この条件の前に "browser" を指定する必要があります。
- これは従来の "react-native" ルートフィールドを置き換えます。
例:
"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。
この機能の開発を支援してくれたMetaの @motiz88 と @robhogan に大きな感謝を送ります。