我们先从一个项目里真实踩过的坑说起。之前接了一个后台管理系统,业务不算复杂,就是表格多、筛选条件多、报表导出多。技术栈是 Vue 2 + Element UI 那套老组合,刚开始一切正常,等数据量一上来,问题就全冒出来了:输入框敲一个字卡一下,表格切换页码要转圈两三秒,打开 DevTools 的 Performance 面板录了一段操作,满屏都是红条。
这种问题绝大多数人第一反应是“换框架”“上服务端渲染”“加硬件”,但实际去压测一看,瓶颈根本不在框架,而是 JavaScript 本身的执行效率太差。这次我打算用一套完整的实战案例,把“性能调优”这件事掰开了讲清楚:怎么定位瓶颈、怎么优化大数据渲染、怎么减少无意义的计算、怎么卡住网络请求的喉咙,最后再聊几个日常开发里最容易忽略的坑。主要内容都是我在真实项目中验证过、能直接抄作业的方案,适合那些已经被“页面卡顿”“请求慢”“内存一直涨”折磨过,但还没系统梳理过排查思路的前端开发者。
1. 性能问题到底出在哪:先量化,再动手
很多人在性能优化上有个误区,一上来就各种骚操作:代码分割、路由懒加载、图片压缩、CDN 加速……全都做了,结果打开页面还是卡。为什么?因为根本没搞清楚瓶颈在哪,全部是盲打。
1.1 用 Performance 面板给页面“拍个片”
Chrome DevTools 的 Performance 面板(也就是以前的 Timeline)是我排查性能问题的第一站。它能把页面加载到交互的整个过程录制下来,然后以时间轴的方式展示:哪些阶段耗时长、哪些函数占了大量执行时间、有没有长时间占用主线程导致页面“假死”。
操作方式不复杂:打开页面,按 F12 进入 DevTools,切到 Performance 面板,点击录制按钮,然后在页面上操作你想要优化的那个流程(比如切换表格页码、输入关键词筛选),操作完点击停止。面板底部会自动生成一份详细报告,重点看两块:
- CPU 占用率:如果长时间处在 90% 以上,说明计算逻辑一定有问题,需要往下钻具体是哪个函数。
- 长任务(Long Task):浏览器主线程执行超过 50ms 的连续任务。页面交互卡顿的元凶基本都这些长任务里。
之前那个项目,我一录制就发现问题了:一个看似普通的表格刷新操作,主线程上挂了一个接近 1.2 秒的长任务,火焰图一展开,好家伙,密密麻麻全是 render 相关的函数调用,再往下看有一个叫 formatDateRange 的函数被反复调了十几万次。
1.2 性能数据的解读:火焰图告诉我们什么
火焰图是 Performance 面板里最有价值的部分,但很多新手看不懂。我简单说一下阅读方法:横轴是执行时间,纵轴是调用栈的层级。某个色块越宽,说明这个函数累计执行时间越长。如果你发现某个函数色块很宽,并且它的下级调用栈里没有任何“网络请求”“DOM 操作”之类的等待事件,那基本可以断定:问题就是 JavaScript 本身算得太慢。
在上面提到的 formatDateRange 案例里,这个函数本身很简单,就是 new Date().getFullYear() 之类取一些时间字段拼接成字符串。之所以被执行了十几万次,是因为它在一个 computed 属性里被调用,而那个 computed 属性依赖了一个会频繁变化的响应式数据。数据一变,依赖它的所有地方全部重新计算。这就是典型的“看不见的暴力计算”——不是代码写错了,是代码的调用规模失控了。
所以,拿到优化任务的第一件事不是写代码,而是量化。记下优化前的指标:白屏时间、可交互时间、某个操作的耗时、内存占用趋势。优化后同口径再测一次。没有这两组数据,你后面的所有优化都是“拍脑袋”,既无法证明效果,也无法说服团队。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据渲染瓶颈:大数据量表格的极致优化之路
表格是后台系统的重灾区。几千行数据一次性渲染进 DOM,哪怕每一步操作都很克制,最终导致的布局计算、样式重绘、事件绑定数量也会压垮主线程。业界有几套比较成熟的优化思路,我全部在项目里试过,这里说说哪种方案在什么场景下最管用。
2.1 最简单的取舍:分页还是全量渲染?
如果你的业务场景允许分页,最好选择分页,这是成本最低的优化。但很多时候产品经理拿着高保真图过来说:“我们要像 Excel 一样能滚动,不要有分页。”这就是虚拟列表的活。
虚拟列表的核心思想特别朴素:视口(viewport)高度固定,里面只能看见十几个 DOM 节点,那我管你有没有十万条数据,DOM 里只挂需要显示的那些行,再加上上下两个“占位区块”,让滚动条的高度看起来跟全部数据匹配。
网上有成熟的第三方库,比如 vue-virtual-scroller、react-window,但在实际业务里,我很少直接用它们,因为需求总是会跑偏:要跨行合并单元格、行头固定、每个单元格内容高度不固定……这些复杂场景下库的定制成本很高。所以很多时候还得是自己手写一个简洁版虚拟列表。
2.2 手写一个固定行高虚拟列表的完整思路
假设你是 Vue 项目,表格的每一行高度固定为 40px。容器高度为 400px,那么可视区域内最多同时渲染 10 行。
计算逻辑如下:
javascript复制const rowHeight = 40
const containerHeight = 400
const visibleCount = Math.ceil(containerHeight / rowHeight) // 10
// 滚动时拿到当前的 scrollTop,计算起始索引
const startIdx = Math.floor(scrollTop / rowHeight)
const endIdx = startIdx + visibleCount + 1 // 多渲染一行,防止滚动时出现白屏
然后模板里渲染 visibleData = data.slice(startIdx, endIdx),同时用一个 padding 容器撑起总高度:
html复制<div class="viewport" @scroll="handleScroll" style="height: 400px; overflow-y: auto;">
<!-- 这个 div 的高度等于所有数据项撑起来的总高度 -->
<div class="phantom" :style="{ height: totalHeight + 'px' }">
<!-- 真正渲染的可视项,用 transform 定位到对应的滚动位置 -->
<div class="content" :style="{ transform: `translateY(${startIdx * rowHeight}px)` }">
<div v-for="item in visibleData" :key="item.id" class="row" :style="{ height: rowHeight + 'px' }">
<!-- 单元格内容 -->
</div>
</div>
</div>
</div>
这里有个非常容易踩的坑:如果直接用 v-for 渲染 visibleData,并且 key 用的是数组索引(index),当你快速滚动时,Vue 的 Diff 算法会把同一位置的 DOM 节点复用给完全不同的数据项,导致内容闪烁。正确的做法是 :key 绑定每行数据唯一的 id,或者退而求其次绑定 item.id + '-' + index 这种组合键,确保 DOM 能正确跟踪数据变化。
实际项目里,我还会在这个基础组件上加一个“滚动节流”,默认使用 requestAnimationFrame 来合并滚动事件触发次数:
javascript复制handleScroll(e) {
const target = e.target
if (this.ticking) return
this.ticking = true
requestAnimationFrame(() => {
this.scrollTop = target.scrollTop
this.ticking = false
})
}
这样做的原因是:滚动事件本身触发频率极高(一秒钟可能几十上百次),每次触发都去 slice 大数组和触发 Vue 重新渲染,代价不容小觑。合并成每帧最多一次后,滚动体验完全不受影响,渲染压力却少了一大半。
2.3 更暴力的数据转换:把计算挪出渲染周期
表格渲染还有一个隐藏性能杀手:格式化。后端返回的数据往往是原始值,比如时间戳、状态码、枚举值,而前端展示需要转换为“2024-12-01 14:30”“已通过”“禁用”这些用户能看懂的内容。
很多人的写法是直接在模板里调用方法:
html复制<td>{{ formatStatus(row.status) }}</td>
这个写法的问题在于:只要这个组件的任一响应式数据变化,formatStatus 就会被重新执行。表格如果是 10 列都有类似的格式化函数,一屏 100 行数据,一次渲染就是 1000 次函数调用。看起来微不足道,但如果这些函数内部又调用了 Date 解析、正则匹配、甚至 Intl.DateTimeFormat(这玩意儿性能极差)等一系列重操作,累计起来就是几百毫秒的额外开销。
更合理的做法是:在数据进入表格之前,先把所有格式化做完。也就是在接口返回后,对数据进行一次预处理:
javascript复制const formatList = (rawList) => rawList.map(item => ({
...item,
statusText: statusMap[item.status],
createTimeText: moment(item.createTime).format('YYYY-MM-DD HH:mm:ss')
}))
这样模板里只需要直接展示 item.statusText 和 item.createTimeText,渲染过程中没有任何额外计算。而且这个预处理只在数据更新时执行一次,不会因为列排序、行选中之类无关的操作触发重复计算。
我在那个后台项目里做了这个改动后,还顺手做了一个更极致的优化:预处理后的列表用 Object.freeze 冻结,彻底冻结掉 Vue 对它的响应式检测。为什么可以这样?因为这些数据渲染后不会被业务改写,它的职责就是纯展示。一旦冻结,Vue 初始化 Observer 时会直接跳过这个对象,省去为每一层属性定义 getter/setter 的开销。表格几千行数据,每行几十个字段,冻结前后的初始化时间差距非常明显。
3. 无脑优化是灾难:响应式系统里那些隐藏的坑
用 Vue 或者 React 做前端开发,框架本身已经做了大量的运行时优化,但如果你不注意一些约定俗成的规范,框架的优化机制就会被绕过,甚至是帮倒忙。
3.1 为什么 computed 也会拖垮性能:依赖收集的代价
computed 属性在 Vue 里的定位是“基于响应式依赖进行缓存的计算属性”。只要依赖的数据没变,多次访问 computed 都会直接返回缓存值,不会重新执行函数体。
但这个机制有一个前提:你写的依赖范围得足够小。前面提到的 formatDateRange 被调了十几万次的场景,本质就是依赖范围被撑得太大了。
举个例子,你有一个大型表单组件,里面有 formData 这个对象,它有几十个字段。然后你写了一个 computed:
javascript复制const fullAddress = computed(() => {
return `${formData.province} ${formData.city} ${formData.district} ${formData.street}`
})
看起来只是依赖了四个字段。但因为 Vue 2 的响应式系统是基于 Object.defineProperty 的,在初始化时就已经为 formData 的所有属性建立了依赖关系。当你修改 formData.name 时,依赖收集器会通知 fullAddress 的订阅者吗?不会,因为 fullAddress 的 getter 里只访问了 province、city、district、street 四个属性,Vue 能精确到字段级别追踪。
问题出在 Vue 3 或者 React 里更隐蔽的地方。Vue 3 改用 Proxy 之后,依赖追踪仍然存在,但它变成了懒收集——只有在某个 effect 真正访问了这个属性才会建立依赖。看起来更精准了,但在响应式数据量大且嵌套深的场景下,Proxy 的 getter/setter 拦截本身就有性能开销。我见过一个 Vue 3 项目,只是打开一个包含大表格的页面,什么都不操作,CPU 占用率就有 30%,原因就是模板里渲染了几千个响应式对象,每个对象在被访问时都要经过代理层的拦截。
这个时候可以考虑把纯展示的大列表数据用 shallowRef 包一下。shallowRef 只追踪 .value 的变化,不会深层次地给对象内部属性建立响应式联系。渲染时当你整体替换 list.value = newList,组件能感知到变化并重新渲染,但数据项内部的变化不会再触发 UI 更新。这个语义恰好匹配大多数列表场景:我只关心列表数据整体有没有被替换,不关心列表里某个对象自身属性的修改。
3.2 v-if 和 v-show 不是用来做性能优化的
另一个被滥用的优化手段是 v-if / v-show。很多人觉得 v-if 能减少 DOM 节点数量、减少初始渲染量,所以在任何可能的地方都加 v-if;又有人听说 v-if 频繁切换会有重建销毁成本,所以一律用 v-show。
实际上它们各自的适用场景完全不同。v-if 是“条件性渲染”,当值为 false 时,组件根本不创建,对应的 DOM 也不会出现在页面上。适合那种“大部分时间不需要展示”的内容,比如弹窗、下拉菜单、权限控制的按钮。v-show 是“条件性显隐”,组件始终会挂载,只是用 display: none 隐藏。适合“切换非常频繁”的场景,比如 tab 切换、折叠面板。
如果你在一个初始就要展示、只是偶尔切换显隐的模块上用了 v-if,反而可能导致切换时重新创建内部的复杂子组件,白白多出几毫秒到几十毫秒的创建开销。反过来,如果某个非常庞大且初始化成本很高的列表用 v-show 常驻,虽然切换快了,但初始加载页面时就已经把列表的数据请求、渲染一整套全做完了。所以,v-if 和 v-show 的选择标准是看“切换频率”和“初始化成本”哪个更让你心痛。
3.3 事件监听的正确打开姿势
很多页面卡顿是事件监听器堆积造成的,但这玩意儿不太容易从表面看出来。我有一个惨痛经历:一个组件在 mounted 里给 window 对象添加了 scroll 监听,用于计算某个元素是否进入视口。后来组件被 v-if 销毁了,但监听器没有在 beforeDestroy(或 onUnmounted)里移除,导致滚动事件每触发一次,销毁的组件对应的处理函数就执行一次。
排查方法也很取巧:切到 DevTools 的 Performance 面板,随便滚动一下页面,录制几秒钟,看火焰图里有没有不应该存在的函数调用;或者在控制台执行 getEventListeners(window) 查看当前 window 上挂了多少监听器。如果发现一个已经被销毁的组件的事件函数还在里面,那就可以实锤了。
这类问题的修复不难,难的是建立规范。我的建议是:任何手动添加的全局事件监听,必须成对出现在同一个生命周期里:
javascript复制mounted() {
window.addEventListener('scroll', this.handleScroll)
},
beforeDestroy() {
window.removeEventListener('scroll', this.handleScroll)
}
用 EventBus 或者第三方库的时候也同理。这看起来像是基本功,但在实际项目里很少有人能严格执行。代码评审时应把“有没有移除事件监听”作为一个必查项。
4. 请求阶段的性能死角:fetch API 的使用方式和并发限制
性能优化不只是让页面“动起来”更快,还要让数据“跑过来”更快。这个部分聊聊网络请求层面的优化,顺便填一个网上讨论得很多但普及率不高的坑:fetch 的用法。
4.1 从 fetch 的各种语法到超时控制
网络热词里有“javascript 的 fetch api 各种语法”,这个确实是很多人的痛点。fetch API 的原生能力其实很有限,它本身不支持超时中断,也不支持取消请求(除非你用 AbortController)。但是这些能力在真实业务里几乎是刚需。
javascript复制async function requestWithTimeout(url, options = {}, timeout = 10000) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, { ...options, signal: controller.signal })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return await response.json()
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('请求超时,已中断')
}
throw error
} finally {
clearTimeout(timer)
}
}
这里有一个细节值得注意:AbortController 的 abort() 会立即中断请求,无论你设置多长的超时时间。如果用户在页面操作中触发了多个相同的请求(比如快速切换筛选条件),上个请求还没返回,下个就发出去了,最终拿到结果的顺序不确定,很可能导致表格数据被旧请求覆盖。这种问题用 AbortController 解决非常合适——每次发新请求之前,先 abort 掉上一次请求。
javascript复制let currentController = null
async function search(keyword) {
if (currentController) currentController.abort()
currentController = new AbortController()
// 请求时把 controller.signal 传进去
}
4.2 并发限制:避免请求风暴打爆服务端
再说一个常见到几乎每个人都碰过的坑:表格里每条数据有一个“获取详情”的按钮,用户眼睛扫过去可能同时点击了十几个按钮,瞬间发出十几个并行请求。服务端如果没做限流,处理时间迅速飙升,前端反而更卡了。
自己做一个简单的并发池会好很多。核心原理是维护一个任务队列,同时只有 N 个请求在执行,任何一个完成,就从队列里取出下一个任务继续执行:
javascript复制async function runWithConcurrency(tasks, limit = 3) {
const results = []
const executing = new Set()
for (const task of tasks) {
const promise = Promise.resolve().then(() => task()).then(result => {
results.push(result)
})
executing.add(promise)
const clean = () => executing.delete(promise)
promise.then(clean, clean)
if (executing.size >= limit) {
await Promise.race(executing)
}
}
await Promise.all(executing)
return results
}
别小看这个简单的封装。之前有一个数据导入功能,前端要把 Excel 解析出来的 500 行数据逐行 POST 到后端做校验,原来一次性发 500 个请求,服务端直接拒绝服务了。改成并发池之后,同时最多发 5 个,剩下的排队走,服务端稳如老狗,前端还能在界面上展示一个真实的上传进度。
4.3 接口返回的大数据的“减重”手段
除了控制请求的并发,另一个方向是把数据本身变小。很多后端接口喜欢把时间格式化为 ISO 字符串返回,比如 "2024-12-01T14:30:00.000Z",19 个字符。如果换成纯数字时间戳,是 13 个字符,能省 30% 的体积。如果再把字段名压缩成单字母,比如 { "id": 1, "n": "张三" },体积还能再省一半以上。这在大型数据集合下收益非常可观。
不过这类改造需要前后端一起配合,很多时候你推动不了后端改接口。退而求其次的方案是:在代码层面做好数据格式的“后处理”,把接口返回的字符串、日期在进入组件前就统一切换为渲染友好的轻量格式,避免在模板里重复做转换计算。这跟前面说的表格数据预处理的思路一脉相承。
5. 实战总结:一套可复制的性能调优工作流
经过前面的这么多分析,很多读者应该已经对“性能优化到底该怎么做”有了一些直观感受。最后我把我自己的完整工作流做一个总结,方便大家直接拿去套用。
5.1 性能问题排查的五个步骤
- 第一步:建立基准数据。用 Performance 面板记录“用户典型操作”的耗时、CPU 占用、DOM 节点数。没有基准,谈不上优化。
- 第二步:定位长任务。火焰图里找宽色块,顺藤摸瓜找到最耗时的函数。
- 第三步:区分问题类型。是计算量大?渲染量大?请求慢?还是内存泄漏?不同类型的手段完全不一样。
- 第四步:应用针对性的优化方案。计算量大就做缓存、削减依赖;渲染量大就上虚拟列表或减少响应式开销;请求慢就做并发控制、缓存、压缩;内存泄漏就排查监听器和全局引用。
- 第五步:复测并对比数据。优化前后同口径对比,量化收益。
5.2 常见性能陷阱速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 输入框输入卡顿 | 大量计算在 input 事件里同步执行 | 防抖 + 把计算移到 computed 或异步 |
| 切换表格分页慢 | 全量数据一次性渲染 | 虚拟列表 / 服务端分页 |
| 页面内存只升不降 | 事件监听器未移除 / 定时器未清理 | 生命周期钩子里成对清理 |
| Vue 组件销毁后仍在更新数据 | 异步请求回调未处理 | 组件卸载标志位 / abort |
| 首屏请求过多 | 前端到处 onMounted 发请求 | 合并接口 / 并发池控制 |
| 构造函数里做重计算 | 每次渲染都执行大开销函数 | 纯函数结果缓存起来 |
| 第三方 UI 库全量引入 | 打包体积大导致解析慢 | babel-plugin-import 按需引入 |
| 图片体积过大 | 首屏加载大量高清图 | 换 WebP / 懒加载 |
5.3 别陷入“框架崇拜”:先查自己写的代码
最后说点掏心窝子的话。我在排查性能问题时,遇到很大比例的原因是开发者自身代码不规范,而不是框架或者浏览器的问题。框架确实有自己的性能陷阱,但“把锅甩给框架”很容易让自己错失真正的问题。
比如之前有个 Vue 项目,el-message 总是提示“未定义”,很多人第一反应是组件注册有问题,折腾半天,最后发现是 ElMessage 这个 API 被用在了组件实例之外,而且 import 的路径不对。这样的报错本身跟性能无关,但排查它消耗的时间如果放到性能优化上,结果早就出来了。所以做性能调优之前,先把这些基础错误排查干净,否则天天处理 JS 运行时报错,哪来的精力去优化流畅度?
我个人在实际项目里的体会是:性能优化这项工作,最迷人的地方不是把数字从 60 分提到 90 分的那一刻,而是通过量化分析、定位瓶颈、修复问题,你慢慢建立起对代码执行效率的敏锐度。到后面写出来的代码几乎不会再出现“创建一万个临时对象”“循环里截取大数组”“没完没了地深拷贝”这类的低级问题了。这种直觉,比任何具体的优化技巧都值钱。
如果你手头正好也有一个性能瓶颈项目,建议从今天开始,先做一次性能基准测试,记录下当前的性能数据,再挑其中一项做优化,然后对比前后差异。一次改动一项指标,慢慢你会发现自己的代码开始跟以前不太一样了。
