从 17MB Vendor 大包到按需加载:Vite 多入口 Vue 组件库首屏优化实践

本文记录一次真实的 Vue 3 + Vite 多入口组件库首屏优化过程。项目中的业务名称、私有包名和内部路径均已匿名化,代码仅保留与构建优化相关的核心逻辑。

前言

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

  • 服务端页面提供挂载节点;
  • Vue 组件被构建成多个独立入口;
  • 页面根据业务场景动态 import() 对应组件;
  • 生产环境通过 Vite manifest.json 解析带 hash 的资源;
  • Vue 和部分基础库由宿主页面统一提供。

随着业务不断增加,构建产物逐渐暴露出几个问题:

  1. 一个名为 vendor-vue 的文件膨胀到约 17MB
  2. 大量与 Vue 无关的依赖被错误打入该文件;
  3. 某个私有富文本包被重复打包两次;
  4. 强制合并所有 composable 后,共享包达到约 12.95MB
  5. 拆分 JavaScript 后,CSS 又被合并成一个数 MB 的大文件;
  6. 启用 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.09MBvendor-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 任一加载失败时:

  1. 使用 no-store 重新获取 manifest;
  2. 同时更新 JS 映射和 CSS 依赖图;
  3. 重新计算加载描述;
  4. 资源地址发生变化时重试一次;
  5. 地址未变化则抛出原始错误,防止无限重试。

十一、优化结果应该怎么看

本次优化过程中观察到的主要变化如下:

项目 优化前 优化后
错误归类的 vendor-vue 约 17.09MB 只保留少量真正 Vue 生态代码
重复的私有富文本分包 约 13.91MB 去重后约 6.9MB
强制聚合的 composable 共享包 约 12.95MB 交由 Rollup 按真实依赖拆分
CSS 单个数 MB 聚合文件 跟随入口和共享 chunk 按需加载
特殊动态组件 JS 正常、CSS 可能丢失 统一经过完整资源加载器

这里不能只看构建后的文件数量,还要关注具体页面实际下载了什么。

建议至少采集:

  • 首屏 JavaScript 传输体积;
  • 首屏 CSS 传输体积;
  • 最大 chunk;
  • 首次内容绘制时间;
  • 组件首次打开耗时;
  • 重复打开组件时的缓存命中;
  • 页面长时间运行后的 stylesheet 数量。

总结

这次优化表面上是在处理几个大包,实际涉及构建期和运行时两个层面。

构建期需要解决:

  1. 不要用完整路径进行模糊包名匹配;
  2. 正确解析 pnpm 下的真实包名;
  3. 使用 dedupe 消除 peer dependency 上下文造成的重复实例;
  4. 不要按目录粗暴聚合所有 composable;
  5. 在 Library 模式下显式开启 cssCodeSplit

运行时需要解决:

  1. 保留完整 manifest 依赖关系;
  2. 递归加载入口和静态依赖 CSS;
  3. 按 URL 对共享 CSS 去重;
  4. CSS 与 JavaScript 并行请求,渲染前共同就绪;
  5. 所有动态组件必须经过统一资源加载器;
  6. 兼容旧构建、开发模式和 manifest 刷新重试。

最后最重要的一点是:

分包优化不是让构建产物看起来更碎,而是让用户只下载当前页面真正需要的代码和样式,同时保证缓存、加载顺序和运行时兼容性。

相关推荐
jsonbro1 小时前
手把手带你实现发布订阅模式
前端·vue.js·设计模式
Cobyte3 小时前
23.Vue Vapor 组件 attrs 的实现
前端·javascript·vue.js
柯克七七5 小时前
给老项目加了 TypeScript,本来只想自己爽,结果全公司代码审查标准被我抬高了
前端·vue.js·typescript
自然 醒7 小时前
前端如何实现在线预览office常见文件功能?
前端·vue.js
肉肉不吃 肉8 小时前
前端调试跨域如何解决
前端·vue.js
卓怡学长8 小时前
w268基于springboot + vue 物流系统
java·数据库·vue.js·spring boot·spring·intellij-idea
迅速的自行车1 天前
从一段模板说起
前端·javascript·vue.js
阿奇__1 天前
uniapp微信小程序图片上传含回显组件(拍照,相册)
前端·javascript·vue.js
前端甜糖1 天前
前端如何实现在线预览office常见文件功能?
前端·vue.js