← 返回面试目录

⚡ 前端性能优化实战

问题场景 → 优化思路 → 关键代码 → 效果验证 · 配 Lighthouse / Performance 实战图

Core Web Vitals 白屏优化 渲染优化 打包优化 缓存策略 面试导向

为什么要做性能优化

面试官最爱问的开场题。不要只说"提升体验",要能说出具体数字和业务影响。

性能直接影响业务转化

场景数据来源
Amazon 页面慢 100ms销售额下降 1%Amazon 研究
Google 搜索慢 400ms搜索量下降 0.59%Google 研究
Walmart 页面快 1s转化率提升 2%Walmart 数据
Pinterest 首屏快 40%注册提升 15%Pinterest 工程
面试话术:"性能优化不是炫技,是直接和钱挂钩的。我们项目 LCP 从 4s 优化到 1.2s 后,跳出率从 62% 降到 28%,转化率提升 34%。"——这样说面试官立刻就懂你做过真实优化。

怎么发现性能问题?(Lighthouse 评分)

Chrome DevTools 自带 Lighthouse,一键跑出性能报告。下面是一个真实页面的评分截图模拟:

Lighthouse Report
https://shop.example.com/products
52
Performance
95
Accessibility
100
Best Practices
78
SEO
诊断结果(点击展开看详情)
❌ 首次内容绘制 2.8s(目标 <1.8s)
❌ 最大内容绘制 4.2s(目标 <2.5s)
⚠️ 累积布局偏移 0.15(目标 <0.1)
⚠️ 未使用 JS 共 1.2MB(lodash 全量引入)
⚠️ 图片未优化(PNG 未转 WebP)
💡 怎么用 Lighthouse?
1. F12 打开 DevTools → 切到 Lighthouse 面板
2. 选 Mobile(手机端更严格,能暴露更多问题)
3. 点 Generate report,等 10-30 秒出报告
4. 重点看 红色 ❌ 项,按优先级修

Core Web Vitals 性能指标

Google 定义的核心 Web 指标,直接影响搜索排名。面试必问,三个指标要能背出来。

三大核心指标(必背)

下面是优化前后的指标对比,直观感受"好"和"差"的差距:

4.2
LCP(优化前)
最大内容渲染 ❌
1.2
LCP(优化后)
最大内容渲染 ✅
320
ms
INP(优化前)
交互延迟 ❌
80
ms
INP(优化后)
交互延迟 ✅
指标含义优秀 ✅需改进 ⚠️差 ❌
LCP
最大内容渲染
页面最大元素(如 banner、大标题)渲染完成的时间 ≤ 2.5s 2.5s ~ 4s > 4s
INP
交互延迟
用户点击/输入后,到页面响应的延迟(2024 替代 FID) ≤ 200ms 200ms ~ 500ms > 500ms
CLS
布局偏移
页面加载过程中,元素位置突然跳动(点错按钮的元凶) ≤ 0.1 0.1 ~ 0.25 > 0.25

📱 手机 App 类比:

LCP = 你打开 App,主界面(首屏大图)多久能完整显示出来。等超过 2.5 秒你就会觉得"这 App 卡"。

INP = 你点了"提交"按钮,App 多久能给你反馈(转圈或弹提示)。点下去卡 300ms+ 就很膈应。

CLS = 你正要点某个按钮,结果按钮突然往下跳了一下,你点到了上面的广告——这种"布局乱跳"就是 CLS 差。

面试加分:2024 年起 Google 用 INP 替代了 FID。INP 测的是所有交互的延迟,不只是第一次。提这个说明你关注最新动态。

白屏问题:6 种解决方案

用户打开页面看到一片白,等几秒才出内容——这是 SPA 最常见的性能问题。面试必问"白屏怎么优化"。

白屏是怎么产生的?

💡 白屏的根本原因
SPA(Vue/React)页面是靠 JS 渲染的:
1. 浏览器下载 HTML(空壳,只有一个 <div id="app">
2. 下载 JS bundle(可能 1MB+,慢)
3. 解析执行 JS,才能渲染出内容
第 2-3 步之间,用户看到的就是白屏。

6 种解决方案(按推荐顺序)

① 骨架屏

在 HTML 里放占位灰色块,JS 加载完再替换。用户看到"内容正在加载"而不是白屏。

② 路由懒加载

首屏只加载首页代码,其他页面按需加载。Bundle 从 1MB 降到 200KB。

③ Gzip/Brotli 压缩

服务器压缩 JS/CSS,传输体积减少 70%+。1MB JS 传输时只有 300KB。

④ CDN 加速

静态资源放 CDN 节点,用户从最近的节点下载,比从源站快 3-5 倍。

⑤ SSR 服务端渲染

服务端直接输出 HTML 内容,浏览器拿到就能显示,不依赖 JS 执行。Nuxt.js/Next.js。

⑥ preload 预加载

<link rel="preload"> 提前下载关键资源(首屏字体、主 JS),不等 HTML 解析完。

方案①:骨架屏(最常用)

💡 思路
问题:白屏期间用户不知道页面在加载,以为卡死了。
思路:在 HTML 的 <div id="app"> 里预先放好灰色占位块(骨架屏),JS 加载完后 Vue 会自动替换掉。
关键点:骨架屏写在 HTML 里,不需要等 JS,浏览器解析到 HTML 立刻就能显示。
<!-- index.html --> <div id="app"> <!-- 骨架屏:JS 加载前用户看到这个 --> <div class="skeleton"> <div class="skeleton-header"></div> <div class="skeleton-content"> <div class="skeleton-line"></div> <div class="skeleton-line"></div> <div class="skeleton-line short"></div> </div> </div> </div> /* CSS:灰色块 + 闪烁动画 */ .skeleton-line { height: 16px; background: #e0e0e0; border-radius: 4px; margin-bottom: 10px; animation: shimmer 1.5s infinite; } .skeleton-line.short { width: 60%; } @keyframes shimmer { 0% { background: #e0e0e0; } 50% { background: #f0f0f0; } 100% { background: #e0e0e0; } } /* Vue 挂载后自动替换 #app 内容,骨架屏消失 */
效果:白屏时间不变,但用户感知从"页面卡死了"变成"内容正在加载",跳出率降 30%+。

方案②:路由懒加载(必做)

💡 思路
问题:打包后所有页面代码在一个 bundle 里,首屏用户要下载整个应用的代码(包括没访问的页面)。
思路:用动态 import() 把每个路由拆成独立的 chunk,首屏只加载首页 chunk,其他页面点进去时才加载。
关键点:动态 import() 会被打包工具识别,自动代码分割。
// ❌ 优化前:所有页面打包在一起 import Dashboard from './views/Dashboard.vue' import Profile from './views/Profile.vue' import Order from './views/Order.vue' // 首屏 bundle 包含所有页面代码 → 1.8MB // ✅ 优化后:路由懒加载 const routes = [ { path: '/dashboard', // 动态 import() → 打包成独立 chunk,访问时才加载 component: () => import('./views/Dashboard.vue') }, { path: '/profile', component: () => import('./views/Profile.vue') }, { path: '/order', component: () => import('./views/Order.vue') } ] // 首屏只加载 Dashboard chunk → 200KB,其他页面按需加载 // React 写法一样 const Dashboard = React.lazy(() => import('./pages/Dashboard')) // 使用时包一层 Suspense,加载时显示 fallback <React.Suspense fallback={<Loading />}> <Dashboard /> </React.Suspense>
效果:首屏 bundle 从 1.8MB → 200KB,加载时间减少 70%+。

加载优化

图片优化(性价比最高)

💡 思路
问题:图片通常占页面体积 50%+,一张未压缩的 banner 图能有好几 MB。
思路(4 招组合):
1. 格式升级:PNG/JPG → WebP,体积小 25-35%,质量几乎不变
2. 懒加载:视口外的图片先不加载,滚到了再加载(loading="lazy"
3. 响应式:手机加载小图,PC 加载大图(srcset
4. CDN:图片走 CDN,从离用户最近的节点下载
<!-- 1. WebP 格式(比 JPEG/PNG 小 25-35%) --> <picture> <source srcset="hero.webp" type="image/webp"> <source srcset="hero.jpg" type="image/jpeg"> <img src="hero.jpg" alt="banner"> <!-- 兜底 --> </picture> <!-- 2. 懒加载:视口外不加载,滚到了再加载 --> <img src="product.jpg" loading="lazy" alt="商品图"> <!-- 3. 响应式图片:手机加载 480w,PC 加载 1200w --> <img srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w" sizes="(max-width: 600px) 480px, 800px" src="medium.jpg" <!-- 默认 --> /> <!-- 4. CDN:静态资源走 CDN 域名 --> <img src="https://cdn.example.com/product.jpg" alt="商品">
效果:图片加载从 3.5s → 0.8s,流量节省 60%。

预加载策略(preload vs prefetch)

💡 思路
preload = 当前页面马上要用的资源,提前下载(高优先级)。比如首屏字体、主 JS。
prefetch = 下一个页面可能要用的资源,空闲时下载(低优先级)。比如用户可能点进去的下个页面。
关键区别:preload 是"现在就要",prefetch 是"待会可能要"。
<!-- preload: 当前页面马上要用,高优先级提前加载 --> <link rel="preload" href="app.js" as="script"> <link rel="preload" href="font.woff2" as="font" crossorigin> <!-- prefetch: 下个页面可能要用,空闲时低优先级加载 --> <link rel="prefetch" href="next-page-chunk.js"> <!-- dns-prefetch: 提前解析域名(DNS 查询很慢) --> <link rel="dns-prefetch" href="//api.example.com"> <!-- preconnect: 提前建立连接(DNS+TCP+TLS) --> <link rel="preconnect" href="https://api.example.com">
📱 类比:preload = 你点外卖时,商家提前把米饭蒸上(确定要用的);prefetch = 你还没点甜点,但商家预判你可能要点,趁空闲先把甜品从冰柜拿出来解冻。

Gzip / Brotli 压缩

💡 思路
问题:JS/CSS 文件全是文本,有很多重复字符,传输时浪费带宽。
思路:服务器返回前先压缩,浏览器收到后自动解压。1MB 的 JS 压缩后只有 300KB,传输快 3 倍。
Brotli > Gzip:Brotli 比 Gzip 再小 15-25%,现代浏览器都支持。
# Nginx 开启 Gzip gzip on; gzip_types text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; # 小于 1KB 不压缩(不划算) # Brotli 压缩(比 Gzip 再小 15-25%) brotli on; brotli_types text/css application/javascript application/json image/svg+xml; # CDN 静态资源强缓存配置 location /static/ { add_header Cache-Control "public, max-age=31536000, immutable"; }

渲染优化

页面加载完后,用户操作时的卡顿问题。重点:虚拟列表、防抖节流、重排重绘。

虚拟列表(大数据量必做)

💡 思路
问题:列表有 10000 条数据,全部渲染 → DOM 节点 10000 个,浏览器直接卡死。
思路:只渲染视口能看到的那几行(比如 20 行),上下用空白撑起滚动条高度。用户滚动时,动态替换可见区域的内容。
关键计算:startIndex = scrollTop / itemHeightendIndex = startIndex + 可见行数
Performance Network 优化前:10000 个 DOM,渲染卡 2.3s
Scripting2.3s
Rendering1.1s
Painting450ms
System200ms
⚠️ 主线程被阻塞 3.8s,用户滑动完全无响应
// 虚拟列表核心实现(React 版,Vue 思路一样) const VirtualList = ({ items, itemHeight, containerHeight }) => { const [scrollTop, setScrollTop] = useState(0) // 1. 计算可视区域的起始和结束索引 const startIndex = Math.floor(scrollTop / itemHeight) const visibleCount = Math.ceil(containerHeight / itemHeight) + 1 const endIndex = Math.min(startIndex + visibleCount, items.length) // 2. 只切片可见部分 const visibleItems = items.slice(startIndex, endIndex) // 3. 用 translateY 偏移,撑起总高度 return ( <div onScroll={e => setScrollTop(e.target.scrollTop)} style={{ height: containerHeight, overflow: 'auto' }} > // 撑起总高度,让滚动条正确显示 <div style={{ height: items.length * itemHeight, position: 'relative' }}> // 只渲染可见行,用 translateY 定位到正确位置 {visibleItems.map((item, i) => ( <div key={startIndex + i} style={{ position: 'absolute', top: (startIndex + i) * itemHeight, height: itemHeight }}> {renderItem(item)} </div> ))} </div> </div> ) } // 生产推荐:react-window / vue-virtual-scroller(已处理边界情况)
效果:DOM 从 10000 个 → 20 个,滚动丝滑,内存占用降 95%。

防抖 & 节流(高频场景必做)

💡 思路
问题:搜索框每输入一个字都触发请求 → 用户打"手机"发 4 次请求,浪费 3 次。滚动事件每秒触发几十次,回调卡死主线程。
防抖(debounce):等用户停止操作一段时间后才执行。适合搜索框——用户停下来 300ms 才发请求。
节流(throttle):固定间隔执行一次。适合滚动事件——不管滚多快,每 100ms 才执行一次。
// 防抖:停止操作后才执行(搜索框输入) function debounce(fn, delay = 300) { let timer = null return function(...args) { clearTimeout(timer) // 每次进来都清掉前一个定时器 timer = setTimeout(() => { fn.apply(this, args) // 停下 delay 毫秒后才真正执行 }, delay) } } // 节流:固定间隔执行(滚动事件) function throttle(fn, interval = 200) { let lastTime = 0 return function(...args) { const now = Date.now() if (now - lastTime >= interval) { // 距离上次执行超过 interval 才执行 lastTime = now fn.apply(this, args) } } } // 使用场景 input.addEventListener('input', debounce(search, 300)) // 搜索框 window.addEventListener('scroll', throttle(onScroll, 100)) // 滚动
📱 类比:防抖 = 电梯门,有人进来就重新等 3 秒,没人进才关门。节流 = 地铁发车,不管多少人,每 5 分钟发一班车。

重排(Reflow)vs 重绘(Repaint)

💡 思路
重排(代价高):改变了元素的几何属性(宽高、位置),浏览器要重新计算布局。
重绘(代价中):只改变了外观(颜色、背景),不改变布局,浏览器只需重新绘制。
关键优化:transform 代替 top/left 做动画——transform 只触发合成,不触发重排!
操作触发优化方式
修改 width/height/top/left 重排 + 重绘(代价高) transform 代替
修改 color/background 只重绘(代价中) 用 CSS 变量统一管理
修改 transform/opacity 只合成(代价低) 动画首选这两个属性
频繁读 offsetHeight 强制同步布局 批量读,再批量写
// ❌ 错误:读写交替,触发多次重排 items.forEach(item => { const h = item.offsetHeight // 读 → 强制浏览器布局 item.style.height = h + 10 + 'px' // 写 → 触发重排 }) // 循环 100 次 = 100 次重排,卡死 // ✅ 正确:先批量读完,再批量写 const heights = items.map(item => item.offsetHeight) // 批量读 items.forEach((item, i) => { item.style.height = heights[i] + 10 + 'px' // 批量写,只触发 1 次重排 })

打包优化

Tree Shaking(摇树优化)

💡 思路
问题:引入了一个工具库(如 lodash),但你只用了 debounce 一个函数,结果整个 lodash(70KB+)都被打包进去了。
思路:打包工具分析代码,发现哪些 export 没被使用,自动"摇掉"它们。
前提:必须用 ES Module(import/export),不能用 CommonJS(require)。因为 ES Module 是静态的,打包时能分析依赖关系。
// math.js — 导出三个函数 export function add(a, b) { return a + b } export function subtract(a, b) { return a - b } export function multiply(a, b) { return a * b } // app.js — 只 import add import { add } from './math' // 打包后:subtract 和 multiply 被摇掉了 ✅ // 前提:用 ES Module(import/export),不能用 CommonJS(require) // ❌ 全量引入 lodash(70KB+) import _ from 'lodash' _.debounce(fn, 300) // ✅ 按需引入 import debounce from 'lodash/debounce' // 只引入这一个 // 或用 lodash-es(支持 Tree Shaking) import { debounce } from 'lodash-es'

代码分割策略

策略说明适用场景
路由级别分割 每个路由一个 chunk 所有 SPA 项目
组件级别分割 弹窗/编辑器等重组件单独打包 大组件(>50KB)
Vendor 分割 第三方库单独打包(长期缓存) 所有项目

Vite 手动分割配置

💡 思路
问题:默认打包把所有第三方库混在一起,每次业务代码变了,第三方库 chunk 也跟着变,缓存全失效。
思路:manualChunks 把第三方库按类别拆分——vue 全家桶一个 chunk、UI 库一个 chunk、工具库一个 chunk。业务代码变了不影响 vendor chunk 的 hash,用户缓存还能用。
// vite.config.js export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { 'vendor': ['vue', 'vue-router', 'pinia'], // 框架 'ui': ['element-plus'], // UI 库 'utils': ['lodash-es', 'axios'], // 工具库 } } }, chunkSizeWarningLimit: 500, // 超过 500KB 警告 } })

构建分析工具

💡 思路
优化打包前先看清楚 bundle 里有什么——哪些包最大、有没有重复打包。用可视化工具一目了然。
# Vite 分析(推荐 rollup-plugin-visualizer) npm install --save-dev rollup-plugin-visualizer // vite.config.js import { visualizer } from 'rollup-plugin-visualizer' plugins: [visualizer({ open: true })] // 打包后自动打开一个网页,显示每个包占多大空间(饼图) # Webpack 分析 npm install --save-dev webpack-bundle-analyzer

缓存策略

面试高频题:"强缓存和协商缓存的区别?"要能说清楚。

强缓存 vs 协商缓存

💡 思路
强缓存:浏览器直接用本地缓存,不发请求。通过 Cache-Control: max-age 控制。
协商缓存:浏览器发请求问服务器"资源变了吗?",变了返回 200 + 新资源,没变返回 304(用缓存)。通过 ETag / If-None-Match 控制。
最佳实践:HTML 用协商缓存(每次验证),JS/CSS/图片用强缓存(带 hash 文件名,内容变了 hash 就变,等于新文件)。
收到请求
有强缓存?
看 Cache-Control
✅ 有且未过期
直接用,不发请求
|
❌ 过期或没有
发协商缓存请求
发 ETag / If-None-Match
没变化?
304 用缓存
变化了?
200 + 新资源
类型HTTP 头特点适用资源
强缓存 Cache-Control: max-age=31536000 不发请求,直接用 JS/CSS/图片(带 hash 文件名)
协商缓存 ETag / If-None-Match 发请求,服务器判断 HTML 文件(入口文件)
📱 类比:强缓存 = 你家冰箱里有昨天剩的菜,直接拿出来吃,不用问餐厅。协商缓存 = 你打电话问餐厅"昨天那道菜还有吗?",有就端过来(304),没有就重新做一份(200)。

为什么 HTML 用协商缓存,JS/CSS 用强缓存?

💡 思路
关键点:打包后的 JS/CSS 文件名带 hash(如 app.a3f5b2.js),内容变了 hash 就变,等于新文件名。
流程:每次发版,HTML 更新(引用新的 hash 文件名),但旧的 hash 文件还在 CDN 上。
1. 用户访问 → HTML 协商缓存 → 服务器返回新 HTML(引用 app.b8c9d1.js
2. 浏览器发现 app.b8c9d1.js 没缓存 → 下载新文件
3. 下次访问 → app.b8c9d1.js 强缓存命中,直接用(1 年内都有效)
结果:HTML 每次验证(小文件),JS/CSS 永久缓存(大文件省流量)。
# Nginx 配置 # HTML:协商缓存(每次验证) location ~* \.html$ { add_header Cache-Control "no-cache"; # 协商缓存 } # JS/CSS/图片:强缓存 1 年(文件名带 hash) location /static/ { add_header Cache-Control "public, max-age=31536000, immutable"; # immutable:告诉浏览器连 304 都不用发,直接用 }

实战案例:电商列表页 4s → 1.2s

真实可复现的优化路径。面试时讲这种有数字的案例,比泛泛说"我做过性能优化"有说服力 100 倍。

📉 优化前数据

• LCP:4.2s   • FCP:2.8s   • TTI:5.1s   • Bundle:1.8MB

• 接口 RT:1.2s   • DOM 节点:~8000 个   • 请求数:43 个

用户反馈:"页面太慢了,等半天还在转圈",跳出率 62%

1接口优化:RT 1.2s → 200ms(提升 1s)

💡 思路
接口慢通常是后端问题,但前端也能推动优化:
1. 让后端加 Redis 缓存热门商品列表
2. 接口返回字段精简(列表页不需要商品详情)
3. 分页从每页 50 条改为 20 条(减少传输量)
// 优化前:一次查 50 条完整商品数据 GET /api/products?page=1&size=50 // RT: 1.2s // 优化后:只查 20 条简要数据 + Redis 缓存 GET /api/products?page=1&size=20&fields=id,name,price,cover // RT: 200ms

2图片优化:加载 3.5s → 0.8s(提升 2.7s)

💡 思路
1. 所有图片转 WebP,体积减少 35%
2. 加 loading="lazy" 懒加载
3. 用 srcset 响应式图片,手机不加载大图
4. 图片走 CDN + Brotli 压缩
5. 首屏图片加 preload

3打包优化:Bundle 1.8MB → 420KB(提升 0.6s)

💡 思路
1. Tree Shaking 清除未使用代码(lodash 全量 → 按需引入)
2. 路由懒加载,首屏不加载详情页/购物车页代码
3. Vendor 分割:vue/pinia 单独 chunk(长期缓存)
4. 组件库按需引入(ElementPlus 全量 → 只引入 6 个组件)
5. 开启 Gzip 压缩后传输体积:420KB → ~130KB

4渲染优化:TTI 5.1s → 1.8s(提升 3.3s)

💡 思路
1. 虚拟列表替换全量渲染(8000 个 DOM → 200 个)
2. 搜索框加防抖(300ms),减少不必要的请求
3. 滚动事件加节流(100ms)+ passive: true
4. 动画使用 transform 替代 top/left(避免重排)

📉 优化前

LCP(最大内容渲染)

4.2s ❌

Bundle Size

1.8MB

DOM 节点数

~8000

请求数

43 个

跳出率

62%

📈 优化后

LCP(最大内容渲染)

1.2s ✅

Bundle Size

420KB

DOM 节点数

~200

请求数

12 个

跳出率

28%

Lighthouse Report(优化后)
https://shop.example.com/products
95
Performance
95
Accessibility
100
Best Practices
92
SEO
✅ 首次内容绘制 0.9s
✅ 最大内容绘制 1.2s
✅ 累积布局偏移 0.05
✅ 未使用 JS 仅 80KB
面试要点:不要只说"我用了虚拟列表",要说优化前什么数据、做了什么、优化后什么数据。数字最有说服力。面试官最爱问:"你怎么发现性能问题的?"→ 答:"通过 Lighthouse 评分 + Performance API 监控发现 LCP 4.2s,定位到图片和 bundle 过大,优化后降到 1.2s。"

性能监控与分析实战

"我没做过监控"——这是很多人的痛点。本节从零开始,手把手教你用工具发现、定位、分析性能问题。分本地分析线上监控两条线。

监控的两条线:本地 vs 线上

对比本地分析(开发时用)线上监控(上线后用)
目的定位具体瓶颈在哪个函数/请求监控真实用户体验,发现回归
工具Lighthouse / DevTools Performance / NetworkPerformance API / Sentry / Web Vitals
数据来源你的电脑跑一次所有真实用户的浏览器上报
使用时机开发阶段、排查问题时7x24 持续运行
工作流:线上监控发现 LCP 异常 → 本地用 Lighthouse 复现 → DevTools Performance 定位瓶颈 → 优化 → 线上监控验证效果。

① Lighthouse:一键体检(最常用)

💡 Lighthouse 是什么
Google 出的免费工具,内置于 Chrome DevTools。模拟用户访问页面,跑出一堆指标和优化建议。本地分析的第一步永远是跑 Lighthouse

操作步骤(跟着做)

1
打开目标页面,比如 https://shop.example.com
2
F12(Mac: Cmd+Option+I)打开 DevTools
3
点击顶部标签栏的 Lighthouse(如果看不到,点 ⟩⟩ 展开更多标签)
4
选择设备:Mobile(手机端更严格,推荐先跑手机)或 Desktop
5
勾选要检测的类别:✅ Performance / ✅ Accessibility / ✅ Best Practices / ✅ SEO
6
点击 Generate report,等 10-30 秒
Elements
Console
Sources
Network
Performance
Memory
Lighthouse
Application
Device: Mobile Desktop
Categories: ✅ Performance ✅ Accessibility ✅ Best Practices ✅ SEO
▶ Generate report
点击后模拟页面加载,约 10-30 秒出报告

报告怎么读?

💡 重点关注三块
① 顶部分数:Performance 分数 < 50 就要重视。但不要只看总分,要看具体指标。
② Metrics 指标区:FCP / LCP / TBT / CLS / TTI,哪个红就优化哪个。
③ Diagnostics 诊断区:列出具体问题和建议,按红色优先修。这里每条建议都附带了"怎么改"的链接,照着做就行。
Lighthouse Report
https://shop.example.com/products
52
Performance
95
Accessibility
100
Best Practices
78
SEO
📊 Metrics(核心指标)
FCP: 2.8s
LCP: 4.2s
TBT: 890ms
CLS: 0.15
TTI: 5.1s
🔧 Diagnostics(诊断建议,按优先级排序)
🔴 Eliminate render-blocking resources — 3 个 CSS 阻塞渲染,节省 1.2s
🔴 Properly size images — 5 张图片过大,节省 800ms
🟡 Minify JavaScript — 未压缩 JS,节省 200ms
🟡 Reduce unused CSS — 60% CSS 未使用
🟢 Use HTTP/2 — 已启用 ✅
面试话术:"我先用 Lighthouse 跑了一遍,发现 Performance 只有 52 分,LCP 4.2s。看 Diagnostics 建议,最大瓶颈是渲染阻塞资源和图片未优化,优先修这两项。"

② Chrome DevTools Performance 面板:定位瓶颈函数

💡 什么时候用
Lighthouse 告诉你"什么慢",Performance 面板告诉你"哪个函数慢"。比如 Lighthouse 说 TBT 高,Performance 面板能看到是哪个 JS 函数占了 2 秒。

操作步骤

1
F12 打开 DevTools → 切到 Performance 标签
2
点击左上角 (录制按钮),变红表示开始录制
3
操作页面(如刷新页面、点击按钮、滚动),录制你复现问题的操作
4
再点 停止录制,生成时间轴 + 火焰图
5
主线程时间轴:红色/黄色长条 = JS 执行久的函数。点击条状可以看详情
6
底部 Summary 看 5 大分类耗时,Bottom-Up 看哪个函数耗时最多
Elements
Network
Performance
Memory
Lighthouse
录制完成 · 总时长 5.2s ⚙️ CPU: 4× slowdown
0ms500ms1.0s1.5s2.0s2.5s3.0s3.5s4.0s4.5s5.0s
Network
HTML
app.js (1.2MB)
style.css
banner.webp
/api/products
Main
Parse HTML
Evaluate Script (app.js)
Vue.mount()
renderList() ×8000
Layout
Paint
⚠️ 主线程被阻塞 3.8s — renderList() ×8000 是瓶颈,渲染了 8000 个 DOM 节点
Scripting 2.3s (44%)
Rendering 1.1s (21%)
Painting 450ms (9%)
System 200ms (4%)
Idle 1.15s (22%)
💡 怎么看火焰图?
横轴 = 时间纵轴 = 调用栈(上面的函数是被下面的函数调用的)。
越宽的条 = 执行越久,重点看最宽的
红色/黄色 = 占用主线程,可能阻塞用户交互
• 看到一个超宽的条,点击它 → 底部 Bottom-Up 看它调用了哪些子函数,逐层钻取定位
• 上图中 renderList() ×8000 宽达 18%,说明渲染了 8000 个节点 → 该上虚拟列表了
效果:Performance 面板让你看到哪个函数在吃 CPU,而不是猜。这是定位复杂性能问题的核心工具。

③ Network 面板:分析请求和加载瀑布

💡 什么时候用
页面加载慢、接口慢、资源太大,都看 Network 面板。它能显示每个请求的详细时间拆解(DNS / TCP / 下载 / 等待)。

操作步骤

1
F12 → 切到 Network 标签
2
勾选 Disable cache(禁用缓存,模拟首次访问)
3
刷新页面(Cmd+R),看请求瀑布图
4
点击任意请求 → 右侧 Timing 标签看时间拆解
5
Size 列:如果有 (from cache) 说明缓存生效;如果传输太大考虑压缩
Elements
Network
Performance
Lighthouse
🔴 🚫 Cache All · JS · CSS · Img · Doc · XHR · Font 12 requests · 1.8MB · 4.2s
Name
Type
Size
Time
Waterfall
index.html
doc
4.2 KB
120ms
app.a3f5b2.js
js
1.2 MB
1.8s
style.css
css
45 KB
200ms
banner.jpg
img
2.8 MB
1.5s
product-1.jpg
img
180 KB
300ms
/api/products
xhr
85 KB
1.2s
font.woff2
font
32 KB
150ms
⚠️ 发现问题:app.js 1.2MB 太大 / banner.jpg 2.8MB 未压缩 / 接口 RT 1.2s 太慢
💡 Timing 时间拆解怎么读
点击某个请求 → Timing 标签,能看到:
Queueing:排队等浏览器有空闲连接(可能被其他请求占满)
Stalled:连接建立等待
DNS Lookup:域名解析(首次访问慢,DNS 缓存后快)
Initial Connection:TCP 握手
SSL:HTTPS 协商
Request sent:发送请求(通常 <1ms)
Waiting (TTFB):等待服务器响应——这个最关键,长 = 后端慢
Content Download:下载响应体——长 = 文件大或带宽小

④ 线上监控:Performance API + Sentry

💡 为什么需要线上监控
本地工具只能看到你的电脑的表现。但真实用户的设备、网络千差万别——你的 Mac 上 LCP 1.2s,用户的低端安卓上可能 5s。线上监控收集所有真实用户的数据,才能发现"部分用户体验差"的问题。

方案 A:Performance API(自建监控,免费)

// 采集 Core Web Vitals 并上报到后端 import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals' function sendToAnalytics(metric) { // 上报到你的监控后端 fetch('/api/analytics/web-vitals', { method: 'POST', body: JSON.stringify({ name: metric.name, // 'LCP' / 'INP' / 'CLS' value: metric.value, // 数值 rating: metric.rating, // 'good' / 'needs-improvement' / 'poor' page: location.pathname, // 哪个页面 sessionId: getSessionId(), // 用户会话 }) }) } // 监听 5 大指标,有数据自动回调 onLCP(sendToAnalytics) // 最大内容渲染 onINP(sendToAnalytics) // 交互延迟 onCLS(sendToAnalytics) // 布局偏移 onFCP(sendToAnalytics) // 首次内容绘制 onTTFB(sendToAnalytics) // 首字节时间 // 采集 JS 错误(性能问题的常见来源) window.addEventListener('error', (e) => { sendError({ type: 'js_error', message: e.message, filename: e.filename, lineno: e.lineno }) }) // 采集未处理的 Promise rejection window.addEventListener('unhandledrejection', (e) => { sendError({ type: 'promise_error', reason: e.reason?.toString() }) })

方案 B:Sentry(开箱即用,推荐生产用)

// npm install @sentry/vue (或 @sentry/react) import * as Sentry from "@sentry/vue" // 初始化 Sentry — 自动采集性能 + 错误 Sentry.init({ dsn: "https://xxx@sentry.io/123", integrations: [ Sentry.browserTracingIntegration(), // 自动采集路由+性能 ], tracesSampleRate: 0.1, // 采样 10%(高流量站点降低采样率省费用) // 自动采集 Web Vitals,无需手动写代码 }) // Vue 3 中传入 app 实例 const app = createApp(App) Sentry.init({ ..., app })
📊 Web Vitals 监控看板(近 24 小时)
更新于 2026-06-20 01:30
1.3s
LCP (P75)
↓ 0.4s vs 昨日
85ms
INP (P75)
↓ 15ms
0.05
CLS (P75)
→ 持平
3
告警数
↑ 2 新告警
LCP 趋势(7 天)
6/146/156/166/176/186/196/20
📱 类比:本地 Lighthouse = 你自己去试驾一辆车(只能看你的体验)。线上监控 = 给每辆卖出去的车装 GPS + 行车记录仪(所有车主的真实数据都回传)。两者结合才能全面了解性能。

⑤ 不同场景的排查指南(速查表)

遇到性能问题,先对号入座,再用对应工具排查。

🔴 场景 1:首屏白屏 / LCP 慢(>2.5s)

症状:用户打开页面,几秒一片白,或首屏大图/大标题迟迟不显示

排查工具: Lighthouse Network 面板

排查步骤:

1. 跑 Lighthouse,看 LCP 数值和 Diagnostics 建议

2. Network 面板看瀑布图:哪个请求最长?(通常是 app.js 太大或图片太大)

3. 如果是 JS 大 → 路由懒加载 + Tree Shaking + Gzip

4. 如果是图片大 → WebP + 懒加载 + 响应式

5. 如果是接口慢 → 推动后端加缓存 / 优化 SQL

🟡 场景 2:交互卡顿 / INP 高(>200ms)

症状:点击按钮后要等一会才有反应,滚动不流畅,输入有延迟

排查工具: Performance 面板

排查步骤:

1. Performance 面板录制 → 操作(点击/滚动)→ 停止

2. 看主线程有没有红色长条(长任务 >50ms)

3. 点击长条 → Bottom-Up 看哪个函数耗时

4. 常见原因:大列表全量渲染(→ 虚拟列表)、事件没防抖节流、同步计算太重(→ 用 Web Worker 或 requestIdleCallback)

🟣 场景 3:CLS 布局偏移(>0.1)

症状:页面加载时元素突然跳动,用户点错按钮

排查工具: Lighthouse Performance → Layout Shifts

排查步骤:

1. Lighthouse 看 CLS 数值

2. Performance 面板录制 → 看紫色 Layout Shift 条状

3. 点击 Layout Shift 条 → 看 Moved from / Moved to,知道哪个元素跳了

4. 常见原因:图片没设宽高、字体加载跳动、异步 DOM 插入

🔵 场景 4:页面越用越卡(内存泄漏)

症状:刚打开很流畅,用 10 分钟后越来越卡,刷新后又好了

排查工具: Memory 面板 Performance Monitor

排查步骤:

1. F12 → Memory 标签 → 选 Heap snapshot

2. 操作前拍一次快照(Snapshot 1)

3. 操作页面 5 分钟(如反复打开/关闭弹窗)

4. 再拍一次快照(Snapshot 2)

5. 选 Comparison → 对比 Snapshot 1 和 2,看哪些对象只增不减

6. 常见原因:定时器没清除、事件监听没移除、闭包持有大对象、Vue 组件卸载没清理

🟢 场景 5:线上偶发慢(本地复现不了)

症状:本地测试一切正常,用户反馈偶尔很慢

排查工具: Sentry Performance API 上报

排查步骤:

1. 看 Sentry Performance 面板,按页面/路由筛选慢请求

2. 查慢请求的 Trace(完整调用链),看哪一段最慢

3. 按设备/浏览器/网络维度分组,看是不是特定人群慢(如低端安卓 + 弱网)

4. 如果是接口慢 → 看后端 APM(如 SkyWalking / 阿里 ARMS)

5. 设置告警:LCP >4s 自动通知

Memory 面板(内存泄漏排查)长啥样:

Elements
Network
Performance
Memory
Heap snapshot Allocation timeline Allocation sampling
Take snapshot 已拍摄 2 个快照
Snapshot 2 vs Snapshot 1(操作 5 分钟后)
String
+2.1 MB ⚠️
Array(8k items)
+1.8 MB ⚠️
Detached DOM
+1.2 MB ⚠️
EventListener
+800 KB
Object (正常)
+200 KB
⚠️ 发现泄漏:String / Array / Detached DOM 持续增长不回收 → 检查是否有定时器/事件监听未清理

⑥ 告警配置:性能回归第一时间发现

💡 思路
性能优化不是一次性的。每次发版可能引入性能回归(如新加了个大库)。配置告警,让性能变差时第一时间通知你,而不是等用户投诉。
指标告警阈值级别通知方式
LCP (P75)> 4sP0 紧急电话 + 钉钉
LCP (P75)> 2.5sP1 警告钉钉
JS 错误率> 0.1%P0 紧急电话 + 钉钉
接口 5xx 率> 1%P0 紧急电话 + 钉钉
CLS (P75)> 0.25P1 警告钉钉
关键:P75(第 75 百分位)而不是平均值。平均值会被极快的用户拉低,掩盖问题。P75 = 75% 的用户体验 ≤ 这个值,更真实反映大多数用户的体验。

面试速答 Q&A(18 题)

点击展开看答案。这些问题覆盖了 90% 的前端性能面试题。

什么是 Core Web Vitals?三个指标分别是什么?

Google 定义的核心 Web 指标,直接影响搜索排名。三个指标:

1. LCP(最大内容渲染):页面最大元素渲染完成时间,目标 ≤2.5s

2. INP(交互延迟):用户交互到页面响应的延迟,目标 ≤200ms(2024 年替代 FID)

3. CLS(累积布局偏移):页面元素位置跳动程度,目标 ≤0.1

白屏怎么优化?

6 种方案:

1. 骨架屏:HTML 里放占位块,JS 加载前用户看到"正在加载"

2. 路由懒加载:首屏只加载首页代码,Bundle 从 1MB → 200KB

3. Gzip/Brotli 压缩:JS 传输体积减少 70%+

4. CDN 加速:静态资源从最近节点下载

5. SSR:服务端渲染,不依赖 JS 执行(Nuxt/Next)

6. preload 预加载:提前下载关键资源

强缓存和协商缓存的区别?

强缓存:浏览器直接用本地缓存,不发请求。通过 Cache-Control: max-age 控制。适合带 hash 的 JS/CSS/图片。

协商缓存:发请求问服务器资源变没变,没变返回 304(用缓存),变了返回 200(新资源)。通过 ETag / If-None-Match 控制。适合 HTML 入口文件。

最佳实践:HTML 协商缓存(每次验证),JS/CSS/图片强缓存 1 年(文件名带 hash,内容变 hash 就变)。

为什么 Vite 比 Webpack 快?

开发环境:Vite 用原生 ESM,浏览器直接按需请求模块,不需要打包。Webpack 要先打包成一个 bundle 再启动。

预构建:Vite 用 esbuild(Go 写的,比 JS 写的快 10-100 倍)预构建依赖。

HMR:Vite 的热更新只更新改动的模块,不影响其他。Webpack 要重新构建整个 chunk。

生产构建:Vite 用 Rollup,Tree Shaking 更彻底,代码更小。

什么是 Tree Shaking?为什么需要 ES Module?

Tree Shaking = 打包时自动删除未使用的代码,"摇掉"没用的 export。

为什么需要 ES Module:ES Module 是静态的import/export 在编译时就能确定依赖关系,打包工具能分析出哪些 export 没被使用。而 CommonJS 的 require动态的,运行时才知道引用什么,无法静态分析。

注意:用 lodash 时要引入 lodash-es 才支持 Tree Shaking,普通的 lodash 不支持。

防抖和节流的区别?各用在什么场景?

防抖(debounce):停止操作一段时间后才执行。期间再次操作会重新计时。

→ 场景:搜索框输入(用户停下来 300ms 才发请求)、窗口 resize

节流(throttle):固定间隔执行一次,不管期间触发多少次。

→ 场景:滚动事件(每 100ms 执行一次)、鼠标拖拽

类比:防抖 = 电梯门(有人进就重新等);节流 = 地铁发车(固定间隔发一班)。

重排和重绘的区别?怎么减少?

重排(Reflow):改变元素几何属性(宽高、位置),浏览器重新计算布局。代价高。

重绘(Repaint):只改变外观(颜色、背景),不改变布局。代价中。

减少方法

1. 动画用 transform/opacity(只触发合成,不重排)

2. 批量读写 DOM(不要读写交替,会强制同步布局)

3. 用 DocumentFragment 批量插入 DOM

4. 复杂容器用 contain: layout style paint 隔离渲染

虚拟列表的原理?什么场景用?

原理:只渲染视口能看到的几行(如 20 行),上下用空白撑起滚动条高度。滚动时动态替换可见内容。

关键计算startIndex = scrollTop / itemHeightendIndex = startIndex + 可见行数

场景:1000+ 条数据的列表/表格,如商品列表、聊天记录、日志查看。

效果:DOM 从 10000 个 → 20 个,滚动丝滑,内存降 95%。

推荐库:react-window、vue-virtual-scroller。

preload 和 prefetch 的区别?

preload:当前页面马上要用的资源,高优先级提前加载。如首屏字体、主 JS。

prefetch:下个页面可能要用的资源,低优先级空闲时加载。如用户可能点进去的下个页面 chunk。

一句话:preload = 现在就要;prefetch = 待会可能要。

另外 dns-prefetch 提前解析域名,preconnect 提前建立连接(DNS+TCP+TLS),适合第三方域名资源。

怎么做图片优化?

4 招组合:

1. 格式升级:PNG/JPG → WebP,体积小 25-35%

2. 懒加载loading="lazy",视口外不加载

3. 响应式srcset + sizes,手机加载小图

4. CDN:图片走 CDN,从最近节点下载

效果:图片加载从 3.5s → 0.8s,流量省 60%。

怎么监控线上性能?

1. Performance API:用 PerformanceObserver 采集 LCP/INP/CLS,上报到监控平台

2. Sentry:错误监控 + 性能监控,支持回放

3. Lighthouse CI:每次发版自动跑分,防止性能回退

4. 告警:LCP >4s 告警(P0),JS 错误率 >0.1% 告警(P0)

关键:监控不是目的,出问题能第一时间发现、快速定位才是。

CLS(布局偏移)怎么优化?

常见原因:图片没设宽高、字体加载导致文字跳动、异步插入的 DOM 把已有内容挤开。

优化方法

1. 图片/视频必须设宽高(或用 aspect-ratio),浏览器提前预留空间

2. 字体用 font-display: swap + 预加载

3. 动态内容用固定高度容器,避免挤压已有元素

4. 避免在已有内容上方插入新内容

什么是代码分割(Code Splitting)?怎么做?

代码分割:把一个大的 bundle 拆成多个小 chunk,按需加载。

三种方式

1. 路由级别:每个路由动态 import(),访问时才加载

2. 组件级别:弹窗/编辑器等重组件单独打包

3. Vendor 分割:第三方库单独打包,用 manualChunks 配置

效果:首屏只加载 200KB,而不是整个 1.8MB。

你做过什么性能优化?(讲案例)

模板(套数字)

"电商商品列表页,通过 Lighthouse 发现 LCP 4.2s。排查发现三个问题:① Bundle 1.8MB(未做路由懒加载、lodash 全量引入)② 图片未优化(PNG 未转 WebP)③ 列表全量渲染 8000 个 DOM。

优化方案:路由懒加载 + Tree Shaking(Bundle 1.8MB→420KB)、图片转 WebP + 懒加载(图片加载 3.5s→0.8s)、虚拟列表(DOM 8000→200)。

结果:LCP 4.2s→1.2s,跳出率 62%→28%,转化率提升 34%。"

关键:有数字、有思路、有结果。不要泛泛说"做过性能优化"。

性能优化的三大原则?

1. 度量先行:不要凭感觉优化,用 Lighthouse / Performance API 数据说话

2. 抓主要矛盾:80% 的性能问题来自 20% 的代码,先优化最大的瓶颈

3. 持续监控:优化不是一次性的,要接监控防止性能回退

怎么监控线上性能?用哪些工具?

分两层:

① 自建监控:用 web-vitals 库采集 LCP/INP/CLS,通过 PerformanceObserver 监听长任务,fetch 上报到后端,自建看板展示 P75 趋势。

② 第三方服务:Sentry / 阿里 ARMS / 腾讯 CDC。开箱即用,自动采集 Web Vitals + JS 错误 + 慢请求 Trace。

关键:用 P75 而不是平均值。P75 = 75% 用户体验 ≤ 这个值,更能反映真实情况。设置告警:LCP >4s P0 紧急。

Lighthouse 和 Performance 面板有什么区别?

Lighthouse:跑一次模拟访问,给出评分 + 指标 + 优化建议清单。适合"体检",告诉你什么慢怎么改

Performance 面板:录制你的真实操作,生成时间轴 + 火焰图。适合"钻取",告诉你哪个函数慢

配合使用:Lighthouse 发现 TBT 高 → Performance 面板录制 → 看火焰图哪个 JS 函数最宽 → 针对性优化。

怎么排查内存泄漏?

症状:页面越用越卡,刷新后又好了。

排查步骤(Memory 面板):

1. F12 → Memory → 选 Heap snapshot

2. 操作前拍快照 1

3. 操作页面 5 分钟(如反复打开/关闭弹窗)

4. 拍快照 2

5. 选 Comparison 对比,看哪些对象只增不减

常见原因:定时器没 clearInterval、事件监听没 removeEventListener、闭包持有大对象、Vue 组件卸载没清理 onUnmounted

🎯 性能优化三原则速记
度量先行(不要凭感觉,用数据说话)
抓主要矛盾(80% 问题来自 20% 代码)
持续监控(防止性能回退)