我们的前端不是常见的单页应用,而是一个嵌入传统服务端页面的 Vue 组件库:

import() 对应组件;manifest.json 解析带 hash 的资源;随着业务不断增加,构建产物逐渐暴露出几个问题:
vendor-vue 的文件膨胀到约 17MB;这次优化的重点并不是简单地“把大文件切成小文件”,而是同时解决:
vendor-vue 会包含几乎所有依赖项目最初使用了类似下面的 manualChunks 配置:
复制代码manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue') && !id.includes('vue-i18n')) {
return 'vendor-vue'
}
if (id.includes('element-plus')) return 'vendor-element'
if (id.includes('axios')) return 'vendor-axios'
return 'vendor'
}
}
乍看没有问题,但项目目录本身包含 vue-ui:
复制代码/project/frontend/vue-ui/node_modules/axios/index.js
因此,即使当前模块是 Axios,完整模块路径中依然包含 vue:
复制代码id.includes('vue') === true
于是第一个条件提前命中,后续针对 Element Plus、Axios、Day.js 等依赖的规则根本没有执行机会。
分包判断不应该基于完整文件路径模糊匹配,而应该先提取 node_modules 后的真实包名:
复制代码function getPackageName(id) {
const normalizedId = id.replace(//g, '/')
const nodeModulesIndex = normalizedId.lastIndexOf('/node_modules/') if (nodeModulesIndex < 0) return null const modulePath = normalizedId.slice(
nodeModulesIndex + '/node_modules/'.length,
)
const moduleParts = modulePath.split('/') return moduleParts[0]?.startsWith('@')
? `${moduleParts[0]}/${moduleParts[1]}`
: moduleParts[0]
}
这里使用 lastIndexOf('/node_modules/'),是为了兼容 pnpm 目录中可能出现的多层 node_modules。
然后按真实包名分组:
复制代码manualChunks(id) {
if (!id.includes('node_modules')) return const packageName = getPackageName(id) // 以下私有包名均为匿名示例
if (packageName === '@internal/rich-text') {
return 'vendor-rich-text'
} if (
packageName === 'element-plus' ||
packageName?.startsWith('@element-plus/')
) {
return 'vendor-element'
} if (packageName === 'pinia') return 'vendor-pinia'
if (packageName === 'vue' || packageName?.startsWith('@vue/')) {
return 'vendor-vue'
}
if (packageName === 'vue-i18n' || packageName?.startsWith('@intlify/')) {
return 'vendor-i18n'
}
if (packageName?.startsWith('@vueuse/')) return 'vendor-vueuse'
if (packageName === 'axios') return 'vendor-axios'
if (packageName === 'dayjs') return 'vendor-dayjs' return 'vendor'
}
修改后,原来约 17.09MB 的 vendor-vue 被拆解为多个职责清晰的包。由于 Vue 主包在本项目中由宿主页面 external 提供,最终 vendor-vue 只剩少量 Vue 生态辅助代码。
需要强调的是:
完成第一轮拆包后,最大的文件变成了私有富文本依赖:
复制代码vendor-rich-text:约 13.91MB
通过 Rollup Visualizer 和 Source Map 继续分析,发现其中存在两份版本号完全相同的入口文件,每份约 6.9MB。
它们的实际文件内容和 SHA-256 完全一致,但 pnpm 目录不同:
复制代码[email protected].../node_modules/<private-package>/dist/index.js
[email protected].../node_modules/<private-package>/dist/index.js
原因是 pnpm 会把 peer dependency 上下文编码进安装路径。即使包版本相同,只要 peer dependency 组合不同,Rollup 看到的就是两个不同模块 ID。
resolve.dedupe在 Vite 中增加:
复制代码export default defineConfig({
resolve: {
dedupe: ['@internal/rich-text'],
},
})
处理后,该分包从约 13.91MB 降到约 6.9MB,说明重复实例已被合并。
dedupe 可以快速解决当前构建问题,但更彻底的方式是统一 workspace 内的:
否则后续仍可能出现 VueUse、React 生态包等多版本并存。
项目原本还有一条规则:
复制代码if (id.includes('src/composables/')) {
return 'shared-composables'
}
最初的目的是把公共逻辑集中起来,减少重复打包。但最终生成的 shared-composables 达到了约 12.95MB。
分析后发现,这个包真正的大头并不是 composable 本身,而是 composable 静态依赖的几个大型预构建产物,例如:
manualChunks 的规则具有“依赖吸附”效应。把某个业务模块强制放进一个 chunk 后,它依赖的大型代码也可能一起进入该 chunk。
删除目录级强制合并:
复制代码// 删除
if (id.includes('src/composables/')) {
return 'shared-composables'
}
让 Rollup 根据真实入口依赖图决定:
仍然可以保留少量边界明确的共享规则:
复制代码if (id.includes('src/stores/')) return 'shared-stores'
if (id.includes('src/components/EditorContainer')) return 'shared-editor'
原则是:
删除 shared-composables 后,JavaScript 分包有所改善,但新的问题出现了:所有组件样式被合并到一个数 MB 的 CSS 文件中。
这是因为 Vite 的 Library 模式默认关闭 CSS 分包。即使 JavaScript 已经拆成多个入口,CSS 仍可能统一汇总。
复制代码export default defineConfig({
build: {
cssCodeSplit: true,
},
})
配置资源输出路径:
复制代码assetFileNames(assetInfo) {
if (assetInfo.name?.endsWith('.css')) {
return 'style/[name]-[hash][extname]'
}
return 'assets/[name]-[hash][extname]'
}
开启后,入口和共享 chunk 对应的 CSS 会分别输出,并在 manifest.json 中形成类似结构:
复制代码{
"src/components/Example.vue": {
"file": "Example-abcd1234.js",
"isEntry": true,
"imports": ["_shared-efgh5678.js"],
"css": ["style/Example-abcd1234.css"]
},
"_shared-efgh5678.js": {
"file": "shared-efgh5678.js",
"css": ["style/shared-efgh5678.css"]
}
}
但到这里还没有结束。
我们的组件由服务端页面动态 import(),浏览器并不会因为读取了 manifest.json 就自动知道应该加载哪些 CSS。应用层必须补齐这一环。
项目原来的 manifest 处理逻辑只保留入口 JS 映射:
复制代码{
'/components/Example.js': '/components/Example-abcd1234.js'
}
它丢失了:
imports;css;因此我们额外生成一份紧凑依赖图:
复制代码{
"entries": {
"Example": "src/components/Example.vue"
},
"chunks": {
"src/components/Example.vue": {
"file": "/dist/Example-abcd1234.js",
"imports": ["_shared-efgh5678.js"],
"css": ["/dist/style/Example-abcd1234.css"]
},
"_shared-efgh5678.js": {
"file": "/dist/shared-efgh5678.js",
"imports": [],
"css": ["/dist/style/shared-efgh5678.css"]
}
},
"hasCss": true
}
页面分别注入:
复制代码<script>
window.__assetManifest = {/* 兼容旧代码的 JS 映射 */}
window.__assetManifestGraph = {/* 新增的依赖图 */}
</script>
保留旧映射非常重要,因为项目中可能还有其他代码直接执行:
复制代码window.__assetManifest[path]
不要为了增加 CSS 信息,直接把旧字符串映射改成对象,否则会造成隐藏的兼容性问题。
动态加载某个组件时,不能只读取入口自身的 CSS。入口依赖的共享 chunk 也可能包含基础样式。
可以采用依赖优先的深度优先遍历:
复制代码function collectManifestCss(entryKey, graph) {
const visitedChunks = new Set()
const seenCss = new Set()
const cssFiles = [] function visit(chunkKey) {
if (visitedChunks.has(chunkKey)) return
visitedChunks.add(chunkKey) const chunk = graph.chunks[chunkKey]
if (!chunk) return for (const importedKey of chunk.imports || []) {
visit(importedKey)
} for (const cssFile of chunk.css || []) {
if (seenCss.has(cssFile)) continue
seenCss.add(cssFile)
cssFiles.push(cssFile)
}
} visit(entryKey)
return cssFiles
}
依赖 CSS 先插入,入口 CSS 后插入,有利于保持:
复制代码基础样式 → 业务组件覆盖样式
需要注意,manifest 并不能完整还原原来单个 CSS 文件中的源码拼接顺序,因此启用 CSS 分包后仍然需要做视觉回归。
同一个共享 CSS 可能被多个动态组件依赖。如果每次挂载都插入 <link>,会造成重复请求和样式顺序漂移。
复制代码const styleLoaders = new Map()function normalizeStyleUrl(href) {
return new URL(href, document.baseURI).href
}function loadStyleOnce(href) {
const absoluteHref = normalizeStyleUrl(href) if (styleLoaders.has(absoluteHref)) {
return styleLoaders.get(absoluteHref)
} const promise = new Promise((resolve, reject) => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href link.onload = resolve
link.onerror = error => {
link.remove()
styleLoaders.delete(absoluteHref)
reject(error)
} document.head.appendChild(link)
}) styleLoaders.set(absoluteHref, promise)
return promise
}
不要先等待 CSS 完成后再请求 JS,否则会产生明显网络瀑布:
复制代码async function loadModule(descriptor) {
const stylePromises = descriptor.cssUrls.map(loadStyleOnce)
const modulePromise = import(descriptor.jsUrl) const [module] = await Promise.all([
modulePromise,
...stylePromises,
]) return module
}
这个过程保证:
<link> 按确定顺序插入;组件卸载时不要急于删除 CSS,因为该样式可能仍被其他组件共享。对于长生命周期后台系统,页面内保留已加载 CSS 通常比实现复杂的引用计数卸载更安全。
完成上述改造后,我们发现某个绘图组件仍然丢失样式。
检查构建产物后确认:
根因是这个组件由富文本编辑器作为“外部组件”加载,它没有经过项目统一的 renderComponent(),而是直接执行:
复制代码import(resolveAssetPath('DrawingComponent.js'))
resolveAssetPath() 只负责把无 hash 路径转换为带 hash 路径,并不会加载 CSS。
统一加载器额外暴露:
复制代码window.importComponentModule = importComponentModuleWithRetry
富文本编辑器中的外部组件改为:
复制代码async function importDistModule(name) {
const path = `/dist/${name}` if (typeof window.importComponentModule === 'function') {
return window.importComponentModule(path)
} // 兼容旧环境
return import(resolveAssetPath(name))
}
随后所有特殊加载链路中的外部组件都统一改成:
复制代码return importDistModule('DrawingComponent.js')
而不是只修复绘图组件一个入口。
这个问题给我们的启示是:
在前后端混合项目中,代码与静态资源不一定能做到绝对原子切换,因此需要兼容旧版单 CSS 构建。
判断 manifest 中是否存在 chunk CSS:
复制代码function hasManifestCssGraph() {
return Boolean(window.__assetManifestGraph?.hasCss)
}
新构建按入口加载 CSS,旧构建继续加载聚合样式:
复制代码if (!hasManifestCssGraph()) {
loadStyleOnce(resolveAssetPath('style/app.css'))
}
开发模式则继续交给 Vite HMR:
复制代码if (window.__viteDevServerUrl) {
// 不加载生产 CSS,Vite 自动注入并更新样式
}
最终形成三套兼容行为:
| 场景 | CSS 加载方式 |
|---|---|
| Vite 开发环境 | Vite 自动注入并 HMR |
| 新版生产构建 | 根据 manifest 按入口递归加载 |
| 旧版生产构建 | 加载单个聚合 CSS |
原来的重试逻辑只比较 JavaScript URL:
复制代码if (retryPath === resolvedPath) {
throw error
}
CSS 分包后,可能出现:
因此加载描述应同时包含:
复制代码{
jsUrl,
cssUrls,
}
刷新 manifest 后比较完整签名:
复制代码function getDescriptorSignature(descriptor) {
return JSON.stringify([
descriptor.jsUrl,
descriptor.cssUrls,
])
}
当 CSS 或 JS 任一加载失败时:
no-store 重新获取 manifest;本次优化过程中观察到的主要变化如下:
| 项目 | 优化前 | 优化后 |
|---|---|---|
错误归类的 vendor-vue | 约 17.09MB | 只保留少量真正 Vue 生态代码 |
| 重复的私有富文本分包 | 约 13.91MB | 去重后约 6.9MB |
| 强制聚合的 composable 共享包 | 约 12.95MB | 交由 Rollup 按真实依赖拆分 |
| CSS | 单个数 MB 聚合文件 | 跟随入口和共享 chunk 按需加载 |
| 特殊动态组件 | JS 正常、CSS 可能丢失 | 统一经过完整资源加载器 |
这里不能只看构建后的文件数量,还要关注具体页面实际下载了什么。
建议至少采集:
这次优化表面上是在处理几个大包,实际涉及构建期和运行时两个层面。
构建期需要解决:
dedupe 消除 peer dependency 上下文造成的重复实例;cssCodeSplit。运行时需要解决:
最后最重要的一点是: