浏览器从用户交互到最终渲染的完整管线,以及 Core Web Vitals 五大指标的对应关系和优化方向
← 返回场景决策
1. 懒加载 — 延迟加载决策
懒加载 = 快递分批送
→
先送急需的,不急的后送,不用一次搬完
不用懒加载 = 一次搬完
→
50个快递一次搬上6楼,累死也搬不完
✅ 什么时候用懒加载
- 长页面图片:商品列表、图库、新闻信息流 — 屏幕外的图片延迟加载
- 路由级组件:SPA 中非首屏路由组件 — 用
() => import() 懒加载
- 重型组件:富文本编辑器、图表库、视频播放器 — 用户点击时才加载
- 弹窗/抽屉内容:Modal 里的内容在打开时才渲染
- 第三方SDK:地图、支付、客服 — 按需加载而非全量引入
❌ 什么时候不用懒加载
- 首屏核心内容:Hero 图、导航栏 Logo、首屏文字 — 懒了反而白屏
- SEO 关键图片:搜索引擎需要的 OG 图片 — 懒加载可能不被爬虫识别
- 小图标/SVG:几 KB 的资源不值得做懒加载,反而增加请求开销
- 悬浮触发内容:Tooltip/Popover hover 触发 — 懒加载延迟会让体验卡顿
📊 懒加载决策表
| 资源类型 | 是否懒加载 | 方案 | 原因 |
| 首屏 Banner 图 | ❌ 不懒 | 直接 <img> | 核心体验,延迟不可接受 |
| 商品列表图片 | ✅ 懒 | IntersectionObserver | 可能几十张,首屏只需5-6张 |
| 富文本编辑器 | ✅ 懒 | 动态 import | 500KB+ JS,不用时不该加载 |
| 路由组件 | ✅ 懒 | Vue: defineAsyncComponent | 用户可能永远不点那个页面 |
| 小图标 (SVG Sprite) | ❌ 不懒 | 内联 SVG | <5KB,懒加载请求开销比资源本身大 |
| Modal 内容 | ✅ 懒 | v-if 控制 | 可能永远不打开 |
💻 图片懒加载 — IntersectionObserver
// 方案1:原生 loading="lazy"(最简单,浏览器原生支持)
<img src="hero.jpg" loading="lazy" alt="商品图">
// 方案2:IntersectionObserver(更精细控制)
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // data-src → src
img.classList.remove('lazy');
observer.unobserve(img); // 加载后停止观察
}
});
}, {
rootMargin: '200px 0px' // 提前200px开始加载,避免看到空白
});
document.querySelectorAll('img.lazy').forEach(img => {
observer.observe(img);
});
面试金句
路由懒加载(Lazy Loading)是 SPA 性能优化的第一优先级:只有访问该路由时才加载对应组件(打包成独立 chunk),首屏加载时间可降低 50%+。Vite 原生支持动态 import(),不需额外配置。需注意 Suspense + ErrorBoundary 处理加载状态和失败回退。
💻 路由懒加载 — Vue 3
// Vue Router 路由懒加载
const routes = [
{
path: '/dashboard',
// 动态导入,Webpack/Vite 会自动代码分割
component: () => import('../views/Dashboard.vue')
},
{
path: '/settings',
// 带加载状态和错误处理
component: defineAsyncComponent({
loader: () => import('../views/Settings.vue'),
loadingComponent: LoadingSpinner,
errorComponent: LoadError,
delay: 200, // 200ms后才显示loading
timeout: 10000 // 10秒超时
})
}
];
面试金句
图片优化是前端性能的重中之重(通常占页面体积 60%+):格式选择(WebP > JPEG/PNG);响应式图片(srcset + sizes 按屏幕加载合适尺寸);懒加载(loading=lazy);CDN 动态缩放。
💻 组件懒加载 — React
// React.lazy + Suspense
const HeavyChart = React.lazy(() => import('./HeavyChart'));
function App() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>查看图表</button>
{showChart && (
<React.Suspense fallback={<div>加载中...</div>}>
<HeavyChart />
</React.Suspense>
)}
</div>
);
}
面试金句
路由懒加载(Lazy Loading)是 SPA 性能优化的第一优先级:只有访问该路由时才加载对应组件(打包成独立 chunk),首屏加载时间可降低 50%+。Vite 原生支持动态 import(),不需额外配置。需注意 Suspense + ErrorBoundary 处理加载状态和失败回退。
⚠️
懒加载注意事项
- CLS 问题:图片懒加载会导致布局偏移(Cumulative Layout Shift),必须设置 width/height 或 aspect-ratio
- rootMargin 预加载:不要等图片进入视口才加载,提前 200-500px 开始加载
- SSR 兼容:服务端渲染时 IntersectionObserver 不存在,需要条件判断
3. 防抖 vs 节流 — 高频事件处理决策
防抖 = 电梯等人
→
有人进来就重新等5秒,直到5秒没人来才关门
节流 = 地铁发车
→
不管多少人,每5分钟发一趟,到点就走
✅ 什么时候用防抖 (Debounce)
- 搜索框输入:用户打字时等停顿 300ms 才发请求 — 避免每敲一个字发一次
- 窗口 resize:调整窗口大小,停止后重新计算布局
- 表单校验:用户停止输入后才校验 — 避免边打字边飘红
- 自动保存:用户停止编辑 1 秒后保存 — 避免每次按键都存
✅ 什么时候用节流 (Throttle)
- 滚动事件:scroll 监听 — 每 100ms 计算一次位置,而非每像素都算
- 拖拽移动:mousemove — 每 16ms 更新一次位置(约 60fps)
- 按钮防连点:提交按钮 — 1 秒内只允许点击 1 次
- 射击游戏:开枪 — 不管你点得多快,每 200ms 只能开一枪
📊 防抖 vs 节流决策表
| 维度 | 防抖 (Debounce) | 节流 (Throttle) |
| 核心逻辑 | 最后一次才执行 | 固定间隔执行 |
| 执行次数 | 可能 0 次(一直在动) | 保证有执行(间隔到了就跑) |
| 适合场景 | "等用户停了再做" | "别太频繁,但得持续响应" |
| 搜索框 | ✅ | ❌(太频繁还是会发请求) |
| 滚动监听 | ❌(停止才响应太迟钝) | ✅ |
| 按钮防连 | ❌(最后一次才执行=用户以为没反应) | ✅ |
| 自动保存 | ✅ | 可用但不够优 |
💻 防抖实现
function debounce(fn, delay = 300) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// Vue 3 使用
<script setup>
import { ref } from 'vue';
const searchQuery = ref('');
// 使用 lodash 或自己实现
const handleSearch = debounce(async (query) => {
const res = await fetch(`/api/search?q=${query}`);
// ... 处理结果
}, 300);
// watch 监听输入变化
watch(searchQuery, (val) => handleSearch(val));
</script>
面试金句
防抖(debounce)是等到最后一次操作后再执行,适合搜索框/窗口 resize;节流(throttle)是固定频率执行,适合滑动监听/scroll。Lodash debounce/throttle经过充分测试,不要自己造轮子。
💻 节流实现
function throttle(fn, interval = 100) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
// 滚动监听使用
window.addEventListener('scroll', throttle(() => {
const scrollTop = document.documentElement.scrollTop;
// 更新回到顶部按钮的显示状态
showBackToTop.value = scrollTop > 300;
}, 100));
面试金句
防抖(debounce)是等到最后一次操作后再执行,适合搜索框/窗口 resize;节流(throttle)是固定频率执行,适合滑动监听/scroll。Lodash debounce/throttle经过充分测试,不要自己造轮子。
💭 类比总结:防抖 = 电梯等人(等最后一个人进来),节流 = 地铁发车(到点就走不管有多少人)
4. Web Worker — 计算密集型任务决策
主线程 = 前台客服
→
只负责接待和展示,不能被卡住
Worker = 后台仓库
→
繁重的活交给仓库慢慢干,干完通知前台
✅ 什么时候用 Web Worker
- 大数据处理:10万行 CSV 解析、JSON 深度遍历 — 超过 100ms 的计算
- 图片处理:Canvas 像素操作、滤镜、裁剪 — 像素级操作很重
- 加密计算:RSA 加密、哈希计算 — 纯 CPU 运算
- 复杂排序/搜索:全文搜索索引构建、大数据排序
- 音视频处理:WebRTC 降噪、音频波形分析
❌ 什么时候不用 Web Worker
- 轻量计算:< 50ms — Worker 通信开销(postMessage 序列化)比计算本身还大
- DOM 操作:Worker 没有 DOM 访问,只能在主线程操作
- 简单异步请求:fetch/axios 本身就是异步的,不会阻塞主线程
- 频繁通信:主线程和 Worker 之间大量小消息 — 序列化/反序列化开销大
- 共享状态:多个组件需要共享同一份数据 — Worker 数据不好同步
📊 Web Worker 决策表
| 任务类型 | 耗时 | 用 Worker? | 替代方案 |
| 100条数据过滤 | <10ms | ❌ | 直接在主线程 |
| 1000条数据排序 | ~50ms | ⚠️ 看情况 | requestIdleCallback |
| 10万行CSV解析 | 200ms+ | ✅ | Web Worker |
| 图片滤镜 | 100ms+ | ✅ | OffscreenCanvas + Worker |
| fetch请求 | 异步 | ❌ | 主线程 async/await |
💻 Web Worker 基本用法
// worker.js — Worker 文件
self.onmessage = function(e) {
const { data, action } = e.data;
if (action === 'parseCSV') {
// 耗时操作在 Worker 中执行,不阻塞主线程
const result = data.split('\n').map(row => row.split(','));
self.postMessage({ action: 'parseComplete', result });
}
};
// main.js — 主线程
const worker = new Worker('./worker.js');
// 发送数据给 Worker
worker.postMessage({ action: 'parseCSV', data: csvString });
// 接收 Worker 处理结果
worker.onmessage = function(e) {
if (e.data.action === 'parseComplete') {
console.log('解析完成:', e.data.result);
// 更新UI
}
};
面试金句
Web Worker 将 CPU 密集型任务(大数据处理/加密/图片压缩)移至后台线程,不阻塞主线程 UI 响应。Comlink 库封装 postMessage 通信,让 Worker 像异步函数一样调用。
💻 Vite 中使用 Worker
// Vue 3 + Vite 使用 Worker
<script setup>
import CsvWorker from './csv.worker?worker'
const worker = new CsvWorker()
worker.postMessage({ action: 'parse', data: largeCsvText })
worker.onmessage = (e) => {
parsedData.value = e.data // 更新响应式数据
}
// 组件卸载时清理 Worker
onUnmounted(() => worker.terminate())
</script>
面试金句
Web Worker 将 CPU 密集型任务(大数据处理/加密/图片压缩)移至后台线程,不阻塞主线程 UI 响应。Comlink 库封装 postMessage 通信,让 Worker 像异步函数一样调用。
5. 前端缓存策略决策
内存缓存 = 口袋里的钱
→
随取随用,最快,但换了衣服就没了
SessionStorage = 当天的包
→
关掉tab就清空,只管当天
LocalStorage = 家里的保险箱
→
长期保存,关浏览器还在,但容量有限
IndexedDB = 银行金库
→
存大量数据,结构化存储,操作稍慢
✅ 什么时候用什么缓存
- 变量/Map:组件内临时数据、函数计算结果 — 生命周期最短
- SessionStorage:表单临时数据、当前会话的筛选条件 — 关 tab 就不要了
- LocalStorage:用户偏好(主题/语言)、Token、功能开关 — 跨会话持久
- IndexedDB:离线数据、大量结构化数据(邮件/笔记)、文件缓存
- HTTP 缓存:静态资源(JS/CSS/图片)— 强缓存 + 协商缓存配合
- Service Worker:PWA 离线缓存、请求拦截 — 离线可用
📊 缓存策略决策表
| 数据类型 | 大小 | 持久性 | 方案 | 示例 |
| API 响应 | <5MB | 会话级 | 内存 Map + TTL | 商品列表5分钟缓存 |
| 用户偏好 | <1KB | 永久 | LocalStorage | 暗色主题/语言 |
| 表单草稿 | <5MB | 会话级 | SessionStorage | 写了一半的文章 |
| 离线数据 | >50MB | 永久 | IndexedDB | 邮件/笔记离线存储 |
| 静态资源 | 不限 | 永久 | HTTP 强缓存 | JS/CSS 带 hash |
| 敏感信息 | - | - | ❌ 不缓存 | 密码/银行卡号 |
💻 内存缓存 + TTL 工具函数
// 简单的内存缓存,带过期时间
class MemoryCache {
constructor() {
this.cache = new Map();
}
set(key, value, ttlMs = 5 * 60 * 1000) { // 默认5分钟
this.cache.set(key, {
value,
expiry: Date.now() + ttlMs
});
}
get(key) {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() > item.expiry) {
this.cache.delete(key); // 过期自动清理
return null;
}
return item.value;
}
clear() { this.cache.clear(); }
}
// Vue 3 组合式使用
const apiCache = new MemoryCache();
async function fetchWithCache(url, ttlMs) {
const cached = apiCache.get(url);
if (cached) return cached;
const res = await fetch(url);
const data = await res.json();
apiCache.set(url, data, ttlMs);
return data;
}
面试金句
计算属性缓存(Vue computed / React useMemo)避免不必要的重复计算。关键原则:只在计算成本远大于缓存成本时才使用。避免在 computed 中做副作用(如发起请求),computed 应该是纯函数。
6. 预加载 vs 预获取 — 提前加载决策
preload = 出门前把伞放包里
→
确定马上要用,提前装好
prefetch = 出门前查了天气预报
→
可能要用,空闲时先准备
preconnect = 先拨号不通就挂
→
先建立连接(DNS+TCP+TLS),不发请求
✅ 什么时候用 preload
- 关键字体:自定义字体必须 preload — 否则 FOIT(文字闪烁)
- 首屏关键 CSS:内联关键CSS,preload 其余样式
- 首屏大图/视频:Hero 图片 — preload 优先级最高
- 关键 JS 模块:首屏交互需要的 JS — preload 让它尽早下载
✅ 什么时候用 prefetch
- 下一页路由:用户可能点击的下一个页面 — 空闲时预加载
- 弹窗内容:大概率会打开的帮助文档
- 分页数据:第2页数据 — 用户很可能翻页
❌ 什么时候不用
- prefetch 所有路由:浪费带宽 — 只 prefetch 最可能的下一个页面
- preload 第三方分析SDK:非关键资源 — 抢占首屏资源带宽
- 移动端大量 prefetch:用户流量有限 — 慎重使用
📊 预加载策略决策表
| 策略 | 优先级 | 触发时机 | 典型场景 |
| preconnect | 最高 | 页面加载时 | 第三方域名(CDN、API) |
| preload | 高 | 当前页面需要 | 关键字体、Hero图、CSS |
| prefetch | 低(空闲时) | 下个页面可能需要 | 路由组件、下页数据 |
| dns-prefetch | 最低 | 未来可能访问 | 第三方域名DNS预解析 |
💻 HTML 预加载标签
<!-- preconnect: 提前建连,省掉 DNS + TCP + TLS 时间 -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="dns-prefetch" href="https://analytics.example.com">
<!-- preload: 当前页面必须用的资源 -->
<link rel="preload" href="/fonts/custom-font.woff2" as="font" crossorigin>
<link rel="preload" href="/hero-image.webp" as="image">
<link rel="preload" href="/critical.css" as="style">
<!-- prefetch: 下一个页面可能需要的资源 -->
<link rel="prefetch" href="/js/dashboard-chunk.js" as="script">
面试金句
资源预加载策略:preload 告诉浏览器这个资源当前页面立即需要(高优先级下载);prefetch 告诉空闲时预加载下个路由的资源(低优先级)。Vite 自动生成 prefetch 提示用于路由懒加载 chunk。
💻 Vue Router 预取下一个路由
// 悬停时预加载路由组件
const router = createRouter({
routes: [
{
path: '/dashboard',
component: () => import('./Dashboard.vue') // Vite 自动 prefetch
}
]
});
// 手动在鼠标悬停时预加载
function prefetchRoute(routeName) {
const route = router.resolve({ name: routeName });
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = route.href;
document.head.appendChild(link);
}
// <router-link @mouseover="prefetchRoute('dashboard')">
面试金句
资源预加载策略:preload 告诉浏览器这个资源当前页面立即需要(高优先级下载);prefetch 告诉空闲时预加载下个路由的资源(低优先级)。Vite 自动生成 prefetch 提示用于路由懒加载 chunk。
7. 代码分割 — 打包优化决策
代码分割 = 快递分箱
→
急的先寄,不急的后寄,不用一箱装完
不分割 = 一箱全塞
→
一个2MB的JS,首屏只需要200KB也得全下
✅ 什么时候做代码分割
- 路由级分割:每个页面独立 chunk — 最基本、必须做
- 第三方库拆分:vue/react/echarts 单独打包 — 利用浏览器缓存
- 按功能拆分:编辑器模块、图表模块、地图模块 — 按需加载
- 条件代码:A/B测试代码、管理后台 — 非全量用户需要
❌ 什么时候不用/过度分割
- 碎文件:每个小组件一个 chunk — HTTP/2 虽然多路复用,但太多小文件仍浪费
- 首屏必须的代码:不要异步加载首屏核心逻辑
- 高频切换页面:用户频繁在 A/B 页面切换 — 合并更好
💻 Vite 代码分割配置
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
// 手动分割第三方库
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['element-plus'],
'chart-vendor': ['echarts'],
'utils': ['lodash-es', 'dayjs'],
}
}
},
// chunk 大小警告阈值
chunkSizeWarningLimit: 500
}
})
面试金句
代码拆分(Code Splitting)是减小首屏 JS 体积的核心手段:路由级拆分(每个路由独立 chunk)+ 组件级拆分(大组件独立加载)+ 第三方库拆分(vendor chunk 独立缓存)。webpack-bundle-analyzer / rollup-plugin-visualizer 可视化分析打包体积。
⚠️
代码分割最佳实践
- vendor chunk 不变:第三方库单独打包,业务代码更新时不影响 vendor 缓存
- chunk 大小:单个 chunk 建议不超过 200KB(gzip 前),超过考虑再拆
- 初始加载:首屏 JS 总量 < 200KB(gzip) 为佳
8. 图片优化 — 格式与加载决策
图片优化 = 快递包装
→
能用纸箱别用木箱,能塞小别寄大
✅ 什么时候用什么格式
- WebP:照片、商品图、背景图 — 比 JPEG/PNG 小 30-50%,兼容性好
- AVIF:极致压缩场景 — 比 WebP 再小 20%,但编码慢、兼容性差
- SVG:图标、Logo、插画 — 矢量无损,代码可编辑,无限缩放
- PNG:需要透明通道的小图 — 图标 Sprite、简单 UI 元素
- JPEG:照片 — 不需要透明度的大图(WebP 不支持时回退)
- GIF → MP4:动画 — GIF 体积巨大,转 MP4 可以小 90%
📊 图片格式决策表
| 格式 | 体积对比 | 透明 | 动画 | 兼容性 | 适用场景 |
| WebP | 基准 | ✅ | ✅ | 97% | 首选通用格式 |
| AVIF | 小20% | ✅ | ✅ | 92% | 极致压缩 |
| JPEG | 大30-50% | ❌ | ❌ | 100% | 照片回退 |
| PNG | 大50-200% | ✅ | ❌ | 100% | 小图标/透明 |
| SVG | 极小 | ✅ | ✅ | 100% | 图标/Logo |
💻 picture 标签 — 格式降级
<!-- 浏览器从上到下选第一个支持的 -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="Hero" width="800" height="450">
</picture>
<!-- 响应式图片 -->
<img
srcset="small.webp 400w, medium.webp 800w, large.webp 1200w"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
src="medium.webp"
alt="响应式图片"
>
面试金句
图片优化是前端性能的重中之重(通常占页面体积 60%+):格式选择(WebP > JPEG/PNG);响应式图片(srcset + sizes 按屏幕加载合适尺寸);懒加载(loading=lazy);CDN 动态缩放。
💻 图片优化 Checklist
// 1. 压缩 — Vite 插件
// npm install vite-plugin-imagemin --save-dev
// 2. 尺寸 — CSS 设置最大宽
// img { max-width: 100%; height: auto; }
// 3. 避免用 img 缩放 — 不要传 2000px 图给 CSS 显示 200px
// <img src="2000px.jpg" style="width:200px"> ❌ 浪费!
// <img src="200px.jpg"> ✅ 用实际需要的尺寸
// 4. CSS 背景图 — 小图标用 base64 内联
.icon {
background-image: url("data:image/svg+xml;base64,...");
}
面试金句
图片优化是前端性能的重中之重(通常占页面体积 60%+):格式选择(WebP > JPEG/PNG);响应式图片(srcset + sizes 按屏幕加载合适尺寸);懒加载(loading=lazy);CDN 动态缩放。
9. SSR vs CSR — 渲染模式决策
SSR = 服务端生成完整HTML
→
服务器返回可直接渲染的HTML,首屏快但每次需请求
CSR = 自助餐食材自做
→
给你食材(JS),你自己做(浏览器渲染)
SSG = 便当预制
→
提前做好的便当,拿起来就吃
✅ 什么时候用 SSR
- SEO 要求高:电商商品页、博客文章、营销页 — 搜索引擎需要完整 HTML
- 首屏速度关键:内容型网站 — 用户期望立即看到内容
- 社交分享:需要 OG 标签预览 — SSR 才能输出 meta 标签
- 低性能设备:用户设备差 — 减少客户端渲染压力
✅ 什么时候用 CSR
- 后台管理系统:不需要 SEO — CSR 更简单
- 交互密集型:在线编辑器、实时协作 — CSR 延迟更低
- 动态内容为主:个性化仪表盘 — SSR 缓存命中率低
- 开发速度优先:快速 MVP — CSR 开发成本低
📊 SSR vs CSR vs SSG 决策表
| 维度 | SSR | CSR | SSG |
| 首屏速度 | 快 | 慢 | 最快 |
| SEO | 好 | 差 | 好 |
| 服务器压力 | 大 | 小 | 最小 |
| 交互响应 | 中等 | 好 | 中等 |
| 动态内容 | 好 | 好 | 差 |
| 开发复杂度 | 高 | 低 | 中 |
| 典型场景 | 电商/博客 | 后台/工具 | 文档/博客 |
| 框架 | Nuxt/Next | Vite SPA | Nuxt/Next SSG |
💻 Vue 3 SSR 基本示例 (Nuxt 3)
// nuxt.config.ts
export default defineNuxtConfig({
// 默认就是 SSR 模式
ssr: true,
// 某些页面用 CSR
routeRules: {
'/admin/**': { ssr: false }, // 管理后台不需要SSR
'/blog/**': { isr: 3600 }, // 博客1小时ISR缓存
}
})
// pages/blog/[id].vue
<script setup>
// useAsyncData 只在服务端执行一次
const { data: article } = await useAsyncData(
`article-${route.params.id}`,
() => $fetch(`/api/articles/${route.params.id}`)
)
// useSeoMeta 设置 SEO 标签
useSeoMeta({
title: article.value.title,
ogTitle: article.value.title,
description: article.value.summary,
})
</script>
面试金句
前端缓存多层架构:HTTP 缓存(Cache-Control/ETag)→ Service Worker 缓存(离线可用)→ CDN 缓存(边缘节点)→ 浏览器内存缓存。缓存策略的核心是平衡新鲜度和性能:静态资源用强缓存 + hash 文件名,动态数据用协商缓存。
10. 骨架屏 — 加载体验决策
骨架屏 = 先展示页面占位结构
→
降低用户感知等待,内容到达后填充
转圈Loading = 让你干等
→
啥也不告诉你,等着吧
✅ 什么时候用骨架屏
- 内容区域加载:卡片列表、文章内容、用户信息 — 有固定布局
- 首屏加载:SPA 进入时的首屏内容 — 减少白屏时间感
- 数据驱动区域:需要等 API 返回的区域 — 骨架屏给用户预期
❌ 什么时候不用骨架屏
- 快速加载:< 200ms 就能渲染 — 骨架屏闪一下反而更差
- 不确定布局:内容形状变化大 — 骨架屏和实际内容差异大不如不做
- 全屏Loading:初始化/登录 — 不如用品牌动画
- 操作反馈:按钮提交后 — 用按钮 loading 状态,不是骨架屏
💻 CSS 骨架屏动画
/* 骨架屏基础样式 */
.skeleton {
background: linear-gradient(
90deg,
#2a2a3a 25%,
#3a3a4a 50%,
#2a2a3a 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; }
}
/* Vue 3 组件 */
<template>
<div v-if="loading" class="skeleton-card">
<div class="skeleton" style="width:60px;height:60px;border-radius:50%"></div>
<div style="flex:1">
<div class="skeleton" style="width:40%;height:16px;margin-bottom:8px"></div>
<div class="skeleton" style="width:80%;height:12px;margin-bottom:4px"></div>
<div class="skeleton" style="width:60%;height:12px"></div>
</div>
</div>
<slot v-else />
</template>
面试金句
场景:💻 CSS 骨架屏动画。这是前端性能优化的重要技术手段,理解其原理和适用场景是面试高频考点。实战建议:结合 Lighthouse 和 Chrome DevTools Performance 面板验证优化效果。
🖥️ Chrome DevTools Performance 面板解读
⚡ Performance — my-app.com (优化前)
📊 Performance
Recording...
⚙️ Settings
● 4.2s Total
0ms500ms1.0s1.5s2.0s2.5s3.0s3.5s4.0s
■ Parse HTML
■ Script
■ Layout/Paint
■ Long Task ⚠️
■ GPU Raster
问题诊断:Main 线程在 77%~95% 区间有 Long Task(红色),对应 JS 阻塞渲染 → 导致 FCP/LCP 偏高
11. 请求优化 — 网络请求场景决策
请求合并 = 拼车
→
3个人去同一个方向,拼一辆车
请求取消 = 不上车了
→
搜索框还没打完,上一个请求取消掉
✅ 什么时候做请求优化
- 竞态请求:搜索框连续输入 — 取消前一个请求(AbortController)
- 批量请求:页面初始化多个独立接口 — Promise.all 并发
- 重复请求:多个组件请求同一接口 — 合并为一次(请求去重)
- 分页数据:后台管理列表 — 只请求当前页,不一次全拉
- 大文件上传:>5MB 文件 — 分片上传 + 断点续传
💻 AbortController — 取消竞态请求
// 搜索时取消上一次请求
let abortController = null;
async function searchProducts(query) {
// 取消上一次还没完成的请求
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: abortController.signal
});
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('请求被取消'); // 正常取消,不报错
} else {
throw err; // 真正的错误要抛出
}
}
}
面试金句
场景:💻 AbortController — 取消竞态请求。这是前端性能优化的重要技术手段,理解其原理和适用场景是面试高频考点。实战建议:结合 Lighthouse 和 Chrome DevTools Performance 面板验证优化效果。
💻 请求去重 — 避免重复请求
// 多个组件同时请求同一接口,只发一次
const pendingRequests = new Map();
async function fetchOnce(url, options = {}) {
const key = url + JSON.stringify(options);
if (pendingRequests.has(key)) {
return pendingRequests.get(key); // 复用正在进行的请求
}
const promise = fetch(url, options).then(res => {
pendingRequests.delete(key); // 完成后移除
return res.json();
}).catch(err => {
pendingRequests.delete(key);
throw err;
});
pendingRequests.set(key, promise);
return promise;
}
// 3个组件同时调用,只发1次请求
const data1 = await fetchOnce('/api/user/info');
const data2 = await fetchOnce('/api/user/info'); // 复用上面的请求
面试金句
场景:💻 请求去重 — 避免重复请求。这是前端性能优化的重要技术手段,理解其原理和适用场景是面试高频考点。实战建议:结合 Lighthouse 和 Chrome DevTools Performance 面板验证优化效果。
💻 分片上传
async function uploadLargeFile(file) {
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB 一片
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('fileId', file.name + '-' + file.size);
await fetch('/api/upload/chunk', {
method: 'POST',
body: formData
});
// 更新上传进度
uploadProgress.value = Math.round(((i + 1) / totalChunks) * 100);
}
// 所有分片上传完,通知后端合并
await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileId: file.name + '-' + file.size,
totalChunks
})
});
}
面试金句
代码拆分(Code Splitting)是减小首屏 JS 体积的核心手段:路由级拆分(每个路由独立 chunk)+ 组件级拆分(大组件独立加载)+ 第三方库拆分(vendor chunk 独立缓存)。webpack-bundle-analyzer / rollup-plugin-visualizer 可视化分析打包体积。
12. 内存泄漏排查 — 生命周期管理决策
内存泄漏 = 房间只扔垃圾不倒
→
每次进房间扔一点,从不清,最后满到住不了
正确清理 = 定期打扫
→
离开房间时带走垃圾,保持空间
✅ 常见内存泄漏场景
- 未取消的定时器:setInterval/setTimeout 组件卸载后还在跑
- 未移除的事件监听:addEventListener 没有对应的 removeEventListener
- 闭包引用:闭包里引用了大对象,对象永远无法 GC
- DOM 引用:JS 变量持有已移除 DOM 节点的引用
- 全局变量:往 window 上挂数据,永远不会被回收
- 未断开的 WebSocket:组件卸载后 WebSocket 还在接收数据
📊 内存泄漏排查决策表
| 症状 | 可能原因 | 排查工具 | 修复方案 |
| 页面越来越卡 | 定时器/监听器未清理 | DevTools Memory → Heap Snapshot | onUnmounted 中清理 |
| 切换路由后内存不降 | 组件引用未释放 | Performance → Memory 实时曲线 | 检查全局 Map/Set 引用 |
| 长时间操作后崩溃 | 数组无限增长 | Heap Snapshot 对比 | 限制数组长度/分页 |
| 频繁GC卡顿 | 大量临时对象 | Performance Monitor → GC频率 | 对象池/减少创建 |
🖥️ Chrome DevTools Memory 堆快照对比
🧠 Memory — Heap Snapshot 对比
📸 快照 #1:页面刚加载
| 构造函数 | 数量 | 占用 |
| (string) | 1,240 | 2.1 MB |
| Array | 86 | 0.4 MB |
| Object | 142 | 0.3 MB |
| (closure) | 24 | 0.1 MB |
| 总计 | ~1,492 | 2.9 MB ✅ |
操作 10 次
切换路由
📸 快照 #2:操作 10 次后
| 构造函数 | 数量 | 占用 |
| (string) | 3,680 | 8.4 MB ⚠️ |
| Array | 286 | 2.1 MB ⚠️ |
| TimerHandler ⚠️ | 10 | 1.8 MB 💀 |
| EventListener ⚠️ | 10 | 0.9 MB 💀 |
| 总计 | ~3,986 | 13.2 MB 🔴 |
🔍 泄漏分析:
• TimerHandler 从 0 → 10 个:每次进入页面创建 setInterval 但组件卸载时没 clearInterval
• EventListener 从 0 → 10 个:addEventListener 没配对 removeEventListener
• (string) 从 2.1MB → 8.4MB:闭包里引用的大字符串无法被 GC 回收
• 内存增长 4.5 倍,操作越多越卡,最终浏览器标签页崩溃
💻 Vue 3 常见泄漏修复
// ❌ 泄漏:定时器没清理
onMounted(() => {
setInterval(() => {
fetchLatestData();
}, 5000);
});
// ✅ 修复:组件卸载时清理
onMounted(() => {
const timer = setInterval(() => {
fetchLatestData();
}, 5000);
onUnmounted(() => clearInterval(timer));
});
// ❌ 泄漏:事件监听没移除
window.addEventListener('resize', handleResize);
// ✅ 修复:卸载时移除
onMounted(() => {
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
// ✅ 更优雅:使用 VueUse
// npm install @vueuse/core
import { useEventListener, useIntervalFn } from '@vueuse/core'
// 自动在卸载时清理
useEventListener(window, 'resize', handleResize);
useIntervalFn(fetchLatestData, 5000); // 自动清理
面试金句
场景:💻 Vue 3 常见泄漏修复。这是前端性能优化的重要技术手段,理解其原理和适用场景是面试高频考点。实战建议:结合 Lighthouse 和 Chrome DevTools Performance 面板验证优化效果。
💻 React 泄漏修复
// ✅ useEffect 清理函数
useEffect(() => {
const timer = setInterval(() => {
fetchLatestData();
}, 5000);
const controller = new AbortController();
// 清理函数:组件卸载时执行
return () => {
clearInterval(timer);
controller.abort();
};
}, []); // 空依赖 = 只在挂载/卸载时执行
面试金句
场景:💻 React 泄漏修复。这是前端性能优化的重要技术手段,理解其原理和适用场景是面试高频考点。实战建议:结合 Lighthouse 和 Chrome DevTools Performance 面板验证优化效果。
⚠️
内存泄漏排查工具
- Chrome DevTools → Memory:拍 Heap Snapshot,对比两次快照找到增长的对象
- Performance Monitor:实时看 JS Heap Size 曲线,持续增长 = 泄漏
- Allocation Timeline:记录内存分配时间线,定位分配热点
- 思路:操作页面 → 拍快照1 → 重复操作 → 拍快照2 → 对比找增长