乐趣区

关于vite:分析vite2xrollup分包原理解决chunk碎片问题

背景

年前开始负责新我的项目开发,是一个 h5 内嵌到企业微信。技术栈是 vite2.x + vue3.x。随着业务的发展,版本迭代,页面越来越多,第三方依赖也越来越多,打进去的包也越来越大。针对这个问题,很容易就会想到分包这个解决方案。依据 vite 官网文档 提醒,做了 vendor 分包之外,还对路由援用的组件做了异步加载解决,也会产生独立分包。这种配置在某个阶段是没问题的。

遇到问题

  1. vite 配置文件,通过 build.rollupOptions.output.manualChunks 配合手动分包策略之后,vite 不会主动生成 vendor
  2. 当页面越来越多,配置了动静引入页面之后,打包进去会产生 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.ts
return {
    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())
    }
  }
  1. 代码不简单,vendor 包生成的逻辑封装 vite 插件的模式
  2. 如果用户没有配置 build.rollupOptions.output,应用插件之后就能够分出 vendor
  3. 如果用户有配置 build.rollupOptions.output,但没有配置 manualChunks,应用插件之后就能够分出 vendor
  4. 如果用户有配置 build.rollupOptions.output,且有配置 manualChunks,就会以手动分包配置为准,不会生成 vendor
  5. vendor 包分包的策略是:模块 id 名是蕴含 'node_modules' 的,示意该模块是在 node_modules 下的,且这个模块不是 css 模块,且这个模块是被动态入口点模块(单页利用的 index.html,多页利用下能够有多个)导入的

小结:用户配置了手动分包,就会疏忽 vite 提供的 vendor 分包逻辑。

那如果心愿在手动分包的根底上还须要 vendor 分包,那么就须要把 vendor 分包的逻辑抄过去就能够了。

备注:

  1. vite2.x2.8 版本之后把默认分 vendor 包的逻辑勾销了,改为了插件式应用。
  2. 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];
    }
  1. 针对手动分包,组装 manualChunkAliasByEntrymap数据类型,key 为以后模块的模块信息,是一个对象数据类型,value 为该模块的入口,是一个string,也就是手动分包的名字,如最终答案中的 vendormanifest
  2. 分包策略还会因为配置 inlineDynamicImportspreserveModules 而扭转,这次不进行剖析。默认是会通过 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;
}
  1. 入参 entryModules 是一个数组,寄存了入口模块的信息。因为是单页利用,入口只有一个,这里的 entryModules 只有一个元素,就是 id"我的项目门路 /index.html" 的模块。如果是多页面利用就会有多个入口。
  2. 针对手动分包,通过 manualChunkAliasByEntry(map 数据类型,key 为模块信息,value 为分包名字)转换成 manualChunkModulesByAlias(对象数据类型,key 为分包名字,value 为以 key 为入口的模块数组),顺便记录modulesInManualChunks(所有手动分包包含的模块),不便后续默认分包的应用。把 manualChunkModulesByAlias 包装成 {alias, modules} 放到 chunkDefinitions 数组。
  3. 通过 entryModules 剖析出 dynamicEntryModules(动静入口模块,即用 import 导入的组件 / 页面)、dependentEntryPointsByModule(map 数据类型,key 为模块信息,value 为该模块的入口点,入口点能够有多个,能够是动态入口点,也能够是动静入口点)、dynamicallyDependentEntryPointsByDynamicEntry(map 数据类型,key 为动静入口模块,value 为动静入口被动静导入的模块的入口点数组,有点拗口,这个数据就是)
  4. 如果动态入口模块或动静入口模块在手动分包的模块中,则不解决,以手动分包为准。
  5. 如果动态入口模块或动静入口模块不在手动分包的模块中,则通过 assignEntryToStaticDependencies 办法结构 assignedEntryPointsByModule(map 数据类型,key 为模块信息,value 为该模块的入口模块)
  6. 通过 createChunks 办法把动态入口模块和动静入口模块转换成 chunk 定义信息,而后推到chunkDefinitions 数组。

小结:

  1. 手动分包优先级比默认分包优先级高,手动分包会笼罩默认分包。当解决默认分包时,会查看以后模块是否在手动分包中,是的话则疏忽该模块。

上面看看通过 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
    }));
}
  1. allEntryPoints 包含了 动态入口模块 动静入口模块
  2. 生成 chunk 签名chunk 签名 是由 X_ 组成的,总长度为入口模块 allEntryPoints 的数量。遍历 allEntryPoints,如果以后模块的入口点中有 allEntryPoints 其中的一个,则记作 X 否则记作 _
  3. 封装成 {alias, modules} 返回 chunk 定义信息

对于生成 chunk 签名 ,举个具体点的例子,allEntryPoints 包含一个动态入口点 index.html,两个动静入口点:Hello.vueWorld.vue。有一个模块 sdkUtils.js 的入口点为 Hello.vue(即被 Hello.vue 导入);有一个模块 api.js 的入口点为 Hello.vue 以及 World.vue;有一个模块 log.js 依赖了 Hello.vueWorld.vueindex.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对应的 chunklog.js 对应的chunk 就是额定多进去的 chunk。

小结:

  1. 默认分包策略,通过 chunk 签名 来标识模块和所有入口点之间依赖关系。有同样 chunk 签名 的模块会分到同一个 chunk。从而奇妙地实现一个模块被多个入口点援用,生成一个新的 chunk。以及每个入口点都会生成一个 chunk。
  2. 正是因为 chunk 签名 是依赖所有入口点来生成,当动静入口点过多(如页面过多,所有页面都动静导入),可复用的 chunk 签名 少,所以分进去的 chunk 就多,且这些被多个入口点引入的模块所生成的 chunk 所蕴含的模块少,所以会产生 chunk 碎片

如何通过手动分包解决 chunk 碎片 问题?

  1. 上文提到,手动分包优先级比默认分包优先级高。当解决默认分包时,会查看以后模块是否在手动分包中,是的话则疏忽该模块。
  2. 参考 chunk 碎片 的生成原理,在手动分包时,不不便获取动静入口点。只管能够参考 analyzeModuleGraph,通过动态入口点来获取,因为代码量多,不好照搬。
  3. 察看这些 chunk 碎片,它们的特色就是以后模块被 2 个或以上的模块援用。而模块信息,有两个字段能够利用,一个是 importers,一个是 dynamicImporters,对应着以后模块被动态引入的模块,以及被动静引入的模块。
  4. 所以,只须要在手动分包时,判断以后模块的 importers.length + dynamicImporters.length > 1,就能够把它放到 manifest chunk 中
  5. 不要遗记,须要对模块限定在 src 目录下,否则会影响 node_modules 下的一些包的依赖关系。所以只须要增加约束条件:id.includes('src')

总结

  1. vite2.x 中,当用户配置了手动分包,就会笼罩 vite 提供的 vendor 分包逻辑。如果想在手动分包中保留 vendor 逻辑,只需把代码拷贝到手动分包;
  2. rollup 的默认分包机制,应用 chunk 签名 来实现分包,除了入口点(动态入口点如 index.html、动静入口点如路由应用 import 导入页面)独自作为一个 chunk,那些有多个入口点且 chunk 签名 统一的模块会打包到同一个 chunk
  3. 手动分包优先级大于默认分包,先解决手动分包,再解决默认分包。当解决默认分包时,会查看以后模块是否在手动分包中,是的话则疏忽该模块。
  4. 配置手动分包时,会提供办法获取以后模块的信息,能够通过 importersdynamicImporters 来获取动态和动静导入以后模块的模块。当importers.length + dynamicImporters.length > 1,就把它放进命名为 manifestchunk
退出移动版