聊聊我在上海租房搞前端性能监控踩过的坑
早上八点,上海的太阳已经有点毒了。我端着杯瑞幸坐到工位上,习惯性地打开 Grafana 看板扫了一眼昨晚的线上数据——FCP 中位数 1.8s,LCP 压到了 2.3s,CLS 稳定在 0.05 以下。看着这条平滑的曲线,我长舒一口气,想起一年半前那个被产品经理追着骂、被测试小姐姐翻白眼的自己,恍如隔世。
先自我介绍一下吧。我今年 35 了,坐标上海,在一家做 B 端 SaaS 的公司写代码。没买房,就租在公司附近一个老破小里,走路十分钟上班,为的就是能多睡半小时——毕竟这个年纪了,头发得省着掉。早上八点开工算是我的生物钟,年纪大了熬不动夜,但早起干活效率是真的高,脑子清醒,bug 都少写两个。平时除了搬砖,就爱琢磨琢磨分布式系统那些事儿,虽然前端和分布式看起来八竿子打不着,但你仔细想想,前端性能监控的数据采集、上报、聚合、分析,本质上不就是个分布式数据管道嘛。
好了,废话不多说,今天想跟大家聊聊我们团队在前端性能监控和用户体验优化这块的一些实践。不整那些虚头巴脑的理论,全是实打实踩过的坑和总结出来的经验。
事情是这么开始的
去年 Q3,我们那个核心产品——一个面向中大型企业的项目管理 SaaS 平台——用户量突然涨了一波。本来是好事对吧?结果运维那边某天下午甩了个截图过来,说客服那边炸了,一堆用户投诉页面卡、加载慢、有时候还白屏。
我当时第一反应是:不可能啊,我本地跑得挺快的。
然后产品经理老张走过来,拍了拍我肩膀说:"兄弟,用户说咱们的页面打开要等五六秒,隔壁竞品两秒就出来了,这不行啊。"
我心想,五六秒?我本地明明一秒多就出来了。后来一查才知道,我们的用户大部分用的是公司内网环境,有些甚至是几年前的老笔记本,跑着 Chrome 70 多版本,网络环境也参差不齐。我本地是 M1 Max 的 MacBook Pro 配千兆光纤,这能一样吗?
那一刻我意识到,我们缺的不是优化,是监控。你得先知道问题出在哪,才能对症下药。
第一步:搞清楚到底慢在哪
做性能优化最怕什么?最怕拍脑袋优化。你觉得是图片大了,结果一查发现是接口慢;你以为是 JS 执行太久,结果是 CSS 阻塞了渲染。所以第一步,必须是建立监控体系。
我们当时调研了一圈市面上的方案:Sentry 偏错误监控,性能这块不够细;自研的话,团队就三个前端,实在没余力。最后选了基于 Performance API 自建一套轻量级监控 SDK,再配合后端的日志聚合来做分析。
核心指标的选择
做前端性能监控,你得先搞清楚要监控什么。W3C 定义了一堆 Performance 相关的 API,但不是每个都有用。我们最终聚焦在这几个核心指标上:
| 指标 | 含义 | 我们的目标值 | 采集方式 |
|---|---|---|---|
| FCP (First Contentful Paint) | 首次内容绘制时间 | < 1.8s | PerformanceObserver |
| LCP (Largest Contentful Paint) | 最大内容绘制时间 | < 2.5s | PerformanceObserver |
| FID (First Input Delay) | 首次输入延迟 | < 100ms | PerformanceObserver |
| CLS (Cumulative Layout Shift) | 累积布局偏移 | < 0.1 | LayoutShift observer |
| TTFB (Time to First Byte) | 首字节到达时间 | < 800ms | PerformanceResourceTiming |
| FMP (First Meaningful Paint) | 首次有意义绘制 | < 2.0s | 自定义打点 |
这里有个坑要提醒大家:FID 在 2024 年之后逐渐被 INP (Interaction to Next Paint) 替代了。INP 更能反映用户在整个页面生命周期中的交互体验,而不只是第一次交互。我们后来也跟进升级了。
SDK 的核心实现
写 SDK 这事儿说难不难,说简单也不简单。关键是要做到对业务代码零侵入,同时采集的数据要准确。
// performance-monitor.js
// 我们的性能监控 SDK 核心逻辑
class PerformanceMonitor {
constructor(options = {}) {
this.sampleRate = options.sampleRate || 0.1; // 默认 10% 采样率
this.reportUrl = options.reportUrl || '/api/perf/report';
this.appId = options.appId || 'default';
this.queue = [];
this.maxQueueSize = 20;
this.flushInterval = 5000; // 5秒批量上报一次
this.init();
}
init() {
// 判断是否命中采样
if (Math.random() > this.sampleRate) return;
this.observePaint();
this.observeLayoutShift();
this.observeLongTask();
this.observeResource();
this.observeNavigation();
// 定时批量上报,减少请求数
setInterval(() => this.flush(), this.flushInterval);
// 页面卸载时立即上报剩余数据
window.addEventListener('pagehide', () => this.flush(true));
}
observePaint() {
// 监听 FCP 和 LCP
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.queue.push({
type: 'paint',
name: entry.name, // 'first-contentful-paint' | 'largest-contentful-paint'
value: entry.startTime,
timestamp: Date.now(),
url: location.href,
appId: this.appId,
ua: navigator.userAgent,
// 记录用户的设备信息,后面分析会用到
deviceMemory: navigator.deviceMemory || 'unknown',
hardwareConcurrency: navigator.hardwareConcurrency || 'unknown',
connectionType: navigator.connection?.effectiveType || 'unknown'
});
}
});
observer.observe({ type: 'paint', buffered: true });
// LCP 比较特殊,需要持续监听,取最后一个
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.currentLCP = lastEntry.startTime;
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
}
observeLayoutShift() {
// 监听布局偏移,计算 CLS
let clsValue = 0;
let clsEntries = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 只计算没有最近用户输入的布局偏移
if (!entry.hadRecentInput) {
clsValue += entry.value;
clsEntries.push(entry);
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
// 页面隐藏时上报最终的 CLS
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.queue.push({
type: 'layout-shift',
name: 'CLS',
value: clsValue,
entries: clsEntries.map(e => ({
value: e.value,
startTime: e.startTime
})),
timestamp: Date.now(),
url: location.href,
appId: this.appId
});
this.flush();
}
});
}
observeLongTask() {
// 监听长任务,超过 50ms 的都算
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.queue.push({
type: 'long-task',
name: 'longtask',
value: entry.duration,
startTime: entry.startTime,
timestamp: Date.now(),
url: location.href,
appId: this.appId
});
}
});
observer.observe({ type: 'longtask', buffered: true });
}
observeResource() {
// 监听资源加载,找出慢资源
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 只关注加载时间超过 1 秒的资源
if (entry.duration > 1000) {
this.queue.push({
type: 'resource',
name: entry.name,
value: entry.duration,
transferSize: entry.transferSize,
startTime: entry.startTime,
timestamp: Date.now(),
url: location.href,
appId: this.appId
});
}
}
});
observer.observe({ type: 'resource', buffered: true });
}
observeNavigation() {
// 采集导航阶段的详细耗时
const observer = new PerformanceObserver((list) => {
const entry = list.getEntries()[0];
const timing = {
type: 'navigation',
dns: entry.domainLookupEnd - entry.domainLookupStart,
tcp: entry.connectEnd - entry.connectStart,
ssl: entry.connectEnd - entry.secureConnectionStart,
ttfb: entry.responseStart - entry.requestStart,
download: entry.responseEnd - entry.responseStart,
domInteractive: entry.domInteractive,
domComplete: entry.domComplete,
loadEvent: entry.loadEventEnd,
timestamp: Date.now(),
url: location.href,
appId: this.appId
};
this.queue.push(timing);
});
observer.observe({ type: 'navigation', buffered: true });
}
flush(isUnload = false) {
if (this.queue.length === 0) return;
const data = this.queue.splice(0, this.maxQueueSize);
// 页面卸载时用 sendBeacon,保证数据不丢
if (isUnload && navigator.sendBeacon) {
navigator.sendBeacon(this.reportUrl, JSON.stringify({
appId: this.appId,
data: data
}));
} else {
// 正常情况用 fetch,设置 keepalive
fetch(this.reportUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appId: this.appId,
data: data
}),
keepalive: true
}).catch(() => {
// 上报失败,把数据放回去,下次重试
this.queue.unshift(...data);
});
}
}
}
// 初始化
const monitor = new PerformanceMonitor({
sampleRate: 0.2, // 20% 采样率,日活大了之后可以调低
reportUrl: 'https://perf-api.ourcompany.com/v1/report',
appId: 'pm-saas-web'
});
这里有个细节值得说:sendBeacon 的使用。页面关闭或者跳转的时候,普通的 fetch 请求可能会被浏览器取消,导致数据丢失。sendBeacon 就是为这个场景设计的,它会保证数据在页面卸载后依然能发出去。但 sendBeacon 有大小限制(一般是 64KB),所以数据量大的时候要注意分片。
数据回来了,然后呢?
SDK 上线两周后,数据开始回流。我们搭了一套基于 ClickHouse + Grafana 的分析平台(这块我比较熟,毕竟平时爱捣鼓分布式的东西,ClickHouse 这种列式数据库做日志分析简直不要太爽)。
结果一看数据,好家伙,问题比想象的严重:
- P50 的 LCP 是 3.2s,P90 直接飙到 7.8s
- 有 15% 的用户体验到了 CLS > 0.25 的严重布局偏移
- 长任务(Long Task)平均每天每用户触发 47 次
- 最离谱的是,有个 API 接口的 TTFB 中位数是 4.2s
我把数据整理成报告甩给产品经理和后端团队的时候,老张看完沉默了三秒,然后说了句让我至今难忘的话:"原来用户骂的不是我们,是我们的服务器。"
好吧,也不全是服务器的锅。
逐个击破:我们做了哪些优化
1. LCP 优化:首屏加载的生死线
LCP 3.2s 是什么概念?Google 的标准是 2.5s 以内算良好。我们超了快一秒。
分析下来,LCP 元素主要是首屏的一个项目看板卡片组件,里面包含了一张用户头像、一段项目描述和一个进度条。问题出在:
图片加载太慢。 用户头像用的是原始尺寸的图片,有些用户上传的是 4K 的图,我们前端直接拿来用,光解码就要几百毫秒。
// 优化前:直接渲染原始图片
<img src={user.avatar} alt="avatar" />
// 优化后:使用 srcset + 图片 CDN 动态裁剪
<img
srcset={`${cdnUrl}/avatar/${user.id}?w=80 1x, ${cdnUrl}/avatar/${user.id}?w=160 2x`}
src={`${cdnUrl}/avatar/${user.id}?w=80`}
alt="avatar"
loading="lazy"
decoding="async"
width="40"
height="40"
/>
加了 loading="lazy" 和 decoding="async" 之后,非首屏的图片不再阻塞主线程渲染。同时通过 CDN 的图片处理能力,把图片裁剪到合适的尺寸再下发,图片体积平均减少了 70%。
关键 CSS 内联。 我们之前的做法是所有 CSS 都打包成一个文件,在 <head> 里引入。但首屏其实只需要一小部分 CSS,剩下的可以异步加载。
<!-- 优化前 -->
<link rel="stylesheet" href="/static/css/main.a1b2c3.css">
<!-- 优化后:关键 CSS 内联,非关键 CSS 异步加载 -->
<style>
/* 这里放首屏关键 CSS,大概 14KB 左右 */
.header { ... }
.project-card { ... }
.sidebar { ... }
</style>
<link rel="preload" href="/static/css/main.a1b2c3.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/static/css/main.a1b2c3.css"></noscript>
这块我们用了 critters 这个工具来自动提取关键 CSS,配合 webpack 插件在构建时完成,不需要手动维护。
预连接关键域名。 我们用了三个外部域名(CDN、API、图片服务),每个域名建立连接需要 100-300ms。
<link rel="preconnect" href="https://cdn.ourcompany.com" crossorigin>
<link rel="preconnect" href="https://api.ourcompany.com" crossorigin>
<link rel="dns-prefetch" href="https://img.ourcompany.com">
就这几行代码,LCP 直接降了 400ms。你说气人不气人,有时候优化就是这么简单粗暴。
2. CLS 优化:别让页面跳来跳去
CLS 0.25 以上的用户体验是很差的——正要点一个按钮,上面的内容突然加载出来把按钮挤下去了,然后就点错了。我们的 CLS 问题主要来自三个方面:
图片没有预设尺寸。 这个老生常谈了,但就是有人不写。
/* 给图片容器一个固定的宽高比 */
.image-container {
aspect-ratio: 16 / 9;
background-color: #f5f5f5; /* 占位色,减少视觉跳动 */
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
字体加载导致的 FOUT(Flash of Unstyled Text)。 我们用了自定义字体,加载过程中文字会用系统字体先显示,字体加载完之后再切换,这就会导致布局偏移。
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 先用系统字体,字体加载完再换 */
/* 更好的做法是用 size-adjust 让系统字体和自定义字体的度量接近 */
}
/* 通过 font-size-adjust 减少切换时的跳动 */
body {
font-family: 'CustomFont', -apple-system, BlinkMacSystemFont, sans-serif;
font-size-adjust: 0.52; /* 根据实际字体调整这个值 */
}
动态注入的广告和弹窗。 这个最坑。运营同学非要搞什么"新用户引导弹窗",DOM 渲染完才注入,直接把整个页面往下推。
// 优化前:直接操作 DOM 插入弹窗
function showGuide() {
const modal = document.createElement('div');
modal.innerHTML = '...';
document.body.appendChild(modal); // 布局偏移!
}
// 优化后:预留空间,用 CSS transform 做动画
function showGuide() {
const modal = document.getElementById('guide-modal'); // 预先在 HTML 中放置
modal.style.visibility = 'visible';
modal.style.transform = 'translateY(0)';
// 用 transform 而不是改变 layout 属性,不会触发重排
}
3. 长任务优化:让主线程喘口气
长任务是最影响交互响应的。用户点了个按钮,主线程正在执行一个 200ms 的 JS 任务,那这个点击事件就得等 200ms 之后才能被处理,用户就会觉得"卡了"。
我们的长任务主要来自两个方面:大数据量的列表渲染和复杂的数据计算。
对于列表渲染,我们用了虚拟滚动。但虚拟滚动有个问题——滚动的时候如果计算量大,还是会卡。我们用了 requestIdleCallback 来分片处理:
// 把一个大任务拆成多个小任务,在浏览器空闲时执行
function scheduleWork(tasks) {
let index = 0;
function work(deadline) {
while (index < tasks.length && deadline.timeRemaining() > 5) {
// 每个小任务控制在 5ms 以内
tasks[index]();
index++;
}
if (index < tasks.length) {
// 还有任务,继续调度
if (window.requestIdleCallback) {
requestIdleCallback(work);
} else {
// 降级方案:用 setTimeout
setTimeout(work, 1);
}
}
}
if (window.requestIdleCallback) {
requestIdleCallback(work);
} else {
setTimeout(work, 1);
}
}
// 使用示例:渲染 10000 条数据
const renderTasks = [];
for (let i = 0; i < 10000; i += 50) {
renderTasks.push(() => {
renderChunk(data.slice(i, i + 50));
});
}
scheduleWork(renderTasks);
后来我们还引入了 Augment Code 来辅助代码优化。说实话,刚开始我对这类 AI 辅助工具是有点抵触的——写了十几年代码了,还需要机器教我写代码?但用下来发现,它在一些模式化的优化场景上确实能省不少事。比如我们有一大段数据处理逻辑,涉及多层嵌套的 reduce 和 filter,Augment Code 帮我们重构成了更高效的单次遍历,还自动加了 memoization。虽然不完美,但确实给了些新思路。
4. 接口优化:前端能做的有限,但也不是什么都做不了
那个 TTFB 4.2s 的接口,后端说是因为要关联查询七八张表。我说我不管,前端也得想办法。
接口缓存。 很多列表接口的数据变化频率很低,完全可以加缓存。我们在前端用了一层 SWR(stale-while-revalidate)策略:
// 使用 stale-while-revalidate 策略
async function fetchProjectList(projectId) {
const cacheKey = `project_list_${projectId}`;
const cached = await caches.match(cacheKey);
if (cached) {
// 先用缓存数据渲染,保证首屏速度
const cachedData = await cached.json();
renderProjectList(cachedData);
// 后台静默更新
fetch(`/api/projects/${projectId}`).then(async (res) => {
if (res.ok) {
const freshData = await res.json();
const response = new Response(JSON.stringify(freshData));
await caches.open('api-cache').then(cache => cache.put(cacheKey, response));
renderProjectList(freshData); // 用新数据更新视图
}
});
return cachedData;
}
// 没有缓存,正常请求
const res = await fetch(`/api/projects/${projectId}`);
const data = await res.json();
const response = new Response(JSON.stringify(data));
await caches.open('api-cache').then(cache => cache.put(cacheKey, response));
return data;
}
接口合并与 BFF 层。 有些页面需要调三四个接口才能渲染完整,我们推动后端加了一层 BFF(Backend for Frontend),把多个接口聚合成一个,减少了请求数的同时,后端也可以在服务端做数据预取和缓存。
一些容易忽略的体验细节
做性能优化做久了,我发现有些东西不是用数据衡量的,但确实影响用户体验。
骨架屏。 在数据加载出来之前,先展示一个页面结构的灰色占位。这不会加快实际加载速度,但会让用户感觉"页面已经在加载了",心理等待时间会缩短。
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
按钮反馈。 用户点了按钮,必须在 100ms 内给出视觉反馈。哪怕数据还没加载完,按钮也要先变成 loading 状态。
function handleSubmit() {
// 同步设置 loading 状态,保证 100ms 内有反馈
setSubmitting(true);
// 异步处理实际逻辑
requestAnimationFrame(async () => {
try {
await submitForm(formData);
message.success('提交成功');
} catch (err) {
message.error('提交失败,请重试');
} finally {
setSubmitting(false);
}
});
}
渐进式加载。 不要等所有数据都加载完才渲染。先渲染框架,再填充内容。这个思路其实和后端流式响应有点像。
监控体系的持续运营
优化不是一锤子买卖。我们上线监控之后,制定了几个规矩:
- 每次发版必须看性能数据。 CI/CD 流水线里加了 Lighthouse CI,性能分数低于 80 直接打回。
- 性能劣化告警。 Grafana 配了告警规则,P90 LCP 超过 3s 自动发钉钉群通知。
- 每周性能周报。 我每周五下午花半小时看看这周的性能趋势,有劣化就排查。
说到工具链,最近团队里有人在用 Antigravity 做性能数据的可视化分析,体验还不错。它能把 Web Vitals 的数据按页面、按设备、按网络类型做交叉分析,比我们之前自己写的 Grafana 面板直观多了。特别是那个"用户旅程回放"功能,可以直接看到用户在哪个页面遇到了性能问题,排查效率提升了不少。当然,这也得益于我们 SDK 采集的数据足够丰富。
优化成果
折腾了一年多,效果还是明显的:
| 指标 | 优化前 | 优化后 | 改善幅度 |
|---|---|---|---|
| LCP P50 | 3.2s | 1.9s | -40.6% |
| LCP P90 | 7.8s | 3.1s | -60.3% |
| CLS P75 | 0.32 | 0.06 | -81.3% |
| INP P75 | 380ms | 120ms | -68.4% |
| FCP P50 | 2.1s | 1.2s | -42.9% |
| 长任务次数/用户/天 | 47 | 8 | -83.0% |
客服那边的投诉从每天几十条降到了偶尔几条。产品经理老张也不再拍我肩膀了——大概是因为没什么好说的了。
一些掏心窝子的话
写了十几年代码,35 岁了还在一线写,有时候也会焦虑。但每次解决一个性能问题,看着监控曲线往下走,那种成就感是真实的。
前端性能优化这事儿,说白了就是跟浏览器较劲。你得理解浏览器的渲染机制,理解网络协议,理解用户设备的局限性,然后在这个框架里找到最优解。它不是那种"学个新框架"就能搞定的事,需要的是积累和耐心。
几个建议给到大家:
- 先监控,再优化。 没有数据支撑的优化都是耍流氓。
- 关注 P90 而不是平均值。 平均值会掩盖很多长尾问题。
- 性能预算要写进需求文档。 别等开发完了再优化,那时候成本太高了。
- 定期做性能回归。 人是最不可靠的,你得靠工具来兜底。
- 别追求完美。 从 3s 优化到 2s 很容易,从 1s 优化到 0.8s 可能要花十倍精力。ROI 要算清楚。
好了,今天就聊到这。十一点半了,该去楼下吃个饭,下午还得跟后端对接口。对了,早上写的两个 bug 还没修呢,测试小姐姐已经在群里 @ 我了。
各位码友,咱们下篇见。
如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区讨论,我一般晚上回家后会统一回复。


评论 0