看 dist/assets 下是否多出了按 Tab 命名的独立 chunk

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客
发布于 2026-09-26 03:05 ·2 浏览 ·2 回复

学完这篇你能拿到一套可落地的 Vue 3 渲染性能优化路径:先用工具定位卡顿来源,再按「列表 diff → 响应式开销 → 组件切换成本」三层依次下手。

第一步:先量再改,别凭感觉优化

打开 Vue DevTools 的 Performance 面板,或者装一个小工具记录渲染次数:

// main.js 里临时加,只在开发环境生效
app.config.performance = true

// 组件内定位是谁在重复渲染
onRenderTriggered((e) => {
  console.log('重渲染原因:', e.key, e.type)
})

跑一遍卡顿的操作(滚动长列表、切换 Tab、输入搜索词),看哪个组件的 `onRenderTriggered` 疯狂刷屏。优化目标就是它。

注意:`app.config.performance = true` 会强制走慢速性能埋点路径,只用于排查,别带到生产环境。

第二步:v-memo 跳过没必要的子树 diff

典型场景:一个几百行的列表,每行结构复杂,但只有「选中状态」在变。Vue 默认会 diff 每一行的整棵子树,白白烧 CPU。

<template>
  <div
    v-for="item in list"
    :key="item.id"
    v-memo="[item.id, item.selected]"
  >
    <Row :item="item" />
  </div>
</template>

依赖数组里所有值都没变时,这个元素及其子树会被整块跳过。实测在 500 行、每行 20 个节点的列表里,滚动帧率通常能从 30fps 左右拉回 55fps 以上。

注意:`v-memo` 的依赖数组必须写全。只要模板里用到了数组外的变量(比如 Pinia store 里的 `keyword`),就必须把它写进依赖数组,否则视图会「不更新」,这种 bug 最难查。另外 `v-memo` 必须和 `v-for` 写在同一个元素上,并且保留 `:key`。

第三步:shallowRef 砍掉深层响应式开销

`ref()` 会递归把对象转成响应式,几千条数据的大数组,光 Proxy 包装就要几十毫秒,后续每次访问还有代理层开销。数据只做整体替换(比如接口返回后一次性赋值)时,用浅层 API 就够:

import { shallowRef, triggerRef, markRaw } from 'vue'

// 大列表:只在整体赋值时触发更新
const bigList = shallowRef([])
bigList.value = await fetchList()   // 触发更新

// 确实要改内部某个元素时,手动通知
bigList.value[0].selected = true
triggerRef(bigList)

// 第三方实例(ECharts、地图、编辑器)永远不要让它进响应式系统
const chart = shallowRef(null)
chart.value = markRaw(echarts.init(el))

同类还有 `shallowReactive()`,适合「只在顶层增删字段」的配置对象。

注意:`shallowRef` 里的嵌套属性修改不会触发更新,这是设计如此,不是 bug。表单、多层嵌套状态千万别用它,否则你会陷入到处补 `triggerRef` 的泥潭。

第四步:动态组件 + KeepAlive 管住切换成本

后台系统常见的 Tab 切换,如果每个 Tab 都是重组件(带图表、长列表),反复创建销毁的代价极高。用动态组件配合缓存:

<script setup>
import { shallowRef, defineAsyncComponent } from 'vue'
import TabBasic from './TabBasic.vue'

const currentView = shallowRef(TabBasic)

// 首屏不用的重组件,改成异步加载
const TabReport = defineAsyncComponent(() => import('./TabReport.vue'))

const tabs = { basic: TabBasic, report: TabReport }
function switchTo(key) {
  currentView.value = tabs[key]
}
</script>

<template>
  <KeepAlive :max="3">
    <component :is="currentView" />
  </KeepAlive>
</template>

`defineAsyncComponent` 把非首屏组件拆成独立 chunk,首屏体积立刻下降;`KeepAlive` 保住组件实例和滚动位置;`max="3"` 限制缓存上限,防止内存无限涨。

注意:`KeepAlive` 的 `include` / `exclude` 匹配的是组件 `name`。用 `<script setup>` 时没有隐式 name,得显式声明 `defineOptions({ name: 'TabReport' })`,否则 include 永远命中不了。另外被缓存的组件不会触发 `unmounted`,定时器、WebSocket 要在 `onDeactivated` 里暂停、`onActivated` 里恢复。

第五步:改完再量一遍

回到 Performance 面板,对比优化前后的帧率和渲染次数。同时看一眼打包产物体积,确认异步拆分真的生效:

npm run build

如果帧率没变,说明瓶颈不在渲染层,可能在接口请求或大数组排序上,别再折腾模板了。

小结

  • 先定位再优化,用 `onRenderTriggered` 找出真正的重渲染组件。
  • `v-memo` 用在大列表行上,依赖数组必须写全,漏一个就会出现「视图不更新」。
  • `shallowRef` + `triggerRef` 对付「整体替换」的大数据;第三方实例一律 `markRaw`。
  • 动态组件配 `defineAsyncComponent` 拆包、配 `KeepAlive` 缓存,`max` 限制内存。
  • `<script setup>` 下 `KeepAlive` 的 include 需要 `defineOptions` 显式声明 name。
  • 每改一步都回测一次,没变化就说明改错了地方。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-595.html
转载请注明出处,版权归原作者所有。

全部回复 2

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员 1楼 2026-09-26 03:11

这三步顺序是对的,但 Tab 切换卡顿这笔账,通常得先从你标题说的那层算起——先去 `dist/assets` 看有没有按 Tab 拆出来的独立 chunk,没有的话,后面运行时优化再精细也只是治标。

怎么判断:`vite build` 之后如果 `dist/assets` 里只有一个 `index-xxx.js` 大包,说明所有 Tab 组件都被打进主包了,切 Tab 只是重新渲染,不是重新加载。改成 `defineAsyncComponent(() => import('./TabA.vue'))`,或者直接走路由懒加载,才会生成对应名字的 chunk。拆完首切会有延迟,配 `delay: 200` + `loadingComponent`(或骨架屏)避免闪白,想再顺滑可以在 `mouseenter` 时预取。

拆完再往下看切换成本,这算你三步之外的第四层:频繁来回切的 Tab 用 `<KeepAlive :max="5">` 缓存,否则每次切回都重新 mount。注意缓存组件的刷新逻辑要写在 `activated` 里,别放 `onMounted`,不然切回来数据永远是旧的。`max` 别省,Tab 一多不设上限就是内存泄漏。

`shallowRef` 那段提醒很到位。补一个写起来舒服点的写法:用 `reactive({ list: shallowRef([]) })` 包一层,模板里就能少写 `.value`。

最后那个坑我深有同感——`v-memo` 依赖写不全导致的「视图不更新」,靠 code review 人眼盯基本拦不住。建议给这类列表页补个最简 e2e:点选中行,断言 DOM 上的 class 变了,比看代码靠谱得多。

dp32323
dp32323 正式会员正式会员 #161 2楼 2026-09-26 03:17
不能说的秘密:这三步顺序是对的,但 Tab 切换卡顿这笔账,通常得先从你标题说的那层算起——先去 `dist/assets` 看有没有按 Tab 拆出来的独立 chunk,没…

你这刀切得比我原文准——Tab 切换那笔账,确实是"能不能拆包"的问题,不过"看 dist/assets 里有没有按 Tab 命名的 chunk"这个判据得加个前提:`defineAsyncComponent` 只保证组件是异步的,不保证一定单独分包。如果 TabA.vue 在别处被静态 import(比如同时挂进了路由表、或被父组件同步引了),Rollup 会把它提到公共 chunk 甚至回流主包。要确定拆开,最稳的是 `build.rollupOptions.output.manualChunks` 手动指定,或者直接看 `vite build` 终端输出的 chunk 表,比翻目录直观。另外 dev server 不产 chunk,必须 build 后判断。

KeepAlive 那段同意,补两个坑:`<script setup>` 组件没显式 name,`include`/`exclude` 会匹配不上,得加 `defineOptions({ name: 'TabA' })`;还有如果 Tab 只是显隐、内容本身不重,`v-show` 比重挂载省得多,只有内容真的贵才值得上 KeepAlive。`activated` 那条是核心,写成 `onMounted` 的人最后都会以为是缓存失效。

`reactive({ list: shallowRef([]) })` 确实舒服,不过它省的是 JS 逻辑层——模板里顶层的 ref 本来就自动解包,所以纯模板场景不用包。注意这个解包只在 reactive 第一层生效,嵌套层不会解,别套两层把自己绕进去。

v-memo 的 e2e 我赞同,再给个更轻的兜底:把选中态彻底移出依赖数组,行模板里不直接读 `item.selected`,选中 class 单独渲染,依赖数组退化到 `[item.id]`,能少一半写错的机会。

分包也别一刀切,Tab 一多,首屏会为几个冷门 Tab 白等;