前段时间紧急上线了一个门户项目,两端静态页面,首页考虑到须要极致体验必须使用硬编码搭建,部分子页面采用可视化搭建,要求Lighthouse必须接近满分,尽管经过一些手段优化了首屏但上线以后,离目标还有一大段误差。因而去挖lh源码关注各种指标对分值的影响程度,有了针对性的方向,剩下的工做就简单的多。css
顺便整理了源码。html
LightHouse流程架构Lighthouse 是一个开源的自动化工具,提供了Node、Chrome Extension App、Chrome DevTool 三端,经过输入审查网址及配置项,经过一系列模拟测试特定环境下的运行情况和性能分析,最后生成性能结果页面供可视化浏览,是前端领域的安兔兔、鲁大师。前端
为何须要Lighthouse?一直以来,前端性能的分析指标过于泛化,得不到有效统一的标准,特别是近几年SPA、微服务、小程序、Flutter、webAssembly、***、ServerLess等前端技术方案百花齐放,获得高速发展的同时,一些传统的性能测量指标和方式落后跟不上脚步,没法支撑现有技术体系和新领域的迭代更新,再加上终端环境复杂、用户体验标准难以衡量、兼容性问题,审计指标愈来愈复杂。
例如阿里云ARMS针对 SPA 应用的FMP计量方式改为了依赖于MutationObserve计算权重变化最大的时间节点;淘宝前端团队的秒开率标准;岳鹰结合jssdk与Android内核查看聚集绘制指令来判断页面是否处于白屏....都代表在大前端趋势不可逆转,而测量性能的方式须要考虑更多环境和因素,变得越发复杂。git
Lighthouse 必定不是大前端下性能统计标准,由于从目前而言仍只适用于web端,而且其统计的指标过于笼统。自己而言依赖于 DevTool 发送回来的综合报告按 Audit 分析,输出对应的抽象分数、核心点和优化项,分数低不同表明性能差,但分数高必定是性能上佳。github
总体流程 名词释义根据 Chrome Debugging Protocol <URL>与浏览器交互的对象web
驱动 Driver 收集到的网页基础信息,用于后续 Auditing 的审计逻辑。chrome
一系列 Gatherers 信息集合。在 Auditing 里会被附加其余信息,被多个Audits共享。json
以指定依赖的 Artifacts 做为输入,测试单个功能/优化/指标,审计测试评估分数,获得一组LHAR(LightHouse Audit Result Object) 标准数据对象。小程序
ReportRender 使用LHR结果建立输出的UI报表。promise
基本概念Lighthouse 驱动 Driver 经过 Chrome DevTool Protocol 与浏览器交互,执行一系列命令,先生成 Gatherers 模块用以收集 Artifacts 信息,这些 Artifacts 信息的聚合会在 Auditing 阶段做为 Audit case 逻辑的输入凭证,经过定义的一系列自定义的审计标准输出分数/优化/详情/描述/缘由/展现形式/错误等信息,最终获得一系列LHR统计结果,按需生成指定文件。
基本经常使用的命令以下,具体命令就不贴了
$ lighthouse --helplighthouse <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以下,
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 核心逻辑,拿到 LHRconst 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); }复制代码
核心逻辑主要分五步
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); }复制代码
假设没传入 configJSON 文件,将默认使用 default-config.js 。setting
// lighthouse-core/config/defaultConfig.jsconst 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[],包含了全部审计项
在输出前每一个 audit 会被注入 lighthouse-core/audits 下的审计逻辑,这些审计逻辑每一个包含 audit(测试分数)、meta(相关信息及计算 Audit 所须要的 Artifact 模块)。
categories :Record<string, CategoryJSON>,也就是日常在DevTool里勾选的几个测试项,包含了要测试了类别。
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 合并到每一个 passconst 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 逻辑引用的审查。
const config = generateConfig(configJSON, flags); // 生成Runner Optionsconst 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 实例进行通讯。
与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…
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 主要作了如下几件事:
完成准备工做后,开始跑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 pa***esults = await GatherRunner.runPass(passContext); Object.assign(artifacts, pa***esults.artifacts); // 遇到页面加载错误直接退出 if (pa***esults.pageLoadError && passConfig.loadFailureMode === 'fatal') { baseArtifacts.PageLoadError = pa***esults.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 }复制代码
分为如下几个步骤
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。
例如 CSSUsage
class CSSUsage extends Gatherer { async afterPass(passContext) {const driver = passContext.driver;const stylesheets = [];// ...// 获取styleSheetconst 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/longTasks.jsclass 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 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);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; }复制代码
LHAR score 仍属于对数正态分布生成的还未与通过映射运算,不算做最终展现的分值,分值是根据设置的 Categories 统计对应 Category 的 (weight(权重)*score(分数))/weight sum(权重总和)。权重声明在默认 config文件,也能够经过外部导入或者命令行参数 --config-path 指定配置文件来改变,分值则依赖于 Audit 审计返回的 AuditResult 聚合,取对应 Category id 标识 score,须要注意的是只有明确展现的 Categoies 才具有分值项。
以后则是国际化与依赖 ReportRender 输出JSON/HTML/CSV报告,至此流程over。回过头再看总体流程图清晰许多。
对 Driver 的学习可以梳理 DevTool 和 Chrome 之间的关系和认知,对 gatherers 和 audit 的学习可以让咱们认清前端性能的最新标准,很是值得深挖。
自绘流程 文件依赖