声明:本文部分内容使用AI辅助生成,经人工编辑、审核和补充个人经验。
更新说明:本文最后更新于 2026-05-07。
前端性能优化实战经验
前端性能优化这个话题说起来老生常谈,但真到了项目里,每个坑都得亲自踩一遍才长记性。我过去一年先后优化了一个React后台管理系统和一个Vue移动端H5项目,把常见的性能问题几乎碰了个全。下面按场景整理一下我的踩坑记录。
首屏加载:从8秒到1.5秒的折腾
React后台项目刚上线的时候,首屏加载要8秒多,老板直接截图发群里问怎么回事。我打开Chrome DevTools一看,Network面板里密密麻麻几十个请求,bundle.js有2.3MB,人都傻了。
踩坑:一股脑打包成一个巨大的bundle
我们项目用Webpack 5,一开始没做任何代码分割,所有页面、所有组件、所有第三方库全部打成一个bundle。更离谱的是,我们把lodash整个引进去了,实际上只用了debounce和throttle两个方法。
1 2 3 4 5 6 7
| import _ from 'lodash'; import moment from 'moment'; import * as echarts from 'echarts';
const debouncedSearch = _.debounce(handleSearch, 300);
|
修正:按需加载 + 代码分割 + 资源压缩
我们做了以下几件事,效果立竿见影:
| 优化手段 |
优化前 |
优化后 |
收益 |
| lodash全量引入 |
70KB |
4KB |
-66KB |
| moment替换为dayjs |
230KB |
6KB |
-224KB |
| echarts按需引入 |
800KB |
120KB |
-680KB |
| 路由懒加载 |
单bundle 2.3MB |
分chunk最大400KB |
-1.9MB |
| Gzip压缩 |
无 |
开启 |
额外-60% |
下面是优化后的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import debounce from 'lodash/debounce'; import dayjs from 'dayjs'; import * as echarts from 'echarts/core'; import { BarChart } from 'echarts/charts'; import { CanvasRenderer } from 'echarts/renderers'; import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
echarts.use([ BarChart, CanvasRenderer, GridComponent, TooltipComponent, LegendComponent ]);
const debouncedSearch = debounce(handleSearch, 300);
|
路由懒加载用React.lazy实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import( '../pages/Dashboard')); const UserList = lazy(() => import( '../pages/UserList')); const OrderManage = lazy(() => import( '../pages/OrderManage')); const DataReport = lazy(() => import( '../pages/DataReport'));
function AppRouter() { return ( <BrowserRouter> <Suspense fallback={<LoadingSpinner />}> <Routes> <Route path="/" element={<Dashboard />} /> <Route path="/users" element={<UserList />} /> <Route path="/orders" element={<OrderManage />} /> <Route path="/reports" element={<DataReport />} /> </Routes> </Suspense> </BrowserRouter> ); }
|
Webpack配置里加上splitChunks,把第三方库单独打包:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| module.exports = { optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all', priority: 10 }, common: { minChunks: 2, chunks: 'all', enforce: true, priority: 5 } } } } };
|
做完这些,首屏加载时间从8秒降到了2.5秒。但老板还是不满意,说竞品只要1秒。
进一步:SSR + 预渲染
后台系统不太适合SSR,但我们用了一种折中方案:对登录页和Dashboard做预渲染。用prerender-spa-plugin在构建时生成静态HTML,用户打开页面先看到内容,JS加载后再hydrate。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const PrerenderSPAPlugin = require('prerender-spa-plugin'); const Renderer = PrerenderSPAPlugin.PuppeteerRenderer;
module.exports = { plugins: [ new PrerenderSPAPlugin({ staticDir: path.join(__dirname, 'dist'), routes: ['/login', '/dashboard'], renderer: new Renderer({ renderAfterDocumentEvent: 'render-event' }) }) ] };
|
配合Gzip和CDN,最终首屏稳定在1.5秒左右,老板终于不催了。
长列表渲染:虚拟列表救了我的命
Vue移动端项目有个订单列表页,用户可能有几千条订单。一开始用v-for直接渲染,滑动到几百条的时候页面直接卡死,在低端机上尤为明显。
踩坑:直接渲染所有数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <!-- 优化前:灾难代码 --> <template> <div class="order-list"> <div v-for="order in orderList" :key="order.id" class="order-item" > <div class="order-header"> <span>{{ order.orderNo }}</span> <span :class="statusClass(order.status)">{{ order.statusText }}</span> </div> <div class="order-products"> <div v-for="product in order.products" :key="product.id" class="product"> <img :src="product.image" /> <div class="product-info"> <p>{{ product.name }}</p> <p>¥{{ product.price }} x {{ product.quantity }}</p> </div> </div> </div> </div> </div> </template>
|
这段代码的问题是:
- 几千个DOM节点同时存在于页面上,内存占用爆炸
- 每个订单项里还有嵌套循环,DOM节点数呈指数增长
- 滚动时浏览器要重排重绘几千个节点,帧率掉到10fps以下
修正:虚拟列表只渲染可视区域
我们引入了vue-virtual-scroller,只渲染可视区域内的列表项。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| <!-- 优化后:虚拟列表 --> <template> <RecycleScroller class="order-list" :items="orderList" :item-size="120" key-field="id" v-slot="{ item }" > <div class="order-item"> <div class="order-header"> <span>{{ item.orderNo }}</span> <span :class="statusClass(item.status)">{{ item.statusText }}</span> </div> <div class="order-products"> <div v-for="product in item.products.slice(0, 3)" :key="product.id" class="product" > <img :src="product.image" loading="lazy" /> <div class="product-info"> <p>{{ product.name }}</p> <p>¥{{ product.price }} x {{ product.quantity }}</p> </div> </div> <p v-if="item.products.length > 3" class="more"> 共 {{ item.products.length }} 件商品 </p> </div> </div> </RecycleScroller> </template>
<script setup> import { RecycleScroller } from 'vue-virtual-scroller'; import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
// 同时做了图片懒加载 const imageObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; imageObserver.unobserve(img); } }); });
onMounted(() => { document.querySelectorAll('img[data-src]').forEach(img => { imageObserver.observe(img); }); }); </script>
|
虚拟列表的原理很简单:只渲染视口内可见的项,滚动时动态替换。对于10000条数据,实际同时存在的DOM节点可能只有20个,内存和渲染压力大幅降低。
另外我们还做了两个优化:
- 图片懒加载:用IntersectionObserver,图片进入视口才加载
- 商品列表截断:一个订单超过3个商品只显示前3个,避免单个列表项过高
改完之后,列表页在千元安卓机上也能流畅滑动,内存占用从180MB降到了45MB。
内存泄漏:setInterval和事件监听没清理
React项目运行一段时间后,页面越来越卡,刷新才能恢复。用Chrome Performance面板录制了一下,发现JS Heap一直在增长,典型的内存泄漏。
踩坑:组件卸载时没清理副作用
我们排查了一圈,发现几个常见问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| function usePolling(fetchData, interval = 5000) { useEffect(() => { setInterval(() => { fetchData(); }, interval); }, [fetchData, interval]); }
function useWindowResize(callback) { useEffect(() => { window.addEventListener('resize', callback); }, [callback]); }
function useDataFetcher(url) { const [data, setData] = useState(null);
useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }) .then(res => res.json()) .then(setData); }, [url]);
return data; }
|
修正:严格清理所有副作用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| function usePolling(fetchData, interval = 5000) { useEffect(() => { const timer = setInterval(() => { fetchData(); }, interval);
return () => clearInterval(timer); }, [fetchData, interval]); }
function useWindowResize(callback) { useEffect(() => { window.addEventListener('resize', callback); return () => window.removeEventListener('resize', callback); }, [callback]); }
function useDataFetcher(url) { const [data, setData] = useState(null);
useEffect(() => { const controller = new AbortController(); let cancelled = false;
fetch(url, { signal: controller.signal }) .then(res => res.json()) .then(result => { if (!cancelled) { setData(result); } }) .catch(err => { if (err.name !== 'AbortError') { console.error('Fetch error:', err); } });
return () => { cancelled = true; controller.abort(); }; }, [url]);
return data; }
|
我们还加了一个ESLint规则强制检查useEffect的返回函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| module.exports = { rules: { 'react-hooks/exhaustive-deps': 'error', 'no-restricted-syntax': [ 'warn', { selector: 'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression:not(:has(ReturnStatement))', message: 'useEffect应该返回清理函数,如果确实不需要请添加注释说明' } ] } };
|
重渲染优化:React.memo和useMemo要用对地方
React项目里另一个性能杀手是不必要的重渲染。我们有个表格组件,一行数据变了整个表格都重渲染。
踩坑:滥用useMemo和React.memo
一开始我们到处加React.memo和useMemo,结果代码变得很难维护,而且有些加的地方根本没用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| function OrderTable({ orders, filter }) { const filteredOrders = useMemo(() => { return orders.filter(order => order.status === filter); }, [orders]);
return ( <div> {filteredOrders.map(order => ( <OrderRow key={order.id} order={order} onClick={() => handleClick(order.id)} // 每次渲染都是新函数 /> ))} </div> ); }
const OrderRow = React.memo(({ order, onClick }) => { return ( <div onClick={onClick}> {order.orderNo} - {order.amount} </div> ); });
|
修正:用useCallback稳定函数引用,用key优化列表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| function OrderTable({ orders, filter }) { const filteredOrders = useMemo(() => { return orders.filter(order => order.status === filter); }, [orders, filter]);
const handleClick = useCallback((orderId) => { console.log('Clicked:', orderId); }, []);
return ( <div> {filteredOrders.map(order => ( <OrderRow key={order.id} order={order} onClick={handleClick} // 现在每次渲染都是同一个函数引用 /> ))} </div> ); }
const OrderRow = React.memo(({ order, onClick }) => { return ( <div onClick={() => onClick(order.id)}> {order.orderNo} - {order.amount} </div> ); });
|
另外,我们用React DevTools的Profiler面板找出了几个重渲染热点组件,针对性地做了优化。有个Chart组件每次父组件更新都重绘,我们用useRef缓存了echarts实例,数据没变就不重新setOption。
总结
前端性能优化没有银弹,关键是先定位问题,再针对性解决。我的建议是:
- 先用工具量化问题:Lighthouse、Chrome DevTools Performance/Network/Memory面板,数据说话
- 按收益排序优化:先搞bundle体积和首屏加载,再搞运行时性能
- 不要过早优化:但也不要等用户投诉了再动,项目中期做一次全面体检
- 建立性能基线:每次发版跑一遍Lighthouse,分数掉了就拦住
我们现在的CI流程里加了Lighthouse CI,PR合并前自动跑性能测试,分数低于85就标红。虽然一开始团队有点抵触,但跑了一个月之后大家都习惯了,代码质量也明显好了很多。