はじめに
こんにちは。Development Team の原です。
本記事はAI Shift Advent Calendar 2022の 15 日目の記事です。
私は 2021 年 12 月から AI Shift に参画したので、ちょうど 1 年になります。
カスタマーサポート向けの管理画面や JS SDK のフロントエンド開発を行っています。
管理画面や SDK の機能追加や改修を行うのが私の主な業務なのですが、システムがリリースされてから年月が経っているので、非推奨のパッケージを利用していたりなどレガシーな構成が見られます。
この度、フロントエンドの環境周りで、現状の課題と目指す未来の構成について考えたので本記事に記載します。
システムについて
主な技術スタックは以下になります。
- React
- Redux
- TypeScript
- Emotion
- webpack
- Babel
- ESLint,Prettier
- Jest
- Firebase
- Cloudflare
- Sentry
ディレクトリ構成は DDD になっており、UI コンポーネントは Atomic Components を採用しています。
Atomic Components は名前から連想できますが、Atomic Design の派生系のデザインパターンです。
また、管理画面と SDK はモノレポ構成となっています。
基本方針
フロントエンド環境改善の全体的な基本方針として、以下の順番で行っていこうと考えています。
- 現状のレガシーな構成の見える化(Renovate やウェブページの品質計測ツール(Lighthouse など)による可視化)
- モダンな構成へ段階的に移行できる状態にする
- 全体的に移行する
システムの規模が大きいため、改修によるバグを極力抑える目的で、まずは「1 機能直してリリースができる」という状態を作り、段階的に移行できるようにしたいと考えています。
(ただし、React のバージョンアップなど段階的移行が難しいものもあります。)
ここからは具体的にどのような変更を行っていくか説明します。
React のバージョンアップ
現状、React のバージョンが 16.12.0 なので、v18 に上げたいです。
React は v17 から後方互換性を重視しているので、React 自体のバージョンアップはいけると思いますが、React のバージョンを上げたことでバージョンを上げざるを得なくなった React エコシステム達がどのぐらい改修が発生するかが懸念です。
状態管理(Global state)ツールの移行
Local state については各コンポーネントで管理するで良いのですが、Global state については色々な方針があると思います。
ここでは、AI Shift での現状の構成と目指す未来の構成について記載します。
現状の構成
システムで扱う Global state はすべて Redux で管理しています。
現状の Redux 周りのディレクトリ構成は以下のようになっています。
├── account
│ ├── __tests__
│ │ └── usecase.spec.ts
│ └── usecase.ts
├── user
│ ├── __tests__
│ │ ├── reducer.spec.ts
│ │ └── usecase.spec.ts
│ ├── action.ts
│ ├── reducer.ts
│ └── usecase.ts
└── dashboard
├── __tests__
│ ├── reducer.spec.ts
│ └── usecase.spec.ts
├── action.ts
├── reducer.ts
└── usecase.ts
...
モジュールごとにディレクトリを分ける re-ducks パターンに近い構成かなと思います。
usecase は UI から呼ばれ、アクションを dispatch する役目です。
上記のディレクトリ構成の例は一部で、全体的には 30 個以上のモジュールでディレクトリ分割されているので、まあまあ規模は大きいです。
目指す未来の構成
現状の Redux ですべて管理されている状態から、以下のように変更したいと考えています。
- サーバーから取得した Global state は SWR で管理する
- クライアントで生成した Global state は Recoil で管理する
サーバーから取得した Global state は SWR で管理する
SWR という名前の由来は HTTP ヘッダーの Cache-Control に指定できるstale-while-revalidate
の頭文字を取ったものです。
SWR の機能を簡単に言うとstale-while-revalidate
と同じようなことがメモリ上でできるというものです。
Cache-Control にstale-while-revalidate
を指定した場合の挙動については、かなり簡単にまとめたものがあるので、よろしかったら以下をご確認ください。
https://zenn.dev/link/comments/2bd6d86093a908
以下は SWR の公式サイトの例になります。
import useSWR from "swr";
function Profile() {
const { data, error } = useSWR("/api/user", fetcher);
if (error) return <div>failed to load</div>;
if (!data) return <div>loading...</div>;
return <div>hello {data.name}!</div>;
}
useSWR はカスタムフックなのですが、その戻り値を簡単に説明すると、以下になります。
- data が undefined の場合、まだローディング状態
- data が Promise.resolve()された場合、データ取得完了
- error が Promise.reject()された場合、データ取得エラー
useSWR の第 1 引数がキャッシュのキーとなり、第 2 引数は Promise を返す関数となります。
useSWR は取得したデータをメモリ上にキャッシュします。stale-while-revalidate
のように、データ取得の際にキャッシュを返すが、同時にデータ取得も行い、キャッシュを更新します。
メモリ上にキャッシュするので初期表示時はデータ取得が必ず走り、メモリが解放されたらもちろんキャッシュは消えます。
以上が SWR の概要になりますが、ほぼ同じ目的/用途を持つライブラリである TanStack Query(旧 React Query)を採用しなかった理由についても記載します。
- SWR の方がバンドルサイズが軽かった
- TanStack Query は SWR より多機能で、また、2022/12 現在、Solid や Vue も(将来的には Svelte も)サポートしており、ライブラリとしてかなりリッチになってきたため、出来ればシンプルな(機能が少ない)ライブラリを使いたい
TanStack Query に魅力的な機能が実装された場合など、将来的に TanStack Query に乗り換える可能性は大いにあります。
SWR の方がシンプルな分、乗り換えのハードルも低いかと思います。
クライアントで生成した Global state は Recoil で管理する
いったん Recoil にしてますが、現状 Redux を使っているので、Redux をラップした Redux Toolkit(RTK)でも他の状態管理ライブラリでも良いと考えています。
1 つ確実に言えることは、Redux をそのまま使うのはやめたいです。
以下の参考記事にあるように、Redux の Store を作る createStoreAPI は現在、非推奨になっており、RTK への移行を強く促しています。
参考
https://github.com/reduxjs/redux/releases/tag/v4.2.0
https://redux.js.org/introduction/why-rtk-is-redux-today
将来的には非推奨の API は削除されると思うので、今のうちに移行しておきたいです。
現状 Redux のソースコードはかなり量が多いので、まずは Server Data Fetching 周りを段階的に SWR に移行して Redux を軽くしつつ、Recoil や RTK に乗り換えたいと考えています。
Firebase(Firebase JS SDK)のバージョンアップ
Firebase のバージョンは 7.13.0 を使っているので、v9 以上には上げたいです。
v9 から SDK が Tree Shaking 可能なモジュール形式に全体的に書き換えられたので、バンドルサイズが大幅に削減されるというのが大きな理由です。
書き方も以下のように変更されます。
// v8以前の書き方
import firebase from "firebase/app";
import "firebase/auth";
const auth = firebase.auth();
auth.onAuthStateChanged((user) => {...});
// v9以降の書き方
import { getAuth, onAuthStateChanged } from "firebase/auth";
const auth = getAuth(firebaseApp);
onAuthStateChanged(auth, (user) => {...});
v9 移行はとても移行コストが高い改修となるので、v8 以下と互換性を持つ Compat モジュールが v9 では提供されています。
これを使えば、v8 以前の書き方を変える必要なく、v9 へバージョンアップ可能です。
import firebase from "firebase/compat/app";
import "firebase/compat/auth";
const auth = firebase.auth();
auth.onAuthStateChanged((user) => {...});
なお、Compat モジュールだと Tree Shaking は使えません。
Compat モジュールは、段階的に v9 へ移行することを可能にする一時的な利用を目的としたモジュールです。
ビルドパイプラインの移行
現状の構成
開発サーバーも本番ビルドも webpack を利用しています。
コンパイルは ts-loader と babel-loader を使っています。
webpack 内でキャッシュしたり、色々工夫はしているのですが、そもそもシステムの規模が大きいので、起動や HMR も時間がかかっています。
目指す未来の構成
開発サーバーは Vite を計画しています。
主な理由はビルドスピードの改善ですが、Babel 依存を将来的には排除したいという目標もあります。
ただ、babel プラグインで削除が難しいものもあり、そのようなプラグインは @vitejs/plugin-react で Babel を読み込ませる対応を一時的に取っています。
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-macros"],
},
}),
],
});
本番環境に関しては、しばらくは webpack を使ってモジュールバンドルする方針で良いと考えています。
バンドルしなくても、モダンなブラウザであれば ESModules が動きますが、import しているモジュール単位で通信が発生し、初期表示に時間がかかるため、現状はバンドルする以外に本番環境で利用する有効な方法はないと考えます。
ただ、Vite にも Rollup を利用したバンドル機能はあるので、将来的には開発環境も本番環境も Vite に統一したいなと思っています。
テスト
現状の構成
usecase と Redux の reducer のみ Unit テストを書いている状態で、テストフレームワークは Jest を使っています。
また、UI テストと E2E テストはないです。
ただ、補足すると AI Shift にはカスタマーサポートチーム(QA チームではない)というチームがあり、開発完了後かなり隅々までテストして頂いています。
目指す未来の構成
Storybook の導入を並行して進めているのですが、合わせて Storyshots も導入しています。
本当は UI テストも書きたいのですが、もう完成されたシステムなので、ここからテストを書くのはモチベーション的にも中々厳しい状況です。
テストフレームワークは Jest から Vitest に移行しました。
Unit テストはすべて移行が完了しており、Storyshots に関しては Vitest がまだサポートされていないため、Jest で動いています。
将来的には Jest をすべて廃止して、Vitest に移行する予定です。
前述したビルドパイプラインと同様にテスト環境も Vite ベースでいこうかなと考えています。
Vitest について
Vitest はまだ新しく、Jest と比べ安定してるとは言えないライブラリと思いますが、Vitest と Jest の互換性の高さ(移行が楽そう)とパフォーマンスに魅力を感じ移行を決断しました。
実際移行コストも少なく、CI 上でのテストにかかる時間も 7〜8 分から 5 分まで短縮できました。
Storyshots の Vitest 対応は、Storybook v7 の Vite サポートの話や Storyshots の Vitest 対応に関する issue もあがっているので、楽しみに待ちたいと思います。
Jest を使いたくないもう 1 つの理由
Vitest へ移行を決断した理由として、Jest で ESModules(ESM)を動かすのが面倒というのがあります。
node_modules 内のパッケージが ESM で提供されている場合、ts-jest が古いと ESM をサポートしていないため、Jest 上で import/export が構文エラーになります。
これの対応としては、babel-jest を併用するやり方があり、以下のように jest.config.js を設定すれば大丈夫です。
また、babel.config ファイルも別途必要です。
module.exports = {
transform: {
"^.+\\.tsx?$": "ts-jest",
"^.+\\.jsx?$": "babel-jest",
},
// ESMからCJSにトランスパイルさせるパッケージを正規表現で指定する
transformIgnorePatterns: ["/node_modules/(?!d3-*|internmap)"],
};
上記以外のやり方だと実験的サポートですが、ts-jest の ESM サポートを試してみるか、@swc/jest パッケージを使えば、ESM でも動きます。
Storybook の導入
AI Shift はもうすでに完成されたシステムなのですが、Storybook を 1 から導入を進めています。
(Story 作成はほとんどが単調な作業ですが、量も多いので暇な時にコツコツとやっています。)
途中から Storybook を導入するというのはあまりないケースだと思いますが、Storybook を導入してやりたいことがあります。
- Storyshots の導入
- Chromatic の導入
Chromatic は Storybook をホスティングして、各 Story の GitHub コミットごとの差分を見ることができるツールです。
Chromatic は Storyshots の Snapshot よりも高い精度で差分チェックができることを期待しています。
UI テストや E2E テストを今から導入するのは開発リソース的に厳しい、かつメンテナンスコストも高いので、Storybook と連携できるツールに注目しています。
モノレポ構成ちょっとやめたい
AI Shift では管理画面と SDK を開発しているのですが、これらで共通部品があるのでモノレポ構成になっています。
ただ、管理画面と SDK は機能や目的がまったく異なるものなので、別プロジェクトに分けた方がよいかなと思っています。
共通部品の扱いはどうするか考える必要がありますが、デプロイ単位が分かれるメリットも大きいので、モノレポ構成脱却は前向きに考えたいところです。
さいごに
フロントエンドの環境周りで今後やりたいことを書かせて頂きました。
現在、チームメンバーと共に機能開発と並行して、環境のモダン化も進めているのですが、まだそのほとんどが手付かずです。
AI Shift ではシステムの機能開発はもちろんのこと、本記事で書かせて頂いたフロントエンドの環境周りの方針決めや開発にも携われる組織なので、もし興味がありましたら採用ページもご覧になってください!
明日はAIチームの東が執筆予定となっております。