Lighthouse是什么
Lighthouse 是一种用于提高网页质量的开源自动化工具。您可以针对任何网页、公共网页或需要身份验证的网页运行它。它对性能、可访问性、渐进式网络应用程序、搜索引擎优化等进行了审核。 您可以在 Chrome DevTools 中、从命令行或作为 Node 模块运行 Lighthouse。您为 Lighthouse 提供一个 URL 进行审核,它会针对页面运行一系列审核,然后生成关于页面表现如何的报告。从那里,使用失败的审计作为如何改进页面的指标。每个审计都有一个参考文档,解释为什么审计很重要,以及如何解决它。
Lighthouse is an open-source, automated tool for improving the quality of web pages. You can run it against any web page, public or requiring authentication. It has audits for performance, accessibility, progressive web apps, SEO and more. You can run Lighthouse in Chrome DevTools, from the command line, or as a Node module. You give Lighthouse a URL to audit, it runs a series of audits against the page, and then it generates a report on how well the page did. From there, use the failing audits as indicators on how to improve the page. Each audit has a reference doc explaining why the audit is important, as well as how to fix it.
Lighthouse分析报告
如下图就是掘金首页的lighthouse生成的分析报告,他从Performance、Accessibility、Best Practices、SEO、PWA五个方面衡量网站的性能。
如何使用
lighthouse提供多种方式: Chrome DevTools 中、从命令行或作为 Node 模块。
- Chrome DevTools
- 命令行工具:
- 运行
lighthouse命令就可以生成项目性能报告,支持html和json形式导出。
- 运行
// 安装
npm install -g lighthouse
// 使用
lighthouse https://juejin.cn --output json --output-path ./report.json
const fs = require('fs');
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
(async () => {
const chrome = await chromeLauncher.launch({chromeFlags: ['--headless']});
const options = {logLevel: 'info', output: 'html', onlyCategories: ['performance'], port: chrome.port};
const runnerResult = await lighthouse('https://juejin.cn', options);
// `.report` is the HTML report as a string
const reportHtml = runnerResult.report;
fs.writeFileSync('lhreport.html', reportHtml);
// `.lhr` is the Lighthouse Result as a JS object
console.log('Report is done for', runnerResult.lhr.finalUrl);
console.log('Performance score was', runnerResult.lhr.categories.performance.score * 100);
await chrome.kill();
})();
建立链接
前面讲到 lighthouse 通过Chrome devtool protocol 和浏览器建立链接,我们来看下是如何建立链接。
- 问题一:lighthouse 和浏览器是如何建立连接的?
- 问题二:Chrome DevTool Protocol是如何工作的?
问题一:Lighthouse 和浏览器是如何建立连接的?
- CriConnection 是
lighthouse和Chrome建立连接的实现- connect:新建tab页面并建立
lighthouse与Chrome之间双向通信。 - _runJsonCommand:通过JSON Schema 给浏览器发送指令。
- new指令代表新建一个tab页面,并返回页面的相关信息以及ws地址。
- _connectToSocket:当执行new指令之后会返回
webSocketDebuggerUrl,这个URL就是之后双向通信的地址;
- connect:新建tab页面并建立
class CriConnection extends Connection {
connect() {
return this._runJsonCommand('new')
.then(response => this._connectToSocket((response)))
}
// 通过webSocketDebuggerUrl,建立双方的双向通信
_connectToSocket(response) {
const url = response.webSocketDebuggerUrl;
this._pageId = response.id;
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, {
perMessageDeflate: false,
});
ws.on('open', () => {
this._ws = ws;
resolve();
});
ws.on('message', data => this.handleRawMessage(/** @type {string} */ (data)));
ws.on('close', this.dispose.bind(this));
ws.on('error', reject);
});
}
// JSON Schema 指令
_runJsonCommand(command) {
return new Promise((resolve, reject) => {
const request = http.get({
hostname: this.hostname,
port: this.port,
path: '/json/' + command,
}, response => {
let data = '';
response.on('data', chunk => {
data += chunk;
});
response.on('end', () => {
if (response.statusCode === 200) {
resolve(JSON.parse(data));
return;
}
});
});
});
}
}
问题二:Chrome DevTool Protocol是如何工作的?
从上面的代码我们知道通过Chrome DevTool Protocol 和 webSocket 建立了lighthouse和Chrome之间的双向通信机制。我们具体了解下是如何使用Devtool。
- 使用浏览器的远程debugger一般需要两步:
- 打开浏览器的远程调试模式
- 发送指令,控制浏览器。
- 打开浏览器的debugger模式
// MAC, 仅指定开启的端口,一般也只选择9222; 相当于隐身模式不含各种extends
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
// 当然也可以指定配置, --user-data-dir可以加载用户配置
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=remote-profile
收集数据
从上面了解到lighthouse是通过Chrome DevTool Protocol和浏览器建立连接并通信,接下来看下lighthouse是如何进行数据收集的。
在这里就会有两个问题;
- 问题一:
lighthouse会收集哪些数据用于后面的指标『审查』? - 问题二:数据收集是如何实现的?
- 问题三:这些数据是通过什么方式被收集的?
- 问题四:
Gatherer收集完之后的里面具体是什么?
问题一:lighthouse会收集哪些数据用于后面的指标『审查』?
- 在讲收集数据之前,我们先了解
lighthouse的默认配置。lighthouse默认会有一套完整的配置文件,其中就包含了整套流程所需的所有信息。- 需要收集的数据纬度(
passes) - 需要进行审查的指标(
audits) pwa、seo等等
- 需要收集的数据纬度(
- 在默认配置项中的
passes中就包含了默认需要收集的指标,详见如下:- defaultPass:默认的基础性能指标需要的数据。
- offlinePass: 离线情况下需要收集的数据,主要是测试
pwa指标。 - redirectPass:重定向相关的数据。
passes: [{
passName: 'defaultPass',
recordTrace: true,
useThrottling: true,
pauseAfterFcpMs: 1000,
pauseAfterLoadMs: 1000,
networkQuietThresholdMs: 1000,
cpuQuietThresholdMs: 1000,
gatherers: [
'css-usage',
'js-usage',
'viewport-dimensions',
'console-messages',
'anchor-elements',
'image-elements',
'link-elements',
'meta-elements',
'script-elements',
'iframe-elements',
'form-elements',
'main-document-content',
'global-listeners',
'dobetterweb/appcache',
'dobetterweb/doctype',
'dobetterweb/domstats',
'dobetterweb/optimized-images',
'dobetterweb/password-inputs-with-prevented-paste',
'dobetterweb/response-compression',
'dobetterweb/tags-blocking-first-paint',
'seo/font-size',
'seo/embedded-content',
'seo/robots-txt',
'seo/tap-targets',
'accessibility',
'trace-elements',
'inspector-issues',
'source-maps',
'full-page-screenshot',
],
},
{
passName: 'offlinePass',
loadFailureMode: 'ignore',
gatherers: [
'service-worker',
],
},
{
passName: 'redirectPass',
loadFailureMode: 'warn',
blockedUrlPatterns: ['*.css', '*.jpg', '*.jpeg', '*.png', '*.gif', '*.svg', '*.ttf', '*.woff', '*.woff2'],
gatherers: [
'http-redirect',
],
}],
问题二:数据收集是如何实现的?
从上面可以看到lighthouse需要收集数据的名称,接下来看一下这些数据收集是如何实现的?
- 数据收集的方式是存在本地文件的,
lighthouse会根据数据名称加载本地文件,具体如下:
static getGathererList() {
const fileList = [
...fs.readdirSync(path.join(__dirname, './gather/gatherers')),
...fs.readdirSync(path.join(__dirname, './gather/gatherers/seo')).map(f => `seo/${f}`),
...fs.readdirSync(path.join(__dirname, './gather/gatherers/dobetterweb'))
.map(f => `dobetterweb/${f}`),
];
return fileList.filter(f => /\.js$/.test(f) && f !== 'gatherer.js').sort();
}
- 打开一个具体收集数据的文件看下是如何实现的。
- 大部分的数据收集函数都会继承
FRGatherer类,具体 - 同时FRGatherer 会提供四个方法:
beforePass: 在页面导航前pass:在页面loaded后。afterPass:在页面加载完毕,且trace信息收集完毕后.
- 大部分的数据收集函数都会继承
class NetworkUserAgent extends FRGatherer {
static getNetworkUserAgent(devtoolsLog) {
for (const entry of devtoolsLog) {
if (entry.method !== 'Network.requestWillBeSent') continue;
const userAgent = entry.params.request.headers['User-Agent'];
if (userAgent) return userAgent;
}
return '';
}
async getArtifact(context) {
return NetworkUserAgent.getNetworkUserAgent(context.dependencies.DevtoolsLog);
}
}
// 父类只是定义方法,具体实现还是在各自的指标收集函数中实现
class FRGatherer {
getArtifact(passContext) { }
async beforePass(passContext) {}
pass(passContext) { }
async afterPass(passContext, loadData) {}
}
问题三:数据是通过什么方式被收集的?
上面讲到收集每项指标数据都会对应一个处理方法
lighthouse是通过Chrome DevTools Protocol发送每个Domain的具体事件才完成整个流程。
上面讲了每个指标数据收集函数都会有三个函数beforePass、pass、afterPass,但是具体是如何执行以及收集的呢?
lighthouse的执行节奏是将所有的收集函数的beforePass全部执行完,之后再执行pass,最后再执行afterPass- 从
runPass函数中,会生成三类数据:artifacts、devTools、trace。
- 从
static async runPass(passContext) {
const gathererResults = {};
const {driver, passConfig} = passContext;
//通过CDP加载一个"about:blank"页面
await GatherRunner.loadBlank(driver, passConfig.blankPage);
// 执行所有收集函数的beforePass函数
await GatherRunner.beforePass(passContext, gathererResults);
// 开始记录devtool日志的相关配置
await GatherRunner.beginRecording(passContext);
// 通过CDP加载目标URL页面
const {navigationError: possibleNavError} = await GatherRunner.loadPage(driver, passContext);
// 开始执行所有指标收集函数的pass函数
await GatherRunner.pass(passContext, gathererResults);
//
const loadData = await GatherRunner.endRecording(passContext);
// 清除掉网络限流(afterPass 数据分析不需要限制)
await emulation.clearThrottling(driver.defaultSession);
// 保存devtoos 和trace 相关数据。
GatherRunner._addLoadDataToBaseArtifacts(passContext, loadData, passConfig.passName);
// 执行所有指标收集函数的afterPass函数
await GatherRunner.afterPass(passContext, loadData, gathererResults);
// 收集并返回artifacts数据。
const artifacts = GatherRunner.collectArtifacts(gathererResults);
return artifacts;
}
loadPage:加载目标页面URL, 这一步就是通过CDP实现的,具体的我们来看下:loadPage关键实现主要是gotoURL函数
async function gotoURL(driver, url, options) {
// 启动网络监听
await networkMonitor.enable();
// Enables page domain notifications。
// 这里的session其实就是driver
await session.sendCommand('Page.enable');
// 控制页面的生命周期函数,为true则开始触发生命周期函数(DOMContentLoaded、load等等)
await session.sendCommand('Page.setLifecycleEventsEnabled', {enabled: true});
// 将当前页面导航到给定的 URL。
const waitforPageNavigateCmd = session.sendCommand('Page.navigate', {url});
}
问题四:Gatherer收集完之后的里面具体有哪些?
最终收集的数据主要有三个文件(artifacts.json、defaultPass.trace.json、defaultPass.devtoolslog.json),这些数据也将被Aduits消费掉。
artifacts.json:这里面包括了gatherer收集的所有数据。 包括配置信息、gatherer的各种测试的数据、link/script标签的加载数据、截图数据等等,有兴趣的同学可以去看下。
{
"fetchTime": "2021-09-23T08:00:11.026Z",
"LighthouseRunWarnings": [],
"HostFormFactor": "desktop",
"HostUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.54 Safari/537.36",
"NetworkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Safari/537.36 Chrome-Lighthouse",
"BenchmarkIndex": 1542,
"WebAppManifest": null,
"InstallabilityErrors": {},
"Stacks": [],
"settings": {},
"URL": {},
"Timing": [],
"PageLoadError": null,
"CSSUsage": {},
"JsUsage": {},
"ViewportDimensions": {},
"ConsoleMessages": [],
"ImageElements": [],
"LinkElements":[],
"MetaElements": [],
"ScriptElements": [],
"GatherContext": {},
"DOMStats": {},
"OptimizedImages":[],
"ResponseCompression": [],
"TagsBlockingFirstPaint": [],
"TraceElements": [],
"SourceMaps": [],
"FullPageScreenshot": {}
}
defaultPass.trace.json:这是页面的trace信息,什么是trace信息呢?其实就是performance信息,记录浏览器加载url整个过程的信息(事件名称、进/线程id、耗时等等)。
{
"traceEvents":
[
{"args":{"name":"swapper"},"cat":"__metadata","name":"thread_name","ph":"M","pid":22199,"tid":0,"ts":0},
{"args":{"name":"CrBrowserMain"},"cat":"__metadata","name":"thread_name","ph":"M","pid":22188,"tid":775,"ts":0},
{"args":{"name":"CrRendererMain"},"cat":"__metadata","name":"thread_name","ph":"M","pid":22206,"tid":775,"ts":0},
{"args":{"name":"Chrome_DevToolsHandlerThread"},"cat":"__metadata","name":"thread_name","ph":"M","pid":22188,"tid":68355,"ts":0},
{"args":{"name":"VizCompositorThread"},"cat":"__metadata","name":"thread_name","ph":"M","pid":22199,"tid":31491,"ts":0},
{"args":{"name":"Chrome_IOThread"},"cat":"__metadata","name":"thread_name","ph":"M","pid":22188,"tid":27139,"ts":0},
]
}
defaultPass.devtoolslog.json:主要记录Chrome Devtool Protocol的调用日志,包括事件名称、请求参数以及返回值。
[
{"method":"Network.requestWillBeSent","params":{"requestId":"44109.8","loaderId":"CE582347CE0611459B267AD9E3F7A6E5","documentURL":"https://www.baidu.com/","request":{"url":"https://dss0.bdstatic.com/5aV1bjqh_Q23odCf/static/superman/img/topnav/baiduyun@2x-e0be79e69e.png","method":"GET","headers":{"Referer":"https://www.baidu.com/","User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Safari/537.36 Chrome-Lighthouse"},"mixedContentType":"none","initialPriority":"Low","referrerPolicy":"unsafe-url","isSameSite":false},"timestamp":117076.151053,"wallTime":1632645080.743717,"initiator":{"type":"parser","url":"https://www.baidu.com/","lineNumber":71,"columnNumber":1792},"type":"Image","frameId":"0B955393CAC0A41B6DB3D0EDABC97924","hasUserGesture":false}},
{"method":"Network.requestWillBeSent","params":{"requestId":"44109.9","loaderId":"CE582347CE0611459B267AD9E3F7A6E5","documentURL":"https://www.baidu.com/","request":{"url":"https://dss0.bdstatic.com/5aV1bjqh_Q23odCf/static/superman/img/topnav/zhidao@2x-e9b427ecc4.png","method":"GET","headers":{"Referer":"https://www.baidu.com/","User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Safari/537.36 Chrome-Lighthouse"},"mixedContentType":"none","initialPriority":"Low","referrerPolicy":"unsafe-url","isSameSite":false},"timestamp":117076.151465,"wallTime":1632645080.744115,"initiator":{"type":"parser","url":"https://www.baidu.com/","lineNumber":71,"columnNumber":2033},"type":"Image","frameId":"0B955393CAC0A41B6DB3D0EDABC97924","hasUserGesture":false}},
{"method":"Network.requestWillBeSent","params":{"requestId":"44109.10","loaderId":"CE582347CE0611459B267AD9E3F7A6E5","documentURL":"https://www.baidu.com/","request":{"url":"https://dss0.bdstatic.com/5aV1bjqh_Q23odCf/static/superman/img/topnav/baike@2x-1fe3db7fa6.png","method":"GET","headers":{"Referer":"https://www.baidu.com/","User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4590.2 Safari/537.36 Chrome-Lighthouse"},"mixedContentType":"none","initialPriority":"Low","referrerPolicy":"unsafe-url","isSameSite":false},"timestamp":117076.151703,"wallTime":1632645080.744354,"initiator":{"type":"parser","url":"https://www.baidu.com/","lineNumber":71,"columnNumber":2271},"type":"Image","frameId":"0B955393CAC0A41B6DB3D0EDABC97924","hasUserGesture":false}},
{"method":"Page.lifecycleEvent","params":{"frameId":"0B955393CAC0A41B6DB3D0EDABC97924","loaderId":"CE582347CE0611459B267AD9E3F7A6E5","name":"firstPaint","timestamp":117076.231633}},
{"method":"Page.lifecycleEvent","params":{"frameId":"0B955393CAC0A41B6DB3D0EDABC97924","loaderId":"CE582347CE0611459B267AD9E3F7A6E5","name":"firstContentfulPaint","timestamp":117076.231633}},
{"method":"Page.lifecycleEvent","params":{"frameId":"0B955393CAC0A41B6DB3D0EDABC97924","loaderId":"CE582347CE0611459B267AD9E3F7A6E5","name":"firstMeaningfulPaintCandidate","timestamp":117076.231633}},
]
- 在这里我们讲完了
lighthouse是如何和Chrome建立连接以及如何收集数据的。简单来说分为三个部分:- 生成配置:
lighthouse会有一套默认收集数据的配置,通过用户也可以自定义要收集的方法 - 建立链接:通过
Chrome DevTool Protocol和Chrome建立链接,控制Chrome(新建tab、加载url、跟踪网络请求等等) - 执行rule:根据配置中的指标收集方法(beforePass、pass、afterPass)收集相关数据并保存起来交给后面的Audit使用。
- 生成配置:
审查指标
- 问题一:
lighthouse默认会审查哪些指标? - 问题二:这些指标是如何被『审查』的?具体是如何实现?
问题一: 审查哪些指标?
- 前面聊过,在收集数据之前,
lighthouse会将默认配置和自定义配置进行merge,在这个阶段就会生成默认的审查方法。
/* 默认config配置;
1、configJSON.audits:是用户自定义的审查函数。
2、configDir:是默认配置文件的目录。
*/
this.audits = Config.requireAudits(configJSON.audits, configDir);
- 和
Gatherer一样,lighthouse会读取audits目录下的审查函数文件,每一个文件就是对一个指标具体审查的实现,包括需要从artifacts.json中获取什么数据,以及具体怎么使用这些数据。- 从加载的这些目录可以看出,其中就包含metrices(性能指标)、seo、accessibility等等。
getAuditList() {
const fileList = [
...fs.readdirSync(path.join(__dirname, './audits')),
...fs.readdirSync(path.join(__dirname, './audits/dobetterweb')).map(f => `dobetterweb/${f}`),
...fs.readdirSync(path.join(__dirname, './audits/metrics')).map(f => `metrics/${f}`),
...fs.readdirSync(path.join(__dirname, './audits/seo')).map(f => `seo/${f}`),
...fs.readdirSync(path.join(__dirname, './audits/seo/manual')).map(f => `seo/manual/${f}`),
...fs.readdirSync(path.join(__dirname, './audits/accessibility'))
.map(f => `accessibility/${f}`),
...fs.readdirSync(path.join(__dirname, './audits/accessibility/manual'))
.map(f => `accessibility/manual/${f}`),
...fs.readdirSync(path.join(__dirname, './audits/byte-efficiency'))
.map(f => `byte-efficiency/${f}`),
...fs.readdirSync(path.join(__dirname, './audits/manual')).map(f => `manual/${f}`),
];
return fileList.filter(f => {
return /\.js$/.test(f) && !ignoredFiles.includes(f);
}).sort();
}
从上面的代码了解到lighthouse需要审查哪些指标,接下来来看第二个问题。
问题二:这些指标是如何被『审查』的?具体是如何实现?
- 随便看一个指标的审查文件
largest-contentful-paint.js。 - 每个审查指标的类都会包含函数audit 和 meta属性
meta:主要是说明该指标的名称(id)、以及需要的数据源字段(requiredArtifacts,这个数据主要从getherer生成的artifacts中获取。)audit:主要负责该指标如何审查的具体实现。
class LargestContentfulPaint extends Audit {
static get meta() {
return {
id: 'largest-contentful-paint',
title: str_(i18n.UIStrings.largestContentfulPaintMetric),
description: str_(UIStrings.description),
scoreDisplayMode: Audit.SCORING_MODES.NUMERIC,
supportedModes: ['navigation'],
// lcp需要的数据源,通过devtool收集来的数据
requiredArtifacts: ['HostUserAgent', 'traces', 'devtoolsLogs', 'GatherContext'],
};
}
static async audit(artifacts, context) {
const trace = artifacts.traces[Audit.DEFAULT_PASS];
const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
const gatherContext = artifacts.GatherContext;
const metricComputationData = {trace, devtoolsLog, gatherContext, settings: context.settings};
let metricResult = await ComputedLcp.request(metricComputationData, context);
const options = context.options[context.settings.formFactor];
return {
score: Audit.computeLogNormalScore(
options.scoring,
metricResult.timing
),
numericValue: metricResult.timing,
numericUnit: 'millisecond',
displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
};
}
}
生成报告
lighthouse主要支持三种形式的报告:json、html、csv。
- 代码也很简单,
outputModes就是传入给lighthouse相关的配置。 - 根据不同的模式, 生成不同的文件。
static generateReport(lhr, outputModes) {
const outputAsArray = Array.isArray(outputModes);
if (typeof outputModes === 'string') outputModes = [outputModes];
const output = outputModes.map(outputMode => {
if (outputMode === 'html') {
return ReportGenerator.generateReportHtml(lhr);
}
if (outputMode === 'csv') {
return ReportGenerator.generateReportCSV(lhr);
}
if (outputMode === 'json') {
return JSON.stringify(lhr, null, 2);
}
throw new Error('Invalid output mode: ' + outputMode);
});
return outputAsArray ? output : output[0];
}
总结
- 梳理完
lighthouse的整体流程,里面具体实现逻辑还是很复杂的,但同时也有很多细节都没有具体展开讲,比如:Chrome DevTools Protocol的具体使用?- 收集数据是如何衡量的, 为什么要收集这些数据?
- 各种指标(FCP、LCP等)具体是如何计算出来的?(下一篇文章会详细介绍)
- 正所谓『不以结婚为目的的谈恋爱都是耍流氓』,同样『不以实践为目的的流程分析都是……』,接下来就会有个问题,
lighthouse能够帮助我们干什么? 下一节会介绍下我的一些实践和思考。
如何大家有什么疑问或者想法,欢迎一起交流,共同学习~~