Ozonlabsの開発で行ったチューニングの話や詰まった事など
現在、フロントエンド・バックエンドにNextJS、インフラにVercel、認証にFirebase Auth、データベースにPlanetScale(via Prisma)という構成で新規サービスを全て1人で開発しています(2022/12/16、無事にサービスがリリースされました!)
また、サービス本体だけではなくユーザーや記事を管理するためのツールも開発しました(使っている技術スタックはほとんど同じです)。そちらも含めての話になります。
読み込み速度やパフォーマンスチューニングを頑張った(頑張っていきたい)ので、やったことをメモがてらにまとめます。変な場所でコケたりもしたので覚えてることを共有しておきます。もし誰かの救いになると嬉しいです。一応Lighthouseのスコアはこんな感じになっています。
Reactの話
メモ化
基本的な話ですが、ハンドラなどで即時関数を使いません。なぜなら、コンポーネントの再レンダリング時にコールバックが再生成されてしまうことを防ぐためです。
また、コンポーネント内部で定義する際にはuseCallback
を使ってメモ化してあげたほうが良いです。更新頻度が高いコンポーネントの場合、再レンダリング時に全てのコールバックを再生成します。基本的には使いまわして良いものなのでuseCallback
でラップしてあげましょう。
// ❌
const handleSomething = () => { ... }
// ✅
const handleSomething = useCallback(() => { ... }, [deps])
また、値についてもコールバック同様です。Hooksに依存した値を用いた計算などはその都度計算せずにuseMemo
を使いましょう。特に計算コストがかかる処理は必須です。
// ❌
<div>{doSomething(value)}</div>
// ✅
const calculatedValue = useMemo(() => doSomething(value), [value])
<div>{calculatedValue}</div>
最後に、更新頻度が高いコンポーネントは React.memo
でメモ化することです。propsが変更されたときに再レンダリングされます。
Concurrent Rendering
体験的な話ですが、ページ全体のレンダリングをキャンセルさせたくなかったので記事のレンダリング等にはSuspenseを使っています。
Lift State Up & Down
Reactはコンポーネント内部でstateをバリバリに扱うものだと思いますが、そのstateが一部に依存しているのであれば、それはコンポーネント化しています(Lift State Down)。
そうしないと更新時に関係のないコンポーネントも巻き込んでしまうためです。無駄なレンダリングを避けて実行時のパフォーマンスを上げるためです。同様なケースで、状態を共有したい場合はstateを親コンポーネント側に移動させます(Lift State Up)。
状態管理
最初は Recoil 辺りを使おうかと思ったのですが、規模感的にContext APIで十分そうだったので採用しませんでした。ただ管理ツールの方では若干データフローが煩雑になってきている気もするので導入しても良さそう。
Nextの話
SSG/ISR/SSR/CSRの使い分け
コンテンツの更新頻度が低いので基本的にSSGで、トップページだけISRとCSRの合せ技をしています。1ページに描画できる範囲はISRしておいて(FHD基準で決めている)、それ以降の更新リクエストであったりクエリが更新された場合はSWR使って再度Fetchしています(CSR)
もう少し具体的には、例えばデータベースに50個の記事があったとして、また1画面に描画する記事を10個と決めておけば、最初の10個だけISRします。なので1ページ分の表示はすぐに行われます。そして、Ozonlabsは useSWRInfinite
を使った無限スクロールによる読み込みを実装しているので、必要であれば都度追加データを取得します。その際の1ページ分の差分は計算して取得します。
また、記事についているタグをクリックすることで絞り込みが行なえます。その際には再度データを取得します。現在はコンテンツが少ないですが、今後配信が始まってコンテンツ量が増えてきたらこの辺りが効いてくると思っています。
TTFBを減らす
そもそもOGPを動的に生成するためにSSRをしていたのですが、思い切ってSSGに切り替えました。そうしたら5倍ほど速くなりました(画像参照)
とはいえ現状Vercelが足を引っ張っている感じが否めません。(SSGしても平均して900ms前後かかってしまう)なのでGCP等に移行することも検討中…
LRUキャッシュ
node-lru-cache を使ってAPIの呼び出しにRate Limitを実装しています。
robots.txt is not valid の対応
ISRを効かせている場所の robots.txt
がおかしなことになっちゃったので、APIを用意してちゃんと対応してあげました。
// next.config.js
async rewrites() {
return [
{
source: '/robots.txt',
destination: '/api/robots'
}
]
},
// pages/api/robots.ts
/** @format */
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(_: NextApiRequest, res: NextApiResponse) {
res.send(`User-agent: *
Allow: /`)
}
セキュリティ
https://securityheaders.com/ の評価を基準にしてヘッダを設定しました。
nextではこういう感じで設定できます。
// next.config.js
async headers() {
return [
{
key: 'X-DNS-Prefetch-Control',
value: 'on'
},
// etc...
]
}
Firebase Authの話
signInWithPopup
は使わないほうが良い
PC等では問題なく動くので実機検証をするまで気が付かなかったこと。iOSではサイトを超えたトラッキングがデフォルトで無効化されているので、ログイン処理がうまく動きませんでした。代わりに signInWithRedirect
を使って実装しました。
iOSの話
Bearer
スキームの罠
開発中にBasic認証をかけていたらログイン処理が動かなかった。iOSのSafariだとAuthorizationヘッダをBasic認証が上書きしてしまうらしいです。
CSS Animationが効かない
これは CriOS で発生した問題です(ChriOSはChrome iOSのUA)解決策としてそもそもtransitionで実装してしまうとかがあるらしい。現状はUAを見てアニメーションのオン/オフを切り替えています。これは後でtransitionに統一したい。
コーディングの意識的な話
今回の開発はすべて1人で行ったので、当然ですがコードレビューをしてくれるエンジニアはいません。なので、自分でPRを建ててそれを自分でレビューをするという謎の行為をしていたのですが、何も確認せずに本番に取り込まずに1度PRを立てて差分を再確認することは割と有用でした(その段階でミスやバグを見つけることが何度かあったので)
また、開発をするに当たって次のような目標を掲げて行っていました。
- 0 any
- 0 warning
- 0 error
まず0 anyというのは当然型の話です。TypeScriptを採用しているので、当然ですが型の恩恵を受けたいです。当たり前かもしれませんがany型は使っていません(まあanyが必ずしも悪であるとは限らないですが)また、目的としてはタイプセーフよりもコーディング時に起こりうるヒューマンエラーを補完機能で防ぐ目的で型を使っています。当然タイプセーフも大事ですが。
ある程度先を見据える
Nextの話 の使い分けにも書いたような、今のうちに想定できることを考えた実装をしています。後からチューニングすることは当然発生しますが、やれるのであれば先にやっておいたほうが後の保守コストは下がります。
外部ツール
- バンドルサイズ計測: bundlephobia
- 画像変換: squoosh
- サイトの指標計測: PageSpeed Insights
- レンダリングの可視化等: React Developer Tools
- ローカルでのOGP確認: Localhost Open Graph Checker
セマンティクス・アクセシビリティ
サービスを利用してくれるユーザーは多種多様です。多くの人々が同じ様にコンテンツを利用できるようにするためにはアクセシビリティを意識する必要があります。またそこにはHTML5のセマンティクスを知ることと関係があります。
要点
- 背景とのコントラスト差に気をつける
- 要素に対して適切な属性を付与する(逆もしかり、不適切な属性を付与しない)
これはキーボードユーザーなどへの配慮です。インタラクティブが可能な要素(クリックしたりなど)はdivなどを使わずbuttonを使うほうが良いとされています。 - コンテンツを見出し(H1など)より前に配置しない