Open10

lighthouse を読む Day 2

mizchimizchi

今日は動的解析する

$ yarn install
$ yarn update:sample-json
# install path
$ CHROME_PATH=/snap/bin/chromium node cli/index.js
mizchimizchi
// cli/index.js
import {begin} from './bin.js';
await begin();

/// cli/bin.js
/**
 * @return {Promise<LH.RunnerResult|void>}
 */
async function begin() {
  // ....
  return runLighthouse(urlUnderTest, cliFlags, config);
}

ここまで config の読み込み

/// cli/run.js
import lighthouse from '../core/index.js';

/**
 * @param {string} url
 * @param {LH.CliFlags} flags
 * @param {LH.Config|undefined} config
 * @return {Promise<LH.RunnerResult|undefined>}
 */
async function runLighthouse(url, flags, config) {
    /// ...
    const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode;
    const shouldUseLocalChrome = UrlUtils.isLikeLocalhost(flags.hostname);
    if (shouldGather && shouldUseLocalChrome) {
      launchedChrome = await getDebuggableChrome(flags);
      flags.port = launchedChrome.port;
    }

    ///...
    const runnerResult = await lighthouse(url, flags, config);

    // If in gatherMode only, there will be no runnerResult.
    if (runnerResult) {
      await saveResults(runnerResult, flags);
    }

    launchedChrome?.kill();
    process.removeListener('unhandledRejection', handleTheUnhandled);

    // Runtime errors indicate something was *very* wrong with the page result.
    // We don't want the user to have to parse the report to figure it out, so we'll still exit
    // with an error code after we saved the results.
    if (runnerResult?.lhr.runtimeError) {
      const {runtimeError} = runnerResult.lhr;
      return printErrorAndExit({
        name: 'LighthouseError',
        friendlyMessage: runtimeError.message,
        code: runtimeError.code,
        message: runtimeError.message,
      });
    }

    return runnerResult;
  } catch (err) {
    launchedChrome?.kill();
    return printErrorAndExit(err);
  }
}

デバッグ用Chromeを起動して、debug port を core の lighthouse に渡す

mizchimizchi

console を挟んで確認

async function _setup({ driver, resolvedConfig, requestor }) {
  await driver.connect();

  /// about:blank を起動
  // We can't trigger the navigation through user interaction if we reset the page before starting.
  if (
    typeof requestor === "string" &&
    !resolvedConfig.settings.skipAboutBlank
  ) {
    // Disable network monitor on the blank page to prevent it from picking up network requests and
    // frame navigated events before the run starts.
    await driver._networkMonitor?.disable();

    await gotoURL(driver, resolvedConfig.settings.blankPage, {
      waitUntil: ["navigated"],
    });
    await driver._networkMonitor?.enable();
  }
  /// Chromeに向けてベンチマークをする
  const baseArtifacts = await getBaseArtifacts(resolvedConfig, driver, {
    gatherMode: "navigation",
  });
  const { warnings } = await prepare.prepareTargetForNavigationMode(
    driver,
    resolvedConfig.settings,
    requestor
  );

  baseArtifacts.LighthouseRunWarnings.push(...warnings);

  return { baseArtifacts };
}

_navigation でどういう artifactsDefinistions が初期化されているか

/**
 * @param {NavigationContext} navigationContext
 * @return {ReturnType<typeof _computeNavigationResult>}
 */
async function _navigation(navigationContext) {
  if (!navigationContext.resolvedConfig.artifacts) {
    throw new Error("No artifacts were defined on the config");
  }

  const artifactState = getEmptyArtifactState();
  const phaseState = {
    url: await navigationContext.driver.url(),
    gatherMode: /** @type {const} */ ("navigation"),
    driver: navigationContext.driver,
    page: navigationContext.page,
    computedCache: navigationContext.computedCache,
    artifactDefinitions: navigationContext.resolvedConfig.artifacts,
    artifactState,
    baseArtifacts: navigationContext.baseArtifacts,
    settings: navigationContext.resolvedConfig.settings,
  };

  const disableAsyncStacks = await prepare.enableAsyncStacks(
    navigationContext.driver.defaultSession
  );

  console.log("----_navigation----");
  console.log(phaseState);
  throw "stop";

  artifactDefinitions: [
    { id: 'DevtoolsLog', gatherer: [Object], dependencies: undefined },
    { id: 'Trace', gatherer: [Object], dependencies: undefined },
    { id: 'RootCauses', gatherer: [Object], dependencies: [Object] },
    {
      id: 'Accessibility',
      gatherer: [Object],
      dependencies: undefined
    },
    {
      id: 'AnchorElements',
      gatherer: [Object],
      dependencies: undefined
    },
    {
      id: 'ConsoleMessages',
      gatherer: [Object],
      dependencies: undefined
    },
    { id: 'CSSUsage', gatherer: [Object], dependencies: undefined },
    { id: 'Doctype', gatherer: [Object], dependencies: undefined },
    { id: 'DOMStats', gatherer: [Object], dependencies: undefined },
    { id: 'FontSize', gatherer: [Object], dependencies: undefined },
    { id: 'Inputs', gatherer: [Object], dependencies: undefined },
    {
      id: 'ImageElements',
      gatherer: [Object],
      dependencies: undefined
    },
    {
      id: 'InspectorIssues',
      gatherer: [Object],
      dependencies: [Object]
    },
    { id: 'JsUsage', gatherer: [Object], dependencies: undefined },
    { id: 'LinkElements', gatherer: [Object], dependencies: [Object] },
    {
      id: 'MainDocumentContent',
      gatherer: [Object],
      dependencies: [Object]
    },
    { id: 'MetaElements', gatherer: [Object], dependencies: undefined },
    {
      id: 'NetworkUserAgent',
      gatherer: [Object],
      dependencies: [Object]
    },
    {
      id: 'OptimizedImages',
      gatherer: [Object],
      dependencies: [Object]
    },
    {
      id: 'ResponseCompression',
      gatherer: [Object],
      dependencies: [Object]
    },
    { id: 'RobotsTxt', gatherer: [Object], dependencies: undefined },
    { id: 'Scripts', gatherer: [Object], dependencies: undefined },
    { id: 'SourceMaps', gatherer: [Object], dependencies: [Object] },
    { id: 'Stacks', gatherer: [Object], dependencies: undefined },
    { id: 'Stylesheets', gatherer: [Object], dependencies: undefined },
    { id: 'TraceElements', gatherer: [Object], dependencies: [Object] },
    {
      id: 'ViewportDimensions',
      gatherer: [Object],
      dependencies: undefined
    },
    { id: 'devtoolsLogs', gatherer: [Object], dependencies: [Object] },
    { id: 'traces', gatherer: [Object], dependencies: [Object] },
    {
      id: 'FullPageScreenshot',
      gatherer: [Object],
      dependencies: undefined
    },
    {
      id: 'BFCacheFailures',
      gatherer: [Object],
      dependencies: [Object]
    }
  ],
  artifactState: {
    startInstrumentation: {},
    startSensitiveInstrumentation: {},
    stopSensitiveInstrumentation: {},
    stopInstrumentation: {},
    getArtifact: {}
  },
mizchimizchi

gatherer

各gathererは段階ごとにデータを集計する。

const phase: "getArtifact" | "startInstrumentation" | "startSensitiveInstrumentation" | "stopSensitiveInstrumentation" | "stopInstrumentation"

Gatherer の例

core/gather/gatherers/meta-elements.js
/**
 * @license
 * Copyright 2019 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import BaseGatherer from '../base-gatherer.js';
import {pageFunctions} from '../../lib/page-functions.js';

/* globals getElementsInDocument getNodeDetails */

/* c8 ignore start */
function collectMetaElements() {
  const functions = /** @type {typeof pageFunctions} */({
    // @ts-expect-error - getElementsInDocument put into scope via stringification
    getElementsInDocument,
    // @ts-expect-error - getNodeDetails put into scope via stringification
    getNodeDetails,
  });

  const metas = functions.getElementsInDocument('head meta');
  return metas.map(meta => {
    /** @param {string} name */
    const getAttribute = name => {
      const attr = meta.attributes.getNamedItem(name);
      if (!attr) return;
      return attr.value;
    };
    return {
      name: meta.name.toLowerCase(),
      content: meta.content,
      property: getAttribute('property'),
      httpEquiv: meta.httpEquiv ? meta.httpEquiv.toLowerCase() : undefined,
      charset: getAttribute('charset'),
      node: functions.getNodeDetails(meta),
    };
  });
}
/* c8 ignore stop */

class MetaElements extends BaseGatherer {
  /** @type {LH.Gatherer.GathererMeta} */
  meta = {
    supportedModes: ['snapshot', 'navigation'],
  };

  /**
   * @param {LH.Gatherer.Context} passContext
   * @return {Promise<LH.Artifacts['MetaElements']>}
   */
  getArtifact(passContext) {
    const driver = passContext.driver;

    // We'll use evaluateAsync because the `node.getAttribute` method doesn't actually normalize
    // the values like access from JavaScript does.
    return driver.executionContext.evaluate(collectMetaElements, {
      args: [],
      useIsolation: true,
      deps: [
        pageFunctions.getElementsInDocument,
        pageFunctions.getNodeDetails,
      ],
    });
  }
}

export default MetaElements;

metaに定義されたdependencies ごとにソートして実行

async function collectPhaseArtifacts(options) {
  const {
    driver,
    page,
    artifactDefinitions,
    artifactState,
    baseArtifacts,
    phase,
    gatherMode,
    computedCache,
    settings,
  } = options;
  const priorPhase = phaseToPriorPhase[phase];
  const priorPhaseArtifacts = (priorPhase && artifactState[priorPhase]) || {};
  const isFinalPhase = phase === 'getArtifact';

  for (const artifactDefn of artifactDefinitions) {
    log.verbose(`artifacts:${phase}`, artifactDefn.id);
    const gatherer = artifactDefn.gatherer.instance;

    const priorArtifactPromise = priorPhaseArtifacts[artifactDefn.id] || Promise.resolve();
    const artifactPromise = priorArtifactPromise.then(async () => {
      const dependencies = isFinalPhase
        ? await collectArtifactDependencies(artifactDefn, artifactState.getArtifact)
        : /** @type {Dependencies} */ ({});

      const status = {
        msg: `Getting artifact: ${artifactDefn.id}`,
        id: `lh:gather:getArtifact:${artifactDefn.id}`,
      };
      if (isFinalPhase) {
        log.time(status);
      }

      const artifact = await gatherer[phase]({
        gatherMode,
        driver,
        page,
        baseArtifacts,
        dependencies,
        computedCache,
        settings,
      });

      if (isFinalPhase) {
        log.timeEnd(status);
      }

      return artifact;
    });

    await artifactPromise.catch((err) => {
      Sentry.captureException(err, {
        tags: {gatherer: artifactDefn.id, phase},
        level: 'error',
      });
      log.error(artifactDefn.id, err.message);
    });
    artifactState[phase][artifactDefn.id] = artifactPromise;
  }
}
mizchimizchi

CLS を計算するには?

# env CHROME_PATH=/snap/bin/chromium lighthouse https://google.com/ --only-audits=cumulative-layout-shift --disable-full-page-screenshot -G --chrome-flags="--headless=new"

これによって、CLSに関するものだけ有効になる。これは何の Audit を必要としているか。

/**
 * @fileoverview This metric represents the amount of visual shifting of DOM elements during page load.
 */
class CumulativeLayoutShift extends Audit {
  /**
   * @return {LH.Audit.Meta}
   */
  static get meta() {
    return {
      id: 'cumulative-layout-shift',
      title: str_(i18n.UIStrings.cumulativeLayoutShiftMetric),
      description: str_(UIStrings.description),
      scoreDisplayMode: Audit.SCORING_MODES.NUMERIC,
      requiredArtifacts: ['traces'],
    };
  }

traces を要求している。

--gather-mode (-G) で実行すると ./latest-run の下に次のようなデータが生成されている。

$ tree latest-run                   
latest-run
├── artifacts.json
├── defaultPass.devtoolslog.json
└── defaultPass.trace.json

この、 defaultPass.trace.json が Chrome内部の トレーシングデータで、 chrome://tracing ページでも食わせることができた。 devtoolslog よりはるかに細かいし、大きい。

$ la latest-run 
.rw-r--r-- 6.2k mizchi 16 Feb 19:21 artifacts.json
.rw-r--r-- 9.0M mizchi 16 Feb 19:21 defaultPass.devtoolslog.json
.rw-r--r-- 104M mizchi 16 Feb 19:21 defaultPass.trace.json
mizchimizchi

CLS 計算処理の実体

core/computed/metrics/cumulative-layout-shift.js

なんか2種類の計算エンジンがあるが、一旦計算に使っているノードだけ見てみる

  /**
   * @param {LH.Trace} trace
   * @param {LH.Artifacts.ComputedContext} context
   * @return {Promise<{cumulativeLayoutShift: number, cumulativeLayoutShiftMainFrame: number, impactByNodeId: Map<number, number>, newEngineResult?: {cumulativeLayoutShift: number, cumulativeLayoutShiftMainFrame: number}, newEngineResultDiffered: boolean}>}
   */
  static async compute_(trace, context) {
    // console.log("CLS: compute_", trace.traceEvents.length);
    const processedTrace = await ProcessedTrace.request(trace, context);
    console.log("CLS: compute_", {
      frameEvents: processedTrace.frameEvents.length,
      frameTreeEvents: processedTrace.frameTreeEvents.length,
      processEvents: processedTrace.processEvents.length,
    });

    const allFrameShiftEvents =
      CumulativeLayoutShift.getLayoutShiftEvents(processedTrace);
    const impactByNodeId =
      CumulativeLayoutShift.getImpactByNodeId(allFrameShiftEvents);
    const mainFrameShiftEvents = allFrameShiftEvents.filter(
      (e) => e.isMainFrame
    );
    const cumulativeLayoutShift =
      CumulativeLayoutShift.calculate(allFrameShiftEvents);
    const cumulativeLayoutShiftMainFrame =
      CumulativeLayoutShift.calculate(mainFrameShiftEvents);

///...

    // console.log("CLS: compute_:end");
    console.log("CLS: compute_", {
      allFrameShiftEvents: allFrameShiftEvents.length,
      impactByNodeId: impactByNodeId.size,
      mainFrameShiftEvents: mainFrameShiftEvents.length,
      cumulativeLayoutShift,
      // cumulativeLayoutShiftMainFrame,
      newEngineResult,
    });
    console.log(JSON.stringify(mainFrameShiftEvents, null, 2));
    return {
      cumulativeLayoutShift,
      cumulativeLayoutShiftMainFrame,
      impactByNodeId,
      newEngineResult,
      newEngineResultDiffered,
    };
CLS: compute_ {
  allFrameShiftEvents: 4,
  impactByNodeId: 8,
  mainFrameShiftEvents: 3,
  cumulativeLayoutShift: 0.05644913431254465,
  newEngineResult: {
    cumulativeLayoutShift: 0.05644913431254465,
    cumulativeLayoutShiftMainFrame: 0.05644913431254465
  }
}
[
  {
    "ts": 23739301316,
    "isMainFrame": true,
    "weightedScore": 0.001218699565871869,
    "impactedNodes": [
      {
        "new_rect": [
          17,
          767,
          151,
          56
        ],
        "node_id": 271,
        "old_rect": [
          17,
          807,
          151,
          8
        ]
      }
    ],
    "event": {
      "args": {
        "data": {
          "cumulative_score": 0.001218699565871869,
          "frame_max_distance": 40.21875,
          "had_recent_input": false,
          "impacted_nodes": [
            {
              "new_rect": [
                17,
                767,
                151,
                56
              ],
              "node_id": 271,
              "old_rect": [
                17,
                807,
                151,
                8
              ]
            }
          ],
          "is_main_frame": true,
          "last_input_timestamp": 183.69999999925494,
          "overall_max_distance": 121.171875,
          "region_rects": [
            [
              17,
              767,
              151,
              56
            ]
          ],
          "score": 0.001218699565871869,
          "weighted_score_delta": 0.001218699565871869
        },
        "frame": "BB76A285A0B7E4E3846986E02A285FB6"
      },
      "cat": "loading",
      "name": "LayoutShift",
      "ph": "I",
      "pid": 228159,
      "s": "t",
      "tid": 1,
      "ts": 23739301316,
      "tts": 1543314
    }
  },
  {
    "ts": 23739374660,
    "isMainFrame": true,
    "weightedScore": 0.01649647909556565,
    "impactedNodes": [
      {
        "new_rect": [
          8,
          791,
          396,
          32
        ],
        "node_id": 247,
        "old_rect": [
          8,
          698,
          396,
          125
        ]
      }
    ],
    "event": {
      "args": {
        "data": {
          "cumulative_score": 0.01771517866143752,
          "frame_max_distance": 93,
          "had_recent_input": false,
          "impacted_nodes": [
            {
              "new_rect": [
                8,
                791,
                396,
                32
              ],
              "node_id": 247,
              "old_rect": [
                8,
                698,
                396,
                125
              ]
            }
          ],
          "is_main_frame": true,
          "last_input_timestamp": 183.69999999925494,
          "overall_max_distance": 121.171875,
          "region_rects": [
            [
              8,
              698,
              396,
              125
            ]
          ],
          "score": 0.01649647909556565,
          "weighted_score_delta": 0.01649647909556565
        },
        "frame": "BB76A285A0B7E4E3846986E02A285FB6"
      },
      "cat": "loading",
      "name": "LayoutShift",
      "ph": "I",
      "pid": 228159,
      "s": "t",
      "tid": 1,
      "ts": 23739374660,
      "tts": 1613600
    }
  },
  {
    "ts": 23739526604,
    "isMainFrame": true,
    "weightedScore": 0.03873395565110713,
    "impactedNodes": [
      {
        "new_rect": [
          8,
          288,
          173,
          95
        ],
        "node_id": 196,
        "old_rect": [
          8,
          295,
          173,
          88
        ]
      },
      {
        "new_rect": [
          193,
          339,
          174,
          45
        ],
        "node_id": 206,
        "old_rect": [
          0,
          0,
          0,
          0
        ]
      },
      {
        "new_rect": [
          193,
          339,
          174,
          31
        ],
        "node_id": 209,
        "old_rect": [
          193,
          339,
          174,
          31
        ]
      },
      {
        "new_rect": [
          379,
          339,
          26,
          45
        ],
        "node_id": 220,
        "old_rect": [
          0,
          0,
          0,
          0
        ]
      },
      {
        "new_rect": [
          379,
          339,
          26,
          31
        ],
        "node_id": 223,
        "old_rect": [
          379,
          339,
          26,
          31
        ]
      }
    ],
    "event": {
      "args": {
        "data": {
          "cumulative_score": 0.05644913431254465,
          "frame_max_distance": 424.96875,
          "had_recent_input": false,
          "impacted_nodes": [
            {
              "new_rect": [
                8,
                288,
                173,
                95
              ],
              "node_id": 196,
              "old_rect": [
                8,
                295,
                173,
                88
              ]
            },
            {
              "new_rect": [
                193,
                339,
                174,
                45
              ],
              "node_id": 206,
              "old_rect": [
                0,
                0,
                0,
                0
              ]
            },
            {
              "new_rect": [
                193,
                339,
                174,
                31
              ],
              "node_id": 209,
              "old_rect": [
                193,
                339,
                174,
                31
              ]
            },
            {
              "new_rect": [
                379,
                339,
                26,
                45
              ],
              "node_id": 220,
              "old_rect": [
                0,
                0,
                0,
                0
              ]
            },
            {
              "new_rect": [
                379,
                339,
                26,
                31
              ],
              "node_id": 223,
              "old_rect": [
                379,
                339,
                26,
                31
              ]
            }
          ],
          "is_main_frame": true,
          "last_input_timestamp": 183.69999999925494,
          "overall_max_distance": 424.96875,
          "region_rects": [
            [
              8,
              288,
              173,
              51
            ],
            [
              8,
              339,
              173,
              44
            ],
            [
              193,
              339,
              174,
              44
            ],
            [
              379,
              339,
              26,
              44
            ],
            [
              193,
              383,
              174,
              1
            ],
            [
              379,
              383,
              26,
              1
            ]
          ],
          "score": 0.03873395565110713,
          "weighted_score_delta": 0.03873395565110713
        },
        "frame": "BB76A285A0B7E4E3846986E02A285FB6"
      },
      "cat": "loading",
      "name": "LayoutShift",
      "ph": "I",
      "pid": 228159,
      "s": "t",
      "tid": 1,
      "ts": 23739526604,
      "tts": 1767404
    }
  }
mizchimizchi

計算処理

  static calculate(layoutShiftEvents) {
    const gapMicroseconds = 1_000_000;
    const limitMicroseconds = 5_000_000;
    let maxScore = 0;
    let currentClusterScore = 0;
    let firstTs = Number.NEGATIVE_INFINITY;
    let prevTs = Number.NEGATIVE_INFINITY;

    for (const event of layoutShiftEvents) {
      if (
        event.ts - firstTs > limitMicroseconds ||
        event.ts - prevTs > gapMicroseconds
      ) {
        firstTs = event.ts;
        currentClusterScore = 0;
      }
      prevTs = event.ts;
      currentClusterScore += event.weightedScore;
      maxScore = Math.max(maxScore, currentClusterScore);
    }

    return maxScore;
  }

タイムスタンプが離れすぎていない範囲で区切って、最も大きい LayoutShift イベントの weightedScore の合計

mizchimizchi

多分衝撃率を計算してると思うんだけど、なんでMaxなんだろう。
累積レイアウトシフトだから、累計じゃないのか?

mizchimizchi

Comuputed*.request() は同じ計算を二回やらないように結果をキャッシュしている

/**
 * Decorate computableArtifact with a caching `request()` method which will
 * automatically call `computableArtifact.compute_()` under the hood.
 * @template {{name: string, compute_(dependencies: unknown, context: LH.Artifacts.ComputedContext): Promise<unknown>}} C
 * @template {Array<keyof LH.Util.FirstParamType<C['compute_']>>} K
 * @param {C} computableArtifact
 * @param {(K & ([keyof LH.Util.FirstParamType<C['compute_']>] extends [K[number]] ? unknown : never)) | null} keys List of properties of `dependencies` used by `compute_`; other properties are filtered out. Use `null` to allow all properties. Ensures that only required properties are used for caching result.
 */
function makeComputedArtifact(computableArtifact, keys) {
  // tsc (3.1) has more difficulty with template inter-references in jsdoc, so
  // give types to params and return value the long way, essentially recreating
  // polymorphic-this behavior for C.
  /**
   * Return an automatically cached result from the computed artifact.
   * @param {LH.Util.FirstParamType<C['compute_']>} dependencies
   * @param {LH.Artifacts.ComputedContext} context
   * @return {ReturnType<C['compute_']>}
   */
  const request = (dependencies, context) => {
    const pickedDependencies = keys ?
      Object.fromEntries(keys.map(key => [key, dependencies[key]])) :
      dependencies;

    // NOTE: break immutability solely for this caching-controller function.
    const computedCache = /** @type {Map<string, ArbitraryEqualityMap>} */ (context.computedCache);
    const computedName = computableArtifact.name;

    const cache = computedCache.get(computedName) || new ArbitraryEqualityMap();
    computedCache.set(computedName, cache);

    /** @type {ReturnType<C['compute_']>|undefined} */
    const computed = cache.get(pickedDependencies);
    if (computed) {
      return computed;
    }

    const status = {msg: `Computing artifact: ${computedName}`, id: `lh:computed:${computedName}`};
    log.time(status, 'verbose');

    const artifactPromise = /** @type {ReturnType<C['compute_']>} */
        (computableArtifact.compute_(pickedDependencies, context));
    cache.set(pickedDependencies, artifactPromise);

    artifactPromise.then(() => log.timeEnd(status)).catch(() => log.timeEnd(status));

    return artifactPromise;
  };

  return Object.assign(computableArtifact, {request});
}