当前位置: 首页
前端开发
Vite多入口Vue组件库首屏优化:从17MB到按需加载

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

热心网友 时间:2026-07-18
转载

针对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

游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

同类文章
更多
React Leaflet地图中心与缩放动态控制实战教程完整版

React Leaflet地图中心与缩放动态控制实战教程完整版

在React-Leaflet中,通过按钮点击跳转至指定坐标并调整缩放级别,应使用whenCreated获取Leaflet原生地图实例,调用setView或flyTo方法,避免依赖center zoomprops的响应式更新,以确保性能与状态一致性。

时间:2026-07-18 18:19
如何用事件委托实现动态卡片翻转的原生JS

如何用事件委托实现动态卡片翻转的原生JS

针对动态生成的卡片列表,采用事件委托将监听器挂载到父容器,利用事件冒泡精准捕获点击目标。通过class替代重复ID,使用card querySelector()限定局部作用域,配合closest()定位卡片容器,实现翻转与还原功能。该方法避免全局查询与状态污染,确保多卡片独立控制,提升性能与可扩展性。

时间:2026-07-18 18:18
OpenCV透视变换源点顺序TL-TR-BR-BL保证方法

OpenCV透视变换源点顺序TL-TR-BR-BL保证方法

针对OpenCV透视变换中源点顺序不一致导致图像翻转的问题,提出一种稳健的四点排序方法:先计算点集质心,再按极角排序,并利用y+x与y-x组合得分确定左上、右上、右下、左下角点,从而自动重排为顺时针顺序,确保透视变换后图像方向一致。

时间:2026-07-18 18:18
OpenCV透视变换中如何确保源点按左上右上右下左下排列

OpenCV透视变换中如何确保源点按左上右上右下左下排列

通过计算几何中心与极角排序,自动将任意顺序的四个轮廓点重排为左上→右上→右下→左下标准顺序,确保透视变换后图像方向一致。该方法对凸四边形鲁棒,性能开销极小,可安全集成到实时流程中。

时间:2026-07-18 18:18
税务计算器ID重复引发JavaScript计算错误修复方法

税务计算器ID重复引发JavaScript计算错误修复方法

税务计算器因HTML元素ID重复导致DOM获取失败与计算错误,通过为输入框和显示区分配唯一ID可彻底解决。需注意输入校验、局部变量声明及清除函数同步更新,确保税额与净薪准确显示。

时间:2026-07-18 18:18
热门专题
更多
刀塔传奇破解版无限钻石下载大全 刀塔传奇破解版无限钻石下载大全
洛克王国正式正版手游下载安装大全 洛克王国正式正版手游下载安装大全
思美人手游下载专区 思美人手游下载专区
好玩的阿拉德之怒游戏下载合集 好玩的阿拉德之怒游戏下载合集
不思议迷宫手游下载合集 不思议迷宫手游下载合集
百宝袋汉化组游戏最新合集 百宝袋汉化组游戏最新合集
jsk游戏合集30款游戏大全 jsk游戏合集30款游戏大全
宾果消消消原版下载大全 宾果消消消原版下载大全
  • 热门数据榜