Webpack 学习 - 进阶篇

304 阅读11分钟

Webpack 学习 - 进阶篇

在前文基础篇中,我们介绍了webpack的一些基础概念和常规配置,了解的webpack的基础方法。但是在实际的项目开发中,通常会进行更加复杂的配置,以达到减少代码体积、优化运行性能、提升开发体验等目的。

本文将对webpack一些常见的进阶用法和优化手段进行介绍,以供自己学习回顾。如有错漏,欢迎指正😊

一、提升开发体验

1.1 代码调试 - SourceMap

在实际的项目开发过程中,经常会遇到代码出错的情况,此时需要在浏览器devtool或执行端口检查报错信息,定位错误。而经过webpack打包压缩后的代码通常与源码差异较大,可读性很差。此时需要一种手段帮助我们快速定位到源码中的报错位置。
SourceMap(源代码映射)是一个用来生成源代码与构建后代码一一映射的文件的方案。它会生成一个 filename.map 文件,里面包含源代码和构建后代码每一行、每一列的映射关系。当构建后代码出错了,浏览器会通过 .map 文件,从构建后代码出错位置找到映射后源代码出错位置,让提示源代码文件出错位置,从而帮助我们更快的找到错误根源。
在官网中,提供了十余种 SourceMap的类型,如 source-mapcheap-module-source-map...,其区别在于不同的类型,所生成的.map文件详细程度不一样,信息越详细,则文件越大,所消耗的资源也就越多。

一般来说,在开发模式中我们采用cheap-module-source-map -> 只包含行映射,而在生产环境中,一般不生成映射文件

//开发模式
module.exports = {
  // 其他省略
  mode: "development",
  devtool: "cheap-module-source-map",
};

//生产模式 -> 不配置devtool 就不会生成映射文件
module.exports = {
  // 其他省略
  mode: "development",
};

1.2 自动刷新与热更新(HotModuleReplacement)

我们使用webpack打包时,每次修改代码后,都需要重新打包,然后刷新浏览器才能看到效果,这样的开发体验是非常不友好的。所以我们需要一个工具,能够实现代码修改后自动打包,自动刷新浏览器,从而提高我们的开发效率。

Webpack Dev Server 是webpack 官方推出的一款开发工具,这款工具可以实现代码修改后自动编译自动打包自动刷新浏览器的功能。其实际上就是提供一个 socket 服务,将 webpack 编译打包的各个阶段的状态信息告知给客户端,通知网页调用reload接口刷新页面,从而实现实时重新加载(live reloading)和模块热替换(hot module replacement)功能。

//webpack.dev.js
module.exports = {
  // 其他省略
  devServer: {
    host: "localhost", // 启动服务器域名
    port: "3000", // 启动服务器端口号
    open: true, // 是否自动打开浏览器
    hot: true, // 开启HMR功能(只用于开发环境)
  },
};

二、提升打包构建速度

随着我们项目的日益庞大,打包构建的速度会越来越慢,此时需要一些额外的配置提高我们的打包构建的速度。

2.1 OneOf

打包时每个文件都会经过所有 loader 处理,虽然因为 test 正则原因实际没有处理上,但是都要过一遍。使用OneOf只会匹配上一个 loader, 剩下的就不匹配了。

module.exports = {
 //...其他配置省略
  module: {
    rules: [
      {
        oneOf: [
          {
            // 用来匹配 .css 结尾的文件
            test: /.css$/,
            // use 数组里面 Loader 执行顺序是从右到左
            use: ["style-loader", "css-loader"],
          },
          //...其他配置省略
          {
            test: /.js$/,
            loader: "babel-loader",
          },
        ],
      },
    ],
    //...其他配置省略
  }
};

2.2 Include/Exclude

Webpack 利用 LoaderPlugin 对文件进行处理时,默认会对所有文件进行处理。然而在开发过程中大部分代码是无需处理的,如node_modules 文件夹下的三方库、部分配置文件等。其中主要的是三方库代码量比较庞大,因此需要将其排除。

module.exports = {
  //...其他配置省略
  module: {
    rules: [
      {
        oneOf: [
          //...
          {
            test: /.js$/,
            // exclude: /node_modules/, // 排除node_modules代码不编译
            include: path.resolve(__dirname, "../src"), // 只检查src目录下的文件
            loader: "babel-loader",
          },
        ],
      },
    ],
  },
  plugins: [
    //...
    new ESLintWebpackPlugin({
      // 指定检查文件的根目录
      context: path.resolve(__dirname, "../src"),
      exclude: "node_modules", // 默认值
    }),
  ],
  //...其他配置省略
};

2.3 Babel Cache 和 Eslint Cache

每次打包时 js 文件都要经过 Eslint 检查 和 Babel 编译,速度比较慢, 我们可以对 Eslint 检查 和 Babel 编译结果进行缓存,这样第二次打包时速度就会更快了。

module.exports = {
  //...其他配置省略
  module: {
    rules: [
      {
        oneOf: [
          //...
          {
            test: /.js$/,
            // exclude: /node_modules/, // 排除node_modules代码不编译
            include: path.resolve(__dirname, "../src"), // 只检查src目录下的文件
            loader: "babel-loader",
            options: {
              cacheDirectory: true, // 开启babel编译缓存
              cacheCompression: false, // 缓存文件不要压缩
            },
          },
        ],
      },
    ],
  },
  plugins: [
    //...
    new ESLintWebpackPlugin({
      // 指定检查文件的根目录
      context: path.resolve(__dirname, "../src"),
      exclude: "node_modules", // 默认值
      cache: true, // 开启缓存
      // 缓存目录
      cacheLocation: path.resolve(
        __dirname,
        "../node_modules/.cache/.eslintcache"
      ),
    }),
  ],
  //...其他配置省略
};

2.4 多进程打包JS

当项目庞大到一定程度时,打包速度会变得十分缓慢。通过speed-measure-webpack-plugin 插件分析,一般来说在耗费时间较多的是JS文件的处理。而对 js 文件处理主要就是 eslintbabelTerser 三个工具,所以我们要提升它们的运行速度。

image.png

需要注意的是,多进程打包仅适用于较大的项目和较为耗时的步骤,因为每个进程启动就有大约为 600ms 左右开销。如我本地起的一个演示Demo,开启了多进程打包后反而整体时长更长。

image.png

const os = require("os");
const ESLintWebpackPlugin = require("eslint-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin");
//...其他配置省略

// cpu核数
const threads = os.cpus().length;

module.exports = {
  module: {
    rules: [
      {
        oneOf: [
          //...其他配置省略
          {
            test: /.js$/,
            // exclude: /node_modules/, // 排除node_modules代码不编译
            include: path.resolve(__dirname, "../src"), // 也可以用包含
            use: [
              {
                loader: "thread-loader", // 开启多进程
                options: {
                  workers: threads, // 数量
                },
              },
              {
                loader: "babel-loader",
                options: {
                  cacheDirectory: true, // 开启babel编译缓存
                },
              },
            ],
          },
        ],
      },
    ],
  },
  plugins: [
    new ESLintWebpackPlugin({
      // 指定检查文件的根目录
      context: path.resolve(__dirname, "../src"),
      exclude: "node_modules", // 默认值
      cache: true, // 开启缓存
      // 缓存目录
      cacheLocation: path.resolve(
        __dirname,
        "../node_modules/.cache/.eslintcache"
      ),
      threads, // 开启多进程
    }),
  ],
  optimization: {
    minimize: true,
    minimizer: [
      // 生产模式会默认开启TerserPlugin,但是我们需要进行其他配置,就要重新写
      new TerserPlugin({
        parallel: threads // 开启多进程
      })
    ],
  }
  //...其他配置省略
};

三、打包减少代码体积

3.1 Tree Shaking

我们经常会在主文件或者组件文件中引入其他模块中的代码,但实际上我们只用其中的一部分,剩下的代码则不需要引入。然而在默认情况下,仍然是全部引入并打包的。
Tree Shaking的实现依赖于ESM规范,因为其是静态编译,能够进行静态分析,这也使得代码裁剪成为可能。在webpack中,当处于生产模式时,默认会开启代码裁剪,无需额外配置。若在开发模式中也需要代码裁剪,则需要加上一些额外配置,可参考 掘友文档

需要注意的是,虽然ESM模块支持代码裁剪,但是如果在代码中使用ES6动态导入的方法则不会进行TreeChaking,个人猜测是因为此时已经涉及到需要计算动态结果才能知道是否应该裁剪,与CommonJS规范类似,因此无法进行静态分析。此外,动态导入的文件一般会单独打包,这也会在后面的代码分割中进行讨论。

3.2 @babel/plugin-transform-runtime

Babel 为编译的每个文件都插入了辅助代码,使代码体积过大!一些公共方法使用了非常小的辅助代码,比如 _extend。默认情况下会被添加到每一个需要它的文件中。你可以将这些辅助代码作为一个独立模块,来避免重复引入
@babel/plugin-transform-runtime: 禁用了 Babel 自动对每个文件的 runtime 注入,而是引入  @babel/plugin-transform-runtime 并且使所有辅助代码从这里引用。

//...
{
    loader: "babel-loader",
    options: {
      plugins: ["@babel/plugin-transform-runtime"], // 减少代码体积
    },
},

3.3 其他

除了上述介绍的两种压缩代码的方案外,我们还有一些其他的方法对代码打包体积进行压缩,常见的比如利用CDN引入一些三方库;压缩图片、字体、视频等媒体资源等等,此处不一一介绍。

四、优化运行性能

4.1 代码分割 Code Split

默认情况下,会将所有的JS文件都打包进一个JS文件中,我们如果只要渲染首页,就应该只加载首页的 js 文件,其他文件不应该加载。所以我们需要将打包生成的文件进行代码分割,生成多个 js 文件,渲染哪个页面就只加载某个 js 文件,这样加载的资源就少,速度就更快。

代码分割一般分为两种情况:

  • 1、多入口打包 -- 一个入口会打包成一个文件,公共模块利用splitChunks配置项单独提取,
  • 2、动态导入 -- 按需加载,需要用到某个模块时,再加载这个模块,动态导入的模块会被webpack自动分包,Vue中的路由懒加载就是依赖这种方法实现。

4.1.1 多入口打包

image.png

//main.js
import { sum } from './math.js';
console.log('@@main', sum(5, 6, 7, 8));

//app.js
import { sum } from './math.js';
console.log('@@app.js', sum(1, 2, 3, 4));

//math.js
export function sum(...args) {
  return args.reduce((acc, cur) => acc + cur, 0)
}

//webapck.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  //多入口文件
  entry: {
    app: "./src/app.js",
    main: './src/main.js'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js'//webpack命名方式,[name]以文件名自己命名
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "public/index.html"),
    })
  ],
  mode: 'production',//开发模式
  optimization: {
    // 公共代码单独提取,代码分割配置,更多splitChunks配置参考官网 - ( https://www.webpackjs.com/plugins/split-chunks-plugin)
    splitChunks: {
      chunks: "all", // 对所有模块都进行分割
      minSize: 1 //测试代码比较体积很小,默认时20000,单位时kb
    },
  },
}

如上所示,当配置多个入口时,每个入口都会打包成一个js文件,公共组件则利用optimization.splitChunks配置也提取为单个js,从而实现代码分割的效果。这种配置一般用于传统的多页应用程序,详细可参考掘友文档

4.1.2 按需加载,动态导入

image.png

在前面小结也讨论过,我们可以利用ES6 动态导入语法实现运行时加载,这种作为虽然会导致Tree Shaking不起作用,但是可以有效降低请求文件的大小,加快首屏的渲染时间。

4.2 预取与预加载(prefetch/preload)

经过代码分割后的代码,会在需要使用时进行加载。然而当遇到某些资源较大的情况或网络情况不佳时,临时加载会有明显的延时和卡顿效果。我们可以利用preload和prefetch设置浏览器立即加载关键资源和在空闲时间加载后续需要使用的资源。

  • preload(预加载)- 告诉浏览器立即加载资源并解析
  • prefetch (预取)- 告诉浏览器在空闲时才开始加载资源-不解析,使用到时才从预缓存中读取并解析

Preload加载优先级高,Prefetch加载优先级低。此特性浏览器兼容性较差,preload相对而言兼容性稍好一些。
我们可以通过, @vue/preload-webpack-plugin 插件实现这一特性

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const PreloadWebpackPlugin = require('@vue/preload-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
    new PreloadWebpackPlugin({
      rel: 'preload',//prefetch
      as: script,
    }),
  ],
};

4.3 Network Cache 文件名优化

开发时我们对静态资源会使用缓存来优化,这样浏览器第二次请求资源就能读取缓存了,速度很快。若前后输出的文件名是一样的,都叫 main.js,一旦将来发布新版本,因为文件名没有变化导致浏览器会直接读取缓存,不会加载新资源,项目也就没法更新了。我们可以使用contenthash根据文件内容生成 hash 值,只有文件内容变化了,hash 值才会变化。

//webpack.config.js
//...
module.exports = {
  entry: "./src/main.js",
  output: {
    path: path.resolve(__dirname, "../dist"), // 生产模式需要输出
    // [contenthash:8]使用contenthash,取8位长度
    filename: "static/js/[name].[contenthash:8].js", // 入口文件打包输出资源命名方式
    chunkFilename: "static/js/[name].[contenthash:8].chunk.js", // 动态导入输出资源命名方式
    assetModuleFilename: "static/media/[name].[hash][ext]", // 图片、字体等资源命名方式(注意用hash)
  },
  //...
  plugins: [
    //...
    // 提取css成单独文件
    new MiniCssExtractPlugin({
      // 定义输出文件名和目录
      filename: "static/css/[name].[contenthash:8].css",
      chunkFilename: "static/css/[name].[contenthash:8].chunk.css",
    }),
  ],
  //...
};

image.png

4.4 core.js

在实际项目中,我们往往会引入core-js这个polyfill库来填补现代JavaScript特性和旧浏览器之间兼容性的差距。其内部实现了一些ES6现代化标准特性如async 函数、promise对象等。core-js让我们在不兼容某些新特性的浏览器上,可以使用该新特性。
然而,它的体积相对较大,不加控制地引入所有特性会导致打包文件体积膨胀,影响加载速度。我们可以使用按需引入的方式控制其大小。

//babel.config.js
{
  "presets": [
    [
      "@babel/preset-env",
      {
        //根据目标浏览器的支持情况,智能地选择需要的polyfills
        "targets": {
          "browsers": ["last 2 versions", "not dead", "ie >= 11"]
        },
        "useBuiltIns": "usage", //按需加载,自动引入
        "corejs": 3 //core-js的版本3支持Tree Shaking
      }
    ]
  ]
}

总结

本文从开发体验打包速度,打包体积,运行性能这四个方面介绍了一些webpack中常见的优化配置与手段,并提供了相应的配置代码示例。如果有错误之处,欢迎提出宝贵的意见和建议。