美文网首页
分析vite2.x/rollup分包原理,解决chunk碎片问题

分析vite2.x/rollup分包原理,解决chunk碎片问题

作者: RockerLau | 来源:发表于2022-05-31 11:06 被阅读0次

    背景

    年前开始负责新项目开发,是一个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

    所以,这个例子中,会产生5个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

    相关文章

      网友评论

          本文标题:分析vite2.x/rollup分包原理,解决chunk碎片问题

          本文链接:https://www.haomeiwen.com/subject/flitjltx.html