前端性能优化实战经验

声明:本文部分内容使用AI辅助生成,经人工编辑、审核和补充个人经验。

更新说明:本文最后更新于 2026-05-07。

前端性能优化实战经验

前端性能优化这个话题说起来老生常谈,但真到了项目里,每个坑都得亲自踩一遍才长记性。我过去一年先后优化了一个React后台管理系统和一个Vue移动端H5项目,把常见的性能问题几乎碰了个全。下面按场景整理一下我的踩坑记录。

首屏加载:从8秒到1.5秒的折腾

React后台项目刚上线的时候,首屏加载要8秒多,老板直接截图发群里问怎么回事。我打开Chrome DevTools一看,Network面板里密密麻麻几十个请求,bundle.js有2.3MB,人都傻了。

踩坑:一股脑打包成一个巨大的bundle

我们项目用Webpack 5,一开始没做任何代码分割,所有页面、所有组件、所有第三方库全部打成一个bundle。更离谱的是,我们把lodash整个引进去了,实际上只用了debouncethrottle两个方法。

1
2
3
4
5
6
7
// 优化前:灾难性的导入方式
import _ from 'lodash'; // 整个lodash 70KB+
import moment from 'moment'; // moment + 语言包 200KB+
import * as echarts from 'echarts'; // 整个echarts 800KB+

// 业务代码...
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
// router/index.js
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 路由级代码分割
const Dashboard = lazy(() => import(/* webpackChunkName: "dashboard" */ '../pages/Dashboard'));
const UserList = lazy(() => import(/* webpackChunkName: "user" */ '../pages/UserList'));
const OrderManage = lazy(() => import(/* webpackChunkName: "order" */ '../pages/OrderManage'));
const DataReport = lazy(() => import(/* webpackChunkName: "report" */ '../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
// webpack.config.js
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
// webpack.config.js
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>

这段代码的问题是:

  1. 几千个DOM节点同时存在于页面上,内存占用爆炸
  2. 每个订单项里还有嵌套循环,DOM节点数呈指数增长
  3. 滚动时浏览器要重排重绘几千个节点,帧率掉到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个,内存和渲染压力大幅降低。

另外我们还做了两个优化:

  1. 图片懒加载:用IntersectionObserver,图片进入视口才加载
  2. 商品列表截断:一个订单超过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
// 问题1:setInterval没清理
function usePolling(fetchData, interval = 5000) {
useEffect(() => {
// 错误:没有返回清理函数
setInterval(() => {
fetchData();
}, interval);
}, [fetchData, interval]);
}

// 问题2:事件监听没移除
function useWindowResize(callback) {
useEffect(() => {
// 错误:没移除监听
window.addEventListener('resize', callback);
}, [callback]);
}

// 问题3:闭包引用导致组件无法回收
function useDataFetcher(url) {
const [data, setData] = useState(null);

useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData);
// 错误:没abort请求
}, [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
// 修正1:setInterval必须清理
function usePolling(fetchData, interval = 5000) {
useEffect(() => {
const timer = setInterval(() => {
fetchData();
}, interval);

// 组件卸载时清理
return () => clearInterval(timer);
}, [fetchData, interval]);
}

// 修正2:事件监听必须移除
function useWindowResize(callback) {
useEffect(() => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}, [callback]);
}

// 修正3:AbortController取消未完成的请求
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
// .eslintrc.js
module.exports = {
rules: {
'react-hooks/exhaustive-deps': 'error',
// 自定义规则:useEffect必须有清理函数或注释说明为什么不需要
'no-restricted-syntax': [
'warn',
{
selector: 'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression:not(:has(ReturnStatement))',
message: 'useEffect应该返回清理函数,如果确实不需要请添加注释说明'
}
]
}
};

重渲染优化:React.memo和useMemo要用对地方

React项目里另一个性能杀手是不必要的重渲染。我们有个表格组件,一行数据变了整个表格都重渲染。

踩坑:滥用useMemo和React.memo

一开始我们到处加React.memouseMemo,结果代码变得很难维护,而且有些加的地方根本没用。

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
// 错误示范:useMemo依赖项不对,每次都会重新计算
function OrderTable({ orders, filter }) {
const filteredOrders = useMemo(() => {
return orders.filter(order => order.status === filter);
}, [orders]); // 漏了filter依赖!

// 错误示范:React.memo包了个纯展示组件,但父组件每次都传新函数
return (
<div>
{filteredOrders.map(order => (
<OrderRow
key={order.id}
order={order}
onClick={() => handleClick(order.id)} // 每次渲染都是新函数
/>
))}
</div>
);
}

const OrderRow = React.memo(({ order, onClick }) => {
// 虽然包了memo,但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]); // 正确的依赖项

// 用useCallback稳定函数引用
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 }) => {
// 现在order和onClick都是稳定的,不会不必要的重渲染
return (
<div onClick={() => onClick(order.id)}>
{order.orderNo} - {order.amount}
</div>
);
});

另外,我们用React DevTools的Profiler面板找出了几个重渲染热点组件,针对性地做了优化。有个Chart组件每次父组件更新都重绘,我们用useRef缓存了echarts实例,数据没变就不重新setOption。

总结

前端性能优化没有银弹,关键是先定位问题,再针对性解决。我的建议是:

  1. 先用工具量化问题:Lighthouse、Chrome DevTools Performance/Network/Memory面板,数据说话
  2. 按收益排序优化:先搞bundle体积和首屏加载,再搞运行时性能
  3. 不要过早优化:但也不要等用户投诉了再动,项目中期做一次全面体检
  4. 建立性能基线:每次发版跑一遍Lighthouse,分数掉了就拦住

我们现在的CI流程里加了Lighthouse CI,PR合并前自动跑性能测试,分数低于85就标红。虽然一开始团队有点抵触,但跑了一个月之后大家都习惯了,代码质量也明显好了很多。