🍣

DeepDive Lighthouse

2025/02/16に公開

Lighthouse のコードを読んだので、その実装を解説していきます。CLIの使い方から、直接APIを叩く方法、そして個別のAuditの実装を理解する流れを解説します。

これは Lighthouse を理解するための資料で、Lighthouseの使い方ではありません。

とはいえ、内部実装を理解することで Lighthouse についての理解が深まることでしょう。

https://zenn.dev/mizchi/scraps/feb5d7632ddd81

https://zenn.dev/mizchi/scraps/b6b9456d531c66

Chrome Devtools Protocol

Puppeteer が Chrome に向けて喋っているもの。Lighthouse も基本的に CDP を直接操作します。

https://chromedevtools.github.io/devtools-protocol/

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.lifecycleEventPage.setLifecycleEventsEnabled で有効化した際に流れてくる、ブラウザのライフサイクルイベントのログです。

https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-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 を使います。

https://github.com/GoogleChrome/lighthouse/blob/main/docs/puppeteer.md

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 を初期化します。

https://github.com/GoogleChrome/lighthouse/blob/main/core/gather/navigation-runner.js#L281-L286

    // 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を初期化してセットアップします。

https://github.com/GoogleChrome/lighthouse/blob/main/core/gather/navigation-runner.js#L260

今回は、同等の処理を抜き出して要約します。

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.enableTracing.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 のソースコードが公開されてない。

https://www.npmjs.com/package/@paulirish/trace_engine

Discussion