背景

前端性能这件事,经常有两个极端:

  • 一种是完全不管,页面卡了再说
  • 另一种是上来就讲虚拟列表、SSR、代码分割,结果项目里真正拖慢页面的点根本不在那

Vue3 本身已经做了不少优化,但框架快,不代表业务代码就一定快。

真正影响体验的,通常还是这些问题:

  • 不必要的响应式开销
  • 大列表重复渲染
  • 首屏加载资源过大
  • watch 写得太随意,副作用失控

这篇文章只聊实战里最常见、最值回票价的优化点。

先判断瓶颈在哪

优化前先确认问题类型。通常分三类:

  1. 首屏慢 JS 包太大、资源太多、接口太慢。
  2. 交互卡 某个状态变更引起大面积重渲染。
  3. 长列表卡 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 数量拖慢。

这时候先做两件事:

  1. 分页或增量加载
  2. 真有必要时上虚拟列表

一个简单的分页计算通常比“直接全量渲染”靠谱得多:

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 慢,是资源策略有问题。

几个很实用的点:

  1. 路由级懒加载
const UserPage = () => import('@/pages/UserPage.vue')
const OrderPage = () => import('@/pages/OrderPage.vue')
  1. 重型组件按需加载
import { defineAsyncComponent } from 'vue'

const MonacoEditor = defineAsyncComponent(() => import('./MonacoEditor.vue'))
  1. 图表库不要全量引入

像 ECharts 这种库很容易把包体积拉上去,按需引入通常立竿见影。

  1. 接口不要串行阻塞

如果首屏必须加载多个独立接口,能并发就并发。

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 的性能优化,重点不在背多少术语,而在于能不能判断真正的瓶颈在哪里。

我的经验是:

  • 先控制数据量和渲染范围
  • 再减少不必要的响应式追踪
  • 最后再看更复杂的优化手段

别把每个页面都当成性能极限挑战,先把最影响体验的那几个点处理掉,收益往往最大。


前端性能优化最怕“用很复杂的方法解决一个本来就不该存在的问题”。