起因

前段时间紧急上线了一个门户项目,两端静态页面,首页考虑到需要极致体验必须使用硬编码搭建,部分子页面采用可视化搭建,要求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 // 国际化相关
}

auditsAuditJSON[],包含了所有审计项

  • 网络层面的是否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 的国际化。
settingSharedFlagsSettings,是应用整个测量流程的全局配置,包括网速限制、最大加载时长、report 输出格式、模拟平台、仿真参数、国际化、审计模式、执行通道、请求头等等…
passesPASSJSON[] ,控制了如何加载 url 请求,以及在加载过程中收集哪些信息,每一项都是页面的一次 load,比如上面passes.length 代表页面两次加载,默认 pass 提供了 offlinePassdefaultPassredirectPass 针对无网、弱网、脚本实际执行代码量比例的 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:https://chromedevtools.github.io/devtools-protocol/
域能力API Protocol:https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker/
域能力API MAP:https://github.com/ChromeDevTools/devtools-protocol/blob/master/types/protocol-mapping.d.ts#L625
Driver Event Map: https://github.com/ChromeDevTools/devtools-protocol/blob/master/types/protocol-mapping.d.ts#L11

收集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