こんにちは。PR TIMES でエンジニアをしている岩元 (@yoiwamoto) です!
プレスリリース配信サイト PR TIMES のフロントエンドは、一昨年ごろまでほぼ全てのページが Smarty + jQuery on PHP で実装されており、直近1、2年は機能追加・改修に合わせてこれらを順次 React 実装にリプレイスを進めています。
このような取り組みをどのような技術構成で行っているか、2023年の振り返りの意味も込めてざっくりと紹介します!
リポジトリ構成
React 実装は、これまでメインのバックエンドサーバーとのモノリスで構成していたリポジトリとは分けて、prtimes-frontend というリポジトリを使用しています。
prtimes-frontend は pnpm の Workspace を使用した薄い monorepo であり、ざっくりと以下のような構成になっています。
.
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── apps
│ ├── clipping
│ ├── prtimes
│ └── prtimes-public
├── common
│ ├── press-release-ui
│ ├── prtimes-company-admin-components
│ └── prtimes-public-components
├── packages
│ ├── eslint-config-prtimes-frontend
│ ├── msw
│ ├── openapi
│ ├── prtimes-basis
│ └── tsconfig
└── tests
├── clipping
├── editor-e2e-vrt
├── prtimes
└── prtimes-public
pnpm-workspace.yaml の内容は以下の通りで、apps、common、packages、tests 直下のディレクトリをそれぞれ package としています。
packages:
- apps/*
- common/*
- packages/*
- tests/*区分は以下の通りです。
| apps | 独立してデプロイを行うアプリケーションの単位 |
| common | packages に依存するが、apps を跨いで依存する共通モジュール |
| packages | apps を跨いで依存する、primitive な共通モジュール |
| tests | Playwright でのブラウザテスト |
common が要らなそう?(package で良さそう)と思われそうですが、一応、packages のモジュール(例えばデザインシステム準拠のコンポーネント等)に依存するかどうかで明確に線引きをしています。理由としては、packages 内で相互依存している状態だと、後々循環依存が発生するなどして管理がややこしくなりそうなので、それを未然に防ぐためのレイヤとして設けたということになります。
そもそも apps 間でモジュールを共有する必要のある状況が発生していることが問題とも考えられますが、apps の切り方はデプロイの単位であり、扱うドメインというよりは SEO やパフォーマンスなどの非機能要件が考慮されています。これにより、Next.js アプリケーションと、SSR 無しの SPA で同じものを扱うということが生じるので、必然的にこのようなレイヤが生まれています。
apps について「独立してデプロイを行うアプリケーションの単位」と書きましたが、Vite でビルドする SPA 実装である apps/prtimes では、主にページ単位でエントリファイルは分割されており、複数アプリケーションを管理しているとも言えます。
フレームワーク・バンドラ
Vite
PR TIMES のフロントエンドでは、SEO やパフォーマンスが極端に重要というわけではない管理画面などのページで、SSR 無しの React SPA を採用しています。
以前までバンドラは Webpack でしたが、今年の2月に Vite に移行しました。(めちゃくちゃ速いです)

Next.js
一方で今年から、要件上 SSR を行うことが妥当だと思われる一部ページでは、Next.js が採用されています。

選定について詳細は以下のエントリなどに書いていますが、現時点では Pages Router を使用しており、動作環境は ECS、前段に CDN の Fastly を置いて、キャッシュは全て Fastly で管理、という形式をとっています。

静的解析
TypeScript の型チェック
単純に tsc --noEmit で型検査を行っています。
XO
ESLint をラップした linter で、well-maintained なルールセットでもある XO を使用しています。
JavaScript/TypeScript linter (ESLint wrapper) with great defaults
ファイル名は全て camel-case.ext にする必要があったり、request を req と略記することを許容しないなどなかなか opinionated なルールが多いのでハードルは少し高いですが、細かい議論の発生する余地を無くすという点でかなり有用だと思っています。
また、XO は Prettier も内蔵しているので、prtimes-frontend では直接は prettier を使用しておらず、xo --fix でフォーマットも行っています。
導入事例としてこちらの記事がよくまとまっていました。
テスト
Vitest
ユニットテストは Vitest で行っています。
先に書いたように、今年の初め頃にバンドラを Webpack → Vite に移行したので、併せて導入した形になります。
実はフロントエンドリプレイスが始まった最初期はユニットテストを書く文化があまりなかったのですが、最近は自然にテストが書かれるようになっており、特に Storybook の play function を活用してテストも書かれるなどしています。

既存実装にテストを書いていくタスクはなかなか進めにくいのですが、新しい package などではカバレッジもそれなりです。ただ、ブランチカバレッジがかなり低かったりと、まだまだ正確なテストを書けていないところはあるので、今後の課題となっています。


Playwright
ブラウザテスト・VRT に Playwright を使用しています。
最初は Cypress を使用していたのですが、今年 Playwright に移行しています。

また、少し紛らわしいんですが、Playwright で行っているブラウザテストは基本的に、API レスポンス等を全てモックしてフロントエンドで完結するテストになっています。(そのため、社内ではこれをインテグレーションテストと呼んでいます)
このようなテスト手法は意外とスタンダードというわけではないようなんですが、ブラウザレベルでの挙動や VRT を、バックエンドの環境や DB セットアップなどを考慮せずに気軽に書いていけるので、弊社ではかなりワークしています。

Lost Pixel
コンポーネントには基本的に Storybook の story が書かれていて、それぞれの story について Lost Pixel で VRT を回す仕組みがあります。

story も多く若干時間がかかるので、影響範囲が大きいプルリクエストなどで特定の Label を貼ることで発火するようになっています。


失敗した際には diff などの情報が生成されるので、それは S3 にアップロードして、ダウンロードするための URL をコメントするようにもしています。
その他の依存
TanStack Query
server state (API から GET で取得するデータ)の管理。

Recoil
client state の管理。 一部のアプリケーションでは server state を async selector で管理。
最近は、global state を持たなくても実装が可能なシンプルなページでは、できるだけ使用しない方向性になっています。
Radix UI
ダイアログやコンボボックスなど、簡単にはアクセシブルな実装ができないコンポーネントでベースに使用しています。
一方で、本来は自前実装で要件を満たせるべきなので、どこまで依存してよいかという議論も一部あります。
Emotion
ほとんどのスタイル記述に使用されています。
runtime-js CSS in JS で、エコシステムの観点で言うと将来性には若干の陰りがありますが、
実際のアプリケーションで課題が生じているような段階ではないので引き続き使用しています。
openapi-generator-cli
OpenAPI spec を yaml に記載していて、API クライアントを生成しています。
CI
package によりますが、各変更に対して、基本的に以下のような検査が、GitHub Actions で回ります。
- TypeScript の型検査
- XO のリント
- Vitest のユニットテスト
- アプリケーションのビルド
- Playwright のブラウザテスト・VRT
- Storybook のビルド・テスト
また、以下のような工夫があります。
1. キャッシュの活用
node_modules や Playwright のバイナリを Actions Cache にキャッシュして利用しています。
冒頭で紹介した pnpm-workspace.yaml で test/* も package として使用していましたが、このような設定だと CI でビルド等の前に、アプリケーションのビルドには不要なテスト用の依存もインストールしてしまうことになります。
workspace から除外すると、不要なインストールが発生せず局所的には速くなり得るのですが、prtimes-frontend では monorepo 全体の依存を含む node_modules を、root の pnpm-lock.yaml の内容を key としてキャッシュするようにしているので、これがほぼ常に HIT するようにした方が結果的に速いと判断し、まとめて扱っています。
2. 実行時間 or コスト最適化
アプリケーションも大きくなってきたり、ユニットテストも増えてきたりで、CI 実行時間はどうしても長くなっていってしまいますが、デプロイ等のいくつかのワークフローではできる限り速く終わって欲しいです。
そういったケースでは以下のように step が並列化されたり、ユニットテスト実行がシャーディングされたりしており、高速に終了するようになっています。

逆に、こういった実行時間の最適化はコスト面でネガティブに働くこともあります。
以下のエントリのように、並列化が有効なデプロイのようなワークフローや、その他極端に遅いものを除いて、直列実行を有効に使うことでコスト最適化も行っています。

3. Storybook がデプロイ・コメントされる
prtimes-frontend では、変更の影響範囲のアプリケーションで、ブランチごとに分離した環境に Storybook がデプロイされ、URL がプルリクエストにコメントされます。

これによって、UI 影響のある変更について、レビュワーも気軽に story を見て動作を確認することができるようになっています。
4. Renovate
Renovate を入れているので、設定に合わせたスケジュール・対象パッケージ・分割単位で、依存のアップデートのプルリクエストが立つようになっています。
例えば Package Grouping を使用して依存パッケージ数の多い Storybook 系、ESLint 系などをグルーピングしてプルリクエストをまとめるようにする、などがあります。
制限しているとは言っても大量のプルリクエストが立つので、CI 実行量が過剰にならないよう、renovate の切ったブランチでは必要最低限のワークフローが回るように制御されており、全てのテストが実行されるのは、リリース前の全ての updates をまとめたブランチになります。
今後の課題
まだまだ色々な課題がありますが、大きなものでテストの実行時間削減などがあります。
テストの量は時間が経つとどうしても増えていってしまいますが、全体のテスト量に各アプリケーションの CI 実行時間ができるだけ引きずられていかないよう、テスト実行対象を変更の影響範囲に閉じるようにするなど、一定賢い判定・分岐を導入する必要がありそうです。
また、Recoil 等のライブラリのインターフェースに深く依存しすぎた実装について、移行が必要になった時にアプリケーションになるべく影響を与えずに剥がせるように設計方針をある程度固めることや、Storybook にテストなどをどこまで依存するかなど、決める必要のあることも多く残っています。
来年も引き続きこの辺りの課題に取り組んでいけたらと思います。
We are hiring!
フロントエンドエンジニアはもちろん、各種ポジションで採用を行っています!



