背景
前端性能这件事,经常有两个极端:
- 一种是完全不管,页面卡了再说
- 另一种是上来就讲虚拟列表、SSR、代码分割,结果项目里真正拖慢页面的点根本不在那
Vue3 本身已经做了不少优化,但框架快,不代表业务代码就一定快。
真正影响体验的,通常还是这些问题:
- 不必要的响应式开销
- 大列表重复渲染
- 首屏加载资源过大
- watch 写得太随意,副作用失控
这篇文章只聊实战里最常见、最值回票价的优化点。
先判断瓶颈在哪
优化前先确认问题类型。通常分三类:
- 首屏慢 JS 包太大、资源太多、接口太慢。
- 交互卡 某个状态变更引起大面积重渲染。
- 长列表卡 DOM 数量过多,滚动和 patch 开销都很高。
这三类问题的解决手段完全不同。不要把“页面卡”都归因到 Vue 响应式。
不要把所有东西都塞进 reactive
很多项目里常见这种写法:
const state = reactive({
tableData: [],
chartInstance: null,
editor: null,
wsConnection: null,
filters: {
keyword: '',
status: 'all',
},
})
看起来统一,实际上问题不少。
像图表实例、编辑器对象、WebSocket 连接这种第三方对象,本来就不是拿来做细粒度响应式追踪的。把它们塞进深层响应式对象里,只会增加代理成本,还可能带来奇怪副作用。
更合理的拆法是:
import { reactive, shallowRef, markRaw } from 'vue'
const filters = reactive({
keyword: '',
status: 'all',
})
const tableData = shallowRef<User[]>([])
const chartInstance = shallowRef<any>(null)
const editor = shallowRef<any>(null)
function initChart(el: HTMLDivElement) {
chartInstance.value = markRaw(createChart(el))
}
这里的思路很明确:
- 业务表单状态,用
reactive - 大数组、外部实例,用
shallowRef - 不希望被代理的对象,用
markRaw
这不是“写法偏好”,而是直接影响更新成本。
大列表别硬渲染
很多后台系统最容易卡的页面,就是表格页。
<tr v-for="item in list" :key="item.id">
<td>{{ item.name }}</td>
<td>{{ item.status }}</td>
<td>{{ item.createdAt }}</td>
</tr>
如果 list 一次性塞 5000 条,哪怕模板写得很普通,浏览器也会被 DOM 数量拖慢。
这时候先做两件事:
- 分页或增量加载
- 真有必要时上虚拟列表
一个简单的分页计算通常比“直接全量渲染”靠谱得多:
import { computed, ref } from 'vue'
const page = ref(1)
const pageSize = 50
const source = shallowRef<User[]>([])
const currentPageData = computed(() => {
const start = (page.value - 1) * pageSize
return source.value.slice(start, start + pageSize)
})
别一上来就把虚拟列表当标准答案。很多业务页面根本不需要滚动 2 万条数据,后端分页就能解决 80% 的问题。
watch 要克制
watch 很好用,也很容易被滥用。
watch(
() => state.filters,
() => {
fetchTableData()
},
{ deep: true }
)
这种写法在小 demo 里没问题,到了真实页面就会带来几个麻烦:
- 任意字段改动都会重新请求
- deep watch 开销高
- 难以控制请求时机
我更倾向于只监听真正需要的输入:
watch(
[() => filters.keyword, () => filters.status],
async () => {
await fetchTableData()
}
)
如果输入是搜索框,再补一层 debounce,通常就够用了。
组件边界要清楚
另一个常见性能问题是:父组件状态一变化,整个页面大片区域跟着重渲染。
例如一个页面里同时包含:
- 查询表单
- 统计卡片
- 图表区域
- 表格区域
如果所有状态都堆在一个组件里,任何一点小改动都可能让整棵子树重新 patch。
更稳的做法是按“变更频率”和“关注点”拆组件,把高频变化区域局部化。
<template>
<SearchPanel @search="handleSearch" />
<StatsCards :summary="summary" />
<TrendChart :data="chartData" />
<UserTable :rows="tableRows" />
</template>
这不只是代码结构更清晰,也是在缩小渲染影响面。
首屏优化别只盯着框架
Vue 项目的首屏慢,很多时候不是 Vue 慢,是资源策略有问题。
几个很实用的点:
- 路由级懒加载
const UserPage = () => import('@/pages/UserPage.vue')
const OrderPage = () => import('@/pages/OrderPage.vue')
- 重型组件按需加载
import { defineAsyncComponent } from 'vue'
const MonacoEditor = defineAsyncComponent(() => import('./MonacoEditor.vue'))
- 图表库不要全量引入
像 ECharts 这种库很容易把包体积拉上去,按需引入通常立竿见影。
- 接口不要串行阻塞
如果首屏必须加载多个独立接口,能并发就并发。
const [summary, table, chart] = await Promise.all([
fetchSummary(),
fetchTable(),
fetchChart(),
])
一个后台列表页的简化写法
<script setup lang="ts">
import { reactive, ref, shallowRef, watch } from 'vue'
interface User {
id: number
name: string
status: string
}
const loading = ref(false)
const filters = reactive({
keyword: '',
status: 'all',
})
const rows = shallowRef<User[]>([])
async function fetchTableData() {
loading.value = true
try {
rows.value = await api.fetchUsers({
keyword: filters.keyword,
status: filters.status,
})
} finally {
loading.value = false
}
}
watch(
[() => filters.keyword, () => filters.status],
() => {
void fetchTableData()
},
{ immediate: true }
)
</script>
这类写法没有太多“炫技”,但对大部分业务系统来说已经足够稳。
总结
Vue3 的性能优化,重点不在背多少术语,而在于能不能判断真正的瓶颈在哪里。
我的经验是:
- 先控制数据量和渲染范围
- 再减少不必要的响应式追踪
- 最后再看更复杂的优化手段
别把每个页面都当成性能极限挑战,先把最影响体验的那几个点处理掉,收益往往最大。
前端性能优化最怕“用很复杂的方法解决一个本来就不该存在的问题”。