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,600のパッケージで使用されています)と連携する方法が改善され、パッケージ作成者にはマルチプラットフォームパッケージが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
を有効にした後に解決の変更が発生するかどうかを確認するために、以下の手順に従うことを推奨します。これは一度きりのプロセスです。おそらく変更は全くないと思われますが、開発者の皆様には確信を持ってオプトインしていただきたいと考えています。
💡 プロジェクトでの変更点の検証
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"
を導入しました(条件付きエクスポートで使用するため)。これは、"node"
や"deno"
などの他の認識されているランタイムと並んで、フレームワークとしてのReact Nativeを表します(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.