美文网首页
webpack代码分离详解

webpack代码分离详解

作者: codingZero | 来源:发表于2020-09-10 19:33 被阅读0次

    上一篇:webpack超详细入门教程

    在上一篇的例子中,我们的 js 都是打包输出到一个文件中的,当内容越来越多的时候,会导致单个文件体积十分巨大,所以我们就需要对代码进行分割,将一个巨大的文件,分割成多个中小型文件,然后可以按需加载或并行加载这些文件

    代码分离的三种方式
    • 入口起点:使用 entry 配置手动地分离代码。
    • 动态导入:通过模块的内联函数调用来分离代码
    • 防止重复:使用 SplitChunksPlugin 去重和分离 chunk。

    一. 多入口

    npm init 初始化一个项目,新建 src/index.js、src/other.js、index.html,创建 webpack.config.js,写入如下配置,并在 package.json 中添加开发模式打包的脚本

    module.export = {
      entry: {
        index: "./src/index.js",
        other: "./src/other.js"
      },
      output: {
        filename: "[name].js"
      },
      plugins: [new HTMLWebpackPlugin({
        template: "index.html"
      })]
    }
    
    // package.json 
    "script": {
      "build": "webpack --mode development"
    }
    

    执行 npm run build,会发现输出了两个 js 文件,并且 html 中自动帮我们引入了这两个文件

    查看打包生成的 index.js 跟 other.js 文件,会发现它们有大量重复的内容,这些都是 webpack 生成的 runtime 代码,由于这两个文件同时在 index.html 里面引入,因此 runtime 代码被引入了两次,我们可以添加如下配置将 runtime 代码分离出来

    module.export = {
      optimization: {
        runtimeChunk: "single" // 它其实是下面这种写法的简写
        /* runtimeChunk: {
          name: "runtime"
        } */
      }
    }
    

    打包后会发现多了一个叫 runtime.js 的文件,index.js 与 other.js 中重复的 runtime 代码都被抽离到了这个文件中,index.html 也同时引入了这三个文件

    对于单页面,应避免使用多入口,可以使用单入口多文件,像下面这样

    entry: {
      index: ["./src/index.js", "./src/other.js"]
    }
    

    二. 动态导入

    使用 import() 来进行动态导入,为了方便演示,修改配置为单入口(将 index.js 作为入口),并将 runtimeChunk: true 删除掉
    我们分别在 index.html、other.js、index.js 中添加如下代码

    // index.html body 中添加
    <button id="btn">点我</button>
    
    // other.js
    console.log("我被加载了")
    
    // index.js
    let btn = document.querySelector("#btn");
    btn.addEventListener("click", () => {
      // import() 返回的是一个 promise,在 then() 方法中执行导入后的操作,也可以使用 async/await
      import("./other").then(res => {
        console.log(res);
      });
    });
    

    打包后在浏览器中打开 index.html

    可以看到,多生成了一个叫 0.js 的文件,并且 html 中并没有引入该文件,点击按钮再看看会发生什么

    生成了一个 script 标签,并加载了 0.js 文件,这就是动态导入。对于这个文件名称,不是很直观,我们使用魔法注释修改一下

    import(/* webpackChunkName: "other" */ "./other").then(res => {
      console.log(res);
    });
    

    最终打包生成的文件名就叫 other.js,如果希望加上 hash 值,可以在配置文件里添加一个参数

    output: {
      filename: "[name]-[chunkhash:10].js", // 入口文件打包生成的文件名
      chunkFilename: "[name]-[chunkhash:10].js" // 动态模块打包生成的文件名,name 默认为数字,如果使用了魔法注释则为魔法注释的名字
    }
    

    这样一来,又引发了一个新的问题,从下图可以看出,虽然 other.js 的内容没有被直接打包进 main.js,但是 main.js 中保存着 other.js 的文件名,当 other.js 内容发生变化时,其文件名也会变化,导致 main.js 跟着变化,那么之前的缓存将会失效

    我们可以使用上面提到的 runtimeChunk 将引用的代码抽离到一个单独的文件中。

    optimization: {
      runtimeChunk: {
        name: entrypoint => `runtime-${entrypoint.name}`
      }
    }
    

    执行打包,随意修改 other.js 中的内容,再次打包,两次生成的文件如下

    可以看到,main.js 的 hash 值并没有变化,而抽离出来的文件用户也访问不到,因此不会影响缓存

    三. splitChunks

    在讲述 splitChunks 之前,我们先来看看 webpack 中 splitChunks 的默认配置

    optimization: {
      splitChunks: {
        chunks: 'async', // 动态导入的模块其依赖会根据规则分离
        minSize: 30000, // 文件体积要大于 30k
        minChunks: 1, // 文件至少被 1 个chunk 引用
        maxAsyncRequests: 5, // 动态导入文件最大并发请求数为 5
        maxInitialRequests: 3, // 入口文件最大并发请求数为 3
        automaticNameDelimiter: '~', // 文件名中的分隔符
        name: true, // 自动命名
        cacheGroups: {
          vendors: { // 分离第三方库
            test: /[\\/]node_modules[\\/]/,
            priority: -10 // 权重
          },
          default: { // 分离公共的文件
            minChunks: 2, // 文件至少被 2 个 chunk 引用
            priority: -20,
            reuseExistingChunk: true // 复用存在的 chunk
          }
        }
      }
    }
    

    splitChunks 中的配置为公共配置,cacheGroups(缓存组)里如果有同名配置,会覆盖公共配置,webpack 就是根据 cacheGroups 里的规则来进行代码分离的。其他的参数都很好理解,接下来我们重点讲讲 chunksmaxAsyncRequestsmaxInitialRequests 这三个参数

    为了方便演示,我们将上面的 splitChunks.minSize 由 30000 改为 0,表示对文件大小不做限制

    四. chunks

    该参数有四种取值

    • async:动态导入的文件其静态依赖会根据规则分离
    • initial:入口文件的静态依赖会根据规则分离
    • all:所有的都会根据规则分离
    • chunk => Boolean:返回 true 表示根据规则分离,false 则不分离

    新建很多文件,并修改配置为多入口,如下图所示

    如果上面的图看着晕,那就看下面这个图,蓝色表示静态导入,红色表示动态导入

    entry: {
      pageA: "./src/pageA.js",
      pageB: "./src/pageB.js"
    }
    output: {} // 清空,用默认值
    
    1. chunks: async

    打包生成的文件如下

    其中 pageA、pageB 是多入口分离出来的文件,async1、async2 是动态导入分离出来的文件,这个在前面已经讲过了。至于 default~async1~async2vendors~async1,看它们的文件名也能知道个大概。

    default~async1~async2 就是分离出来的 common2.js 文件,因为它同时被 async1 与 async2 导入,满足 cacheGroups 中的 default 规则

    那 common1 也同时被 pageA 与 pageB 导入,为啥没有分离出来呢?这就跟 chunks 的值有关了,刚刚说过了,chunks 的默认值为 async,只对动态导入的文件的依赖做处理。而 pageA 跟 pageB 是入口,所以 common1 不会被分离。

    同理,虽然 util 同时被 pageB 与 async1 导入,但是 pageB 是入口,所以 util 也不会被分离

    vendors~async1 就是分离出来的第三方库 axios,因为它被 async1 导入,满足 cacheGroups 中的 vendors 规则。至于 vue, 作为一个第三方库,且被 pageA 导入,为啥没有分离出来,我想就不必多说了吧。

    2. chunks: initial

    接下来我们将 chunks 的值修改为 initial,重新打包,看看生成的文件

    default~async1~async2vendors~async1 没有了,取而代之的是 default~pageA~pageBvendors~pageA

    default~pageA~pageB 就是分离出来的 common1.js 文件,chunks 的值为 initial,表示入口文件的依赖会被处理,而 pageA 跟 pageB 是入口文件,所以 common1 被分离了。同样的,因为 async1 不是入口,所以 util 依然没有被分离, vendor~pageA 自然就是分离出来的 vue 文件。

    3. chunks: all

    如果你能理解 async 与 initial,那么就应该能很好的理解 all,将 chunks 改为 all,打包后的文件如下

    相比于 async 与 initial,在它们两者的基础上还多了一个 default~async1~pageB,这个就是被分离出来的 util 文件,因为它同时被 pageB 与 async1 导入。

    所以,all 并不是简单的 async + initial,而是无论你是入口文件的依赖,还是动态导入的文件的依赖,只要满足分离的条件就行,可以看成 async || initial

    还有一个难以理解的问题,比如 async1 不再导入 util,让 async2 去导入,结构图如下,红叉表示删除,绿色箭头表示新增的静态导入

    会发现并没有将 util 分离出来生成 default~async2~pageB 文件,这是为啥呢?

    下面内容纯属猜测,当然不是瞎猜,而是有依据的

    因为 pageB 依赖于 util,那么 pageB 肯定会导入 util,而 async2 又是被且仅被 pageB 动态导入的,当 async2 被加载的时候,util 肯定已经存在了,所以 async 完全可以去使用 pageB 里面的 util,直接将 util 打包进 pageB 就行了,没有必要单独分离出来。

    那依据是什么呢?在 pageA 里面也动态导入 async2 ,会发现 util 被单独分离出来了,因为 pageA 里面没有导入 util,async2 没法用它的,只能自己整一份

    4. chunks: chunk => Boolean

    关于 chunks 的三个字符串类型的值就讲完了,最后来讲讲函数类型的值,函数接受一个 chunk 为参数,修改 chunks 的值如下

    chunks: chunk => chunk.name === "pageA"
    

    打包后的结果

    vue 被分离出来了,这个很好理解,不做解释。难理解的是,被 pageA、pageB 共同导入的 common1 为啥没有分离出来?恩,因为 pageB 的 chunkname 不等于 pageA

    也许有人会有这样的疑问,只分离满足规则的依赖吗?那依赖的依赖呢?依赖的依赖的依赖呢......不用试了,明确告诉你,算!(别问为什么,继续往下看)

    最后补充一点

    a 跟 b 同时引入 m,那么 m 会被分离成一个独立的文件,如果 a 跟 b 同时引入 m 跟 n,此时 m 跟 n 会被分离到同一个文件 default~a~b 中去,而不是分离成两个独立的文件。如果此时又有一个 c 引入了 n,那么 n 又会被分离到文件 default~a~b~c 中去

    5. 从头再来

    关于 chunks 各个值的区别,到这里就讲完了。
    什么?这就讲完了?没搞懂啊!!!
    没关系,不要慌,既然没搞懂,那就把前面的内容都忘了吧,咱们换种方式再讲一遍

    声明:以下内容纯属个人看法,仅仅为了方便理解,随便看看就行,切勿纠结

    还是最开始的例子,我们先不管 splitChunks 如何分离代码,只关注前面两种方式,即入口起点动态导入
    根据这两种规则,在打包的过程中,生成了四个 chunk,分别是:

    • chunk~pageA(pageA.js、common1.js、vue)
    • chunk~async1(async1.js、common2.js、util.js、axios)
    • chunk~pageB(pageB.js、common1.js、util.js)
    • chunk~async2(async2.js、common2.js)

    chunk 的名称前面是没有 chunk~ 的,这里加上前缀是为了与同名文件做区分

    接下来我们给每个 chunk 加上标记,标记一共有三种,async、initial、all,如下

    • chunk~pageA(initial、all)
    • chunk~async1(async、all)
    • chunk~pageB(initial、all)
    • chunk~async2(async、all)

    所有的 chunk 都有 all 这个标记,chunk~pageA 与 chunk~pageB 额外拥有 initial 这个标记,chunk~async1 与 chunk~async2 额外拥有 async 这个标记

    接下来就是 splitChunks 代码分离了,以 chunks: async 为例(表示必须拥有 async 标记)

    // 放在这里方便查看
    splitChunks: {
      chunks: "async",
      minSize: 0,
      minChunks: 1,
      // ...
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10 // 权重
        },
        default: {
          minChunks: 2, // 文件至少被 2 个 chunk 引用
          priority: -20, // 权重
          reuseExistingChunk: true // 复用存在的 chunk
        }
      }
    }
    
    1. 处理 chunk~pageA,发现只有 initial 与 all 标记,所以直接 pass

    2. 处理 chunk~async1,拥有 async 标记,去匹配 cacheGroups 中的规则

      2.1 匹配 vendors(vendors 的权重高于 default),来自 node_modules 且被至少一个 chunk 引用

      • 遍历当前 chunk 中的模块,查看其是否来自 node_modules,是则创建数组变量 chunkNameList = [chunk 的名称],执行下一步,否则继续遍历
        axios 满足规则,创建 chunkNameList = ["async1"]

      • 遍历其他拥有 async 标记的 chunk,查看其是否也引用了当前模块,如果是,则 chunkNameList.push(chunk 的名称)
        并没有,所以 chunkNameList 最终为 ["async"]

      • 判断 chunkNameList.length 是否满足 minChunks,满足则生成文件名,否则回到第一步
        将 chunkNameList 中的文件名升序排列,生成文件名 "vendors~" + chunkNameList.join("~") => vendors~async1

      • 查找已分离出的文件列表中是否有同名文件,有则追加内容,没有则创建。完成之后继续第一步的遍历
        没有同名文件,于是创建文件 vendors~async1,将 axios 的内容打包进去

      遍历完成之后,没有被分离出去的还有 common2.js 与 util.js(async1.js 本身不会再分离)

      2.2 匹配 default,需要至少被两个 chunk 引用,过程与 2.1 类似,只是 minChunks 由 1 变成了 2,生成的文件名由 vendors 开头变成了 default 开头

      最终 common2.js 被分离,util.js 继续在 chunk~async1 中保留

    3. 处理 chunk~pageB,与 chunk~pageA 一样,直接 pass

    4. 处理 chunk~async2,与 chunk~async1 一样,最后发现 common2.js 需要分离,由于之前已经分离过了,且 reuseExistingChunk 的值为 true,所以直接复用

    至此,chunks: async 的分离过程就讲完了,initial 与 all 同理,只需要记住,查找与 chunks 拥有同样标记的 chunk 就行了

    至于 chunks: chunk => Boolean,就是把 chunk 当做参数传递给函数,根据返回值决定是继续还是 pass,一个是看标记,一个是看返回值,道理都一样。

    所以,现在应该明白,依赖的依赖,依赖的依赖的依赖.....为什么也算了吧?因为它们都在同一个 chunk 中

    五. maxAsyncRequests

    maxAsyncRequests 与 maxInitialRequests 其实都是用来限制代码分离的数量的,上面我们说到了,chunk 可以分为两类,为了方便理解,我称之为 入口chunk动态chunk
    maxAsyncRequests 就是用来限制动态chunk的分离数量的,那 maxInitialRequests 自然就是用来限制入口chunk的分离数量的

    废话不多说,直接看例子,为了方便讲解,我们修改一下参数

    splitChunks: {
      chunks: "all",
      maxAsyncRequests: 3 // 表示最多分离成三个
      //  其他不变
    }
    

    新建一个项目,创建很多文件,具体如下图

    还是整个引用关系图吧

    暂不打包看结果,先根据前面我们所讲的知识,思考一下,会被分离出哪些文件?

    1. 入口 pageA、pageB 肯定存在
    2. 动态导入的 async1、async2 也必然存在
    3. async1 中导入的第三方库 react 分离到文件 vendors~async1 中
    4. async1 与 pageB 共同导入的 util 分离到文件 default~async1~pageB 中
    5. async1 与 async2 共同导入的 common 分离到文件 default~async1~async2 中

    好,打包看结果

    对比我们自己的分析,发现少了 default~async1~async2 这个文件,为什么呢?还记得我们将 maxAsyncRequests 设置成了 3 吧

    动态chunk async1 相关的文件有 async1、vendors~async1、default~async1~pageB 这三个。没错,已经有三个了,不能再分离了,所以 async1 与 async2 共同导入的 common 没有分离出来

    那为啥要分离 react 跟 util,却偏偏不分离 common?很简单,因为 react 及 util 的文件体积都比 common 大。不理解的话可以在 common.js 里面加点内容,让它比 util 大,就会发现 common 被分离了,而 util 却没有

    将 maxAsyncRequests 的值修改为4,再次打包看生成的文件列表。是的,没错,default~async1~async2 它出来了

    六. maxInitialRequests

    如果理解了 maxAsyncRequests 的作用,那么 maxInitialRequests 自然不在话下,它限制的是入口chunk的分离数量

    直接看例子吧,修改文件引用关系如下

    按照 splitChunks 的规则,最终被分离出的文件列表应该是

    1. pageA、pageB
    2. async1、async2
    3. pageA 中导入的第三方库 react 分离到文件 vendors~pageA 中
    4. pageA 与 pageB 共同导入的 util 分离到文件 default~pageA~pageB 中
    5. pageA 与 async2 共同导入的 common 分离到文件 default~pageA~async2 中

    其中与入口chunk pageA 相关的文件有 pageA、vendors~pageA、default~pageA~pageB 及 default~pageA~async2 这四个

    虽然 pageA.js 中动态导入了 async1,但是它们不属于同一个 chunk,所以 async1 不能算在里面

    然而由于 maxInitialRequests 的默认值为 3,所有应该是没有 default~pageA~async2 的,打包看结果

    果然不出所料!修改 maxInitialRequests 的值为4,default~pageA~async2 就会出现,这里就不演示贴图了

    再看一个东西,更好的理解一下,webpack 配置中添加一个之前用到过的插件

    plugins: [
      new HTMLWebapckPlugin({
        excludeChunks: ["pageB"] // 排除chunk pageB
      })
    ]
    

    打包,看生成的 index.html 文件

    这就是所谓的入口文件最大并发请求数 3 个!!!

    七. 再说回分离过程

    还记得第四节第5点所说的代码分离的过程吗?现在看来,其实是有问题的
    按照前面的说法,是先根据 vendors 规则分离,但是按照第五节第六节例子,其实应该是体积大的优先分离,所以总结分离过程如下

    再次声明:个人理解,有错误的地方请指出
    1. 循环遍历所有 chunk,去匹配 chunks 的值
      1.1 值为 all,匹配通过,跳到 2
      1.2 值为 async 或 initial,对比 chunk 的类型是否一致,一致则跳到 2
      1.3 值为函数,将 chunk 当做参数传递过去,返回 true 则跳到 2
    2. 取出 chunk 中的所有模块,按体积大小降序排列,循环遍历每个模块
      2.1 匹配 vendors(权重高),如果模块来自 node_modules,则匹配成功
        2.1.1 查看模块体积是否达到 minSize,如果达到,跳到 3
      2.2 匹配 default,查看模块体积是否达到 minSize,如果达到,跳到 3
    3. 创建数组变量 chunkNameList,保存当前的 chunk name,循环遍历其他 chunk
      3.1 查看 chunk 是否符合 chunks 规则,符合则下一步
      3.2 查看 chunk 是否也包含了该模块,包含则 chunkNameList.push(chunkname)
    4. 判断 chunkNameList.length 是否大于 minChunks,如果为true,生成文件名
    5. 从已分离的文件列表中查看是否有同名文件
      5.1 如果有,查看该文件中是否已经有该模块,没有则添加
      5.2 如果没有,统计文件列表中与当前 chunk 相关的文件数量,如果数量小于 max 限制,跳到 6
    6. 根据文件名创建文件,将模块内容写入
    

    按照上面的思路,用 js 模拟了一个简单的代码分离,核心内容如下

    // 遍历所有chunk
    function traversalChunk() {
      chunkList.forEach(chunk => {
        if (getIsMatch(chunk)) traversalModule(chunk);
      });
    }
    
    // 判断 chunk 是否匹配
    function getIsMatch(chunk) {
      let chunks = splitChunks.chunks;
      if (typeof chunks === "function") {
        return chunks(chunk)
      }
      if (chunks === "all") return true;
      return chunk.type === chunks;
    }
    
    // 将 cacheGroups 按权重进行排序
    function getGroups() {
      let tempGroups = splitChunks.cacheGroups;
      let {minSize, minChunks} = splitChunks;
      let cacheGroups = Object.keys(tempGroups).map(key => {
        let group = tempGroups[key];
        return {name: key, minSize, minChunks, ...group}
      });
      cacheGroups.sort((g1, g2) => g2.priority - g1.priority)
      return cacheGroups;
    }
    
    // 遍历 chunk 中所有模块
    function traversalModule(chunk) {
      let modules = chunk.modules;
      modules.sort((m1, m2) => m2.size - m1.size)
      let cacheGroups = getGroups();
      for (let module of modules) {
        if (["entry", "dynamic"].includes(module.type))continue;
        for (let group of cacheGroups) {
          if (group.test) {
            let b = group.test.test(module.path)
            if (!b) continue;
          }
          if (module.size >= group.minSize) {
            traversalOtherChunk(chunkList, chunk, module, group)
          }
        }
      }
    }
    
    // 查看其他 chunk 是否引用
    function traversalOtherChunk(chunkList, currentChunk, module, group) {
      let chunkNameList = [currentChunk.name];
      for (let chunk of chunkList) {
        if (chunk === currentChunk) continue;
        if (!getIsMatch(chunk)) continue;
        let modules = chunk.modules;
        let m = modules.find(item => item.name === module.name);
        if (m) chunkNameList.push(chunk.name)
      }
      if (chunkNameList.length >= group.minChunks) {
        chunkNameList.sort();
        chunkNameList.unshift(group.name);
        let fileName = chunkNameList.join(splitChunks.automaticNameDelimiter)
        canSplit(fileName, currentChunk, module, group)
      }
    }
    
    // 判断是否允许分离
    function canSplit(fileName, currentChunk, module) {
      let file = fileList.find(file => file.name === fileName);
      if (file) {
        if (!file.content.includes(module.name)) {
          file.content.push(module.name) // 模拟文件写入
        }
      } else {
        let delimiter = splitChunks.automaticNameDelimiter
        let relativeCount = getFileCount(currentChunk);
        let {maxAsyncRequests, maxInitialRequests} = splitChunks;
        let max = currentChunk.type === "async" ? maxAsyncRequests : maxInitialRequests;
        if (relativeCount + 1 < max) {
          let chunkNameList = fileName.split(delimiter);
          chunkNameList.shift();
          for (let chunkName of chunkNameList) {
            if (chunkName === currentChunk.name) continue;
            if (isOverflow(chunkName)) return;
          }
          fileList.push({
            name: fileName,
            content: [module.name]
          })
        }
      }
    }
    
    // 获取与 chunk 相关文件的数量
    function getFileCount(chunk) {
      let delimiter = splitChunks.automaticNameDelimiter
      let relativeFileList = fileList.filter(file => {
        let chunkNameList = file.name.split(delimiter);
        chunkNameList.shift();
        return chunkNameList.includes(chunk.name);
      });
      return relativeFileList.length;
    }
    
    // 判断与它共同导入模块的 chunk 分离数是否达到上限
    function isOverflow(chunkName) {
      let chunk = chunkList.find(chunk => chunk.name === chunkName);
      let {maxAsyncRequests, maxInitialRequests} = splitChunks;
      let max = chunk.type === "async" ? maxAsyncRequests : maxInitialRequests;
      return getFileCount(chunk) >= max;
    }
    
    traversalChunk();
    

    完整代码,点这里

    关于 webpack 代码分离就讲完了~~~

    上一篇:webpack超详细入门教程

    相关文章

      网友评论

          本文标题:webpack代码分离详解

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