jsconf2024 で発表したスライドのソースコードだが、テキスト版のが読みやすい気がしたので公開する。 画像のデッドリンクは面倒なので、気が向いたら…
@mizchi at JSConfJP 2024
- https://x.com/mizchi
- Node.js とフロントエンドの専門家
- 120万*達成率で御社のフロントエンドの高速化をやります
- 今日話す内容ができます。社内勉強会等も承ります。
- 話すこと
- パフォーマンスという予算の考え方
- Lighthouseによる計測
- ソースコードを二分探索して問題を特定
- 計測した問題の扱い方
- 話さないこと
- 具体的な問題の解決方法(都度調べて)
- 特定コードの影響範囲のみを計測する方法を周知したい
- サードパーティスクリプト開発で大量のウェブサイトを計測していた
- 暗黙知化しているのを棚卸ししたい
- フロントエンド計測≒E2E計測
- フロントエンド課題に限定せずに全部に向き合う
- ユーザーと同じ目線のE2E計測こそが真のUX
- フロントエンドにも「予防的可観測性」を周知したい
一度高速なウェブサイトを作ったとしても、パフォーマンスを維持するのは想像以上に困難です。 新しい機能の追加や、サードパーティの計測タグの設定、意図せず巨大なファイルサイズの写真を配信してしまった等、長期的に見ると開発をする中で起きる大小さまざまな出来事によりパフォーマンスは劣化してしまいます。
Google Developers Japan: パフォーマンスバジェットのご紹介 - ウェブパフォーマンスのための予算管理
- Tailwind がモノレポの
**/*.{ts,tsx}
を探索してビルド時間が爆発 - 毎フレーム
document.querySelectorAll('*')
の全要素に再帰オブジェクト探索 - GraphQLなのに設計上のコンポジションに失敗して150超の並列リクエスト
- 計測していない
- 対象を計測/解決できるという認識がない
- コード量とパフォーマンスが反比例すると思い込んでいる(実際関係ない)
- 安易なアンチパターンの採用からの、爆発
- ドキュメント上の非推奨を無視するのが常態化
- 目的や手段が間違ってるときは、間違った解に辿り着く
- 開発体験/DX/UXの悪化を過度に許容する文化
- 非機能要件に不平を言うのが社会人らしくない行為と思われている?
- (プログラマの三大美徳の短気/怠惰/傲慢は現代だと主張しづらいよね…)
- 採用したボイラープレートが理論上最速
- Lighthouse満点を出せるかを最初に確認
- これ以上は前段のCDNでキャッシュしないと無理
- 開発者は速度/複雑性のバジェット(予算)を消費することで機能追加する
- 機能提供とは、バジェットを運用している 状態
- バジェットを食い潰すまで機能追加は止まらない
- 健全なサービス提供には、失ったバジェットを認識し予防的に取り返す必要がある
幸福な家庭はどれも似たものだが、不幸な家庭はいずれもそれぞれに不幸なものである アンナ・カレーニナ (トルストイ)
現代のソフトウェアの複雑性の前では、事前に問題を想定することはできない。
想定できないなら、計測するしかない
あなたはプログラムがどこで時間を消費しているかを理解していない。 ボトルネックは予想外のところにある。推測するな。どこにボトルネックがあるか証明されるまで高速化するな。 計測せよ。計測するまで高速化するな。計測して、コードの一部分が他の部分より圧倒的に時間を消費しているのでなければ、高速化をするな。 ...
Synthetic monitoring (合成モニタリング) - MDN Web Docs
合成モニタリングは、可能な限り一貫性のある環境で、通常は自動化ツールを使用して、「実験室」環境でページの性能をモニタリングすることです。 一貫性のあるベースラインがあれば、合成モニタリングはコード変更が性能に及ぼす影響を測定するのに適しています。しかし、必ずしもユーザーが体験していることを反映しているとは限りません。
Lighthouseのフロントエンド計測≒アプリケーション全体(E2E)の推測
- Web Vitals
- FCP: 最初に意味のある要素が表示されるまでの時間
- LCP: 初期表示において一番大きな要素が確定したタイミング
- TBT: ユーザーのCPUをブロックした時間
- CLS: 累積レイアウト。CPU適用やJSによる要素書き換えの回数
- Lighthouse: WebVitalsを計測するツール
- Chrome DevTools を Lighthouseの推奨設定に合わせて計測
- 最初は計測に徹する
- ソースコードの意味論から推測しない。事前知識は計測においてはバイアス
- (フリーランスの立場だからできる)
- DevToolsとソースコード解析を繰り返して問題を絞り込む
- DevTools で傾向を掴み、ソースコードを書き換えて確認
- プロダクションに近い環境で、必ず複数回(3回以上)計測する
- 本来はアプリケーションに応じた指標がある
- WebVitalsは参考値すぎないがよく練られている(し、SEOに響く)
DevTools
>Lighthouse
>Analyze page load
- FCP → 初期レスポンスが悪い
- LCP → RTTに問題
- TBT → CPU/JSバンドル処理の問題
- CLS → クリティカルパスのCSS
- SI → 結果的な総計(※見る必要なし)
- 目標2.5s (Lighthouse:100)
- 何の表示がLCPを確定させたか?
- LCPまでの経路がチューニング対象
- 同じ処理が起点(のはず)
- 縦のグループが何列あるかがRTT
- MainThread: CPU負荷 →TBT
- Network: リクエストキュー待機数
Size,Time でソートして読む
- Size: 単純に転送量が多いアセット
- Time: 遅いリクエスト
- Waterfall: リクエスト発生順を視覚的に確認
- リクエストを右クリックで Block request URL
- リロードして副作用を確認
- この状態でLighthouseを計測
「例えばGTM起点で何点悪化するんだっけ?」 「WebFontsやめたら何点?」
- レスポンスを書き換えて検証
- 配列を空にしたり…
- (有効化がやや面倒)
- Overrides > Select folders to Override ☑ Enable Local Overrides
使って体で覚えろ!
chrome for developpers が一番まとまってるが、基本的にない。
More tools 全部試すのがオススメ
- 問題が絞り込んであるのが前提
- CPU / Network / RTT
- ソースコードを書き換えて問題が再現する最小状態を探る
- どんなに汚いコードを書いてもいい!
- ブランチは切っておく(説明用に使う)
- ボトルネックが特定できたら伸びしろを確認
- モック or 単に処理をスキップ
- この問題が解決できたら N点伸びる、というのがわかる
- ボトルネックが移動したらLighthouseで再計測
- プリントデバッグ
console.log()
: いつものやつconsole.time();console.timeEnd()
: 同じラベルの区間を計測(ミリ秒)- PerformanceObserver
- タイムスタンプ
Date.now()
: UnixTime ミリ秒精度。CPU処理を測るには不向きperformance.now()
: ナビゲーション開始からマイクロ秒精度
- デバッガ
debugger;
: DevTools Debugger で停止させてデバッグ
- 基本はプリントデバッグ一本
- 調査対象に依存しない
- 高度なツールは環境依存しがち
- ※人による
const started = performance.now();
console.log("js:started", started);
function MyApp(props) {
useEffect(() => {
console.log('useEffect with', props);
console.time('req:xxx');
fetch('/xxx').then((res) => {
console.timeEnd('req:xxx')
debugger;
});
}, []);
return <div>...</div>
}
仮にこういうコードがあるとする
import a from './a';
import b from './b';
import c from './c';
import d from './d';
function async run() {
await a();
await b();
await c();
await d();
}
- 後半をコメントアウト
- 計測用コードを挿入
async function run() {
console.time('a');
await await a(); // 200-300
console.timeEnd('a');
console.time('b');
await await b(); // 30
console.timeEnd('b');
// await c();
// await d();
}
async function run() {
// await a(); // 200-300
// await b(); // 30
console.time('c');
await c(); // 0
console.timeEnd('c');
console.time('d');
await d(); // 1000-1800
consloe.timeEnd('d');
}
- 後半で同じことをやる
- ロジックに依存がある場合...
- 簡単ならモックを試みる
- モックが難しい場合そのまま残す
- 計測結果をメモ
async function run() {
await a(); // 200-300
// await b();
// await c();
await d(); // 1000-1800
}
// 再帰的に計測
export default function d() {
d1(); // 0
await d2(); // 1000-1700 <- これ!
await d3(); // 100
}
- 実行に必要なパスだけ残す
- 一番重い箇所に対し、再帰的に計測
export default function d() {
d1(); // 0
await d2(); // 1000-1700
// await d3(); // 100
}
async function d2() {
console.time(`d2:fetch`);
let cnt = 0;
while(cnt < 10) {
const ret = await fetch(`/api/d/${cnt}`);
if (!ret.ok) break;
cnt++;
}
console.timeEnd(`d2:fetch`);
return cnt;
}
- 実際のボトルネックを特定しにいく
- (経験的には、ライブラリAPI or ネイティブコードであることが多い)
async function d2() {
let cnt = 0;
// while(cnt < 10) {
// const ret = await fetch(`/api/posts/${cnt}`);
// if (!ret.ok) break;
// cnt++;
// }
// console.timeEnd(`d2:fetch`);
return 0;
}
- メインブランチ(
origin/main
)から新しいブランチを切る - 最小手数(diff) で問題を取り除く
- この状態で Lighthouse が何点改善するかを計測
- この点数の差が、改善の伸びしろ
- 40 -> 70 (+30)
- +30 のうち、実現可能な範囲は?
async function d2() {
let cnt = 0;
while(cnt < 10) {
console.time(`d/${cnt}`); // 200-300
const ret = await fetch(`/api/d/${cnt}`);
console.timeEnd(`d/${cnt}`);
if (!ret.ok) break;
cnt++;
}
console.log("end cnt", cnt); // 6
return cnt;
}
// 止血的に、こうできる?
async function d2_parallel() {
return Promise.all([...Array(10).keys()].map(idx => {
await fetch(`/api/d/${idx}`).catch(console.error)
}))
}
// サーバー実装ごと修正
async function d2_once() {
return await fetch(`/api/d`)
}
- そもそもこのコードは何なんだ
- API 自体は改善できるか?
- 本質的に対応可能なもの?
- 長期傾向が自分の観測値と一致してるか確認
- (SEOスコアが高いサイトほど計測されてる傾向がある)
※zenn.devの例
- 例
- LCP:
/api/xxx
が直列で 300ms * 3RTT- 難易度: 中 +20~
- FCP: 初回レスポンスが 1800ms
- 難易度: 高 +10~
- TBT: バンドルに800kb のライブラリが入り込んでいた
- 難易度: 低 +10
- CLS: サードパーティ読み込み後に画像サイズが変更される
- 難易度: 低 +5, 仕様変更: 有
- LCP:
- 難易度が低く、伸びしろが大きく、仕様変更がないものからやる
- 担当範囲を割り振る(フロント、サーバー、仕様)
- 大きな問題同士を混ぜて計測する
- 問題 A, B, C を (A & B), (A & C), (B & C) で再計測
- 同根の問題は干渉する!
- -10(CPU) -10(CPU) => Total 80 (TBT -20)
- -15(Network) -10(CPU) => Total 85 (LCP -15)
- アプリケーション vs サードパーティ
- サードパーティ(GTM起点)は初期化のリクエストキューに割り込む
- サードパーティに極端に問題がある場合、運用を確認する
- TBT:
@next/bundle-analyzer
/ vite-bundle-visualizer - CI: lighthouse-ci で計測を自動化
- 文化: 計測方法の周知(この発表自体が業界へ向けての再発防止策)
- 組織: パフォーマンスバジェットにどれだけリソースを割くかを確認
- 機械的に修正できる範囲は、どこかで限界が来る
- 日頃からKPIを測定できていないと仕様の引き算ができない
- あなたの会社は、一度入れた機能が使われてるか、測定できてますか?
- 失っているパフォーマンスバジェットに見合った価値があるか?
- プログラマは実装上の「痛み」がないものを提案するのも仕事
- 「推測するな、計測せよ」でも、最終的に 「経験と勘」
- 問題と向き合う姿勢/大量のケースを見る経験が大事
- DevToolsの使い方は、使って覚える以外にない
- コスパよく直せる部分は偏っている
- 問題A/Bが同じ -10 でも、修正するコストは点数に比例しない
- 累積的な問題を初期に認識できなかった場合、発覚時点では手遅れ
- 「安易なハック」が起点であることが多い
- 最後に大事なのは仕様
- Dev/Biz 相互に提案を行うコミュニケーションが一番大事
- サーバー:フロントエンド:GTM の速度比率は、組織パワーバランスの発露
計測/改善の仕事だけでなく、計測手法の勉強会もできます。