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 优化的核心不是“把文件变小”这一句经验,而是回答四个具体问题:
- 当前页面为了启动和交互,实际需要哪些模块?
- 哪些代码虽然进入了依赖图,但最终不会执行?
- 哪些代码应该放在同一个 chunk,哪些代码应该延迟到路由或功能触发时再加载?
- 这些资源的体积、请求数量和加载时机是否符合产品可接受的预算?
一、先建立模型:Bundle 是依赖图的另一种表示
1.1 模块、依赖边和依赖图
把每个 JavaScript、TypeScript、Vue 单文件组件或 CSS 模块看作一个节点,把一个模块导入另一个模块看作一条有向边,可以得到模块依赖图:
其中:
- 是模块集合;
- 是依赖边集合;
- 如果模块 中存在
import B from './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.vue 和 SettingsView.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
如果所有路由组件都静态导入,入口可达集合近似为:
那么 HomeView.vue 和 SettingsView.vue 都属于初始集合。
改成异步路由后:
const routes = [
{
path: '/',
component: () => import('@/views/HomeView.vue')
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue')
}
]
初始集合变成:
而路由页面集合变成:
因此,初始下载量和访问设置页后的增量下载量可以分别估算为:
这里的 transferSize 应明确口径:可以是压缩后传输大小,也可以是解压后的资源大小,但两者不能混用。
三、Tree Shaking:删除“不可影响结果”的代码
3.1 Tree Shaking 的严格含义
Tree Shaking 是构建器基于 ES Module 静态结构,删除不会影响保留代码语义的导出、声明或模块部分的过程。
它不是:
- 删除所有没有被调用的函数;
- 自动识别所有运行时不可达代码;
- 只要使用了命名导入,就一定能删除其余代码;
- 压缩器简单地把代码“压扁”。
Tree Shaking 通常分两层:
- 模块级裁剪:某个模块没有被任何保留模块使用,整个模块可能被删除;
- 导出级或声明级裁剪:模块被保留,但其中未使用的导出、函数或常量可能被删除。
例如:
// 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 被认为没有副作用,构建结果理论上只需保留 add,multiply 可以被删除。
但下面的模块不能简单删除:
// 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 成立的形式化条件
设模块或语句为 ,保留代码集合为 。删除 安全,需要满足:
其中:
- 是原程序;
- 是删除 后的程序;
- 表示程序的可观察行为,例如返回值、DOM 修改、网络请求、日志、全局变量变化和异常。
构建器无法完全证明任意 JavaScript 都满足这个条件,因为 JavaScript 具有动态属性访问、原型修改、运行时 eval、全局状态和副作用。因此,实际 Tree Shaking 依赖以下静态证据:
- ES Module 的导入导出关系是静态可解析的;
- 未使用导出没有被其他保留模块引用;
- 模块顶层代码没有必须保留的副作用,或构建器有足够准确的副作用信息;
- 压缩器可以进一步证明某些表达式不会改变结果。
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
这段代码的生命周期是:
- 构建器解析
main.ts; - 解析
router/index.ts; - 识别
import()作为异步边界; - 为路由组件及其依赖生成异步加载关系;
- 浏览器首次执行路由时,Vue Router 请求对应模块;
- 模块加载完成后,路由组件才可以挂载。
如果用户首次访问 /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可显示异步依赖等待状态。
需要区分两个问题:
- 模块加载失败:应提供重试、错误提示或降级界面;
- 组件内部请求失败:应由组件自己的请求状态处理。
不要用无限重试,因为网络断开时会造成持续请求和用户无法退出的加载状态。
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。
更可靠的顺序通常是:
- 先用动态导入建立符合业务的异步边界;
- 用报告确认共享依赖和重复内容;
- 只有确实存在稳定缓存或隔离需求时,才引入
manualChunks; - 修改后同时测初始加载、路由切换和重复访问。
六、预加载:提前请求,不等于提前执行
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')
}
这是一种应用层预加载:鼠标悬停、菜单展开或权限确认后,提前启动同一个动态导入。
但它有两个风险:
- 用户可能永远不访问该页面,预加载造成浪费;
- 预加载时间过早,会抢占当前页面图片、字体和接口的带宽。
因此,预加载是否有效取决于命中率和资源竞争,而不只是“请求更早”。
6.3 首屏预加载的因果链
假设首屏需要:
index.html
├── entry.js
├── vue.js
├── app.css
└── logo.svg
合理的请求顺序应让入口模块、首屏 CSS 和必要字体尽早获得带宽。若错误地预加载报表编辑器:
index.html
├── entry.js
├── app.css
├── editor.js ← 用户可能永远不访问
└── report-chart.js
那么可能出现:
- 编辑器请求占用连接和带宽;
- 首屏 CSS 或入口脚本排队;
- JavaScript 下载更早,却不代表首屏更早;
- 用户没有访问编辑器,预加载成本完全浪费。
所以,预加载的判断条件至少包括:
其中 是后续使用概率。它不是严格性能公式,而是用于避免“所有异步 chunk 都提前加载”的错误直觉。
七、Bundle 预算:先定义口径,再定义阈值
7.1 预算不是一个数字
Bundle 预算是对资源大小或加载阶段的约束。至少应区分:
- 初始 JavaScript 传输大小;
- 初始 CSS 传输大小;
- 首个路由的增量大小;
- 单个异步 chunk 的最大大小;
- gzip 或 Brotli 大小;
- 未压缩解析大小;
- chunk 数量;
- 重复依赖大小;
- 关键资源请求数量。
例如“首页 JS 不超过 200 KB”必须补充:
是 gzip 后还是原始大小?
是所有初始 JS 还是单个文件?
是否包含 polyfill?
是否只针对生产构建?
是否还要限制 JS 解压后的执行成本?
不明确口径的预算,无法稳定执行。
7.2 传输大小和执行成本是不同维度
设某资源:
- 原始大小为 ;
- gzip 大小为 ;
- Brotli 大小为 ;
- 下载时间为 ;
- 解析、编译和执行时间为 。
网络主要受压缩传输大小影响:
而浏览器主线程压力更接近:
因此,某个库压缩后只有几十 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 “用了按需导入,首页还是很大”
可能原因包括:
- 某个入口文件仍静态导入大型库;
- 异步页面的共享依赖被提升到了初始 chunk;
- 库本身存在顶层副作用,无法充分 Tree Shake;
- 依赖通过插件或自动注册机制间接进入入口;
- 查看的是原始大小,而预算看的是 gzip 大小,或反之;
- CSS、字体和资源没有计入同一套统计。
应同时检查:
npm run build
以及 bundle 可视化报告、浏览器 Network 面板和 Performance 面板。构建报告回答“代码在哪里”,Network 回答“何时下载”,Performance 回答“何时执行”。
8.2 “拆得越多越快”
过度分包会产生三个成本:
- HTTP 请求数量增加;
- 小 chunk 的压缩效率和缓存收益下降;
- 主线程需要处理更多模块加载和执行边界。
假设一个页面被拆成 个必须串行发现的 chunk,则总等待时间不只是字节总量,还包含请求发现和调度成本:
现代 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、混合模块格式或错误声明副作用,效果也会受影响。
诊断时应比较:
- 改动前后的可视化依赖图;
- 生产构建输出,而不是开发服务器模块列表;
- 原始、gzip、Brotli 三种口径;
- 功能是否因错误副作用声明而失效。
8.5 “预加载了,LCP 就一定更快”
预加载只有在资源确实位于关键路径上时才可能改善关键指标。以下资源通常更值得优先考虑:
- 当前页面入口模块;
- 当前页面必需的 CSS;
- 首屏确定使用的字体或关键图片;
- 已确认即将发生的高概率路由切换资源。
以下资源不应无条件预加载:
- 低概率后台页面;
- 大型编辑器;
- 用户必须点击多层菜单后才会使用的功能;
- 只用于错误分支的组件。
应在浏览器 Network 面板中验证:
- 请求是否真的提前发生;
- 优先级是否压制了 CSS、图片或接口;
- 预加载后是否仍再次下载;
- 未使用资源的浪费比例;
- 弱网和移动设备上的实际变化。
九、把分析、分包、预加载和预算串成一个完整流程
一个可重复的生产流程可以写成:
源代码
│
▼
确定静态入口与动态边界
│
▼
构建生产 Bundle
│
├── 生成资源与 manifest
├── 生成依赖可视化报告
└── 执行 gzip/Brotli 预算检查
│
▼
浏览器验证
├── Network:请求时机和传输大小
├── Performance:解析、执行和长任务
└── 路由错误:异步 chunk 加载失败
│
▼
根据用户路径调整
├── 删除不必要的静态依赖
├── 增加合理的动态 import
├── 延迟大型功能初始化
├── 谨慎配置共享 chunk
└── 更新可解释的预算
关键判断可以归纳为:
- 依赖图分析回答“代码为什么进入构建结果”;
- Tree Shaking回答“进入图后哪些声明可以被安全删除”;
- 分包回答“剩余代码在哪个用户阶段下载”;
- 预加载回答“异步代码是否应在真正使用前开始下载”;
- 预算回答“这些选择是否超过团队定义的约束”。
这几个机制不能互相替代。Tree Shaking 不能解决错误的静态依赖;分包不能删除代码;预加载不能减少代码体积;预算也不能告诉你某段代码为什么进入入口。只有把依赖图、构建产物和浏览器运行时行为放在同一条因果链中,Bundle 优化才会从“看文件大小”变成可验证的工程分析。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue DevTools 调试:组件、状态、时间线、性能和生产诊断
- 下一篇:Vue Source Map 与发布诊断:生成、上传、隐私和版本映射
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论