Appearance
前端性能优化体系
看在前面
后端性能优化的对象是 RT 和吞吐。前端性能优化的对象是用户体验:多快看到主要内容、点了多快有响应、页面跳不跳。Google 把这类体验抽象成 Core Web Vitals 三个指标。这篇就按"指标 → 加载优化 → 构建产物 → 端差异"四层展开。
我的对照锚点:Core Web Vitals ≈ 前端的 SLA 指标。资源加载优化 ≈ 缓存与预热策略,tree-shaking ≈ ProGuard 的 dead code elimination,code-splitting ≈ 服务按需加载懒 bean。素材全部可追溯:star-site-frontend 的 vite 配置和路由是生产在跑的代码。风格对齐 typescript/从Java视角看TypeScript-开篇,渲染原理见姊妹篇 浏览器渲染与事件循环。
Core Web Vitals:前端的 SLA 三指标
面试官:LCP、INP、CLS 分别度量什么?阈值多少?
| 指标 | 度量什么 | 好 | 需改进 | 差 | 典型元凶 |
|---|---|---|---|---|---|
| LCP 最大内容绘制 | 首屏主要内容何时可见 | ≤2.5s | ≤4s | >4s | 大图慢加载、渲染被 JS 阻塞、TTFB 慢 |
| INP 交互到下一帧绘制 | 交互响应性(取代 FID) | ≤200ms | ≤500ms | >500ms | 长任务占主线程、事件回调太重 |
| CLS 累积布局偏移 | 视觉稳定性 | ≤0.1 | ≤0.25 | >0.25 | 无尺寸图片、动态插入内容、字体切换 |
三个指标各管一段:LCP 管加载(进来快不快),INP 管交互(点了卡不卡),CLS 管稳定(看不看得清)。INP 取页面全生命周期最差的那次交互(近似 p98)。它比老指标 FID(只测首次输入延迟、不含处理时间)严格得多。这正对应上一篇的长任务治理:INP 的优化手段就是拆/挪/省长任务。CLS 的修复手段最"纪律化":img/video 写死 width/height(或 aspect-ratio),插入内容预留占位,font-display: swap 配字体加载策略。
一个 Java 视角的提醒:这三项是用户侧 p75 分位指标,不是实验室均值。Lighthouse 跑分是实验室数据,真实用户监控(RUM,Performance API 上报)才算数。这和后端"压测好看不如线上 p99 好看"是同一课。
LCP 拆解:把一个大指标切成四个时间段子问题
LCP 不是原子指标,它由四段累加,先定位最长的子段再对症下药:
LCP = TTFB + 资源加载延迟 + 资源加载耗时 + 渲染延迟
│ │ │ │
服务器/CDN 慢 发现晚(没preload) 图片太大/弱网 JS阻塞/主线程忙
→ 边缘缓存SSR → preload+high → WebP/srcset → defer/拆长任务这个拆法和后端排查慢接口一模一样。RT 高先分"网络往返 / 排队 / 服务处理 / 反序列化",每段有独立的药方。不做拆解就上手优化,等于不看火焰图就调线程池。INP 同理可用 LoAF(Long Animation Frames API)定位是哪段脚本吃掉了帧预算。
RUM 上报的最小实现
线上真实用户数据靠 Performance API 采,5 行代码起一个雏形:
ts
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 上报到自己的埋点接口,后端按 p75 聚合——和 APM 上报慢事务同构
report('/web-vitals', { type: entry.entryType, value: entry.startTime })
}
}).observe({ type: 'largest-contentful-paint', buffered: true })生产上一般直接用 web-vitals 库(回调里拿到 LCP/INP/CLS 的最终值)。要点是采样率与分位聚合:全量上报太贵,按用户抽样 + 后端算 p75,报表才能对齐 Google 的判定口径。
资源加载优化:关键路径是主线,其余全是支线
面试官:首屏关键渲染路径怎么优化?
关键渲染路径(CRP):从拿到 HTML 到渲染首屏内容必须经过的资源链。这条链是 HTML → CSSOM(CSS 阻塞渲染)→ 首屏字体/图片 + 同步 JS(阻塞解析)。优化就是缩短这条链、挪走不在链上的东西:
优化前(串行阻塞):
HTML ──▶ 阻塞的 <script src=app.js>(3s)──▶ 解析继续 ──▶ 首屏图片才开始下载 ──▶ LCP
优化后(并行 + 预取):
HTML ─┬─▶ <script defer>(并行下载,DOM 解析不中断)
├─▶ <link rel=preload as=image href=hero.webp>(首屏图提前抢带宽)
└─▶ 非首屏图片 loading=lazy、路由组件动态 import ──▶ LCP 大幅提前手段清单按资源类型分。JS 用 defer/async(defer 保序、DOM 解析不阻塞)或 module 天然 defer。CSS 关键 CSS 内联进 HTML、非关键异步加载。图片用 WebP/AVIF 换格式、srcset 按屏宽分发、首屏图 preload/fetchpriority=high、非首屏 loading=lazy。字体font-display: swap 防 FOIT 白屏。preload 是"我立即要用,提前下载",prefetch 是"我可能要用,空闲时下载"——后者是低优先级提示,别混用。
追问:preload 和 prefetch 用错了会发生什么?
preload 抢高优先级带宽:把一堆非首屏资源标成 preload,等于把首屏图挤出队列,LCP 反而劣化——优先级通胀。纪律:preload 只给关键路径上、且浏览器自己发现不了的资源(CSS 里的背景图、JS 里动态 import 的首屏 chunk)。
构建产物优化:tree-shaking 与 code-split
面试官:tree-shaking 的原理是什么?为什么必须 ESM?
tree-shaking 是构建期的 ProGuard:从入口做可达性分析,没人 import 的导出直接不打包。它必须基于 ESM 的静态结构。import/export 写在顶层、不能塞进 if 里,构建工具才能不执行代码就画出依赖图。CommonJS 的 require 是运行时函数,可以写在任何分支里,静态分析臣妾做不到。这就是模块格式影响产物体积的原理(演进细节见 模块化与构建工具演进)。
副作用是坑点:模块顶层执行了 console.log 或改了全局变量,工具不敢删。package.json 的 sideEffects: false 就是向构建器宣誓"我没有副作用,随便删"。
code-splitting(按需加载):把一个大 bundle 拆成多个 chunk,用到哪个下哪个。star-site-frontend/src/router/index.ts 是教科书式落点——13 个路由全部动态 import:
ts
// star-site-frontend/src/router/index.ts(片段)
{
path: '/insurance',
component: () => import('@/views/insurance/PoliciesView.vue'), // 每个路由一个 chunk
meta: { requiresAuth: true },
}对照 Java:这等于把"启动时全量加载所有 Controller"改成"请求哪个接口再装配哪个 bean"。首屏只加载登录页 chunk,管理端十几个域的代码按路由切分。vite 侧的真实优化还有一条,vite.config.ts 里 Element Plus 按需自动导入:
ts
// star-site-frontend/vite.config.ts(片段)
plugins: [
AutoImport({ resolvers: [ElementPlusResolver()] }), // 用到的 API 才进 bundle
Components({ resolvers: [ElementPlusResolver()] }), // 用到的组件才进 bundle
]不全量引 Element Plus 而是按需解析,组件库体积只按实际使用计费——依赖按需化是 tree-shaking 在 UI 库维度的翻版。产物纪律再加两条。build 脚本先 vue-tsc -b 类型检查再构建(类型问题挡在 CI)。分析用 rollup-plugin-visualizer 看体积构成,找出意外进包的大依赖——等价于后端审查依赖树里的传递依赖。
缓存与分发:HTTP 层的免费午餐
静态资源最优分发是"文件名带内容 hash + 长缓存"。app.a3f9c2.js 内容变则名变,Cache-Control: max-age=31536000, immutable 可以放心签一年。index.html 本身不缓存保证拿到最新引用。缓存失效问题被内容寻址一次性解决,比后端"配置变更后手动刷新缓存"优雅得多。gzip 之上再开 brotli(br),文本产物再瘦 15-20%。静态资源走 CDN 边缘分发,缩短 TTFB——LCP 的第一公里。
PC 与无线端差异清单
面试官:PC 和移动端做性能优化,侧重点差在哪?
| 维度 | PC | 无线端(H5/小程序) |
|---|---|---|
| 网络 | 稳、带宽大 | RTT 高、弱网切换常见 → 首屏 payload 是生命线,接口合并/降级 |
| CPU/内存 | 富余 | 弱,长任务更易触发 → 动画优先 transform/合成层,列表虚拟化 |
| 耗电 | 不敏感 | 敏感 → 减少定时器、后台页暂停轮询、rAF 替代 setInterval |
| 屏幕适配 | 大屏多列 | 小屏 + 安全区 + 1px 问题(见 移动端适配与踩坑) |
| 图片 | 高清原图可接受 | srcset/DPR 分发,WebP 优先 |
| 环境 | 标准 API 齐全 | WebView 内核碎片化、小程序双线程(见 小程序双线程模型) |
一句话总结:PC 优化偏"加载链路",无线端优化是"加载 + 弱设备 + 弱网 + 耗电"的四重约束。所以同一套指标在两端的目标值可以不同。无线端对包体积的敏感度高一个量级(小程序主包还有 2M 硬限制)。
优化工作流:先测量,再动手,后回归
把性能优化当项目管,而不是当偏方攒:
① 量化基线:RUM p75 / Lighthouse / Performance 面板,锁定最差指标
② 定位拆解:LCP 按四子段拆、INP 用 LoAF 找脚本、CLS 找跳动元素
③ 按杠杆排序:TTFB 慢先上 CDN,包大先 code-split,交互卡先拆长任务
④ 改动回归:预算(performance budget)进 CI——bundle 超阈值直接红第④步最像后端工程师的条件反射:没有回归防护的优化会被下一次需求悄悄吃掉。预算可以配在 CI 里(构建产物 gzip 后超 200 KB 的 chunk 报警)。也可以配进 Lighthouse CI 断言分数不回退——纪律的价值大于任何单项技巧。
面试官视角小结:30 秒版本
- 指标:LCP ≤2.5s 管加载。INP ≤200ms 管交互(看最差交互,治理手段是拆长任务)。CLS ≤0.1 管稳定(图定尺寸、内容预留占位)。看 p75 真实用户数据,不是实验室跑分。
- 加载:关键渲染路径最短化——defer JS、关键 CSS 内联、首屏图 preload + fetchpriority、非首屏 lazy。preload 抢带宽会优先级通胀。
- 构建:tree-shaking 靠 ESM 静态结构 +
sideEffects声明,构建期 ProGuard。code-split 按路由拆 chunk(() => import()),UI 库按需导入。内容 hash + 长缓存 + CDN。 - 端差异:PC 偏加载链路;无线端加载 + 弱设备 + 弱网 + 耗电四重约束,包体敏感。
开场模板:
"我把前端性能当成三层 SLA 治理。指标层盯 LCP/INP/CLS 的 p75,加载层缩短关键渲染路径。构建层靠 tree-shaking 和路由级 code-split 瘦身。我的管理端就是这么配的,13 个路由全部动态 import,Element Plus 按需解析。"
2026-10 新增(F 轨前端 · 首批)。