フロントエンドの React 18 バージョンアップとtypescript導入の取り組み
この記事は OPENLOGI Advent Calendar 2023 18日目の記事です。
先日、オープンロジのフロントエンドの技術スタックを最新へと更新しました。この記事ではその経緯や詳細について共有します。
経緯
オープンロジのフロントエンドはおよそ10年間の開発を経て50万行を超える規模になりました。しかし技術スタックの改善がしばらくの間行えておらず、新規開発においても既存の修正においても開発者体験が良いとは言えない状況が続いていました。たとえば、
- 複数のアプリケーションが一つのコードベースで管理されており、チームを跨いで共有している。またライブラリのバージョンアップへの影響が非常に大きい
- すでに利用されていないファイルが数多く存在する(それにより不必要な修正が発生)
- 使っていない大量のimport文
- 無視され続けた10000件ほどのESLintエラー
- Reactのバージョン15である
- そのほか古いバージョンのライブラリが大量に利用されている
などなど、まぁ書き出してみると酷いものです。
フロントエンドをより楽しく、効率的に安全に開発できるように上記課題の改善に取り組み、
- フロントエンドのコード分離
- 不要なファイルやライブラリの削除
- React 18へのバージョンアップ
- TypeScriptの導入
- ESLintの整備
を行いました。
細かい対応についてはまた別の記事を記載したいと思いますが、本記事ではこれらの対応の一部について、対応内容はポイントについて事例として紹介させて頂きます。
対応事例
コードベースの分離と不要なファイル・ライブラリの削除
弊社のフロントエンドアプリケーションは小さいものも含め4つのアプリケーションが存在しますが、全て一つのコードベースで管理をしていました。ライブラリのバージョンアップが複数のアプリケーション/チームに影響がでる状態で、これを改善するためまずはコードベースの分離を行いました。
一応エントリーポイントとなるファイルは分かれており、webpackを利用して最終的な成果物も分離されている状態です。そこで、以下の流れでアプリケーションごとに分離を行いました。
- 既存のソースコードを全て4つのディレクトリに複製し、完全に動作する状態で分離する
- eslint-plugin-unused-importsを用いて不要なimport分を全て削除
- dependency-cruiserを利用して、どこからもimportされていないファイルの抽出と削除
- npx depcheckにより、不要なライブラリの抽出と削除
React18へのバージョンアップ
基本的には公式のアップグレードガイドやBreaking Changeを確認して、逐次対応を行います。
その中でも特に影響の大きな点を紹介します。
prop-typesの移行
prop-typesの移行を行わないとモジュールの解決すら行えないため、優先して対応が必要です。ローカルのコードは公式ツールを使ってマイグレーションします。
依存ライブラリの中にはreact18対応が行われず、古い実装のままのものがいくつかありますが、これらはforkした上で移行処理を行っています。
一部lifecycleメソッドの移行
同様に、componentWillMount
などの一部のlifecycleメソッドが廃止になるため、 UNSAFE_***
への変更を行います。今後は UNSAFE_***
自体も削除される予定ですが、今回のアップグレード時では一旦 UNSAFE_***
への移行を行い、別のタイミングで利用するlifecycleメソッドへの移行を行うことにしました。
componentWillMountの呼び出しタイミングの変更
この変更が触れられている記事は比較的少ないようですが、弊アプリケーションではいくつか対応が必要でした。
元々は削除されるコンポーネントの componentWillUnmount
が呼ばれた後で、 新しいコンポーネントの コンストラクタ
、 render
、 componentWillMount
が呼ばれていました。
react16以降では、新しいコンポーネントの componentWillMount
が呼ばれた後で、削除されるコンポーネントの componentWillUnmount
が呼ばれるようになります。
When replacing <A /> with <B />, B.componentWillMount now always happens before A.componentWillUnmount. Previously, A.componentWillUnmount could fire first in some cases.
ReactDOM.renderの廃止
本来react18の concurrent rendering
に対応するには、Reactの描画処理である ReactDOM.render
から React.createRoot
への変更が必要となります。
この対応により、非同期処理においても状態更新処理( ≒ setState によるrendering)がバッチ処理となりレンダリング中の処理として呼ばれるようになります。
具体的には以下のような処理があった場合、
<button onClick={() => {
new Promise((resolve, reject) => {
reject();
}).catch(() => {
console.log('catch 1');
this.setState({
hoge: 3,
}, () => {
console.log('catch callback 1');
})
console.log('catch 2');
this.setState({
hoge: 4,
}, () => {
console.log('catch callback 2');
throw new Error('unhandled in callback!');
});
});
}
}> hoge!!</button>
元々の動作は
> catch 1
> catch callback 1
> catch 2
> catch callback 2
の順で呼び出されるようになります。
しかし、concurrent renderingを有効にした状態では、
> catch 1
> catch 2
> catch callback 1
> catch callback 2
の順で呼び出される事になります。
これによって、 this.state
に依存した書き方をしている場合に挙動への影響が発生します。
また、元々Promise内でのエラーは Unhandled Promise Rejection
となりとしてReactのツリー描画に影響を及ぼすことはありませんでした。しかし、automatic batchingの処理により、上述のcallbackが後続のイベントループでの描画処理で実行される形になるため、Promise内でのエラーが直接ツリーの描画に影響します。
React16移行、レンダリング処理におけるエラーはコンポーネントツリーのアンマウントにつながるため、アプリケーションへの影響の大きな点となります。
TypeScript対応
webpackで babel-loader
を利用し、コードのトランスパイルにはbabel-preset-typescriptを利用しました。
tsc
による型チェックはCIとcommit hookによる対応を行っています。
また、ESLintは一新し、今後作成するTypeScriptのファイルについては厳格な運用ができるように拡張子毎に異なるルールを設けるようにしています。
"overrides": [
{
"files": ["*.js"],
"extends": ["./eslint/js.eslintrc.js"]
},
{
"files": ["*.ts", "*.tsx"],
"extends": ["./eslint/ts.eslintrc.js"]
}
]
TypeScriptは strict
での運用をしており、既存のJavaScriptファイルを呼び出す際は適宜 d.ts
ファイルを作成しています。
まとめ
フロントエンドの改善についてまとめました。
改めて、Reactのバージョンアップ、TypeScriptの導入、ESLintの厳格な対応によってかなりの開発パフォーマンスや体験の向上を感じています。
進化が激しいフロントエンドの技術スタックは掘っておくとかなり味のある状況に陥ってしまします。これ以上ない状態からでも一つ一つ整理して進めれば新しい環境に生まれ変わらせることができます。
これからは計画性を持って、着実に改善を進めていければなと思っています。
Discussion