DeepDive Lighthouse
Lighthouse のコードを読んだので、その実装を解説していきます。CLIの使い方から、直接APIを叩く方法、そして個別のAuditの実装を理解する流れを解説します。
これは Lighthouse を理解するための資料で、Lighthouseの使い方ではありません。
とはいえ、内部実装を理解することで Lighthouse についての理解が深まることでしょう。
Chrome Devtools Protocol
Puppeteer が Chrome に向けて喋っているもの。Lighthouse も基本的に CDP を直接操作します。
Lighthouseの実装自体も、あんまり Puppeteer に依存せずに直接 CDP を操作する方向性を感じます。
CLI でデータを収集/解析
まず、lighthouse は計測対象である audits が存在します。これらの一覧を見てみましょう。
$ npx lighthouse --list-all-audits
{
"audits": [
"accessibility/accesskeys",
"accessibility/aria-allowed-attr",
"accessibility/aria-allowed-role",
"accessibility/aria-command-name",
"accessibility/aria-conditional-attr",
"accessibility/aria-deprecated-role",
# 略
]
}
これらの audits は5つの categories
で分類されています。performance,seo...
みたいなやつですね。これらは最終的に Category のスコアとして集計されます。
--only-audits=
で Audits を指定して実行します。今回は viewport
に関するものだけにします。
$ npx lighthouse https://google.com --only-audits=viewport --output=json --output-path=report.json
$ cat report.json | jq ".audits[] | {id: .id, score: .score}"
{
"id": "viewport",
"score": 1
}
lighthouse は 各 Audits
を計算するために、まず Artifacts
というものを収集します。
これを理解するために -G
(gather-mode) で実行してみましょう。実際にURLにアクセスしてデータを収集します。
Audits の計算は行いません。
$ npx lighthouse https://google.com --only-audits=viewport -G
LH:ChromeLauncher Waiting for browser. +0ms
LH:ChromeLauncher Waiting for browser... +0ms
LH:ChromeLauncher Waiting for browser..... +508ms
LH:ChromeLauncher Waiting for browser....... +501ms
LH:ChromeLauncher Waiting for browser......... +502ms
LH:ChromeLauncher Waiting for browser........... +501ms
LH:ChromeLauncher Waiting for browser...........✓ +1ms
LH:status Connecting to browser +393ms
LH:status Navigating to about:blank +20ms
LH:status Benchmarking machine +6ms
LH:status Preparing target for navigation mode +1s
LH:status Cleaning origin data +20ms
LH:status Cleaning browser cache +6ms
LH:status Preparing network conditions +11ms
LH:status Navigating to https://google.com/ +7ms
LH:status Getting artifact: DevtoolsLog +4s
LH:status Getting artifact: MetaElements +0ms
LH:status Getting artifact: NetworkUserAgent +6ms
LH:status Getting artifact: Stacks +0ms
LH:status Collect stacks +0ms
LH:status Getting artifact: FullPageScreenshot +12ms
LH:status Saving artifacts +2s
LH:Artifacts saved to disk in folder: /home/mizchi/repo/GoogleChrome/lighthouse/latest-run +20ms
LH:ChromeLauncher Killing Chrome instance 284021 +1ms
# ファイル出力
$ ls -al latest-run
.rw-r--r-- 26k mizchi 16 Feb 22:03 artifacts.json
.rw-r--r-- 3.5M mizchi 16 Feb 22:03 defaultPass.devtoolslog.json
カレントディレクトリの下に、最新の実行結果が保存されます。
defaultPass.devtoolslog.json
が CDP の生のログで、 artifacts.json
が Audit(=今回はviewport) を計算するために事前に計算される対象です。
ここで devtoolslog.json
を覗いてみます。
[
{"method":"Page.lifecycleEvent","params":{"frameId":"80C6FB61CB60834A4AAA3EA3F423AED1","loaderId":"3CFFEC6C710C45ED1C58ED3C29568FB6","name":"commit","timestamp":30346.242963},"targetType":"page","sessionId":"B21FE68668450937758E28E85AEC6B1D"},
{"method":"Page.lifecycleEvent","params":{"frameId":"80C6FB61CB60834A4AAA3EA3F423AED1","loaderId":"3CFFEC6C710C45ED1C58ED3C29568FB6","name":"DOMContentLoaded","timestamp":30346.243435},"targetType":"page","sessionId":"B21FE68668450937758E28E85AEC6B1D"},
{"method":"Page.lifecycleEvent","params":{"frameId":"80C6FB61CB60834A4AAA3EA3F423AED1","loaderId":"3CFFEC6C710C45ED1C58ED3C29568FB6","name":"load","timestamp":30346.24402},"targetType":"page","sessionId":"B21FE68668450937758E28E85AEC6B1D"},
{"method":"Page.lifecycleEvent","params":{"frameId":"80C6FB61CB60834A4AAA3EA3F423AED1","loaderId":"3CFFEC6C710C45ED1C58ED3C29568FB6","name":"networkAlmostIdle","timestamp":30346.243889},"targetType":"page","sessionId":"B21FE68668450937758E28E85AEC6B1D"},
Page.lifecycleEvent
は Page.setLifecycleEventsEnabled
で有効化した際に流れてくる、ブラウザのライフサイクルイベントのログです。
データが集まってることは確認できました。次に、 -A
(audit-mode) で、収集した対象(latest-run/*
)で、Audits を計算します。ブラウザは起動しません。
$ npx lighthouse --only-audits=viewport -A
LH:Reading artifacts from disk: /home/mizchi/repo/GoogleChrome/lighthouse/latest-run +307ms
LH:status Analyzing and running audits... +15ms
LH:status Auditing: Has a `<meta name="viewport">` tag with `width` or `initial-scale` +1ms
LH:status Generating results... +1ms
LH:Printer json output written to report.json +14ms
$ cat latest-run/lhr.report.json | jq ".audits[]| {id: .id, score: .score}"
{
"id": "viewport",
"score": 1
}
latest-run/lhr.report.json
に出力されています。
Lighthouseが扱うデータ
今解説した artifacts
, audits
, devtoolslog
以外に、Tracing
データがあります。
たとえばCLSを集計するのにはこの Tracing が必要です。
$ npx lighthouse https://google.com --only-audits=cumulative-layout-shift -G
$ ls -al latest-run
.rw-r--r-- 22k mizchi 16 Feb 22:28 artifacts.json
.rw-r--r-- 3.5M mizchi 16 Feb 22:28 defaultPass.devtoolslog.json
.rw-r--r-- 23M mizchi 16 Feb 22:28 defaultPass.trace.json
.rw-r--r-- 37k mizchi 16 Feb 22:17 lhr.report.json
Tracing は桁違いにデータが大きいです。これは、 Tracing.start
, Tracing.end
間でChrome 内部で流れているデータです。
lhr.report.json
は Lighthouse Report と呼ばれ、最終的に人間が読むために整形されたデータです。
読みやすさは lhr
> audits
> artifacts
> devtoolslog
> tarcing
で、データ量はその逆です。
ソースコードの Overview
大雑把にこう
core/
gather/ # GatherMode の処理
gather/gatherers/* # データ収集処理の個別の実装
gather/driver/* # Puppeteer を握って計測用に Chrome を初期化する処理
audits/* # Audit 一覧
computed/* # Audit を計算するための計算処理
config/* # 引数をパースしてLighthouseの設定を組み立てる
cli/
index.js # lighthouse cli のエントリポイント。core を初期化する
Lighthouse を自分で初期化する
CLIじゃない経路で Lighthouse を使います。
import puppeteer from 'puppeteer';
import lighthouse from 'lighthouse';
const url = 'https://chromestatus.com/features';
// Use Puppeteer to launch headless Chrome
// - Omit `--enable-automation` (See https://github.com/GoogleChrome/lighthouse/issues/12988)
// - Don't use 800x600 default viewport
const browser = await puppeteer.launch({
// Set to false if you want to see the script in action.
headless: 'new',
defaultViewport: null,
ignoreDefaultArgs: ['--enable-automation']
});
const page = await browser.newPage();
// Wait for Lighthouse to open url, then inject our stylesheet.
browser.on('targetchanged', async target => {
if (page && page.url() === url) {
await page.addStyleTag({content: '* {color: red}'});
}
});
// Lighthouse will open the URL.
// Puppeteer will observe `targetchanged` and inject our stylesheet.
const {lhr} = await lighthouse(url, undefined, undefined, page);
console.log(`Lighthouse scores: ${Object.values(lhr.categories).map(c => c.score).join(', ')}`);
await browser.close();
lighthouse は puppeteer の page オブジェクトを引数に、ブラウザを初期化できます。
page 自体をいじってから lighthouse を初期化するときは、この経路でなんとかなります。
ちなみにこれは Page | undefined
なので undefined
でもいいんですが、その場合 lighthouse が自力で Page を初期化します。
// For navigation mode, we shouldn't connect to a browser in audit mode,
// therefore we connect to the browser in the gatherFn callback.
if (!page) {
const {hostname = DEFAULT_HOSTNAME, port = DEFAULT_PORT} = flags;
lhBrowser = await puppeteer.connect({browserURL: `http://${hostname}:${port}`, defaultViewport: null});
lhPage = await lhBrowser.newPage();
page = lhPage;
}
CDP を初期化してデータを集める
Lighthouse の自体は navigationGather(navigationContext)
でCDPを初期化してセットアップします。
今回は、同等の処理を抜き出して要約します。
import puppeteer from "puppeteer";
import * as chromeLauncher from "chrome-launcher";
const chrome = await chromeLauncher.launch({
userDataDir: false,
chromeFlags: ["--no-sandbox"],
// chromePath: "/snap/bin/chromium",
});
const browser = await puppeteer.connect({
browserURL: `http://localhost:${chrome.port}`,
defaultViewport: null,
});
const page = await browser.newPage();
const cdp = await page.createCDPSession();
// hooks
cdp.on("*", (...args) => {
console.log("[cdp]", ...args);
});
await cdp.send("Target.setAutoAttach", {
autoAttach: true,
flatten: true,
waitForDebuggerOnStart: true,
});
await cdp.send("Page.enable");
await cdp.send("Network.enable");
await cdp.send("Runtime.enable");
await cdp.send("Page.setLifecycleEventsEnabled", { enabled: true });
await page.goto("https://github.com", {
waitUntil: "networkidle2",
});
page.createCDPSession()
で CDP Client を初期化して、 *.enable
で有効化します。
CDP は EventEmitter なので、*
で全部抜き出すと、 Lighthouse の devtoolslog.json
相当になります。
これで page.goto
でGitHubに初期化したときのCDPがいろいろと取れるわけです。
Artifacts の収集処理
core/gather/gatherers/*.js
にデータ収集処理が書かれています。
Tracing の例。
import BaseGatherer from '../base-gatherer.js';
import {TraceProcessor} from '../../lib/tracehouse/trace-processor.js';
class Trace extends BaseGatherer {
////....
/**
* @param {LH.Gatherer.Context} passContext
*/
async startSensitiveInstrumentation({driver, gatherMode, settings}) {
const traceCategories = Trace.getDefaultTraceCategories()
.concat(settings.additionalTraceCategories || []);
await driver.defaultSession.sendCommand('Page.enable');
await driver.defaultSession.sendCommand('Tracing.start', {
categories: traceCategories.join(','),
options: 'sampling-frequency=10000', // 1000 is default and too slow.
});
if (gatherMode === 'timespan') {
await driver.defaultSession.sendCommand('Tracing.recordClockSyncMarker',
{syncId: TraceProcessor.TIMESPAN_MARKER_ID});
}
}
///....
async stopSensitiveInstrumentation({driver}) {
this._trace = await Trace.endTraceAndCollectEvents(driver.defaultSession);
}
}
phase があり、startSensitiveInstrumentation
| stopSensitiveInstrumentation
で開始、終了処理が記述されています。
ここでは Page.enable
と Tracing.start
が設定されています。
Audits の計算
Viewport の例
class Viewport extends Audit {
// ...
/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Product>}
*/
static async audit(artifacts, context) {
//...
return {
score: Number(viewportMeta.isMobileOptimized),
metricSavings: {
INP: inpSavings,
},
warnings: viewportMeta.parserWarnings,
details,
};
}
}
あとは読むだけ!
すごい大雑把に解説しましたが、基本的に gather で集めて、 audits で計算するだけです。
計算が重い処理は core/computed
にあります。
とにかくあとは既存実装を参考に、 https://chromedevtools.github.io/devtools-protocol/ を睨めばなんかわかるみたいなノリです。
困ってること
とにかくデータ量が多くてファイルが開けない。
頻出する trace_engine のソースコードが公開されてない。
Discussion