webpack 5 优化配置-代码分割 (3)

747 阅读6分钟

1. 代码分割

  • 针对大型 Web 应用来讲,将所有的代码都打包成一个文件显然是不够有效的,特别是当某些代码块是在某些特殊的时候才会被用到。
  • webpack5 能够将代码库分割成 chunks 语块,当代码运行到需要它们的时候再进行加载。

2. 入口点分割

  • Entry Points:入口文件设置的时候可以配置,就像多入口配置,entry 配置成对象形式
  • 该方式存在问题
    • 如果入口 chunks 之间包含重复的模块 (比如:lodash),那些重复模块都会被引入到各个 bundle 中
    • 不够灵活,并且不能将核心应用程序代码逻辑进行动态拆分出来

webpack.config.js

const path = require("path");
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "production",
+  entry: {
+    one: "./src/one.js",
+    two: "./src/two.js",
+    vendor: ["jquery", "lodash", "other-lib"],
+  },
  context: process.cwd(),
  output: {
    path: path.resolve("dist"),
    filename: "[name].[chunkhash:8].js",
  },
  plugins: [
    new webpack.ProgressPlugin(),
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "src/index.html"),
    }),
  ],
};

3. 动态导入和懒加载

  • 用户当前需要用什么功能就只加载该功能的对应代码段,即 按需加载
  • 给单页应用做按需加载优化时,一般采用以下原则:
    • 对网站功能进行划分,每一功能一个 chunk。
    • 对于首次打开页面需要的功能直接加载,尽快展示给用户,某些依赖大量代码的功能点可以按需加载。
    • 被分割出去的代码需要一个按需加载的时机。

src/index.html

+ <button id="btn">click</button>

src/index.js

const oBtn = document.querySelector("button#btn");

oBtn.addEventListener("click", async () => {
  // import 就会按需加载了,只有在点击之后,才加载 hello.js 的代码内容
  const result = await import("./hello.js");
  console.log("result: ", result);
});

PS: 通过魔法注释改变打包后的 chunkName,import("./hello" /* webpackChunkName: "hello" */);

3.1 preload

  • preload 通常用于本页面要用到的关键资源,包括关键js、字体、css文件的加载
  • preload将会把资源得下载顺序权重提高,使得关键数据提前下载好,优化页面打开速度
  • 在资源上添加预先加载的注释,你指明该模块需要立即被使用
  • 一个资源的加载的优先级被分为五个级别,分别是
    • Highest 最高
    • High 高
    • Medium 中等
    • Low 低
    • Lowest 最低
  • 异步/延迟/插入的脚本(无论在什么位置)在网络优先级中是 Low

PS:谨慎使用 preload

实现简易 preload

src/index.html

+ <button id="btn">click</button>

src/index.js

const oBtn = document.querySelector("button#btn");

oBtn.addEventListener("click", async () => {
    // webpack 源码中 4 年前已经删除该功能/* webpackPreload: true */的解析: https://github.com/webpack/webpack/pull/7056#event-1578278014    https://github.com/webpack/webpack/pull/7056
+  const result = await import(/* webpackPreload: true, webpackChunkName: "aaa" */ "./hello");

  console.log("result: ", result);
});

PS:生成

<link rel="preload" as="script" href="http://localhost:9999/395.9e689745.js">

const HtmlWebpackPlugin = require("html-webpack-plugin");

/**
 * 1. 找到异步加载的代码块
 * 2. 对每个异步的代码块,生成 <link rel="preload" as="script" href="http://localhost:9999/395.9e689745.js">
 * 3. 把这个 link 的标签添加到 html 页面
 */
class PreloadWebpackPlugin {
  constructor(options) {
    this.options = options || {};
    this.preloadSources = new Set();
  }

  apply(compiler) {
    compiler.hooks.compilation.tap(
      "RequireContextPlugin",
      (compilation, { contextModuleFactory, normalModuleFactory }) => {
        normalModuleFactory.hooks.parser
          .for("javascript/auto")
          .tap("RequireContextPlugin", (parser, parserOptions) => {
            parser.hooks.importCall.tap("ImportParserPlugin", (expr) => {
              const { options: importOptions } = parser.parseCommentOptions(
                expr.range
              );
              // import(/* webpackPreload: true */ "./hello"),读取到解析后的 webpackPreload 值
              if (importOptions && importOptions.webpackPreload) {
                const source = expr.source.value;
                this.preloadSources.add(source);
                // source 是 import("./hello") 这了的 ./hello
                console.log("source", source);
                // 设置为 preload
                this.preload = "preload";
              }
            });
          });
      }
    );

    compiler.hooks.compilation.tap("PreloadWebpackPlugin", (compilation) => {
      HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
        "PreloadWebpackPlugin",
        (html, callback) => {
          this.generateLinks(compilation, html, callback);
        }
      );
    });

    compiler.hooks.compilation.tap("PreloadWebpackPlugin", (compilation) => {
      HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tap(
        "PreloadWebpackPlugin",
        (html) => {
          const { resourceHints } = this;
          if (resourceHints) {
            html.assetTags.styles = [
              ...resourceHints,
              ...html.assetTags.styles,
            ];
          }
          return html;
        }
      );
    });
  }

  generateLinks(compilation, html, callback) {
    const { rel, include } = this.options;
    let chunks = [...compilation.chunks];
    if (include === undefined || include === "asyncChunks") {
      // chunk.canBeInitial() 返回 true 是入口代码块
      chunks = chunks
        .filter((chunk) => !chunk.canBeInitial())
        .filter((chunk) => {
          // chunk.name 是 webpackChunkName : import(/* webpackPreload: true, webpackChunkName: "aaa" */ "./hello")
          const module = chunk.modulesIterable.values();
          const { value } = module.next();
          // 获取到 ./hello 是 import("./helle") 括号中的
          const rawRequest = value.rawRequest;

          return this.preloadSources.has(rawRequest);
        }); // 留下异步代码块
    }
    const allFiles = chunks.reduce((set, chunk) => {
      return set.add(...chunk.files);
    }, new Set());

    const links = [];
    for (const file of allFiles.values()) {
      links.push({
        tagName: "link",
        attributes: {
          rel: this.preload, // preload prefetch
          href: file,
          as: "script",
        },
      });
    }
    this.resourceHints = links;
    callback();
  }
}

module.exports = PreloadWebpackPlugin;

3.2 prefetch

  • prefetch 跟 preload 不同,作用是告诉浏览器未来可能会使用到的某个资源,浏览器就会在闲时去加载对应的资源,若能预测到用户的行为,比如懒加载,点击到其它页面等则相当于提前预加载了需要的资源

  • 通过魔法注释预加载 import("./hello" /* webpackPrefetch: true */);

    • 给 link 标签 添加 rel="prefetch"

      <link rel="prefetch" as="script" href="http://localhost:9999/395.9e689745.js">

prefetch-preload.png

3.3 preload vs prefetch

  • preload 告诉浏览器页面必定需要的资源,浏览器一定会加载这些资源。
  • 而 prefetch 是告诉浏览器页面可能需要的资源,浏览器不一定会加载这些资源。

建议:对于当前页面很有必要的资源使用 preload,对于可能在将来的页面中使用的资源使用 prefetch。

4. 提取公共代码

4.1 提取原则

  • 提取基础类库,便于长期缓存。
  • 提取页面之间的公用代码。
  • 提取出各个页面单独生成文件。

4.2 module、chunk、bundle

  • module:是 javascript 的模块化,webpack 支持 commonJS、ES6 等模块化规范,简单来说就是你通过import 语句引入的代码。
  • chunk: chunk 是 webpack 根据功能拆分出来的,包含三种情况
    • 项目入口(entry)
    • 通过 import() 动态引入的代码
    • 通过 splitChunks 拆分出来的代码
  • bundle:bundle 是 webpack 打包之后的各个文件,一般就是和 chunk 是一对一的关系,bundle 就是对chunk 进行编译压缩打包等处理之后的产出。

4.3 webpack.config.js

const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "production",
  entry: {
    page1: "./src/page1.js",
    page2: "./src/page2.js",
    page3: "./src/page3.js",
  },
  context: process.cwd(),
  output: {
    path: path.resolve("dist"),
    filename: "[name].[chunkhash:8].js",
  },

  optimization: {
    splitChunks: {
      // 表示选择哪些 chunks 进行分割,可选值有:async,initial 和 all
      chunks: "all",
      // 表示新分离出的chunk必须大于等于minSize,默认为30000,约30kb。
      minSize: 0, // 大于该值就需要分割出去
      // 表示一个模块至少应被 minChunks 个 chunk 所包含才能分割。默认为1。
      minChunks: 1, // 大于该值就需要分割出去,被几个代码块导入表示的次数
      // 表示按需加载文件时,并行请求的最大数目。默认为5。
      maxAsyncRequests: 3,
      // 表示加载入口文件时,并行请求的最大数目。默认为3
      maxInitialRequests: 5,
      // 表示拆分出的chunk的名称连接符。默认为~。如 chunk~vendors.js
      automaticNameDelimiter: "~",
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10, // 优先级,一个 chunk 很可能满足多个缓存组,会被抽取到优先级高的缓存组中,为了能够让自定义缓存组有更高的优先级(默认0),默认缓存组的priority属性为负值.
        },
        default: {
          minChunks: 2, ////被多少模块共享,在分割之前模块的被引用次数
          priority: -20,
        },
      },
    },
    runtimeChunk: true, // 为 true 或 'multiple',会为每个入口添加一个只含有 runtime 的额外 chunk
  },
    
  plugins: [
    new CleanWebpackPlugin(),

    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "src/index.html"),
      chunks: ["page1"], // entry 入口配置的属性
      filename: "page1.html",
    }),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "src/index.html"),
      chunks: ["page2"], // entry 入口配置的属性
      filename: "page2.html",
    }),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "src/index.html"),
      chunks: ["page3"], // entry 入口配置的属性
      filename: "page3.html",
    }),
      
  ],
};

src/page1.js

const module1 = require('./module1');
const module2 = require('./module2');
const $ = require('jquery');
console.log(module1, module2, $);
import( /* webpackChunkName: "asyncModule1" */ './asyncModule1');

src/page2.js

const module1 = require('./module1');
const module2 = require('./module2');
const $ = require('jquery');
console.log(module1, module2, $);

src/page3.js

const module1 = require('./module1');
const module3 = require('./module3');
const $ = require('jquery');
console.log(module1, module3, $);

src/module1.js

module.exports = 'module1';

src/module2.js

module.exports = 'module2';

src/module3.js

module.exports = 'module3';

src/asyncModule1.js

import _ from 'lodash';
console.log(_);

结果:

// 入口代码块
page1.js
page2.js
page3.js

// 异步加载代码块
src_asyncModule1_js.js

// defaultVendors 缓存组对应的代码块
defaultVendors-node_modules_jquery_dist_jquery_js.js
defaultVendors-node_modules_lodash_lodash_js.js

// default 代缓存组对应的代码块
default-src_module1_js.js
default-src_module2_js.js

// runtimeChunk: true
runtime~page1.js
runtime~pag2.js
runtime~page3.js

webpack 5 基本配置(1)

webpack 5 的优化配置(2)