本文记录一次真实的 Vue 3 + Vite 多入口组件库首屏优化过程。项目中的业务名称、私有包名和内部路径均已匿名化,代码仅保留与构建优化相关的核心逻辑。
前言
我们的前端不是常见的单页应用,而是一个嵌入传统服务端页面的 Vue 组件库:
- 服务端页面提供挂载节点;
- Vue 组件被构建成多个独立入口;
- 页面根据业务场景动态
import()对应组件; - 生产环境通过 Vite
manifest.json解析带 hash 的资源; - Vue 和部分基础库由宿主页面统一提供。
随着业务不断增加,构建产物逐渐暴露出几个问题:
- 一个名为
vendor-vue的文件膨胀到约 17MB; - 大量与 Vue 无关的依赖被错误打入该文件;
- 某个私有富文本包被重复打包两次;
- 强制合并所有 composable 后,共享包达到约 12.95MB;
- 拆分 JavaScript 后,CSS 又被合并成一个数 MB 的大文件;
- 启用 CSS 分包后,部分由特殊加载链路引入的组件出现样式丢失。
这次优化的重点并不是简单地"把大文件切成小文件",而是同时解决:
- 分包规则准确性;
- 首屏按需加载;
- 长期缓存;
- CSS 去重;
- 动态组件样式加载;
- 新旧构建产物兼容。
一、为什么 vendor-vue 会包含几乎所有依赖
项目最初使用了类似下面的 manualChunks 配置:
js
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:
text
/project/frontend/vue-ui/node_modules/axios/index.js
因此,即使当前模块是 Axios,完整模块路径中依然包含 vue:
js
id.includes('vue') === true
于是第一个条件提前命中,后续针对 Element Plus、Axios、Day.js 等依赖的规则根本没有执行机会。
正确做法:先解析真实包名
分包判断不应该基于完整文件路径模糊匹配,而应该先提取 node_modules 后的真实包名:
js
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。
然后按真实包名分组:
js
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 生态辅助代码。
需要强调的是:
分包不会自动减少总代码量,它主要改善加载粒度、缓存命中率和问题可观测性。
二、利用 Source Map 发现同一个私有包被打包两次
完成第一轮拆包后,最大的文件变成了私有富文本依赖:
text
vendor-rich-text:约 13.91MB
通过 Rollup Visualizer 和 Source Map 继续分析,发现其中存在两份版本号完全相同的入口文件,每份约 6.9MB。
它们的实际文件内容和 SHA-256 完全一致,但 pnpm 目录不同:
text
...typescript@5.8.x.../node_modules/<private-package>/dist/index.js
...typescript@5.9.x.../node_modules/<private-package>/dist/index.js
原因是 pnpm 会把 peer dependency 上下文编码进安装路径。即使包版本相同,只要 peer dependency 组合不同,Rollup 看到的就是两个不同模块 ID。
使用 resolve.dedupe
在 Vite 中增加:
js
export default defineConfig({
resolve: {
dedupe: ['@internal/rich-text'],
},
})
处理后,该分包从约 13.91MB 降到约 6.9MB,说明重复实例已被合并。
长期治理建议
dedupe 可以快速解决当前构建问题,但更彻底的方式是统一 workspace 内的:
- TypeScript 版本;
- Vue 版本;
- React 版本;
- 关键 peer dependency 版本。
否则后续仍可能出现 VueUse、React 生态包等多版本并存。
三、不要按目录强制合并所有 composable
项目原本还有一条规则:
js
if (id.includes('src/composables/')) {
return 'shared-composables'
}
最初的目的是把公共逻辑集中起来,减少重复打包。但最终生成的 shared-composables 达到了约 12.95MB。
分析后发现,这个包真正的大头并不是 composable 本身,而是 composable 静态依赖的几个大型预构建产物,例如:
- 私有表格组件库;
- 私有 UI 组件库;
- 私有图标库;
- 其他业务基础库。
manualChunks 的规则具有"依赖吸附"效应。把某个业务模块强制放进一个 chunk 后,它依赖的大型代码也可能一起进入该 chunk。
调整方案
删除目录级强制合并:
js
// 删除
if (id.includes('src/composables/')) {
return 'shared-composables'
}
让 Rollup 根据真实入口依赖图决定:
- 仅被一个入口使用的 composable 跟随该入口;
- 被多个入口共同使用的模块自动提取;
- 不使用大型表格功能的页面不必被迫加载表格相关代码。
仍然可以保留少量边界明确的共享规则:
js
if (id.includes('src/stores/')) return 'shared-stores'
if (id.includes('src/components/EditorContainer')) return 'shared-editor'
原则是:
按稳定的业务边界拆包,而不是简单地按文件夹名称拆包。
四、JavaScript 拆小后,为什么 CSS 反而变成一个大文件
删除 shared-composables 后,JavaScript 分包有所改善,但新的问题出现了:所有组件样式被合并到一个数 MB 的 CSS 文件中。
这是因为 Vite 的 Library 模式默认关闭 CSS 分包。即使 JavaScript 已经拆成多个入口,CSS 仍可能统一汇总。
显式开启 CSS 分包
js
export default defineConfig({
build: {
cssCodeSplit: true,
},
})
配置资源输出路径:
js
assetFileNames(assetInfo) {
if (assetInfo.name?.endsWith('.css')) {
return 'style/[name]-[hash][extname]'
}
return 'assets/[name]-[hash][extname]'
}
开启后,入口和共享 chunk 对应的 CSS 会分别输出,并在 manifest.json 中形成类似结构:
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 依赖图
项目原来的 manifest 处理逻辑只保留入口 JS 映射:
js
{
'/components/Example.js': '/components/Example-abcd1234.js'
}
它丢失了:
imports;- 每个 chunk 的
css; - 入口名与 manifest key 的关系。
因此我们额外生成一份紧凑依赖图:
json
{
"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
}
页面分别注入:
html
<script>
window.__assetManifest = {/* 兼容旧代码的 JS 映射 */}
window.__assetManifestGraph = {/* 新增的依赖图 */}
</script>
保留旧映射非常重要,因为项目中可能还有其他代码直接执行:
js
window.__assetManifest[path]
不要为了增加 CSS 信息,直接把旧字符串映射改成对象,否则会造成隐藏的兼容性问题。
六、递归收集入口及其静态依赖 CSS
动态加载某个组件时,不能只读取入口自身的 CSS。入口依赖的共享 chunk 也可能包含基础样式。
可以采用依赖优先的深度优先遍历:
js
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 后插入,有利于保持:
text
基础样式 → 业务组件覆盖样式
需要注意,manifest 并不能完整还原原来单个 CSS 文件中的源码拼接顺序,因此启用 CSS 分包后仍然需要做视觉回归。
七、CSS 按 URL 去重,并与 JavaScript 并行加载
同一个共享 CSS 可能被多个动态组件依赖。如果每次挂载都插入 <link>,会造成重复请求和样式顺序漂移。
使用 Promise Map 去重
js
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 并行请求
不要先等待 CSS 完成后再请求 JS,否则会产生明显网络瀑布:
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
}
这个过程保证:
- CSS
<link>按确定顺序插入; - CSS 和 JavaScript 并行下载;
- 组件只有在样式和模块都加载完成后才开始渲染;
- 避免无样式内容闪烁。
组件卸载时不要急于删除 CSS,因为该样式可能仍被其他组件共享。对于长生命周期后台系统,页面内保留已加载 CSS 通常比实现复杂的引用计数卸载更安全。
八、最容易忽略的坑:不是所有组件都经过统一加载器
完成上述改造后,我们发现某个绘图组件仍然丢失样式。
检查构建产物后确认:
- 组件 CSS 已经正常编译;
- CSS 文件中存在目标选择器;
- JavaScript 入口也能正常加载;
- 但页面没有请求该组件对应的 CSS。
根因是这个组件由富文本编辑器作为"外部组件"加载,它没有经过项目统一的 renderComponent(),而是直接执行:
js
import(resolveAssetPath('DrawingComponent.js'))
resolveAssetPath() 只负责把无 hash 路径转换为带 hash 路径,并不会加载 CSS。
暴露完整模块加载接口
统一加载器额外暴露:
js
window.importComponentModule = importComponentModuleWithRetry
富文本编辑器中的外部组件改为:
js
async function importDistModule(name) {
const path = `/dist/${name}`
if (typeof window.importComponentModule === 'function') {
return window.importComponentModule(path)
}
// 兼容旧环境
return import(resolveAssetPath(name))
}
随后所有特殊加载链路中的外部组件都统一改成:
js
return importDistModule('DrawingComponent.js')
而不是只修复绘图组件一个入口。
这个问题给我们的启示是:
CSS 分包是否生效,不只取决于构建配置,还取决于运行时是否存在绕过统一资源加载器的动态 import。
九、兼容旧构建产物
在前后端混合项目中,代码与静态资源不一定能做到绝对原子切换,因此需要兼容旧版单 CSS 构建。
判断 manifest 中是否存在 chunk CSS:
js
function hasManifestCssGraph() {
return Boolean(window.__assetManifestGraph?.hasCss)
}
新构建按入口加载 CSS,旧构建继续加载聚合样式:
js
if (!hasManifestCssGraph()) {
loadStyleOnce(resolveAssetPath('style/app.css'))
}
开发模式则继续交给 Vite HMR:
js
if (window.__viteDevServerUrl) {
// 不加载生产 CSS,Vite 自动注入并更新样式
}
最终形成三套兼容行为:
| 场景 | CSS 加载方式 |
|---|---|
| Vite 开发环境 | Vite 自动注入并 HMR |
| 新版生产构建 | 根据 manifest 按入口递归加载 |
| 旧版生产构建 | 加载单个聚合 CSS |
十、资源 hash 失效时,同时刷新 JS 和 CSS
原来的重试逻辑只比较 JavaScript URL:
js
if (retryPath === resolvedPath) {
throw error
}
CSS 分包后,可能出现:
- JS hash 没变,但 CSS hash 已变化;
- CSS 404,JS 正常;
- 页面缓存了旧 manifest,而服务器已经切换到新资源。
因此加载描述应同时包含:
js
{
jsUrl,
cssUrls,
}
刷新 manifest 后比较完整签名:
js
function getDescriptorSignature(descriptor) {
return JSON.stringify([
descriptor.jsUrl,
descriptor.cssUrls,
])
}
当 CSS 或 JS 任一加载失败时:
- 使用
no-store重新获取 manifest; - 同时更新 JS 映射和 CSS 依赖图;
- 重新计算加载描述;
- 资源地址发生变化时重试一次;
- 地址未变化则抛出原始错误,防止无限重试。
十一、优化结果应该怎么看
本次优化过程中观察到的主要变化如下:
| 项目 | 优化前 | 优化后 |
|---|---|---|
错误归类的 vendor-vue |
约 17.09MB | 只保留少量真正 Vue 生态代码 |
| 重复的私有富文本分包 | 约 13.91MB | 去重后约 6.9MB |
| 强制聚合的 composable 共享包 | 约 12.95MB | 交由 Rollup 按真实依赖拆分 |
| CSS | 单个数 MB 聚合文件 | 跟随入口和共享 chunk 按需加载 |
| 特殊动态组件 | JS 正常、CSS 可能丢失 | 统一经过完整资源加载器 |
这里不能只看构建后的文件数量,还要关注具体页面实际下载了什么。
建议至少采集:
- 首屏 JavaScript 传输体积;
- 首屏 CSS 传输体积;
- 最大 chunk;
- 首次内容绘制时间;
- 组件首次打开耗时;
- 重复打开组件时的缓存命中;
- 页面长时间运行后的 stylesheet 数量。
总结
这次优化表面上是在处理几个大包,实际涉及构建期和运行时两个层面。
构建期需要解决:
- 不要用完整路径进行模糊包名匹配;
- 正确解析 pnpm 下的真实包名;
- 使用
dedupe消除 peer dependency 上下文造成的重复实例; - 不要按目录粗暴聚合所有 composable;
- 在 Library 模式下显式开启
cssCodeSplit。
运行时需要解决:
- 保留完整 manifest 依赖关系;
- 递归加载入口和静态依赖 CSS;
- 按 URL 对共享 CSS 去重;
- CSS 与 JavaScript 并行请求,渲染前共同就绪;
- 所有动态组件必须经过统一资源加载器;
- 兼容旧构建、开发模式和 manifest 刷新重试。
最后最重要的一点是:
分包优化不是让构建产物看起来更碎,而是让用户只下载当前页面真正需要的代码和样式,同时保证缓存、加载顺序和运行时兼容性。