Vue 基础体系 · 第 55/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue Bundle 分析:依赖图、Tree Shaking、分包、预加载和预算

在 Vue 3 项目中,浏览器最终下载的并不是“源代码目录”,而是一组经过解析、转换、裁剪、合并和压缩后的资源:

.vue / .ts / .css
        │
        ▼
模块解析与转换
        │
        ▼
依赖图
        │
        ├── Tree Shaking:删除不会产生有效影响的代码
        ├── 分包:将图划分为多个可独立加载的 chunk
        ├── 预加载:提前请求后续确定需要的资源
        └── 压缩与资源输出
                │
                ▼
        index.html、JS、CSS、图片、字体

Bundle 优化的核心不是“把文件变小”这一句经验,而是回答四个具体问题:

  1. 当前页面为了启动和交互,实际需要哪些模块?
  2. 哪些代码虽然进入了依赖图,但最终不会执行?
  3. 哪些代码应该放在同一个 chunk,哪些代码应该延迟到路由或功能触发时再加载?
  4. 这些资源的体积、请求数量和加载时机是否符合产品可接受的预算?

一、先建立模型:Bundle 是依赖图的另一种表示

1.1 模块、依赖边和依赖图

把每个 JavaScript、TypeScript、Vue 单文件组件或 CSS 模块看作一个节点,把一个模块导入另一个模块看作一条有向边,可以得到模块依赖图:

G=(V,E)G=(V,E)

其中:

  • VV 是模块集合;
  • EE 是依赖边集合;
  • 如果模块 AA 中存在 import B from './B',则存在边 ABA \rightarrow B

例如:

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
import SettingsView from '@/views/SettingsView.vue'

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: HomeView },
    { path: '/settings', component: SettingsView }
  ]
})

此时,静态依赖关系至少可以表示为:

flowchart TD
    HTML[index.html] --> Main[src/main.ts]
    Main --> Vue[vue]
    Main --> App[App.vue]
    Main --> Router[src/router/index.ts]
    Router --> VueRouter[vue-router]
    Router --> Home[HomeView.vue]
    Router --> Settings[SettingsView.vue]

如果 HomeView.vueSettingsView.vue 都使用静态 import,那么它们都是入口模块的静态依赖。即使用户当前只访问 /,构建器也可能把两个页面都放入初始依赖图中。

这解释了一个常见误解:

“路由配置了两个页面”不等于“两个页面已经自动分包”。

只有当模块边界中出现异步导入,例如:

const HomeView = () => import('@/views/HomeView.vue')
const SettingsView = () => import('@/views/SettingsView.vue')

构建器才获得一个明确的异步加载边界。


1.2 静态依赖和动态依赖

静态导入具有固定的模块说明符:

import Chart from './Chart.vue'

构建阶段可以直接知道:

当前模块 → Chart.vue

动态导入返回一个 Promise:

const module = await import('./Chart.vue')

它表示:

当前模块 ──异步边界──> Chart.vue

浏览器不会在加载当前模块时自动执行这个动态导入对应的模块,除非运行时执行到了该语句或构建工具显式安排了预加载。

动态导入是分包的基础,但并不保证一定产生“一个源文件对应一个 chunk”。构建器还会根据共享依赖、配置、CSS 和优化策略重新组织模块。例如:

const A = () => import('./A.vue')
const B = () => import('./B.vue')

最终可能得到:

index.js
A-xxx.js
B-xxx.js
shared-xxx.js

也可能在某些条件下合并为更少的文件。应当把 import() 理解为异步加载边界,而不是固定的输出文件边界。


1.3 Bundle、chunk 和资源不是同一个概念

这几个词经常被混用,但含义不同:

  • Bundle:通常泛指构建后的资源集合,或某个打包结果。
  • Chunk:构建图被切分后的 JavaScript 代码块。
  • Asset:非模块资源,例如 CSS、图片、字体、SVG。
  • Entry:应用或某个 HTML 页面开始构建的入口。
  • Dependency graph:构建器分析出的模块关系图。

一个 Vue 页面可能对应:

index.html
├── index-abc.js
├── index-def.css
├── SettingsView-ghi.js
└── logo-jkl.svg

其中 SettingsView-ghi.js 是异步 chunk,CSS 和 SVG 通常属于 asset,而不是 JavaScript chunk。


二、从依赖图到初始加载:浏览器真正经历了什么

假设应用包含以下模块:

main.ts
├── vue
├── App.vue
├── router.ts
│   ├── vue-router
│   ├── HomeView.vue
│   └── SettingsView.vue
└── global.css

如果所有路由组件都静态导入,入口可达集合近似为:

Rinitial={mm 从入口通过静态边可达}R_{\text{initial}} = \{m \mid m \text{ 从入口通过静态边可达}\}

那么 HomeView.vueSettingsView.vue 都属于初始集合。

改成异步路由后:

const routes = [
  {
    path: '/',
    component: () => import('@/views/HomeView.vue')
  },
  {
    path: '/settings',
    component: () => import('@/views/SettingsView.vue')
  }
]

初始集合变成:

Rinitial={mm 从入口仅通过静态边可达}R_{\text{initial}} = \{m \mid m \text{ 从入口仅通过静态边可达}\}

而路由页面集合变成:

Rsettings=Rinitial{mm 从 SettingsView 的异步边界可达}R_{\text{settings}} = R_{\text{initial}} \cup \{m \mid m \text{ 从 SettingsView 的异步边界可达}\}

因此,初始下载量和访问设置页后的增量下载量可以分别估算为:

Binitial=cCinitialtransferSize(c)B_{\text{initial}} = \sum_{c \in C_{\text{initial}}} \operatorname{transferSize}(c)

Bsettings=Binitial+cCsettings, additionaltransferSize(c)B_{\text{settings}} = B_{\text{initial}}+ \sum_{c \in C_{\text{settings, additional}}} \operatorname{transferSize}(c)

这里的 transferSize 应明确口径:可以是压缩后传输大小,也可以是解压后的资源大小,但两者不能混用。


三、Tree Shaking:删除“不可影响结果”的代码

3.1 Tree Shaking 的严格含义

Tree Shaking 是构建器基于 ES Module 静态结构,删除不会影响保留代码语义的导出、声明或模块部分的过程。

它不是:

  • 删除所有没有被调用的函数;
  • 自动识别所有运行时不可达代码;
  • 只要使用了命名导入,就一定能删除其余代码;
  • 压缩器简单地把代码“压扁”。

Tree Shaking 通常分两层:

  1. 模块级裁剪:某个模块没有被任何保留模块使用,整个模块可能被删除;
  2. 导出级或声明级裁剪:模块被保留,但其中未使用的导出、函数或常量可能被删除。

例如:

// src/math.ts
export function add(a: number, b: number) {
  return a + b
}

export function multiply(a: number, b: number) {
  return a * b
}
// src/main.ts
import { add } from './math'

console.log(add(1, 2))

如果 math.ts 被认为没有副作用,构建结果理论上只需保留 addmultiply 可以被删除。

但下面的模块不能简单删除:

// src/register.ts
console.log('register global plugin')

window.appVersion = '1.0.0'

export const version = '1.0.0'

即使没有使用 version,顶层 console.log 和对 window 的写入都可能影响程序行为。删除整个模块会改变结果,因此构建器通常必须保留这些副作用。


3.2 Tree Shaking 成立的形式化条件

设模块或语句为 xx,保留代码集合为 SS。删除 xx 安全,需要满足:

Obs(Px)=Obs(P)\operatorname{Obs}(P - x) = \operatorname{Obs}(P)

其中:

  • PP 是原程序;
  • PxP-x 是删除 xx 后的程序;
  • Obs\operatorname{Obs} 表示程序的可观察行为,例如返回值、DOM 修改、网络请求、日志、全局变量变化和异常。

构建器无法完全证明任意 JavaScript 都满足这个条件,因为 JavaScript 具有动态属性访问、原型修改、运行时 eval、全局状态和副作用。因此,实际 Tree Shaking 依赖以下静态证据:

  1. ES Module 的导入导出关系是静态可解析的;
  2. 未使用导出没有被其他保留模块引用;
  3. 模块顶层代码没有必须保留的副作用,或构建器有足够准确的副作用信息;
  4. 压缩器可以进一步证明某些表达式不会改变结果。

3.3 反例:CommonJS 和动态访问削弱裁剪能力

下面的 CommonJS 写法不如 ES Module 适合静态分析:

const utils = require('./utils')
module.exports = utils

如果 utils 导出了很多成员,构建器可能难以确定哪些属性一定不会被访问。

动态属性访问也会扩大保留范围:

import * as icons from './icons'

const name = getIconName()
render(icons[name])

因为 name 只有运行时才知道,构建器通常不能只保留某一个图标。

如果可以改写为静态映射:

import SearchIcon from './icons/SearchIcon.vue'
import HomeIcon from './icons/HomeIcon.vue'

const iconMap = {
  search: SearchIcon,
  home: HomeIcon
} as const

const icon = iconMap[getIconName() as keyof typeof iconMap]

构建器至少可以看见候选集合,压缩效果通常更可预测。不过只要两个成员都出现在静态映射中,它们仍可能都被保留。


3.4 反例:副作用导入不能随意删除

以下导入没有读取导出值:

import './global.css'
import './polyfill'
import './register-components'

但它们的意义在于执行模块顶层代码:

  • CSS 导入会产生样式资源;
  • polyfill 可能修改全局对象;
  • 全局组件注册会改变 Vue 运行时行为。

因此,不应通过“删除未使用导入”的方式处理它们。

package.json 中的 sideEffects 字段可以帮助构建工具判断副作用。例如某个库可能声明:

{
  "sideEffects": [
    "*.css",
    "./dist/register.js"
  ]
}

这表示普通模块可以更积极地裁剪,但 CSS 和注册脚本必须保留。这个字段是库作者对模块语义的声明;声明错误会导致真实功能被删除,不能为了减小体积而随意设置为 false


3.5 Vue 组件与 Tree Shaking 的边界

Vue 组件本身可以参与模块级 Tree Shaking,但模板编译不会替你理解所有运行时行为。

例如:

import Button from './Button.vue'
import Dialog from './Dialog.vue'

export default {
  components: {
    Button
  }
}

如果构建链可以静态分析该对象,Dialog 没有被使用,相关代码可能被裁剪。

但以下模式会扩大依赖:

const components = import.meta.glob('./components/*.vue', {
  eager: true,
  import: 'default'
})

eager: true 会在构建时把匹配文件全部静态导入,适合确实需要批量注册的场景,但不适合只使用一个组件的页面。

将其改为懒加载:

const components = import.meta.glob('./components/*.vue')

生成的映射值通常是返回 Promise 的函数。调用具体键时才加载对应模块:

const loadComponent = components['./components/Dialog.vue']

if (loadComponent) {
  const Dialog = defineAsyncComponent(loadComponent)
}

import.meta.glob 是 Vite 提供的编译期语法,匹配范围和生成形式受 Vite 版本与选项影响,不能把它当作浏览器原生 API。


四、如何验证 Tree Shaking,而不是凭感觉判断

先建立一个最小 Vite 项目:

npm create vite@latest vue-bundle-demo -- --template vue-ts
cd vue-bundle-demo
npm install
npm run build

默认生产构建会输出类似:

dist/
├── index.html
└── assets/
    ├── index-xxxxx.js
    └── index-xxxxx.css

文件名、字节数和 chunk 数量会因 Vite、依赖版本、压缩器和代码内容变化,不能把示例数字当成固定基准。

添加一个分析插件:

npm install -D rollup-plugin-visualizer

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    visualizer({
      filename: 'dist/stats.html',
      open: false,
      gzipSize: true,
      brotliSize: true
    })
  ]
})

执行:

npm run build

然后用浏览器打开:

dist/stats.html

应重点观察:

  • 哪些依赖进入了初始入口;
  • 某个大型库是否被多个 chunk 重复包含;
  • 路由异步 chunk 是否确实生成;
  • CSS 是否被提取到初始 CSS;
  • 某个包的源码体积、gzip 体积和 Brotli 体积差异;
  • 是否出现本来只在后台页面使用的库,却进入首页入口。

rollup-plugin-visualizer 是分析工具,不是 Vite 核心规范的一部分。团队应锁定插件版本,并把报告作为诊断证据,而不是把图形报告本身当作预算系统。


五、分包:把依赖图切成加载阶段

5.1 分包解决什么问题

分包(code splitting)是把一个依赖图划分成多个 chunk,使浏览器可以按入口、路由或功能逐步加载代码。

没有分包时:

index.js
├── 首页
├── 设置页
├── 管理后台
├── 图表库
└── 编辑器

有路由级分包时:

index.js
├── Vue 运行时
├── App
└── 路由基础设施

HomeView-xxx.js
SettingsView-xxx.js
AdminView-xxx.js

访问首页时不必立即下载管理后台和编辑器。分包降低的是当前阶段的下载和解析负担,不一定减少整个应用的总字节数。


5.2 Vue Router 的端到端示例

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'home',
      component: () => import('@/views/HomeView.vue')
    },
    {
      path: '/reports',
      name: 'reports',
      component: () => import('@/views/ReportsView.vue')
    },
    {
      path: '/settings',
      name: 'settings',
      component: () => import('@/views/SettingsView.vue')
    }
  ]
})

export default router

这段代码的生命周期是:

  1. 构建器解析 main.ts
  2. 解析 router/index.ts
  3. 识别 import() 作为异步边界;
  4. 为路由组件及其依赖生成异步加载关系;
  5. 浏览器首次执行路由时,Vue Router 请求对应模块;
  6. 模块加载完成后,路由组件才可以挂载。

如果用户首次访问 /reports,通常会同时需要:

初始入口 chunk
+ ReportsView chunk
+ ReportsView 的共享依赖 chunk
+ 相关 CSS

路由级分包的故障路径也必须考虑:

router.onError((error, to) => {
  console.error('路由加载失败', to.fullPath, error)
})

常见原因包括:

  • 部署后旧 HTML 引用了已被清理的旧 chunk;
  • CDN 缓存了旧入口和新 chunk 的不匹配版本;
  • 网络中断;
  • chunk URL 的 base 配置错误;
  • 权限或 CSP 阻止脚本加载。

router.onError 只能感知路由加载错误,不能代替版本一致性和缓存策略。生产环境还应确保一次部署中的 HTML、入口 chunk 和异步 chunk 可同时访问,并避免部署过程产生半套资源。


5.3 组件级和功能级分包

大型组件也可以按交互时机加载:

<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue'

const showEditor = ref(false)

const MarkdownEditor = defineAsyncComponent({
  loader: () => import('@/components/MarkdownEditor.vue'),
  timeout: 10_000,
  onError(error, retry, fail, attempts) {
    if (attempts <= 2) {
      retry()
    } else {
      fail(error)
    }
  }
})

function openEditor() {
  showEditor.value = true
}
</script>

<template>
  <button @click="openEditor">编辑</button>

  <Suspense>
    <MarkdownEditor v-if="showEditor" />
    <template #fallback>编辑器加载中……</template>
  </Suspense>
</template>

这个示例中:

  • defineAsyncComponent 延迟加载组件模块;
  • timeout 控制等待时间;
  • onError 提供有限次数重试;
  • Suspense 可显示异步依赖等待状态。

需要区分两个问题:

  1. 模块加载失败:应提供重试、错误提示或降级界面;
  2. 组件内部请求失败:应由组件自己的请求状态处理。

不要用无限重试,因为网络断开时会造成持续请求和用户无法退出的加载状态。


5.4 manualChunks:能控制,但不应先手工拆包

某些 Vite 配置会使用 build.rollupOptions.output.manualChunks

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-core': ['vue', 'vue-router'],
          'editor': ['some-editor-package']
        }
      }
    }
  }
})

它表达的是把指定依赖放入命名 chunk,但有几个边界:

  • 依赖之间存在共享关系时,结果不一定等于直觉中的目录划分;
  • 把所有第三方包合成一个 vendor,可能使首页下载大量后台依赖;
  • 过度拆分会增加请求、解析和缓存管理成本;
  • 包名变化、依赖升级或构建器实现变化可能改变结果;
  • 这类配置属于构建器能力,不是 Vue 组件 API。

更可靠的顺序通常是:

  1. 先用动态导入建立符合业务的异步边界;
  2. 用报告确认共享依赖和重复内容;
  3. 只有确实存在稳定缓存或隔离需求时,才引入 manualChunks
  4. 修改后同时测初始加载、路由切换和重复访问。

六、预加载:提前请求,不等于提前执行

6.1 预加载的含义

预加载(preload)是浏览器在当前资源尚未真正执行或使用前,提前发起资源请求。

在 ES Module 场景中,常见形式包括:

<link rel="modulepreload" href="/assets/index-abc.js">

它和普通脚本加载不同:

  • modulepreload 提前获取模块及其静态依赖;
  • 资源在下载后仍需要由模块系统执行;
  • 预加载不等于页面已经渲染;
  • 预加载错误地覆盖太多资源,会与首屏 CSS、字体和图片竞争带宽。

Vite 的构建结果通常会为入口模块及其静态依赖生成相应的预加载关系,并处理模块预加载的兼容性细节。具体标签形式、兼容处理和依赖展开方式属于 Vite 版本相关实现,应以当前构建产物和官方文档为准。


6.2 预加载、动态导入和预取的关系

可以把三者按意图区分:

机制 请求时机 典型用途
静态 import 当前模块加载阶段 当前阶段必需的代码
modulepreload 当前页面阶段提前请求 已知很快必需的模块
动态 import() 运行到语句时请求 路由或功能按需加载
prefetch 浏览器空闲或低优先级时请求 未来可能需要但不确定的资源

动态导入:

const loadReports = () => import('./ReportsView.vue')

默认表达“需要时再加载”。

如果在用户点击前主动调用:

let reportsPromise: Promise<typeof import('./ReportsView.vue')> | undefined

function warmReports() {
  reportsPromise ??= import('./ReportsView.vue')
}

function openReports() {
  warmReports()
  router.push('/reports')
}

这是一种应用层预加载:鼠标悬停、菜单展开或权限确认后,提前启动同一个动态导入。

但它有两个风险:

  1. 用户可能永远不访问该页面,预加载造成浪费;
  2. 预加载时间过早,会抢占当前页面图片、字体和接口的带宽。

因此,预加载是否有效取决于命中率和资源竞争,而不只是“请求更早”。


6.3 首屏预加载的因果链

假设首屏需要:

index.html
  ├── entry.js
  ├── vue.js
  ├── app.css
  └── logo.svg

合理的请求顺序应让入口模块、首屏 CSS 和必要字体尽早获得带宽。若错误地预加载报表编辑器:

index.html
  ├── entry.js
  ├── app.css
  ├── editor.js  ← 用户可能永远不访问
  └── report-chart.js

那么可能出现:

  1. 编辑器请求占用连接和带宽;
  2. 首屏 CSS 或入口脚本排队;
  3. JavaScript 下载更早,却不代表首屏更早;
  4. 用户没有访问编辑器,预加载成本完全浪费。

所以,预加载的判断条件至少包括:

预加载收益P(未来需要)×等待时间减少资源竞争成本\text{预加载收益} \approx P(\text{未来需要}) \times \text{等待时间减少} - \text{资源竞争成本}

其中 P(未来需要)P(\text{未来需要}) 是后续使用概率。它不是严格性能公式,而是用于避免“所有异步 chunk 都提前加载”的错误直觉。


七、Bundle 预算:先定义口径,再定义阈值

7.1 预算不是一个数字

Bundle 预算是对资源大小或加载阶段的约束。至少应区分:

  • 初始 JavaScript 传输大小;
  • 初始 CSS 传输大小;
  • 首个路由的增量大小;
  • 单个异步 chunk 的最大大小;
  • gzip 或 Brotli 大小;
  • 未压缩解析大小;
  • chunk 数量;
  • 重复依赖大小;
  • 关键资源请求数量。

例如“首页 JS 不超过 200 KB”必须补充:

是 gzip 后还是原始大小?
是所有初始 JS 还是单个文件?
是否包含 polyfill?
是否只针对生产构建?
是否还要限制 JS 解压后的执行成本?

不明确口径的预算,无法稳定执行。


7.2 传输大小和执行成本是不同维度

设某资源:

  • 原始大小为 UU
  • gzip 大小为 GG
  • Brotli 大小为 BB
  • 下载时间为 TdT_d
  • 解析、编译和执行时间为 TpT_p

网络主要受压缩传输大小影响:

TdG有效吞吐T_d \approx \frac{G}{\text{有效吞吐}}

而浏览器主线程压力更接近:

TmainTp+Tlayout+TapplicationT_{\text{main}} \approx T_p + T_{\text{layout}} + T_{\text{application}}

因此,某个库压缩后只有几十 KB,仍可能因为未压缩代码复杂、初始化逻辑多或大量数据结构创建而造成明显执行成本。

反过来,图片可能传输体积很大,但不一定增加 JavaScript 解析成本。预算应按资源类型和用户可感知阶段拆分,而不能只看一个总和。


7.3 一个可执行的构建预算脚本

下面的脚本按 gzip 后的 JavaScript 文件大小检查阈值。它不是 Vite 内置预算功能,而是项目自定义 CI 检查。

安装依赖:

npm install -D fast-glob gzip-size

创建 scripts/check-bundle-budget.mjs

import fg from 'fast-glob'
import { gzipSize } from 'gzip-size'
import { readFile } from 'node:fs/promises'

const files = await fg('dist/assets/**/*.js')

const budget = {
  initialJsGzipBytes: 180 * 1024,
  maxChunkGzipBytes: 120 * 1024
}

const results = await Promise.all(
  files.map(async (file) => {
    const content = await readFile(file)
    return {
      file,
      rawBytes: content.byteLength,
      gzipBytes: await gzipSize(content)
    }
  })
)

for (const item of results) {
  console.log(
    `${item.file}: raw=${item.rawBytes} bytes, gzip=${item.gzipBytes} bytes`
  )
}

const initialFiles = results.filter((item) => item.file.includes('index-'))
const initialJsGzipBytes = initialFiles.reduce(
  (sum, item) => sum + item.gzipBytes,
  0
)

const oversized = results.filter(
  (item) => item.gzipBytes > budget.maxChunkGzipBytes
)

let failed = false

if (initialJsGzipBytes > budget.initialJsGzipBytes) {
  console.error(
    `Initial JS gzip budget exceeded: ${initialJsGzipBytes} > ${budget.initialJsGzipBytes}`
  )
  failed = true
}

if (oversized.length > 0) {
  console.error('Oversized chunks:')
  for (const item of oversized) {
    console.error(`- ${item.file}: ${item.gzipBytes} bytes gzip`)
  }
  failed = true
}

if (failed) {
  process.exit(1)
}

package.json 中加入:

{
  "scripts": {
    "build": "vite build",
    "check:bundle": "npm run build && node scripts/check-bundle-budget.mjs"
  }
}

执行:

npm run check:bundle

可能输出:

dist/assets/index-a1b2c3.js: raw=612000 bytes, gzip=174200 bytes
dist/assets/reports-d4e5f6.js: raw=420000 bytes, gzip=132800 bytes
dist/assets/settings-g7h8i9.js: raw=41000 bytes, gzip=12100 bytes

此时:

  • 初始入口可能通过;
  • reports-d4e5f6.js 超过单 chunk 预算;
  • 失败原因是报表页自身过大,而不一定是首页初始加载过大。

这个示例有一个重要限制:通过文件名包含 index- 推断初始 chunk 并不稳健。生产项目若需要精确区分入口和异步 chunk,应读取 Vite 的 manifest 或构建器输出元数据,而不是依赖文件名约定。

可以启用:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    manifest: true
  }
})

构建后通常会生成 manifest 文件,记录入口、动态入口、导入关系和相关资源。manifest 的具体字段属于 Vite 版本相关输出,应在当前版本中实际检查字段结构。


7.4 预算失败后的诊断流程

预算失败时,不应立即删除依赖。应按下面的因果路径排查:

flowchart TD
    A[预算失败] --> B{哪个阶段超标}
    B -->|初始入口| C[检查静态 import]
    B -->|某个路由 chunk| D[检查路由和功能依赖]
    B -->|重复依赖| E[检查 chunk 划分与版本]
    B -->|原始大小大但压缩后小| F[检查重复文本或生成代码]
    B -->|压缩后不大但运行慢| G[分析解析、执行和初始化]
    C --> H[改为动态 import 或移除不必要依赖]
    D --> I[拆分功能、延迟初始化]
    E --> J[调整边界并验证缓存收益]
    F --> K[检查压缩配置与资源格式]
    G --> L[延迟执行、减少运行时工作]

例如首页入口增加了图表库,常见原因不是“Tree Shaking 失效”这一单一结论,而可能是:

// 这会让图表库成为首页静态依赖
import * as Charts from 'large-chart-library'

若图表只在报表页使用,应先改为:

const ReportsView = () => import('@/views/ReportsView.vue')

并在报表组件内部导入图表库:

// src/views/ReportsView.vue
<script setup lang="ts">
import { onMounted } from 'vue'

onMounted(async () => {
  const { createChart } = await import('large-chart-library')
  createChart('#chart')
})
</script>

但这会把“报表组件加载”与“图表库加载”绑定在一起。若报表页面先展示筛选器,图表稍后才可见,还可以把图表库延迟到图表容器接近可见或用户展开图表时再加载。


八、常见失败表现与诊断边界

8.1 “用了按需导入,首页还是很大”

可能原因包括:

  1. 某个入口文件仍静态导入大型库;
  2. 异步页面的共享依赖被提升到了初始 chunk;
  3. 库本身存在顶层副作用,无法充分 Tree Shake;
  4. 依赖通过插件或自动注册机制间接进入入口;
  5. 查看的是原始大小,而预算看的是 gzip 大小,或反之;
  6. CSS、字体和资源没有计入同一套统计。

应同时检查:

npm run build

以及 bundle 可视化报告、浏览器 Network 面板和 Performance 面板。构建报告回答“代码在哪里”,Network 回答“何时下载”,Performance 回答“何时执行”。


8.2 “拆得越多越快”

过度分包会产生三个成本:

  • HTTP 请求数量增加;
  • 小 chunk 的压缩效率和缓存收益下降;
  • 主线程需要处理更多模块加载和执行边界。

假设一个页面被拆成 nn 个必须串行发现的 chunk,则总等待时间不只是字节总量,还包含请求发现和调度成本:

TTconnection+i=1nTdiscover,i+i=1nTtransfer,i+TexecuteT \approx T_{\text{connection}} + \sum_{i=1}^{n} T_{\text{discover},i} + \sum_{i=1}^{n} T_{\text{transfer},i} + T_{\text{execute}}

现代 HTTP/2 和 HTTP/3 可以并发传输多个请求,但不能消除模块发现、服务器排队、浏览器调度和执行成本。因此,分包目标是形成符合用户路径的加载阶段,而不是最大化文件数量。


8.3 “把所有依赖放进 vendor 就能长期缓存”

例如:

manualChunks: {
  vendor: ['vue', 'vue-router', 'large-chart-library', 'editor-library']
}

这会产生一个看似稳定的 vendor chunk,但任何一个依赖变化都可能使整个 chunk 哈希变化。更重要的是,首页可能被迫加载只在后台使用的编辑器。

缓存稳定性应与加载范围同时考虑:

  • 框架运行时可形成稳定共享 chunk;
  • 低频大型功能应保留在异步边界之后;
  • 高频变化业务代码不应无意义地混入长期缓存 chunk;
  • 必须通过实际构建报告确认重复和命中效果。

8.4 “Tree Shaking 后代码没删掉,说明构建失败”

不一定。以下情况可能使代码合法保留:

// 顶层副作用
registerPlugin()

// 动态属性访问
plugins[name]

// 可能抛出或触发 getter
const value = object[key]

// 运行时反射
eval(code)

另外,某些库在入口文件中重新导出全部成员,构建器可能只能在更后面的优化阶段裁剪;如果库使用 CommonJS、混合模块格式或错误声明副作用,效果也会受影响。

诊断时应比较:

  1. 改动前后的可视化依赖图;
  2. 生产构建输出,而不是开发服务器模块列表;
  3. 原始、gzip、Brotli 三种口径;
  4. 功能是否因错误副作用声明而失效。

8.5 “预加载了,LCP 就一定更快”

预加载只有在资源确实位于关键路径上时才可能改善关键指标。以下资源通常更值得优先考虑:

  • 当前页面入口模块;
  • 当前页面必需的 CSS;
  • 首屏确定使用的字体或关键图片;
  • 已确认即将发生的高概率路由切换资源。

以下资源不应无条件预加载:

  • 低概率后台页面;
  • 大型编辑器;
  • 用户必须点击多层菜单后才会使用的功能;
  • 只用于错误分支的组件。

应在浏览器 Network 面板中验证:

  • 请求是否真的提前发生;
  • 优先级是否压制了 CSS、图片或接口;
  • 预加载后是否仍再次下载;
  • 未使用资源的浪费比例;
  • 弱网和移动设备上的实际变化。

九、把分析、分包、预加载和预算串成一个完整流程

一个可重复的生产流程可以写成:

源代码
  │
  ▼
确定静态入口与动态边界
  │
  ▼
构建生产 Bundle
  │
  ├── 生成资源与 manifest
  ├── 生成依赖可视化报告
  └── 执行 gzip/Brotli 预算检查
          │
          ▼
浏览器验证
  ├── Network:请求时机和传输大小
  ├── Performance:解析、执行和长任务
  └── 路由错误:异步 chunk 加载失败
          │
          ▼
根据用户路径调整
  ├── 删除不必要的静态依赖
  ├── 增加合理的动态 import
  ├── 延迟大型功能初始化
  ├── 谨慎配置共享 chunk
  └── 更新可解释的预算

关键判断可以归纳为:

  • 依赖图分析回答“代码为什么进入构建结果”;
  • Tree Shaking回答“进入图后哪些声明可以被安全删除”;
  • 分包回答“剩余代码在哪个用户阶段下载”;
  • 预加载回答“异步代码是否应在真正使用前开始下载”;
  • 预算回答“这些选择是否超过团队定义的约束”。

这几个机制不能互相替代。Tree Shaking 不能解决错误的静态依赖;分包不能删除代码;预加载不能减少代码体积;预算也不能告诉你某段代码为什么进入入口。只有把依赖图、构建产物和浏览器运行时行为放在同一条因果链中,Bundle 优化才会从“看文件大小”变成可验证的工程分析。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。