はじめに
Reactは最近 v17.0.0-rc.0 をリリースしました。前のメジャーバージョンである v16.0(2017/9/27リリース) からすでに3年近くが経過しています。
新機能が目白押しだった React 16 やこれまでのメジャーバージョンと比較して、React 17 は非常に特殊です。なぜなら、新機能がないからです:
React v17.0 Release Candidate: No New Features
それだけでなく、7つの破壊的変更(breaking change)が伴っています……
1. 本当に新機能はないの?
Reactの公式発表によると、v17 の位置づけは技術的な改修版であり、主な目標は今後のバージョンのアップグレードコストを下げることです:
This release is primarily focused on making it easier to upgrade React itself.
したがって、v17 は単なる布石であり、重要な新機能をリリースする意図はありません。v18、v19……といった今後のバージョンが、よりスムーズに、より早くアップグレードできるようにするためのものです:
When React 18 and the next future versions come out, you will now have more options.
しかし、いくつかの改修は後方互換性を壊さざるを得なかったため、v17 というメジャーバージョンの変更として提示され、ついでに2年以上にわたって蓄積された歴史的負債(レガシーな部分)を取り除くことになりました。
2. 漸進的なアップグレードが可能に
v17 以前は、異なるバージョンの React を混在させることができませんでした(イベントシステムに問題が生じます)。そのため、開発者は古いバージョンを使い続けるか、多大な労力をかけて全体を新しいバージョンにアップグレードするかの二択を迫られていました。長年要件がないロングテールモジュール(利用頻度の低いモジュール)でさえ、全体的な対応とリグレッションテストが必要でした。開発者のアップグレードコストを考慮すると、React メンテナチームも身動きが取れず、非推奨の API を簡単に削除できず、長期間、あるいは無期限にメンテナンスを続けるか、古いアプリケーションを切り捨てるしかありませんでした。
これに対して、React 17 は新たな選択肢を提供します。それが漸進的なアップグレードであり、React の複数バージョンの共存を許可します。これは大規模なフロントエンドアプリケーションにとって非常に親切な設計です。例えば、ポップアップコンポーネントや一部のルーティング配下のロングテールページは一旦アップグレードを見送り、部分ごとにスムーズに新しいバージョンへ移行させることができます(公式 Demo を参照)。
P.S. 複数のバージョンの React を(必要に応じて)読み込むことは、パフォーマンスに小さからぬオーバーヘッドをもたらすため、慎重に検討すべきであることに注意してください。
複数バージョンの共存とマイクロフロントエンドアーキテクチャ
複数バージョンの共存と新旧混在のサポートにより、マイクロフロントエンドアーキテクチャ が目指す段階的なリファクタリングが可能になりました:
フロントエンドの機能の一部を段階的にアップグレード、更新、さらには書き換えることが可能になりました。
React が複数バージョンの共存をサポートし、段階的にバージョンアップを完了させることと比較すると、マイクロフロントエンドは異なる技術スタックの共存を許可し、アップグレード後のアーキテクチャへスムーズに移行することに重点を置いており、より広範な問題を解決しようとしています。
一方で、React 技術スタックにおいて複数バージョンの混在という難題が解消されたとき、マイクロフロントエンドについても再考する必要があります:
-
一部の問題は技術スタック自体で解決した方が適切ではないか?
-
複数技術スタックの共存は常態か、それとも短期的な過渡期か?
-
短期的な過渡期であれば、より軽量なソリューションは存在しないか?
マイクロフロントエンドがどのような問題を解決しているかについての詳細は、Why micro-frontends? を参照してください。
3. 7つの破壊的変更 (Breaking change)
イベント委譲が document ではなくなる
以前、複数バージョンの共存における主な問題は**Reactイベントシステムのデフォルトの委譲メカニズム**にありました。パフォーマンスの考慮から、React はイベントリスナーを document にのみアタッチしていました。DOM イベントがトリガーされると document までバブリングし、React は対応するコンポーネントを見つけて React イベント(SyntheticEvent)を作成し、コンポーネントツリーに沿ってイベントのバブリングをシミュレートしていました(この時点でネイティブの DOM イベントはすでに document まで到達しています):
[caption id="attachment_2263" align="alignnone" width="625"]
react 16 delegation[/caption]
このため、異なるバージョンの React コンポーネントがネストされている場合、e.stopPropagation() が正常に動作しませんでした(2つの異なるバージョンのイベントシステムは独立しており、両方とも document に到達した時点ではすでに遅すぎるためです):
If a nested tree has stopped propagation of an event, the outer tree would still receive it.
P.S. 実際、Atom は数年前にこの問題に直面していました。
この問題を解決するために、React 17 ではイベント委譲を document にアタッチするのをやめ、DOM コンテナにアタッチするようになりました:
[caption id="attachment_2264" align="alignnone" width="625"]
react 17 delegation[/caption]
例:
const rootNode = document.getElementById('root');
// render の場合
ReactDOM.render(<App />, rootNode);
// Portals も同様
// ReactDOM.createPortal(<App />, rootNode)
// React 16 のイベント委譲(document にアタッチ)
document.addEventListener()
// React 17 のイベント委譲(DOM container にアタッチ)
rootNode.addEventListener()
一方で、イベントシステムを document から縮小させたことで、React が他の技術スタックと共存しやすくなりました(少なくともイベントメカニズムにおける差異が減りました)。
ブラウザのネイティブイベントへの接近
さらに、React イベントシステムにはいくつかの小さな変更が加えられ、ブラウザのネイティブイベントにより近づきました:
-
onScrollがバブリングしなくなりました。 -
onFocus/onBlurがネイティブのfocusin/focusoutイベントを直接使用するようになりました。 -
キャプチャフェーズのイベントリスニングに、ネイティブの DOM イベントリスニングメカニズムを直接採用するようになりました。
注意点として、onFocus/onBlur の下層実装の切り替えはバブリングには影響しません。つまり、React における onFocus は依然としてバブリングします(この特性は有用であると考えられており、変更される予定はありません)。
DOM イベントプーリングの廃止
以前はパフォーマンスの考慮から、SyntheticEvent を再利用するためにイベントプールが維持されていました。このため、React イベントは伝播過程でのみ利用可能で、その後は直ちに回収・解放されていました。例えば:
<button onClick={(e) => {
console.log(e.target.nodeName);
// BUTTON と出力される
// e.persist();
setTimeout(() => {
// エラー: Uncaught TypeError: Cannot read property 'nodeName' of null
console.log(e.target.nodeName);
});
}}>
Click Me!
</button>
伝播過程外ではイベントオブジェクト上のすべての状態が null に設定されます。ただし、手動で e.persist() を呼び出す(または直接値をキャッシュする)場合は例外です。
React 17 ではこのイベント再利用メカニズムが削除されました。最新のブラウザにおいてこの種のパフォーマンス最適化は意味を持たず、かえって開発者に混乱をもたらしていたためです。
Effect Hook のクリーンアップ操作が非同期実行に
useEffect 自体は非同期で実行されますが、そのクリーンアップ処理は同期的に実行されていました(Class コンポーネントの componentWillUnmount が同期的に実行されるのと同じように)。これにより、タブの切り替えなどのシナリオでパフォーマンスが低下する可能性があったため、React 17 ではクリーンアップ処理が非同期実行に変更されました:
useEffect(() => {
// This is the effect itself.
return () => {
// 以前は同期実行でしたが、React 17からは非同期実行に変更されました
// This is its cleanup.
};
});
同時に、クリーンアップ関数の実行順序も修正され、コンポーネントツリーの順序に従って実行されるようになりました(以前は順序が厳密には保証されていませんでした)。
P.S. 同期的なクリーンアップが必要な特殊なケースでは、LayoutEffect Hook を代わりに使用できます。
render で undefined を返すとエラーに
React では render 関数が undefined を返すとエラーになります:
function Button() {
return; // Error: Nothing was returned from render
}
当初の目的は、return を書き忘れるというよくあるミスを警告するためでした:
function Button() {
// We forgot to write return, so this component returns undefined.
// React surfaces this as an error instead of ignoring it.
<button />;
}
しかし、その後のアップデートで forwardRef や memo に対するチェックが漏れており、React 17 でそれが補完されました。今後は、クラスコンポーネント、関数コンポーネント、forwardRef、memo など、React コンポーネントを返すことが期待されるあらゆる場所で undefined のチェックが行われます。
P.S. 空のコンポーネントは null を返すことができ、エラーは発生しません。
エラーメッセージにコンポーネントの「コールスタック」を表示
React 16 から、エラー発生時にコンポーネントの「コールスタック」が表示されるようになり、問題の特定に役立つようになりました。しかし、JavaScript のエラースタックと比べるとまだ大きな差がありました:
-
ソースコードの位置情報(ファイル名、行番号など)が不足しており、コンソールでクリックしてエラー発生箇所にジャンプできない。
-
本番環境では使用できない(
displayNameがミニファイにより失われるため)。
React 17 では新しいコンポーネントスタック生成メカニズムが採用され、JavaScript ネイティブのエラースタックに匹敵する効果(ソースコードへのジャンプ)を実現し、本番環境にも適用できるようになりました。おおよその仕組みとしては、エラー発生時にコンポーネントスタックを再構築し、各コンポーネント内部で一時的なエラーを発生させ(各コンポーネントタイプに対して1回ずつ)、error.stack から重要な情報を抽出してコンポーネントスタックを構築します:
var prefix;
// div などの組み込みコンポーネントの「コールスタック」を構築
function describeBuiltInComponentFrame(name, source, ownerFn) {
if (prefix === undefined) {
// Extract the VM specific prefix used by each line.
try {
throw Error();
} catch (x) {
var match = x.stack.trim().match(/\n( *(at )?)/);
prefix = match && match[1] || '';
}
} // We use the prefix to ensure our stacks line up with native stack frames.
return '\n' + prefix + name;
}
// および Class、関数コンポーネントの「コールスタック」を構築する describeNativeComponentFrame
// ...長すぎるため省略します。興味のある方はソースコードをご覧ください。
コンポーネントスタックは JavaScript ネイティブのエラースタックから直接生成されるため、クリックしてソースコードにジャンプでき、本番環境でも sourcemap を使って元に戻すことが可能です。
P.S. コンポーネントスタックを再構築する過程で render や Class コンポーネントのコンス���ラクタが再実行されます。この部分は破壊的変更(Breaking change)に該当します。
P.S. コンポーネントスタックの再構築に関する詳細は、Build Component Stacks from Native Stack Frames および react/packages/shared/ReactComponentStackFrame.js を参照してください。
一部公開されていたプライベートAPIの削除
React 17 ではいくつかのプライベート API が削除されました。これらは元々 React Native for Web のために公開されていたものがほとんどですが、現在の React Native for Web の新しいバージョンではこれらの API に依存しなくなりました。
また、イベントシステムの変更に伴い、ReactTestUtils.SimulateNative ユーティリティメソッドも削除されました。この動作がセマンティクスと一致していなかったためで、代わりに React Testing Library の使用が推奨されています。
4. まとめ
要するに、React 17 は布石です。このバージョンのコアな目標は React を段階的にアップグレードできるようにすることであり、最大の変更点は複数バージョンの混在を許可し、将来の新機能がスムーズに着地できるように準備を整えたことです。
We’ve postponed other changes until after React 17. The goal of this release is to enable gradual upgrades.
コメントはまだありません