PerformanceNavigationTimingインターフェースを使って継続的な改善を
UI開発者 加藤Webサイトのパフォーマンスを改善するには、まずボトルネックを調査、解析し、その結果をもとにコードの最適化、リソースの圧縮、CDNを利用した配信などさまざまな施策を行います。そのためWebサイトが完成した後に「さて、パフォーマンスをあげるか」と対策を始めたら想定していたよりもコストがかかってしまった、という経験をしたことがある方も多いかもしれません。
たしかに常にパフォーマンスに気を配りながら実装することはなかなか難しく、パフォーマンスの改善は後回しにしてしまいがちです。また、外部のプラグインなど自分の目の届かないところで予期せぬ箇所がパフォーマンスを下げていることも少なくありません。そこで今回はNavigation Timing APIに含まれるPerformanceNavigationTimingインターフェースを使用して、どのタイミングでどのくらい時間がかかっているかを計測し、テストを行うことで気づきを与えてくれる仕組みを作ってみます。
PerformanceNavigationTimigインターフェース
まずはPerformanceNavigationTimingについて簡単に説明します。今回の記事はNavigation Timing Level 2をもとに実装を行っています。また、Navigation Timing APIはPerformance APIを拡張したものであるため、そちらもあわせてご参照ください。
PerformanceNavigationTimingは、サーバーからレスポンスを受け取ったタイミングや、DOMContentLoadedイベントが発火したタイミング、DOMContentLoadedイベントのリスナー関数の処理が終わったタイミングなどを取得できるインターフェースです。タイミング間の差分を計算することで、どの処理にどのくらいの時間がかかっているかを計測できます。クライアントサイドの処理にかかった時間だけでなく、サーバーからレスポンスを受け取るまでにかかった時間や、リダイレクトにかかった時間なども取得できるため、サーバーサイドのチューニングにも活用できます。下記の画像は取得できるタイミングを一覧した例です。
これをもとにクライアントサイドの各ステップにかかった時間を計算します。コードにまとめると下記のようになります。
const [entry] = performance.getEntriesByType('navigation');
const {
requestStart, // リクエストを開始した時間
responseStart, // レスポンスが始まった時間
responseEnd, // レスポンスが完了した時間
domInteractive, // document.readyStateに「interactive」がセットされる直前の時間
domComplete, // document.readyStateに「complete」がセットされる直前の時間
domContentLoadedEventStart, // DOMContentLoadedイベントが発火する直前の時間
domContentLoadedEventEnd, // DOMContentLoadedのリスナー関数の処理が完了した時間
loadEventStart, // loadイベントが発火する直前の時間
loadEventEnd, // loadのリスナー関数の処理が完了した時間
} = entry;
const result = {
リクエスト開始時からレスポンスの返却が始まるまでの時間: responseStart - requestStart,
レスポンスし終えるまでにかかった時間: responseEnd - responseStart,
DOMの解析完了までにかかった時間: domInteractive - responseEnd,
domContentLoadedイベントのリスナー関数にかかった時間: domContentLoadedEventEnd - domContentLoadedEventStart,
onLoadイベントのリスナー関数にかかった時間: loadEventEnd - loadEventStart,
サブリソースのロードにかかった時間: domComplete - domInteractive,
};
では、この値をもとにテストコードを書いてみます。
テストコード
テストにはPuppeteerとJestを使用します。先ほどご紹介した所要時間を取得するコードをテストの最初に実行し、取得した値をもとに評価を行います。
const puppeteer = require('puppeteer');
describe('Performance Test', () => {
let browser;
let page;
let result;
beforeAll(async () => {
browser = await puppeteer.launch({headless: true});
page = await browser.newPage();
await page.goto('http://localhost:8080/', {waitUntil: 'networkidle0'});
const [performance] = JSON.parse(await page.evaluate(
() => JSON.stringify(performance.getEntriesByType('navigation'))
));
const {
requestStart,
responseStart,
responseEnd,
domInteractive,
domComplete,
domContentLoadedEventStart,
domContentLoadedEventEnd,
loadEventStart,
loadEventEnd
} = performance;
result = {
リクエスト開始時からレスポンスの返却が始まるまでの時間: responseStart - requestStart,
レスポンスし終えるまでにかかった時間: responseEnd - responseStart,
DOMの解析完了までにかかった時間: domInteractive - responseEnd,
domContentLoadedイベントのリスナー関数にかかった時間: domContentLoadedEventEnd - domContentLoadedEventStart,
onLoadイベントのリスナー関数にかかった時間: loadEventEnd - loadEventStart,
サブリソースのロードにかかった時間: domComplete - domInteractive,
};
}, 30000);
test('リクエスト開始時からレスポンスの返却が始まるまでの時間', async () => {
await expect(result.リクエスト開始時からレスポンスの返却が始まるまでの時間).toBeLessThan(1000);
});
test('レスポンスし終えるまでにかかった時間', async () => {
await expect(result.リクエスト開始時からレスポンスの返却が始まるまでの時間).toBeLessThan(1000);
});
test('DOMの解析完了までにかかった時間', async () => {
await expect(result.DOMの解析完了までにかかった時間).toBeLessThan(1000);
});
test('domContentLoadedイベントのリスナー関数にかかった時間', async () => {
await expect(result.domContentLoadedイベントのリスナー関数にかかった時間).toBeLessThan(1000);
});
test('onLoadイベントのリスナー関数にかかった時間', async () => {
await expect(result.onLoadイベントのリスナー関数にかかった時間).toBeLessThan(1000);
});
test('サブリソースのロードにかかった時間', async () => {
await expect(result.サブリソースのロードにかかった時間).toBeLessThan(1000);
});
afterAll(async () => {
await browser.close();
});
});
上記のテストコードでは各所要時間について1000ms以下になることを期待してテストしていますが、サイトごとにそれぞれの指標を決めるのがよいと思います。ためしに計測対象ページのhead要素内でfor文を100億回繰り返してテストをした結果が下記です。
「DOMの解析にかかった時間」でテストに失敗していることが分かります。head要素内の同期的で重い処理はコンテンツのレンダリングを遅らせる要因になってしまいます。今ではscript要素にasync属性や、defer属性を指定し、JavaScriptを遅延読み込みするのが当たり前になってきていますが、タイミングを遅らせたところで処理が重ければ根本的な解決にならないかもしれません。PerformancePaintTimingインターフェースと組み合わせれば、ファーストペイントにかかった時間なども計測することが可能です。CIに組み込み継続的に計測することで、パフォーマンスが落ちたタイミングに気づきやすくなり、効率のよいチューニングができるのではないでしょうか。
注意点として、Puppeteerで計測した場合と、普通にブラウザでページを開いて計測した場合では値に若干の誤差がありました(最大で100ms程)。そのためこの方法は開発中の目安として考えるとよいかもしれません。
「早すぎる最適化は諸悪の根源である」という有名な言葉もある通り、パフォーマンスを追い求めすぎると非常に読みにくいコードになってしまうこともあります。常に最適化をするのではなく、細かく計測をしながらリーダブルなコードを書けるように努めていきたいと思います。