背景
年前开始负责新我的项目开发,是一个h5内嵌到企业微信。技术栈是 vite2.x
+ vue3.x
。随着业务的发展,版本迭代,页面越来越多,第三方依赖也越来越多,打进去的包也越来越大。针对这个问题,很容易就会想到分包这个解决方案。依据 vite 官网文档 提醒,做了 vendor
分包之外,还对路由援用的组件做了异步加载解决,也会产生独立分包。这种配置在某个阶段是没问题的。
遇到问题
- 在
vite
配置文件,通过build.rollupOptions.output.manualChunks
配合手动分包策略之后,vite
不会主动生成vendor
包 - 当页面越来越多,配置了动静引入页面之后,打包进去会产生
chunk碎片
,如几个页面专用的文件api.js
sdkUtils.js
http.js
等,这些独立的分包大小都很小,加起来gzip
之后都不到1kb
,减少了网络申请。
最终解决方案
通过浏览源码,以及官网文档,剖析了vite和rollup的分包策略,最初得出这个解决方案:
rollupOptions: { output: { manualChunks(id: any, { getModuleInfo }) { const cssLangs = `\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)`; const cssLangRE = new RegExp(cssLangs); const isCSSRequest = (request: string): boolean => cssLangRE.test(request); // 分vendor包 if ( id.includes('node_modules') && !isCSSRequest(id) && staticImportedByEntry(id, getModuleInfo, cache.cache) ) { return 'vendor'; } else if ( // 分manifest包,解决chunk碎片问题 ((getModuleInfo(id).importers.length + getModuleInfo(id).dynamicImporters.length) > 1) && id.includes('src') ) { return 'manifest'; } } }}
export class SplitVendorChunkCache { constructor() { this.cache = new Map(); } reset() { this.cache = new Map(); }}export function staticImportedByEntry( id, getModuleInfo, cache, importStack = []) { if (cache.has(id)) { return !!cache.get(id); } if (importStack.includes(id)) { cache.set(id, false); return false; } const mod = getModuleInfo(id); if (!mod) { cache.set(id, false); return false; } if (mod.isEntry) { cache.set(id, true); return true; } const someImporterIs = mod.importers.some((importer) => staticImportedByEntry( importer, getModuleInfo, cache, importStack.concat(id) ) ); cache.set(id, someImporterIs); return someImporterIs;}
上面来看看过后是如何剖析,以及一步一步来揭开默认分包策略的神秘面纱。
剖析
vite2.x
什么状况下能够触发 vendor
包的生成?
通过测试,在 vite
配置文件,通过 build.rollupOptions.output.manualChunks
配合手动分包策略之后,不会主动生成vendor包。想要晓得更清晰 vite
在什么状况会分 vendor
包,什么时候不会分 vendor
包,须要关上源码看清楚。
// vite// packages\vite\src\node\plugins\splitVendorChunk.tsreturn { name: 'vite:split-vendor-chunk', config(config) { let outputs = config?.build?.rollupOptions?.output if (outputs) { outputs = Array.isArray(outputs) ? outputs : [outputs] for (const output of outputs) { const viteManualChunks = createSplitVendorChunk(output, config) if (viteManualChunks) { if (output.manualChunks) { if (typeof output.manualChunks === 'function') { const userManualChunks = output.manualChunks output.manualChunks = (id: string, api: GetManualChunkApi) => { return userManualChunks(id, api) ?? viteManualChunks(id, api) } } // else, leave the object form of manualChunks untouched, as // we can't safely replicate rollup handling. } else { output.manualChunks = viteManualChunks } } } } else { return { build: { rollupOptions: { output: { manualChunks: createSplitVendorChunk({}, config) } } } } } }, buildStart() { caches.forEach((cache) => cache.reset()) } }
- 代码不简单,vendor包生成的逻辑封装vite插件的模式
- 如果用户没有配置
build.rollupOptions.output
,应用插件之后就能够分出vendor
包 - 如果用户有配置
build.rollupOptions.output
,但没有配置manualChunks
,应用插件之后就能够分出vendor
包 - 如果用户有配置
build.rollupOptions.output
,且有配置manualChunks
,就会以手动分包配置为准,不会生成vendor
包 vendor
包分包的策略是:模块id名是蕴含'node_modules'
的,示意该模块是在node_modules下的,且这个模块不是css
模块,且这个模块是被动态入口点模块(单页利用的index.html,多页利用下能够有多个)导入的
小结:用户配置了手动分包,就会疏忽 vite
提供的 vendor
分包逻辑。
那如果心愿在手动分包的根底上还须要 vendor
分包,那么就须要把 vendor
分包的逻辑抄过去就能够了。
备注:
vite2.x
在2.8
版本之后把默认分vendor
包的逻辑勾销了,改为了插件式应用。vite2.x
底层是通过rollup
打包的。
rollup
默认分包策略的 chunk碎片
是如何产生的?
为什么会产生 chunk碎片
?参考对 webpack
分包的了解,除了入口点(动态入口点、动静入口点)独自生成一个chunk之外,当一个模块被两个或以上的 chunk
援用,这个模块须要独自生成一个 chunk
。
上面从源码的角度看看 rollup
是如何生成这些 chunk碎片
的。
// rollup// src\Bundle.ts private async generateChunks(): Promise<Chunk[]> { const { manualChunks } = this.outputOptions; const manualChunkAliasByEntry = typeof manualChunks === 'object' ? await this.addManualChunks(manualChunks) : this.assignManualChunks(manualChunks); const chunks: Chunk[] = []; const chunkByModule = new Map<Module, Chunk>(); for (const { alias, modules } of this.outputOptions.inlineDynamicImports ? [{ alias: null, modules: getIncludedModules(this.graph.modulesById) }] : this.outputOptions.preserveModules ? getIncludedModules(this.graph.modulesById).map(module => ({ alias: null, modules: [module] })) : getChunkAssignments(this.graph.entryModules, manualChunkAliasByEntry)) { sortByExecutionOrder(modules); const chunk = new Chunk( modules, this.inputOptions, this.outputOptions, this.unsetOptions, this.pluginDriver, this.graph.modulesById, chunkByModule, this.facadeChunkByModule, this.includedNamespaces, alias ); chunks.push(chunk); for (const module of modules) { chunkByModule.set(module, chunk); } } for (const chunk of chunks) { chunk.link(); } const facades: Chunk[] = []; for (const chunk of chunks) { facades.push(...chunk.generateFacades()); } return [...chunks, ...facades]; }
- 针对手动分包,组装
manualChunkAliasByEntry
(map
数据类型,key
为以后模块的模块信息,是一个对象数据类型,value
为该模块的入口,是一个string
,也就是手动分包的名字,如最终答案中的vendor
、manifest
) - 分包策略还会因为配置
inlineDynamicImports
和preserveModules
而扭转,这次不进行剖析。默认是会通过getChunkAssignments
返回的chunk定义数据
,而后生成chunk
。
上面来看看 getChunkAssignments
做了什么。
function getChunkAssignments(entryModules, manualChunkAliasByEntry) { const chunkDefinitions = []; debugger; const modulesInManualChunks = new Set(manualChunkAliasByEntry.keys()); const manualChunkModulesByAlias = Object.create(null); for (const [entry, alias] of manualChunkAliasByEntry) { const chunkModules = (manualChunkModulesByAlias[alias] = manualChunkModulesByAlias[alias] || []); addStaticDependenciesToManualChunk(entry, chunkModules, modulesInManualChunks); } for (const [alias, modules] of Object.entries(manualChunkModulesByAlias)) { chunkDefinitions.push({ alias, modules }); } const assignedEntryPointsByModule = new Map(); const { dependentEntryPointsByModule, dynamicEntryModules } = analyzeModuleGraph(entryModules); const dynamicallyDependentEntryPointsByDynamicEntry = getDynamicDependentEntryPoints(dependentEntryPointsByModule, dynamicEntryModules); const staticEntries = new Set(entryModules); function assignEntryToStaticDependencies(entry, dynamicDependentEntryPoints) { const modulesToHandle = new Set([entry]); for (const module of modulesToHandle) { const assignedEntryPoints = getOrCreate(assignedEntryPointsByModule, module, () => new Set()); if (dynamicDependentEntryPoints && areEntryPointsContainedOrDynamicallyDependent(dynamicDependentEntryPoints, dependentEntryPointsByModule.get(module))) { continue; } else { assignedEntryPoints.add(entry); } for (const dependency of module.getDependenciesToBeIncluded()) { if (!(dependency instanceof ExternalModule || modulesInManualChunks.has(dependency))) { modulesToHandle.add(dependency); } } } } function areEntryPointsContainedOrDynamicallyDependent(entryPoints, containedIn) { const entriesToCheck = new Set(entryPoints); for (const entry of entriesToCheck) { if (!containedIn.has(entry)) { if (staticEntries.has(entry)) return false; const dynamicallyDependentEntryPoints = dynamicallyDependentEntryPointsByDynamicEntry.get(entry); for (const dependentEntry of dynamicallyDependentEntryPoints) { entriesToCheck.add(dependentEntry); } } } return true; } for (const entry of entryModules) { if (!modulesInManualChunks.has(entry)) { assignEntryToStaticDependencies(entry, null); } } for (const entry of dynamicEntryModules) { if (!modulesInManualChunks.has(entry)) { assignEntryToStaticDependencies(entry, dynamicallyDependentEntryPointsByDynamicEntry.get(entry)); } } chunkDefinitions.push(...createChunks([...entryModules, ...dynamicEntryModules], assignedEntryPointsByModule)); return chunkDefinitions;}
- 入参
entryModules
是一个数组,寄存了入口模块的信息。因为是单页利用,入口只有一个,这里的entryModules
只有一个元素,就是id
为"我的项目门路/index.html"
的模块。如果是多页面利用就会有多个入口。 - 针对手动分包,通过
manualChunkAliasByEntry
(map数据类型,key为模块信息,value为分包名字)转换成manualChunkModulesByAlias
(对象数据类型,key为分包名字,value为以key为入口的模块数组),顺便记录modulesInManualChunks
(所有手动分包包含的模块),不便后续默认分包的应用。把manualChunkModulesByAlias
包装成{ alias, modules }
放到chunkDefinitions
数组。 - 通过
entryModules
剖析出dynamicEntryModules
(动静入口模块,即用import导入的组件/页面)、dependentEntryPointsByModule
(map数据类型,key为模块信息,value为该模块的入口点,入口点能够有多个,能够是动态入口点,也能够是动静入口点)、dynamicallyDependentEntryPointsByDynamicEntry
(map数据类型,key为动静入口模块,value为动静入口被动静导入的模块的入口点数组,有点拗口,这个数据就是) - 如果动态入口模块或动静入口模块在手动分包的模块中,则不解决,以手动分包为准。
- 如果动态入口模块或动静入口模块不在手动分包的模块中,则通过
assignEntryToStaticDependencies
办法结构assignedEntryPointsByModule
(map数据类型,key为模块信息,value为该模块的入口模块) - 通过
createChunks
办法把动态入口模块和动静入口模块转换成chunk定义信息,而后推到chunkDefinitions
数组。
小结:
- 手动分包优先级比默认分包优先级高,手动分包会笼罩默认分包。当解决默认分包时,会查看以后模块是否在手动分包中,是的话则疏忽该模块。
上面看看通过 createChunks
是如何进行默认分包的
function createChunks(allEntryPoints, assignedEntryPointsByModule) { const chunkModules = Object.create(null); for (const [module, assignedEntryPoints] of assignedEntryPointsByModule) { let chunkSignature = ''; for (const entry of allEntryPoints) { chunkSignature += assignedEntryPoints.has(entry) ? 'X' : '_'; } const chunk = chunkModules[chunkSignature]; if (chunk) { chunk.push(module); } else { chunkModules[chunkSignature] = [module]; } } return Object.values(chunkModules).map(modules => ({ alias: null, modules }));}
allEntryPoints
包含了动态入口模块
和动静入口模块
- 生成
chunk签名
。chunk签名
是由X
和_
组成的,总长度为入口模块allEntryPoints
的数量。遍历allEntryPoints
,如果以后模块的入口点中有allEntryPoints
其中的一个,则记作X
否则记作_
- 封装成
{alias, modules}
返回chunk定义信息
对于生成 chunk签名
,举个具体点的例子,allEntryPoints
包含一个动态入口点 index.html
,两个动静入口点: Hello.vue
和 World.vue
。有一个模块 sdkUtils.js
的入口点为 Hello.vue
(即被 Hello.vue
导入);有一个模块 api.js
的入口点为 Hello.vue
以及 World.vue
;有一个模块 log.js
依赖了 Hello.vue
、World.vue
和 index.html
。
- 如果以后模块,入口点只有
index.html
,遍历allEntryPoints
,入口点有index.html
,则chunk签名
为X
;入口点没有Hello.vue
,则chunk签名
为X_
;入口点没有World.vue
,则chunk签名
为X__
。拿到签名之后,用变量chunkModules
寄存不同chunk签名
的模块,以chunk签名
为key,value为数组,把以后模块push进去。 - 如果以后模块,入口点只有
Hello.vue
,遍历allEntryPoints
,入口点没有index.html
,则chunk签名
为_
;入口点有Hello.vue
,则chunk签名
为_X
;入口点没有World.vue
,则chunk签名
为_X_
。拿到签名之后,用变量chunkModules
寄存不同chunk签名
的模块,以chunk签名
为key,value为数组,把以后模块push进去。 - 如果以后模块,入口点只有
World.vue
,同理,chunk签名
为__X
- 如果以后模块为
sdkUtils.js
,则chunk签名
为_X_
。 - 如果以后模块为
api.js
,则chunk签名
为_XX
。 - 如果以后模块为
log.js
,则chunk签名
为XXX
。
所以,这个例子中,会产生6个chunk,且 api.js
对应的 chunk
和 log.js
对应的chunk
就是额定多进去的chunk。
小结:
- 默认分包策略,通过
chunk签名
来标识模块和所有入口点之间依赖关系。有同样chunk签名
的模块会分到同一个chunk
。从而奇妙地实现一个模块被多个入口点援用,生成一个新的chunk。以及每个入口点都会生成一个chunk。 - 正是因为
chunk签名
是依赖所有入口点来生成,当动静入口点过多(如页面过多,所有页面都动静导入),可复用的chunk签名
少,所以分进去的chunk
就多,且这些被多个入口点引入的模块所生成的chunk
所蕴含的模块少,所以会产生chunk碎片
如何通过手动分包解决 chunk碎片
问题?
- 上文提到,手动分包优先级比默认分包优先级高。当解决默认分包时,会查看以后模块是否在手动分包中,是的话则疏忽该模块。
- 参考
chunk碎片
的生成原理,在手动分包时,不不便获取动静入口点。只管能够参考analyzeModuleGraph
,通过动态入口点来获取,因为代码量多,不好照搬。 - 察看这些
chunk碎片
,它们的特色就是以后模块被2个或以上的模块援用。而模块信息,有两个字段能够利用,一个是importers
,一个是dynamicImporters
,对应着以后模块被动态引入的模块,以及被动静引入的模块。 - 所以,只须要在手动分包时,判断以后模块的
importers.length + dynamicImporters.length > 1
,就能够把它放到manifest
chunk中 - 不要遗记,须要对模块限定在
src
目录下,否则会影响node_modules
下的一些包的依赖关系。所以只须要增加约束条件:id.includes('src')
总结
vite2.x
中,当用户配置了手动分包,就会笼罩vite
提供的vendor
分包逻辑。如果想在手动分包中保留vendor
逻辑,只需把代码拷贝到手动分包;rollup
的默认分包机制,应用chunk签名
来实现分包,除了入口点(动态入口点如index.html、动静入口点如路由应用import
导入页面)独自作为一个chunk,那些有多个入口点且chunk签名
统一的模块会打包到同一个chunk
- 手动分包优先级大于默认分包,先解决手动分包,再解决默认分包。当解决默认分包时,会查看以后模块是否在手动分包中,是的话则疏忽该模块。
- 配置手动分包时,会提供办法获取以后模块的信息,能够通过
importers
和dynamicImporters
来获取动态和动静导入以后模块的模块。当importers.length + dynamicImporters.length > 1
,就把它放进命名为manifest
的chunk