1、前言
页面性能对于任何平台而言都至关重要,糟糕的性能会导致用户流失和口碑下降等。当我们在说一个页面很快或很慢的时候,快慢只是一个主观感受,我们需要具体的数据来进行衡量页面访问的性能情况并进行性能优化,那么如何定义合适的性能指标呢?目前,业界关于页面性能指标的定义已经有非常多的内容,例如:
- Lighthouse 10 这一流行的合成监控方案对网站的性能评分主要基于 First Contentful Paint 首次内容绘制、Speed Index 速度指数、Largest Contentful Paint 最大内容渲染、Total Blocking Time 总阻塞时间和 Cumulative Layout Shift 累计布局偏移等 5 个维度加权计算生成;
- web.dev 中介绍了 Largest Contentful Paint、Cumulative Layout Shift、First Input Delay、Interaction to Next Paint、Time to First Byte、First Contentful Paint、Total Blocking Time等以用户为中心的性能指标,还可以通过 PerformanceObserver 等 Web API 来自定义性能指标;
对于研发人员而言,业界所给出的这些性能指标固然是我们所需要关注的内容,然而,对于普通用户而言,他们并不关心平台这些性能指标的具体表现如何,他们的直观感受是我访问的页面是否快速展现内容?我进行的操作是否快速给出反应?目前业界所给出的性能指标并没有能够直接反应用户这些主观感受的,或者说需要结合多个指标来进行衡量,并且页面的性能情况不仅仅是前端渲染还有后端接口等因素综合而成的。基于这个情况,我们使用“关键交互耗时”和“用户体感耗时”这两个性能指标用于衡量页面的性能情况,以更加贴近用户视角的真实感受。
2、性能指标定义
2.1 用户体感耗时
所谓“用户体感耗时”,即用户进入页面展示内容的耗时,具体来说可以分为几种情况:
- 用户刷新进入页面到核心接口返回并且页面渲染完毕;
- SPA 页面首次进入指定 tab 到 tab 核心接口返回并且页面渲染完毕;
- ……
举例来说,打开掘金网站首页,通过FCP等性能指标可以表示网站打开快慢情况,但是用户核心关注的是推荐展示的列表内容,只有关心的内容展示出来了用户才觉得网站加载完了,并可以开始进行下一步操作,无关紧要的内容加载完成用户其实并不太在意。用户体感耗时统计的时长就是用户进入页面开始,到推荐列表接口内容返回到渲染完成这部分的时间。
2.2 关键交互耗时
所谓“关键交互耗时”,即用户交互操作的响应耗时,用户通过页面可交互区域触发某个行为场景到该场景结束,关键交互可以分为两种类别:
- 查询类:关键交互的响应需要依赖于后端请求;
- 非查询类:关键交互的响应不包含后端接口请求,仅涉及前端逻辑计算和渲染。
举例来说,当我们需要在掘金进行内容搜索时,搜索这一个关键交互的耗时应该从点击搜索按钮开始,到后端请求内容返回,再到前端将搜索结果渲染到页面上时为止,这才是用户所感知到的所执行这个操作是否快速响应的时间。
3、性能数据埋点
3.1 用户体感耗时埋点
用户体感耗时 = 页面核心内容渲染完成时刻 - 进入路由时刻,具体统计方法如下:
- 开始时间:监听路由的变化情况,在路由变化时重新开始计时,并会将其作为当前页面耗时统计的开始时间点,路由的监听可以通过监听 pushState/replaceState/popState 事件来完成,Vue 项目使用 Vue Router 可在 beforeEach 中进行拦截处理。
- 结束时间:用户体感耗时的结束时间点由我们自行确定并控制,将页面重要内容渲染完成时刻作为结束时间点,一般在页面核心内容的请求接口返回内容并渲染完成后上报。
伪代码如下,仅供参考:
/**
* 用户体感耗时统计方法
*/
class FePerformanceCollection {
// 记录用户体感耗时开始时间
private pageKeyLoadStartTimestamp: number | null;
// 记录性能数据
private reportMetrics: {
[key: string]: {
currentPath: string; // 当前页面
duration: number; // 用户体感耗时
extraInfo?: string; // 额外信息
};
};
constructor() {
this.pageKeyLoadStartTimestamp = null;
this.reportMetrics = {};
}
/**
* 初始化用户体感耗时计时
* 以基于 vue-router beforeEach 方法为例,通过 beforeEach 来自动记录 start 时间,也可以用户手动控制记录 start 时间
*/
public initPageKeyLoad(vueRouterInstance: any) {
// 传入路由实例注入 beforeEach 方法
if (vueRouterInstance) {
this.reportMetrics = {};
vueRouterInstance.beforeEach((to: any, from: any, next: any) => {
try {
this.pageKeyLoadStartTimestamp = Date.now();
} finally {
next();
}
});
}
}
/**
* 用户体感耗时上报
* @param currentPath 用户体感耗时所属页面
* @param extraInfo 上报额外信息
*/
public collectPageKeyLoad(currentPath: string, extraInfo?: any) {
// 同一个key上报过一次就不再上报
if (!this.pageKeyLoadStartTimestamp || this.reportMetrics[currentPath]) {
return;
}
const pageKeyLoadEndTimestamp = Date.now();
const duration = pageKeyLoadEndTimestamp - this.pageKeyLoadStartTimestamp;
if (!duration || duration < 0) {
return;
}
this.reportMetrics[currentPath] = {
currentPath,
duration,
extraInfo,
};
// 重置
this.pageKeyLoadStartTimestamp = null;
}
/**
* 上报性能埋点
*/
public reportPageKeyLoadMetric() {
// 自定义的上报方法
report(this.reportMetrics);
this.reportMetrics = {};
}
}
/**
* 用户体感耗时埋点方法
*/
// step1. 初始化方法
const performanceCollection = new FePerformanceCollection();
performanceCollection.initPageKeyLoad(vueRouterInstance); // 通过vue-router beforeEach的话全局只需调用一次即可
// step2. 结束并计算用户体感耗时
performanceCollection.collectPageKeyLoad("home");
// step3. 上报所有采集到的用户体感耗时
performanceCollection.reportPageKeyLoadMetric();
以 Vue Router beforeEach 方法来监听路由变化为例,initPageKeyLoad
在初始化时传入路由实例,对其注入一个 beforeEach 方法,每次路由变动都会拦截并重新开始计时,将当前页面的开始时间点记录到变量pageKeyLoadStartTimestamp
中。在合适的上报时间点调用collectPageKeyLoad
会得到一个结束时间点,将其减去pageKeyLoadStartTimestamp
就得到了当前页面的用户体感耗时,结合传入的currentPath
和extraInfo
完成一次用户体感耗时埋点采集,最终可调用reportPageKeyLoadMetric
方法将采集到的用户体感耗时上报,当然也可以设置定时批量上报或利用requestIdleCallback
等方法在浏览器空闲时进行上报。
3.2 关键交互耗时埋点
关键交互耗时 = 交互自定义结束时间 - 交互自定义开始时间,具体统计方法如下:
- 开始时间:交互开始的时间,例如用户操作按钮某个查询点击的时间点;
- 结束时间:交互所期望的核心内容响应并渲染完毕,例如点击查询按钮后接口请求数据返回并完成渲染的时间点;
伪代码如下,仅供参考:
/**
* 关键交互耗时统计方法
*/
enum TIME_TYPE {
START = "start",
END = "end",
}
type CustomerTimeT = {
[TIME_TYPE.START]: number | null;
[TIME_TYPE.END]: number | null;
};
class FePerformanceCollection {
/**
* 记录关键交互耗时开始/结束时间
*/
private pageKeyInteractionTimestamps: { [key: string]: CustomerTimeT };
/**
* 记录性能数据
*/
private reportMetrics: {
[key: string]: {
dimension: string; // 当前操作
duration: number; // 用户体感耗时
extraInfo?: string; // 额外信息
};
};
constructor() {
this.pageKeyInteractionTimestamps = {};
this.reportMetrics = {};
}
/**
* 关键交互记录时间
*/
private setCustomerTimeEvent = (params: { key: string; timeEvent: TimeProp }) => {
const { key, timeEvent } = params || {};
if (!this.pageKeyInteractionTimestamps?.[key]) {
this.pageKeyInteractionTimestamps[key] = { start: null, end: null };
}
if (!this.pageKeyInteractionTimestamps[key]?.[timeEvent]) {
this.pageKeyInteractionTimestamps[key][timeEvent] = Date.now();
}
};
/**
* 关键交互开始计时
* @param key 计时枚举值
*/
private startCustomerTimeEvent(key: string) {
if (this.pageKeyInteractionTimestamps[key]?.start) {
this.pageKeyInteractionTimestamps[key].start = null;
}
if (this.pageKeyInteractionTimestamps[key]?.end) {
this.pageKeyInteractionTimestamps[key].end = null;
}
this.setCustomerTimeEvent({ key, timeEvent: TIME_TYPE.START });
}
/**
* 关键交互结束计时并采集
* @param key 计时枚举值
* @param dimension 上报枚举值
* @param extraInfo 上报额外信息
*/
private endAndCollectCustomerTimeEvent(key: string, dimension: string, extraInfo?: any) {
if (!this.pageKeyInteractionTimestamps[key]?.end) {
this.setCustomerTimeEvent({ key, timeEvent: TIME_TYPE.END });
}
const start = this.pageKeyInteractionTimestamps[key].start;
const end = this.pageKeyInteractionTimestamps[key].end;
let duration;
if (end && start) {
duration = end - start;
}
if (duration) {
this.reportMetrics[key] = {
dimension,
duration,
extraInfo,
};
}
// 重置
delete this.pageKeyInteractionTimestamps[key];
}
/**
* 关键交互上报方法
* @param dimension 关键交互上报维度值(必填)
* @param config.extraKey 自定义时长统计key(可选,优先级更高)
*/
public useCustomerTimeEvent(dimension: string, config?: { extraKey?: string }) {
const startCustomerEvent = () => {
// 通过时间戳来唯一区分,避免同时请求多个导致时序问题,也可以用户自定义key
const key = config?.extraKey ?? `${dimension}-${new Date().getTime()}`;
this.startCustomerTimeEvent(key);
return key;
};
const endAndReportCustomerEvent = (key: string) => {
// 上报数据
this.endAndCollectCustomerTimeEvent(key, dimension);
};
return {
startCustomerEvent,
endAndReportCustomerEvent,
};
}
/**
* 上报性能埋点
*/
public reportPageKeyLoadMetric() {
// 自定义的上报方法
report(this.reportMetrics);
this.reportMetrics = {};
}
}
/**
* 关键交互耗时埋点方法
*/
// step1. 初始化方法
const performanceCollection = new FePerformanceCollection();
// step2. 开始和结束用户体感耗时的计时
const { startCustomerEvent, endAndReportCustomerEvent } = performanceCollection.useCustomerTimeEvent("search-click");
async function handleSearchClick() {
const key = startCustomerEvent();
try {
// ... 接口请求
} finally {
// 等待内容渲染完毕可将其写在 nextTick/setTimeout 中
endAndReportCustomerEvent(key);
}
}
// step3. 上报所有采集到的用户体感耗时
performanceCollection.reportPageKeyLoadMetric();
相比于用户体感耗时,关键交互耗时的开始时间和结束并上报时间均有我们业务代码中自行控制统计时机。通过useCustomerTimeEvent
hook返回的开始方法startCustomerEvent
和结束方法endAndReportCustomerEvent
即可完成关键交互耗时时长的计算,一般为了等待内容渲染完毕再上报可将endAndReportCustomerEvent
写在 nextTick/setTimeout
中,同时为了避免出现上报时序错乱,通过 key 来关联开始和结束的统计方法。与用户体感耗时一样,最终可调用reportPageKeyLoadMetric
方法将采集到的用户体感耗时上报,也可以设置定时批量上报或利用requestIdleCallback
等方法在浏览器空闲时进行批量上报。
4、总结
本文详细介绍了“用户体感耗时”和“关键交互耗时”这两个性能指标的定义和性能埋点上报方法,通过更加符合用户使用体验的性能数据表现页面的性能情况。通过采集得到性能数据这只是第一步,更重要的是我们需要建立性能监控体系,实现良性循环并可持续,通过不断地改进和完善性能监控体系,基于数据驱动性能优化,提升系统的性能表现,提高用户体验,同时实现系统的持续稳定运行,可参考 👉 性能优化进阶 💥 前端性能埋点&监控体系建设! - 掘金。