游乐游手机版
首页/前端开发/文章详情

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

时间:2026-07-18 06:42
针对Vite多入口Vue组件库首屏优化,修正了基于路径模糊匹配的分包规则,通过解析真实包名准确拆分vendor包;利用resolve dedupe消除同一私有包因pnpm路径差异导致的重复打包;取消目录级强制合并composable,避免依赖吸附效应;开启CSS分包并构建依赖图,递归收集入口及其静态依赖的CSS,实现按需加载并兼容旧映射。

前言

先说一下这个项目的背景,它并不是常见的单页应用,而是一个嵌入传统服务端页面的 Vue 组件库。结构大概是这样:

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

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

随着业务不断增长,构建产物渐渐暴露出一堆问题:

  1. 一个叫 vendor-vue 的文件膨胀到了差不多 17MB
  2. 大量跟 Vue 八竿子打不着的依赖也被错误地塞了进去;
  3. 某个私有富文本包被重复打包了两次;
  4. 强制合并所有 composable 后,共享包达到了约 12.95MB
  5. 拆分 Ja vaScript 后,CSS 又被合并成一个数 MB 的大文件;
  6. 启用 CSS 分包后,部分由特殊加载链路引入的组件出现了样式丢失。

这次优化的重点,其实不是简单地“把大文件切成小文件”,而是要同时解决下面这几个问题:

  • 分包规则准确性;
  • 首屏按需加载;
  • 长期缓存;
  • CSS 去重;
  • 动态组件样式加载;
  • 新旧构建产物兼容。

一、为什么 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.09MBvendor-vue 被拆解成了多个职责清晰的包。由于 Vue 主包在本项目中由宿主页面 external 提供,最终 vendor-vue 只剩下了少量 Vue 生态辅助代码。

需要强调的是:


二、利用 Source Map 发现同一个私有包被打包两次

完成第一轮拆包后,最大的文件变成了私有富文本依赖:

 复制代码vendor-rich-text:约 13.91MB

通过 Rollup Visualizer 和 Source Map 继续分析,发现里面存在两份版本号完全相同的入口文件,每份大概 6.9MB。

它们的实际文件内容和 SHA-256 完全一致,但 pnpm 目录不同:

 复制代码...typescript@5.8.x.../node_modules//dist/index.js
...typescript@5.9.x.../node_modules//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 内的:

  • TypeScript 版本;
  • Vue 版本;
  • React 版本;
  • 关键 peer dependency 版本。

否则后续还是可能出现 VueUse、React 生态包等多版本并存的情况。


三、不要按目录强制合并所有 composable

项目原本还有一条规则:

 复制代码if (id.includes('src/composables/')) {
  return 'shared-composables'
}

初衷是想把公共逻辑集中起来,减少重复打包。但最终生成的 shared-composables 达到了约 12.95MB

分析后发现,这个包真正的大头并不是 composable 本身,而是 composable 静态依赖的几个大型预构建产物,比如:

  • 私有表格组件库;
  • 私有 UI 组件库;
  • 私有图标库;
  • 其他业务基础库。

manualChunks 的规则其实有“依赖吸附”效应。把某个业务模块强制放进一个 chunk 后,它依赖的大型代码也可能一起进入这个 chunk。

调整方案

删除目录级强制合并:

 复制代码// 删除
if (id.includes('src/composables/')) {
  return 'shared-composables'
}

让 Rollup 根据真实的入口依赖图来决定:

  • 仅被一个入口使用的 composable 就跟随该入口;
  • 被多个入口共同使用的模块自动提取;
  • 不使用大型表格功能的页面,不必被迫加载表格相关代码。

仍然可以保留少量边界明确的共享规则:

 复制代码if (id.includes('src/stores/')) return 'shared-stores'
if (id.includes('src/components/EditorContainer')) return 'shared-editor'

原则是:


四、Ja vaScript 拆小后,为什么 CSS 反而变成一个大文件

删除 shared-composables 后,Ja vaScript 分包有所改善,但新的问题来了:所有组件样式被合并到了一个数 MB 的 CSS 文件中。

这是因为 Vite 的 Library 模式默认关闭了 CSS 分包。即使 Ja vaScript 已经拆成了多个入口,CSS 仍然可能统一汇总。

显式开启 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 依赖图

项目原来的 manifest 处理逻辑只保留入口 JS 映射:

 复制代码{
  '/components/Example.js': '/components/Example-abcd1234.js'
}

它丢失了:

  • imports
  • 每个 chunk 的 css
  • 入口名与 manifest key 的关系。

因此我们额外生成了一份紧凑的依赖图:

 复制代码{
  "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

动态加载某个组件时,不能只读取入口自身的 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 按 URL 去重,并与 Ja vaScript 并行加载

同一个共享 CSS 可能被多个动态组件依赖。如果每次挂载都插入 ,会造成重复请求和样式顺序漂移。

使用 Promise Map 去重

 复制代码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,否则会产生明显的网络瀑布:

 复制代码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 按确定顺序插入;
  • CSS 和 Ja vaScript 并行下载;
  • 组件只有在样式和模块都加载完成后才开始渲染;
  • 避免无样式内容闪烁。

组件卸载时不要急于删除 CSS,因为该样式可能仍被其他组件共享。对于长生命周期的后台系统,页面内保留已加载 CSS 通常比实现复杂的引用计数卸载更安全。


八、最容易忽略的坑:不是所有组件都经过统一加载器

完成上述改造后,我们发现某个绘图组件仍然丢失样式。

检查构建产物后确认:

  • 组件 CSS 已经正常编译;
  • CSS 文件中存在目标选择器;
  • Ja vaScript 入口也能正常加载;
  • 但页面没有请求该组件对应的 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

十、资源 hash 失效时,同时刷新 JS 和 CSS

原来的重试逻辑只比较 Ja vaScript URL:

 复制代码if (retryPath === resolvedPath) {
  throw error
}

CSS 分包后,可能出现:

  • JS hash 没变,但 CSS hash 已经变化;
  • CSS 404,JS 正常;
  • 页面缓存了旧 manifest,而服务器已经切换到新资源。

因此加载描述应同时包含:

 复制代码{
  jsUrl,
  cssUrls,
}

刷新 manifest 后比较完整签名:

 复制代码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 可能丢失统一经过完整资源加载器

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

建议至少采集:

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

总结

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

构建期需要解决:

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

运行时需要解决:

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

最后,最重要的一点是:构建优化不只是改几个配置项,而是要理解整个资源加载链路,确保每个环节都衔接得上。

来源:https://juejin.cn/post/7660464212903493632
上一篇老项目引入TypeScript意外提升全公司代码审查标准 下一篇手把手带你实现Vue源码中的发布订阅模式
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
JavaScript有效防止原型污染影响属性存在性检查
前端开发 · 2026-07-18

JavaScript有效防止原型污染影响属性存在性检查

防止原型污染,进行属性存在性检查应使用`Object hasOwn()`绕过原型链上的属性,兼容旧环境时则使用`Object prototype hasOwnProperty call()`。避免使用`in`操作符或直接值判断。治本之策是移除`__proto__`等危险键,用`Object create(null)`创建无原型链的对象,并冻结`Object

如何利用Promise简化前端状态管理的高效实用方法
前端开发 · 2026-07-18

如何利用Promise简化前端状态管理的高效实用方法

Promise的三种状态与链式调用天然适合组织异步流程,结合Promise withResolvers可解耦创建与决议。在UI框架中,Promise实例可直接驱动声明式状态渲染,减少手动维护变量,让状态管理回归本质,并提升代码可读性与可维护性,实现直观的异步状态驱动。

JavaScript内存泄露常见代码模式识别方法
前端开发 · 2026-07-18

JavaScript内存泄露常见代码模式识别方法

JavaScript内存泄漏源于对象不可达却被强引用,典型模式包括意外全局变量、未清理的定时器、DOM引用残留、闭包长期持有大对象及模块静态引用累积。需借助DevTools堆快照定位持续增长的对象与保留者。

JavaScript展开运算符与深拷贝的不可变数据实现对比
前端开发 · 2026-07-18

JavaScript展开运算符与深拷贝的不可变数据实现对比

展开运算符仅浅拷贝第一层属性,对嵌套对象无效但执行高效;深拷贝递归复制所有层级实现完全隔离,但存在性能开销与循环引用问题。实际开发中推荐组合展开运算符与手动深层覆盖,或借助Immer库,仅在嵌套极深时使用structuredClone。

V8引擎编译优化对JavaScript内存占用的影响
前端开发 · 2026-07-18

V8引擎编译优化对JavaScript内存占用的影响

V8引擎的编译优化通过字节码紧凑表示节省约一半内存,隐藏类共享减少对象结构开销,TurboFan编译器执行常量折叠、死代码消除、内联及类型反馈精简表示,分代垃圾回收采用新生代半空间复制与老生代增量标记,有效控制内存峰值并加速短期对象回收。