Lighthouse 流程架构

2,105 阅读18分钟

起因

前段时间紧急上线了一个门户项目,两端静态页面,首页考虑到需要极致体验必须使用硬编码搭建,部分子页面采用可视化搭建,要求Lighthouse必须接近满分,尽管通过一些手段优化了首屏但上线之后,离目标还有一大段偏差。 于是去挖lh源码关注各类指标对分值的影响程度,有了针对性的方向,剩下的工作就简单的多。

顺便整理了源码。

LightHouse流程架构

Lighthouse 是一个开源的自动化工具,提供了Node、Chrome Extension App、Chrome DevTool 三端,通过输入审查网址及配置项,通过一系列模拟测试特定环境下的运行状况和性能分析,最后生成性能结果页面供可视化浏览。

为什么需要Lighthouse?一直以来,前端性能的分析指标过于泛化,得不到有效统一的标准,特别是近几年SPA、微服务、小程序、Flutter、webAssembly、SSR、ServerLess等前端技术方案百花齐放,得到高速发展的同时,一些传统的性能测量指标和方式落后跟不上脚步,无法支撑现有技术体系和新领域的迭代更新,再加上终端环境复杂、用户体验标准难以衡量、兼容性问题,审计指标越来越复杂。

例如阿里云ARMS针对 SPA 应用的FMP计量方式改成了依赖于MutationObserve计算权重变化最大的时间节点;淘宝前端团队的秒开率标准;岳鹰结合jssdk与Android内核查看汇集绘制指令来判断页面是否处于白屏....都表明在大前端趋势不可逆转,而测量性能的方式需要考虑更多环境和因素,变得愈加复杂。

Lighthouse 一定不是大前端下性能统计标准,因为从目前而言仍只适用于web端,并且其统计的指标过于笼统。本身而言依赖于 DevTool 发送回来的综合报告按 Audit 分析,输出对应的抽象分数、核心点和优化项,分数低不一样代表性能差,但分数高一定是性能上佳。

整体流程

image.png

名词释义

Driver

根据 Chrome Debugging Protocol <URL>与浏览器交互的对象

Gatherers

驱动 Driver 收集到的网页基础信息,用于后续 Auditing 的审计逻辑。

Artifacts

一系列 Gatherers 信息集合。在 Auditing 里会被附加其他信息,被多个Audits共享。

Audits

以指定依赖的 Artifacts 作为输入,测试单个功能/优化/指标,审计测试评估分数,得到一组LHAR(LightHouse Audit Result Object) 标准数据对象。

Report

ReportRender 使用LHR结果创建输出的UI报表。

基本概念

Lighthouse 驱动 Driver 通过 Chrome DevTool Protocol 与浏览器交互,执行一系列命令,先生成 Gatherers 模块用以收集 Artifacts 信息,这些 Artifacts 信息的聚合会在 Auditing 阶段作为 Audit case 逻辑的输入凭证,通过定义的一系列自定义的审计标准输出分数/优化/详情/描述/原因/展示形式/错误等信息,最终得到一系列LHR统计结果,按需生成指定文件。

基本常用的命令如下,具体命令就不贴了

文档传送门

$ lighthouse --help

lighthouse <url> <options>

Logging:
  --verbose  是否显示详细的日志  [boolean] [default: false]
  --quiet    不显示进度、调试、错误日志  [boolean] [default: false]

Configuration:
  --save-assets                  将跟踪内容和 devTools 日志保存到磁盘  [boolean] [default: false]
  --list-all-audits              打印所有审计列表内容  [boolean] [default: false]
  --list-trace-categories        打印所有必需跟踪类别的列表  [boolean] [default: false]
  --print-config                 输出规范化的配置  [boolean] [default: false]
  --additional-trace-categories  跟踪并捕获附加类别 (逗号分隔).  [string]
  --config-path                  JSON配置路径 lighthouse-core/config/lr-desktop-config.js
  --preset                       应用内置配置,与config-path冲突, [choices: "perf", "experimental", "desktop"]
  --chrome-flags                 自定义flag 空格区分,省略则默认使用 Chrome桌面版或者金丝雀版,all flag List: https://bit.ly/chrome-flags
  --port                         调试协议端口,0表示随机  [number] [default: 0]
  --hostname                     调试协议的hostname  [string] [default: "localhost"]
  --form-factor                  审计的模式,桌面/无线端  [string] [choices: "mobile", "desktop"]
  --screenEmulation              设置模拟屏幕的参数. 见--preset, 使用 --screenEmulation.disabled 以禁用. 否则默认: --screenEmulation.mobile --screenEmulation.width=360 --screenEmulation.height=640 --screenEmulation.deviceScaleFactor=2
  --emulatedUserAgent            设置用户UA  [string]
  --max-wait-for-load            设置最大的加载时间,以审计较完整的过程,过大会导致评分审计方式偏差  [number]
  --enable-error-reporting       启用错误报表覆盖偏好配置. --no-enable-error-reporting 相反. More: https://git.io/vFFTO  [boolean]
  --gather-mode, -G              从交互的浏览器收集artifact保存到磁盘. 
  --audit-mode, -A               处理磁盘上保存的 artifacts. 默认 ./latest-run/
  --only-audits                  仅执行指定的审计项  [array]
  --only-categories              仅测量指定的功能: accessibility, best-practices, performance, pwa, seo  [array]
  --skip-audits                  跳过指定的审计项  [array]

Output:
  --output       报表输出格式 "json", "html", "csv"  [array] [default: ["html"]]
  --view         通过浏览器打开报表  [boolean] [default: false]

Options:
	--extra-headers                      调试额外的HttpHeaders
  --precomputed-lantern-data-path      模拟数据的文件路径, 覆盖对服务器延迟和RTT,可以降低受网络层面的影响.  [string]
  --lantern-data-output-path           基于`precomputed-lantern-data-path` 输出文件的路径.  [string]
  --plugins                            执行指定插件  [array]
  --channel  													 通道 [string] [default: "cli"]
  --chrome-ignore-default-flags  			 忽略掉浏览器默认的flag [boolean] [default: false]

Examples:
  lighthouse <url> --view                                                                          报表生成打开浏览器预览
  lighthouse <url> --config-path=./myconfig.js                                                     自定义配置
  lighthouse <url> --output=json --output-path=./report.json --save-assets                         保存跟踪、截图、JSON报表
  lighthouse <url> --screenEmulation.disabled --throttling-method=provided --no-emulatedUserAgent  禁用设备模拟和限流
  lighthouse <url> --chrome-flags="--window-size=412,660"                                          启用特定size窗口
  lighthouse <url> --quiet --chrome-flags="--headless"                                             启用无头浏览器及忽略所有日志
  lighthouse <url> --extra-headers "{\"Cookie\":\"monster=blue\", \"x-men\":\"wolverine\"}"        request添加请求头
  lighthouse <url> --extra-headers=./path/to/file.json                                             request添加JSON请求头
  lighthouse <url> --only-categories=performance,pwa                                               只测量Performance和PWA项

For more information on Lighthouse, see https://developers.google.com/web/tools/lighthouse/.

Download Repo 到本地,运行

lighthoust https://xixikf.com

从入口开始

在入口处 lighthouse-cli/bin.js 收集命令行 cliFlags 生成配置,收集和合成配置完,生成flags如下,
image.png
runLighthouse 负责唤起 ChromeLauncher 和调用 lighthouse 。

  let launchedChrome; // 浏览器实例

  try {
    const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode;
    // 启动浏览器实例
    if (shouldGather) {
      launchedChrome = await getDebuggableChrome(flags);
      flags.port = launchedChrome.port; // 原flags port可能会被占用,chromelauncher会自动更新
    }
    // 执行 lighthouse-core 核心逻辑,拿到 LHR
    const runnerResult = await lighthouse(url, flags, config);

    // 仅执行 gatherMode 策略,不会有runnerResult, 需要额外保存.
    if (runnerResult) {
      await saveResults(runnerResult, flags);
    }
		// 测量结束杀掉 Chrome 进程
    await potentiallyKillChrome(launchedChrome);
    
    // ...
    
		// 有错误直接退出,不希望用让用户看到
    if (runnerResult && runnerResult.lhr.runtimeError) {
     // ...
    }
    return runnerResult;
  } catch (err) {
    // 过程出错,杀死进程退出
    await potentiallyKillChrome(launchedChrome).catch(() => {});
    return printErrorAndExit(err);
  }

流程概览

核心逻辑主要分五步

  • 生成 Runner Options,即准备需要测量的各功能/优化/指标项与调试配置
  • 通过 ChromeProtocol 协议约定 hostname/port 建立连接进行通信,获取到Connection实例
  • 执行 Runner 逻辑生成 Driver 控制 Connection 实例发送交互命令,执行Collect主流程
    • 创建 Tab 后应用并预配置参数。
    • 对 passes 遍历每个 pass 的 Gatherers 实例,调用对应 lifecycle 拿到 GatherersResult。
  • 将 GathererResult 传递给 Audits,遍历 Audits case,导入依赖执行审计逻辑最终输出标准LHR对象。
  • LHR对象JSON化并统计各类 Categories 分值,根据配置偏好输出到本地。
async function lighthouse(url, flags = {}, configJSON, userConnection) {
  // 设置日志级别,一般情况吐出info
  flags.logLevel = flags.logLevel || 'error';
  log.setLevel(flags.logLevel);

  // configJSON: Lighthouse 运行配置,flags: 可选配置
  const config = generateConfig(configJSON, flags);
  const options = { url, config };
  const connection = userConnection || new ChromeProtocol(flags.port, flags.hostname);

  const gatherFn = ({requestedUrl}) => { // 第3/4/5步
    return Runner._gatherArtifactsFromBrowser(requestedUrl, options, connection);
  };
  return Runner.run(gatherFn, options);
}

生成Runner Options

假设没传入 configJSON 文件,将默认使用 default-config.jssetting

// lighthouse-core/config/defaultConfig.js
const defaultConfig = {
  setting,
	audits: [ // 主要的审计项
    'is-on-https', // 是否使用了https
    'service-worker', // 是否包含SW
    'metrics/first-contentful-paint', // fcp 首次内容绘制
    'metrics/largest-contentful-paint', // lcp 最后内容绘制
    'metrics/first-meaningful-paint',  // fmp 首次主要内容绘制
    'metrics/speed-index', // SI 加载性能指标、填充速度
    // … 
  ],
  categories:{ // 需要测量的类别项
  	performance: {…}, 
    accessibility: {…}, 
    best-practices: {…}, 
    seo: {…}, 
    pwa: {…}
	},
  groups:{ // 报表功能项标题的聚合及国际化
    metrics: {…}, 
    seo-mobile: {…}, 
    diagnostics: {…}, 
    pwa-installable: {…},
    // …
  },
  passes: [ // 控制如何加载urlPage,及在加载过程中收集哪些信息
  	{
      passName:'redirectPass', // 唯一标示
      blankPage:'about:blank',
      // 加载页面时要阻止的请求的URL, * 为放行all
      blockedUrlPatterns:['*.css', '*.jpg', '*.jpeg', '*.png', '*.gif', '*.svg', '*.ttf', '*.woff', '*.woff2'],
      cpuQuietThresholdMs:0, // Driver 选项,CPU空闲阈值
      gatherers: ['http-redirect'],// 收集项
      loadFailureMode:'warn', // 加载失败的处理方式,影响后续pass
      networkQuietThresholdMs:0,// 距离上个pass完成后安静时长,以确保所有请求瀑布流走完,默认5000
      pauseAfterFcpMs:0, // 与 pauseAfterLoadMs 类似
      pauseAfterLoadMs:0, // 页面加载后的阻塞的时间,以确保其他的JS脚本已经加载了
      recordTrace:false, // 是否启用上个pass跟踪记录
      useThrottling:false,// 是否启用限流
    },
    {
      passName:'offlinePass', 
      blockedUrlPatterns: [],
      gatherers: ['service-worker'],
      loadFailureMode:'ignore'
    },
    {
      passName: 'slowPass',
      recordTrace: true, 
      useThrottling: true, 
      networkQuietThresholdMs: 5000, 
      gatherers: ['slow-gatherer'],
    }
  ],
  settings:{ // 测量运行过程中的配置
    output: 'json',  // 输出格式
    maxWaitForFcp: 30000,  // 最大等待绘制边界时间
    maxWaitForLoad: 45000,  // 最大等待加载时间
    formFactor: 'mobile', // 无线端模式
    throttling: {…}, // 限流配置
    // …
  },
  UIStrings (get):() => UIStrings // 国际化相关
}

audits :AuditJSON[],包含了所有审计项

  • 网络层面的是否https、RTT 、服务器延迟/响应、prereload、preconnect
  • 页面加载周期相关的FCP(首次内容绘制)、FMP(首次主内容绘制)、LCP(最后内容绘制)、FCI(首次CPU空闲) 、最大内容元素绘制...
  • 性能情况:预加载脚本/字体、资源汇总、布局位移、长任务、未移除的监听事件...
  • 交互视觉:首次可交互时间、icon、响应式图片、非合成动画、未显示指定size的图片...
  • 可访问性:ARIA(无障碍)、HTML规范、逻辑制表符、ARIA( —— 无障碍)...
  • 解析效率:css/js minified、文本压缩、离屏元素隐藏、是否使用webp、重复脚本、sourcemap...
  • web标准:pwa、long-cache-ttl、manifest、doctype、users-http2...
  • SEO优化:Robots-txt、meta元信息、结构化数据、hreflang...

在输出前每个 audit 会被注入 lighthouse-core/audits 下的审计逻辑,这些审计逻辑每个包含 audit(测试分数)、meta(相关信息及计算 Audit 所需要的 Artifact 模块)。

categories :Record<string, CategoryJSON>,也就是平常在DevTool里勾选的几个测试项,包含了要测试了类别。
image.png
groups :Record<string, GroupJSON>,聚合了每个审计项的 title 及 description,支持后续 UI Report 的国际化。
setting :SharedFlagsSettings,是应用整个测量流程的全局配置,包括网速限制、最大加载时长、report 输出格式、模拟平台、仿真参数、国际化、审计模式、执行通道、请求头等等...
passes :PASSJSON[] ,控制了如何加载 url 请求,以及在加载过程中收集哪些信息,每一项都是页面的一次 load,比如上面passes.length 代表页面两次加载,默认 pass 提供了 offlinePass 、defaultPass 、redirectPass 针对无网、弱网、脚本实际执行代码量比例的 case,每个会被注入默认 passConfig 以确保各配置项存在,每个 pass 都有对应的 gatherers,这些 gatherers 在输出前被注入对应位置下的实例引用,以在 gathering 阶段执行收集逻辑。

// 将 Pass 的 defaultConfig 合并到每个 pass
const passesWithDefaults = Config.augmentPassesWithDefaults(configJSON.passes);
// 根据 throttlingMethod 判断是否需要5s来计算指标,默认情况下不需要
Config.adjustDefaultPassForThrottling(settings, passesWithDefaults); 
// 注入实例引用
const passes = Config.requireGatherers(passesWithDefaults, configDir);


然后应用 configJSON 拓展配置(目前只有官方默认的lighthouse:default)、合并配置插件与flags插件、校验flags(向下兼容旧版本)、初始化测量运行过程中的配置,最终产生一个集成gathers收集项、审计项、运行配置项的Runner options.

详细过程过还有对 OnlyAudits/OnlyCategories/skipAudits 配置项的处理,以及对setting、pass、categories的校验每个audit、categorie 逻辑引用的审查。

ChromeProtocol 交互

const config = generateConfig(configJSON, flags); // 生成Runner Options
const options = { url, config }; 
const connection = userConnection || new ChromeProtocol(flags.port, flags.hostname);

与 Chrome extension App 类似,通过维护的 Chrome Protocol 协议 chrome.debuggger API 连接通信。

Lighthouse 基于 Websocket 和底层依赖 EventEmit 搭建的 Connection 建立,通过 chrome.debuggger API 与 ChromeLauncher 实例进行通信。
image.png
与ChromeLauncher的通信是在实例化Connection的过程中建立的,但仅仅是建立连接,大部分操作(e.g. 唤起实例是在Lighthouse初始化之前,首次创建tab窗口在实例化Driver之后(connect))。新建RequestUrl tab窗口后通过 ChromeLauncher 返回的 webSocketDebuggerUrl 创建 webSocket 连接,调用域能力,派发给 Driver 收集 Gatherers。

浏览器API Protocol:chromedevtools.github.io/devtools-pr…
域能力API Protocol:chromedevtools.github.io/devtools-pr…
域能力API MAP:github.com/ChromeDevTo…
Driver Event Map: github.com/ChromeDevTo…

收集Gatherer

requestUrl 仅支持以下几种协议类型的 href

const allowedProtocols = ['https:', 'http:', 'chrome:', 'chrome-extension:'];

校验通过后,执行 gatherFn ,开始加载页面,尝试收集所有 passes 聚合的 Artifacts。但在收集过程中,还需要做初始化环境及收集 gatherers,主要逻辑在 GatherRunner.run 内执行。

  static async _gatherArtifactsFromBrowser(requestedUrl, runnerOpts, connection) {
    if (!runnerOpts.config.passes) {
      throw new Error('No browser artifacts are either provided or requested.');
    }
    const driver = runnerOpts.driverMock || new Driver(connection);
    const gatherOpts = {
      driver,
      requestedUrl,
      settings: runnerOpts.config.settings,
    };
    const artifacts = await GatherRunner.run(runnerOpts.config.passes, gatherOpts);
    return artifacts;
  }

Driver 作为 Connection 的驱动程序,控制 Connection 以 Chrome.debugger API 规范调用域能力。

async run(passConfigs, options) {
    const driver = options.driver;
    const artifacts = {};
    try {
      // 创建新tab,与返回的 webSocketDebuggerUrl 建立 socket 连接
      await driver.connect(); 
      
    	// 加载about:blank 空白页,执行一次仿真逻辑
      await GatherRunner.loadBlank(driver); 
      
      // 初始化 Artifacts 结构以便后续数据填充
      const baseArtifacts = await GatherRunner.initializeBaseArtifacts(options);
      
      // 计算CPU基准性能? https://docs.google.com/spreadsheets/d/1E0gZwKsxegudkjJl8Fki_sOwHKpqgXwt8aBAfuUaB8A/edit#gid=0
      baseArtifacts.BenchmarkIndex = await options.driver.getBenchmarkIndex();
			
      // 设定 Driver 偏好
      await GatherRunner.setupDriver(driver, options, baseArtifacts.LighthouseRunWarnings);

      // ...跑pass
    } catch (err) {
      GatherRunner.disposeDriver(driver, options);
      throw err;
    }
  }

需要尽可能纯净的环境,摒弃Chrome程序本身带来的影响,为了防止其他服务/程序与Driver共享目标URL Tab,初次会自动导航到 about:blank,进行一次仿真模拟流程以初始化空白的上下文。在跑 pass 之前设定 Driver 偏好,setupDriver 主要做了以下几件事:

  • 检查是否有作用域当前origin的ServiceWork,屏蔽干扰。
  • 设置 UA 和仿真参数。
  • 启用 Runtime 上下文,为现有上下文立即执行事件。
  • 跳过 DebuggerPause 并且设异步Request跟踪深度处理过度嵌套的回调
  • 缓存原生对象 (Promise,Performance,Error,URL,ElementMatches) 以防止被外部引入的 polyfill 破坏。
  • 启用 PerformanceObserver,开始监听 longTask 及 CPU 空闲状况
  • 静默对话框 (alert/confirm/prompt) 保证流程通畅。
  • 利用 requestIdleCallback 进行CPU降速,也就是 Performance 面板的 CPU slowdown。


完成准备工作后,开始跑pass用例。不指定passes情况下默认为 offlinePass、defaultPass、redirectPass。

let isFirstPass = true;
for (const passConfig of passConfigs) {
  const passContext = {
    driver,
    url: options.requestedUrl,
    settings: options.settings,
    passConfig,
    baseArtifacts,
    LighthouseRunWarnings: baseArtifacts.LighthouseRunWarnings,
  };
  // 从about:blank开始加载目标页面并从 pass 中执行 gatherers 以收集 artifacts
  const passResults = await GatherRunner.runPass(passContext);
  Object.assign(artifacts, passResults.artifacts);

  // 遇到页面加载错误直接退出
  if (passResults.pageLoadError && passConfig.loadFailureMode === 'fatal') {
    baseArtifacts.PageLoadError = passResults.pageLoadError;
    break;
  }

  if (isFirstPass) {
    // 填充 manifest 相关信息
    await GatherRunner.populateBaseArtifacts(passContext);
    isFirstPass = false;
  }
	// 禁用请求拦截器
  await driver.fetcher.disableRequestInterception();
}

每次runPass都是一次完整的加载页面

async runPass(passContext) {
  const gathererResults = {};
  const {driver, passConfig} = passContext;
  await GatherRunner.loadBlank(driver, passConfig.blankPage);
  await GatherRunner.setupPassNetwork(passContext);
  if (GatherRunner.shouldClearCaches(passContext)) {
    await driver.cleanBrowserCaches(); // Clear disk & memory cache if it's a perf run
  }
  await GatherRunner.beforePass(passContext, gathererResults);
  await GatherRunner.beginRecording(passContext);
  const {navigationError: possibleNavError} = await GatherRunner.loadPage(driver, passContext);
  await GatherRunner.pass(passContext, gathererResults);
  const loadData = await GatherRunner.endRecording(passContext);
  await driver.setThrottling(passContext.settings, {useThrottling: false});
  GatherRunner._addLoadDataToBaseArtifacts(passContext, loadData, passConfig.passName);
  await GatherRunner.afterPass(passContext, loadData, gathererResults);
  const artifacts = GatherRunner.collectArtifacts(gathererResults);
  return artifacts
}

分为以下几个步骤

  • 先将页面导航到 about:blank。
  • 根据 pass 预配置网络环境。
  • 按需清除硬盘、内存中的缓存。
  • 执行 beforePass,过程中遍历当前 pass 的 Gatherers,执行每个 gatherer 实例的 beforePass Hook,拿到结果存到 gathererResults 供 pass 使用。
  • 记录 DevToolLog 和 Trace,后续 Auditing 分析可能用到。
  • 将页面导航到目标URL,处理重定向等待完整加载后更新 Navigation 信息。
  • 执行 pass Hook,执行时还未收集到相关 Log 及 Trace。
  • 停止 DevToolLog 监听,输出 DevToolLogs、NetworkLogs、TraceLogs。
  • 禁用网络节流,为 afterPass 分析提供准备。
  • 判断是否存在页面加载错误,如果存在,则不返回 Artifacts ,终止后续步骤。
  • 保存 DevtoolLogs 和 Trace 记录到 Artifacts。
  • 执行 afterPass Hook,遍历当前 pass 中每个 gatherer 实例并提供 DevtoolLogs 与 Trace 给 afterPass Hook。
  • 收集 gathererResult 每个 gatherer afterPass 结果。输出 Artifacts。
class Gatherer {
  get name() { return this.constructor.name;}
  // 导航前调用
  beforePass(passContext) { }
  // 页面加载后调用
  pass(passContext) { }
  // gatherers 所有 pass 都执行完毕后执行。
  afterPass(passContext, loadData) { }
}

每个 gatherer 包含三个Hook,Artifact 取最后一次Hook输出的结果,e.g.当afterPass未吐出,则采用 pass 结果,以此类推。在每个 Hook 内控制 Driver 调用域能力获取采集结果,最终输出 Artifacts。
以 css-usage 为例

class CSSUsage extends Gatherer {
  async afterPass(passContext) {
    const driver = passContext.driver;
    /** @type {Array<LH.Crdp.CSS.StyleSheetAddedEvent>} */
    const stylesheets = [];
    /** @param {LH.Crdp.CSS.StyleSheetAddedEvent} sheet */
    const onStylesheetAdded = sheet => stylesheets.push(sheet);
    
    // 收集已注入的样式表
    driver.on('CSS.styleSheetAdded', onStylesheetAdded);
    await driver.sendCommand('DOM.enable'); // 启用 DOM
    await driver.sendCommand('CSS.enable'); // 启用 CSS
    await driver.sendCommand('CSS.startRuleUsageTracking'); // 开始记录选择器使用率情况
    await driver.evaluateAsync('getComputedStyle(document.body)'); // why do? 抄底?
    driver.off('CSS.styleSheetAdded', onStylesheetAdded);
    
    // 获取 styleSheet 文本内容
    const promises = stylesheets.map(sheet => {
      const styleSheetId = sheet.header.styleSheetId;
      return driver.sendCommand('CSS.getStyleSheetText', { styleSheetId }).then(content => {
        return {
          header: sheet.header,
          content: content.text,
        };
      });
    });
    const styleSheetInfo = await Promise.all(promises);
    // 停止记录,并获取CSS使用率情况
    const ruleUsageResponse = await driver.sendCommand('CSS.stopRuleUsageTracking');
    // 去重避免多次引入同一样式表使结果偏离预期
    const dedupedStylesheets = new Map(styleSheetInfo.map(sheet => {
      return [sheet.content, sheet];
    }));
    return {
      rules: ruleUsageResponse.ruleUsage,
      stylesheets: Array.from(dedupedStylesheets.values()),
    };
  }
}

收集完 Artifacts 后 Driver 完成了它的使命,被 disconnect。 baseArtifacts 也完成定稿,Gathering 阶段结束,开始执行审计逻辑。

执行审计

审计的流程依赖于 Artifacts 收集的信息聚合,每个审计由 lighthouse-core/audits 下的内置 Audit 和 configPath 指定的组成,通过传递 Artifacts 给 Audit.audit 审计函数,audit 拿到自己想要的数据进行逻辑运算,返回该审计函数对结果评估的分数和一系列详情数据。该分数大部分情况下处于(0-1)之间,分值的范围取决于对应 Audit id 设置的权重。

audit 的数量远胜 gatherers,分开管理的原因是为了方便管理和拓展额外指标与audit,将两者责任与分工梳理清除。

const auditResultsById = await Runner._runAudits(settings, runOpts.config.audits, artifacts, lighthouseRunWarnings);

每个 audit 的主要结构如下

class Audit {
  // 计分方式
  static get SCORING_MODES() {}
  // 审计组件元信息 包含id标识、标题、失败标题、描述、审计所需Artifact模块、分数展示模式
  static get meta() {}
  // 审计主逻辑
  static audit(artifacts, context) {}
  // 给定分数根据对数正态分布生成分数
  static computeLogNormalScore(controlPoints, value) {}
  
  // 生成表形式的详情和总览
  static makeTableDetails(headings, results, summary) {}
  // 生成列表形式的详情
  static makeListDetails(items) {}
  // 生成片段详情
	static makeSnippetDetails() {}
  // 生成可能的优化点列表信息
  static makeOpportunityDetails(headings, items, overallSavingsMs, overallSavingsBytes) {}
  // 生成错误结果
  static generateErrorAuditResult(audit, errorMessage) {}
  // 生成Audit结果
  static generateAuditResult(audit, product) {}
}

审计过程:

  • 每个 Audit 导入所依赖的 Artifact 模块并检查是否是有效的模块。
  • 收集好 Artifact 依赖传递给 Audit.audit 执行审计主逻辑。
  • 将审计结果再传递给 generateAuditResult 返回 LHAR 对象。

以 longTask 为例

// audit/longTasks.js
class LongTasks extends Audit {
  static get meta() {
    return {
      id: 'long-tasks',
      scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE,
      title: str_(UIStrings.title),
      description: str_(UIStrings.description),
      requiredArtifacts: ['traces', 'devtoolsLogs'],
    };
  }

  static async audit(artifacts, context) {
    const settings = context.settings || {};
    // 获取页面加载跟踪信息
    const trace = artifacts.traces[Audit.DEFAULT_PASS];
    // 获取主线程上每个 task 记录,包含开始执行时间、耗时、结束时间 事件详情、归属栈、类型等信息
    const tasks = await MainThreadTasks.request(trace, context);
    // 获取DevToolLog
    const devtoolsLog = artifacts.devtoolsLogs[LongTasks.DEFAULT_PASS];
    // 生成 network 记录,包含每个请求的url、timing、发起者、request/response Header、其他请求内容等信息
    const networkRecords = await NetworkRecords.request(devtoolsLog, context);

    /** @type {Map<LH.TraceEvent, LH.Gatherer.Simulation.NodeTiming>} */
    const taskTimingsByEvent = new Map();
		
    // 网络模式为仿真情况下 需要进行配合模拟器评估以提高准确性
    if (settings.throttlingMethod === 'simulate') {
      const simulatorOptions = {trace, devtoolsLog, settings: context.settings};
      // 分析 task 间依赖关系,并梳理每个 task 的类型/耗时
      const pageGraph = await PageDependencyGraph.request({trace, devtoolsLog}, context);
      const simulator = await LoadSimulator.request(simulatorOptions, context);
      const simulation = await simulator.simulate(pageGraph, {label: 'long-tasks-diagnostic'});
      
      // 过滤掉非cpu操作
      for (const [node, timing] of simulation.nodeTimings.entries()) {
        if (node.type !== 'cpu') continue;
        taskTimingsByEvent.set(node.event, timing);
      }
    } else {
      for (const task of tasks) {
        if (task.unbounded || task.parent) continue;
        taskTimingsByEvent.set(task.event, task);
      }
    }
		// 找出所有外链脚本
    const jsURLs = BootupTime.getJavaScriptURLs(networkRecords);
    // 提取前20耗时超过50ms的基本操作
    const longtasks = tasks
      .map(t => {
        const timing = taskTimingsByEvent.get(t.event) || DEFAULT_TIMING;
        return {...t, duration: timing.duration, startTime: timing.startTime};
      })
      .filter(t => t.duration >= 50 && !t.unbounded && !t.parent)
      .sort((a, b) => b.duration - a.duration)
      .slice(0, 20);

    // 将长任务的来源按脚本进行分类,如果没有来源就算到 chrome 本身损耗上。
    const results = longtasks.map(task => ({
      url: BootupTime.getAttributableURLForTask(task, jsURLs),
      duration: task.duration,
      startTime: task.startTime,
    }));

    const headings = [
      {key: 'url', itemType: 'url', text: str_(i18n.UIStrings.columnURL)},
      {key: 'startTime', itemType: 'ms', granularity: 1, text: str_(i18n.UIStrings.columnStartTime)},
      {key: 'duration', itemType: 'ms', granularity: 1, text: str_(i18n.UIStrings.columnDuration)},
    ];
		// 合成表格信息以可视化
    const tableDetails = Audit.makeTableDetails(headings, results);

    let displayValue;
    if (results.length > 0) {
      displayValue = str_(UIStrings.displayValue, {itemCount: results.length});
    }

    return {
      score: results.length === 0 ? 1 : 0,
      notApplicable: results.length === 0,
      details: tableDetails,
      displayValue,
    };
  }
}

建立表将 LHAR 收集起来,供给 Categories 统计分值使用。

async _runAudits(settings, audits, artifacts, runWarnings) {
  const auditResultsById = {}; // auditResult聚合
  for (const auditDefn of audits) {
    const auditId = auditDefn.implementation.meta.id;
    const auditResult = await Runner._runAudit(auditDefn, artifacts, sharedAuditContext, runWarnings);
    auditResultsById[auditId] = auditResult;
  }
  return auditResultsById;
}

async _runAudit(auditDefn, artifacts, sharedAuditContext, runWarnings) {
  const audit = auditDefn.implementation;
  for (const artifactName of audit.meta.requiredArtifacts) {
    // ... 校验依赖
  }
  const auditOptions = Object.assign({}, audit.defaultOptions, auditDefn.options);
  const auditContext = {
    options: auditOptions,
    ...sharedAuditContext,
  };
  // 引入依赖
  const requestedArtifacts = audit.meta.requiredArtifacts.concat(audit.meta.__internalOptionalArtifacts || []);
  const narrowedArtifacts = requestedArtifacts.reduce((narrowedArtifacts, artifactName) => {
    const requestedArtifact = artifacts[artifactName];
    narrowedArtifacts[artifactName] = requestedArtifact;
    return narrowedArtifacts;
  }, {});
  // 执行审计主流程
  const product = await audit.audit(narrowedArtifacts, auditContext);
  runWarnings.push(...product.runWarnings || []);
  // 生成LHR对象
  auditResult = Audit.generateAuditResult(audit, product);
	return auditResult;
}

JSON & Output

LHAR score 仍属于对数正态分布生成的还未与经过映射运算,不算作最终展示的分值,分值是根据设置的 Categories 统计对应 Category 的 (weight(权重)*score(分数))/weight sum(权重总和)。权重声明在默认 config文件,也可以通过外部导入或者命令行参数 --config-path 指定配置文件来改变,分值则依赖于 Audit 审计返回的 AuditResult 聚合,取对应 Category id 标识 score,需要注意的是只有明确展示的 Categoies 才具备分值项。
image.png
之后则是国际化与依赖 ReportRender 输出JSON/HTML/CSV报告,至此流程over。现在再看整体流程图,清晰许多。
image.png

自绘流程

image.png

对Driver的学习能够梳理 DevTool 和 Chrome 之间的关系和认知,对 gatherers 和 audit 的学习能够让我们认清前端性能的最新标准,非常值得深挖。

文件依赖

image.png