webpack5四部曲---高级

124 阅读17分钟

高级

主要对webpack的配置进行优化.包括以下几个方面:

  1. 提升开发体验
  2. 提升打包速度
  3. 减少打包体积
  4. 优化代码运行性能

提升开发体验

sourcemap

源代码映射,可以将编译后的代码映射为源代码文件,便于定位错误的位置.他会生成xxx.map文件里面包含构建代码和源代码每一行每一列的映射关系.出现错误后通过这个关系从构建代码找到源代码的位置,使浏览器提示源代码中出错位置

使用

devtool官方文档,通过devtool选项可以开启不同类型的映射方式,具体参考官网,实际开发中常用两种类型:

  • 开发模式: cheap-module-source-map,只映射到行,不映射列,构建速度较快

      devtool: "cheap-module-source-map",
    
  • 生产模式:source-map,映射到行和列,生产环境使压缩后的代码,只有一行,所以映射到列才能找到正确位置,缺点就是构建速度慢

      devtool: "source-map", 会在打包文件中生成.map文件
    

提升打包速度

HotModuleReplacement

开发模式下,我们修改了某个文件时,webpack会将所有文件重新打包构建,速度很慢

  • 尝试: 修改文件后,保存,可以看到整个页面重新刷新,表示文件全部重新加载了一遍

我们希望重新打包时,只打包修改的文件,其他文件模块使用缓存,来提高打包速度,这就是hmr热更新(HotModuleReplacement)

开启热更新

webpack5默认开启了热更新:

devServer: {
    hot: true, // 默认值
}
css文件的热更新

style-loader完成,引入这个加载器即可

js文件热更新
  • 手写

    main.js文件中引入了one.js和two.js,如果想one.js和two.js文件实现热更新,可以在main.js中这样写:
    ​
    if(module.hot){ // 判断一下浏览器支不支持热更新
        module.hot.accept('./js/one.js')
        module.hot.accept('./js/two.js');
    }
    

当然这种手写非常麻烦,在实际开发时,根据项目技术栈种类可以使用官方提供的loader:

  • vue项目: vue-loader
  • react项目: react-hot-loader

oneOf

当前配置的loader匹配解析的顺序是: 一个文件,在配置的所有loader中从上到下依次匹配,即使匹配上了也不会停止,直到全部loader都匹配一遍

这样就导致了大量不必要的匹配,使用oneOf,指定匹配到一个loader之后即停止下面的匹配,从而节省时间,开发模式和生产模式都可以使用:

module: [
 {
     oneOf: [
        // loader配置
     ]
 }   
]
将loader配置写在oneOf中

include|exclude

js的处理中较为常见,指定loader处理文件的范围,目的是为了某个loader只对需要处理的文件做处理,如: node_module下的js文件别人已经处理过了,我们就不需要重复处理了

  • include: 指定处理文件的路径,路径之外的不处理
  • exclude: 指定不处理文件的路径,路径之外的都处理

开发模式和生产模式都可以使用,但要注意两者不能同时存在

对于目前来说,只需要修改Babel和eslint的配置即可

{
    test: /.js$/,
    include: path.join(__dirname,"../src")
    use: ['babel-loader']
}
{
    new ESLintPlugin({
      context: path.join(__dirname, "../src"), // 指定eslint校验的文件范围
      exclude: "node_modules" // 默认值
    }),
}

cache

类似babeleslint的热更新,每次修改时只对修改过的文件进行处理,其他文件直接使用缓存,以提高打包速度.

因此给babeleslint开启缓存,可以提高除第二次打包及之后的打包速度

babel设置缓存

babel-loader设置opation选项: cacheDirectory:true,默认将缓存文件放在node_modules/.cache/babel-loader.官方连接

{
    test: /.js$/,
    include: path.join(__dirname, "../src"),
    loader: "babel-loader",
    options: {
+      cacheDirectory: true, // 默认为false,开启缓存
+      cacheCompression: false, // 是否开启压缩,缓存文件没必要压缩,默认为true
    },
 },

之后重新运行,就可以看到缓存文件放在node_modules/.cache/babel-loader中了

eslint设置缓存

同样的eslint-loader也可以开启缓存,同时需要指定缓存文件存放目录,这里我们设置和babel一样,都存储在node_modules/cache

new ESLintPlugin({
  context: path.join(__dirname, "../src"), // 指定eslint校验的文件范围
  exclude: "node_modules",
  cache: true,
  cacheLocation: path.join(__dirname, "../node_modules/.cache/eslintCache"),
}),

Thead多进程打包

js文件的处理一般会使用三个loaderplugin: babel,eslint,terser,terser主要时用来做代码压缩的,内置在webpack中,生产模式自动激活这个插件,

这三个插件在处理js文件时都是使用一个进程在处理,因此对三个插件分别开启多个进程处理可以提高打包速度.

特别注意: 启动进程就需要600ms左右,因此不是特别耗时的loader不要一上来使用thread-loader

  1. 安装thead-loader加载器

    npm install --save-dev thread-loader
    
  2. 获取电脑的cpu核数,一个进程对应一个核,开启的进程不能大于所运行电脑的核数

    const os = require('os')
    const threads = os.cpus().length
    
  3. 设置babel的多进程

    // 在babel之后执行thead-loader,所以添加在babel之前
    use: [
    +  {
    +    loader: "thread-loader", // 在babel之后执行
    +    options: {
    +      workers: threads, // 设置开启进程数量
    +    },
    +  },
      {
        loader: "babel-loader",
        options: {
          cacheDirectory: true, // 默认为false,开启缓存
          cacheCompression: false, // 是否开启压缩,缓存文件没必要压缩,默认为true
        },
      },
    ],
    
  4. 设置eslint的多进程

    new ESLintPlugin({
      context: path.join(__dirname, "../src"), // 指定eslint校验的文件范围
      exclude: "node_modules",
      cache: true,
      cacheLocation: path.join(__dirname, "../node_modules/.cache/eslintCache"),
    +  threads, // 设置eslint的进程开启数量
    }),
    
  5. 设置terser的多进程(仅生产模式设置,开发模式没有压缩文件,所以不用设置),这里修改了之前css压缩书写的位置,统一将压缩文件的相关的配置放在第一级optimization.minimize

    1. 引入插件,已内置,无需安装

      const TerserWebpackPlugin = require('terser-webpack-plugin')
      
    2. 配置,生产模式下虽默认开启,但修改压缩的配置时需要重新new

      optimization: {
          minimizer: {
              new TerserWebpackPlugin({
                  parallel: threads, // 压缩时开启多进程
              }),
              new CssMinimizerPlugin(),
          }
      }
      

减少打包体积

Tree shaking

当我们引入一个库时,可能只是用这个库的一小部分功能,但打包的时候会打包整个库,因此我们希望打包时只打包用到的代码,这就叫做tree-shaking

在webpack5中该功能已经内置,无需进行任何配置即可使用

babel减少辅助代码

babel编译代码时会为每个文件插入辅助代码,即使这些辅助代码时相同的,如_extends每个需要_extends的文件都会加上它,这就导致打包产物中很多冗余的代码

我们希望的是,将这些辅助代码统一放在一个文件中,然后通过引入的方式进行使用,babel的插件@babel/plugin-transform-runtime就可以做到这一点:

  • @babel/plugin-transform-runtime会禁用babel自动对每个文件的runtime注入,转而引入@babel/plugin-transform-runtime,并使所有辅助代码都从这里引用

配置

  1. 安装babel的插件

    npm install -D @babel/plugin-transform-runtime
    
  2. babel-loader中配置

    {
        loader: "babel-loader",
        options: {
          cacheDirectory: true, // 默认为false,开启缓存
          cacheCompression: false, // 是否开启压缩,缓存文件没必要压缩,默认为true
    +      plugins: ["@babel/plugin-transform-runtime"],
        },
    },
    

压缩图片

对项目中使用本地图片进行压缩,以减小打包体积,对于项目中全部使用的外部链接图片则不用配置

  1. 安装压缩插件,插件文档:压缩图片

    npm install image-minimizer-webpack-plugin imagemin  --save-dev 
    
  2. 无损压缩

    npm install imagemin-gifsicle imagemin-jpegtran imagemin-optipng imagemin-svgo --save-dev
    

    安装的时候可能会安装失败,需要多尝试几次,安装后打包时可能报错,检查是否是部分依赖没下载完整

  3. 有损压缩

    npm install imagemin-gifsicle imagemin-mozjpeg imagemin-pngquant imagemin-svgo --save-dev
    
  4. 统一在optimization.minimizer中添加压缩相关的配置

    const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin");
    ...
    // 压缩本地图片
      new ImageMinimizerPlugin({
        minimizer: {
          implementation: ImageMinimizerPlugin.imageminGenerate,
          options: {
            plugins: [
              ["gifsicle", { interlaced: true }],
              ["jpegtran", { progressive: true }],
              ["optipng", { optimizationLevel: 5 }],
              [
                "svgo",
                {
                  plugins: [
                    "preset-default",
                    "prefixIds",
                    {
                      name: "sortAttrs",
                      params: {
                        xmlnOrder: "alphabetical",
                      },
                    },
                  ],
                },
              ],
            ],
          },
        },
      }),
    

优化代码运行性能

code split

现在我们打包的代码都是放在同一个文件中的,页面加载时不管某一部分的代码是否需要,都会全部加载,这就使文件体积过大,同时存在一些不必要的加载,因此我们希望页面加载时只加载需要的js,从而提升加载速度.

通过code split代码分割可以做到上述要求,它主要做了两件事:

  • 分割文件
  • 按需加载

代码分割有多种实现方案

多入口情况

设置多个入口文件,就会有多个出口文件.

webpack.config.js:
​
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
​
module.exports = {
  mode: "production",
  entry: {
    // 键名对应输出的文件名
    app: "./src/app.js",
    main: "./src/main.js",
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "./js/[name].js", // name的值是entry的键名
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./public/index.html",
    }),
  ],
};
代码分割

多入口打包之后还有个问题: 如果一个公共模块文件被多个入口文件引入,那么每个入口文件都会将这个公共模块文件打包进去,这样体积就会变大,如何将这个公共模块文件单独打包,让其他文件引用呢,可以配置webpack分割chunk进行实现,配置后,符合条件的chunk会进行单独打包:

  optimization: {
    // 代码分割配置,打包的app.js和main.js都叫chunk,splitChunk就是对chunk进行分割
    splitChunks: {
      chunks: "all", // 对所有chunk都进行分割
​
      // 以下注释的是默认值
      // minSize: 20000, // 分割代码最小大小
      // minRemainingSize: 0, // 确保最后提取的文件大小不能为0
      // minChunks: 1, // chunk至少被不同入口文件引用的次数,满足条件才会被分割
      // maxAsyncRequests: 30, // 按需加载时并行加载的文件最大数量,也就是打包后bundle的数量,超过30个就不再抽取成块了
      // maxInitialRequests: 30, // 入口js文件最大并行请求数量,也就是最开始请求的数量不能超过30个
      // enforceSizeThreshold: 50000, // 大小超过这个值(50kb)一定会被单独打包,此时会忽略minRemainingSize,minChunks,maxAsyncRequests,maxInitialRequests
      // cacheGroups: {
      //   // 组 哪些模块要打包到一个组(一个文件),默认有以下两个组, 组中其他没有写的配置会使用上面的默认值
      //   defaultVendors: {
      //     // 组名
      //     test: /[\/]node_modules[\/]/, // 需要打包到一起的模块,nodemodules中的代码会打包到defaultVendors这个组中
      //     priority: -10, // 权重 越大越高
      //     reuseExistingChunk: true, //如果当前chunk包含已从主bundle中拆分出的模块,则它将会被重用,而不是生成新的模块
      //   },
      //   default: {
      //     minChunks: 2, // 这里的minChunk要比默认的minChunks权重更大,被不同的入口文件引入两次才打包到default组中
      //     priority: -20,
      //     reuseExistingChunk: true,
      //   },
      // },
          
        // 实际开发的配置
      cacheGroups: {
        defaultVendors: {
          test: /[\/]node_modules[\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minSize: 0, // 文件太小了,为了演示设置为0
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },

运行以下,就会看到,公共代码被打包进了10.js

dist           
├─ js          
│  ├─ 10.js    
│  ├─ app.js   
│  └─ main.js  
└─ index.html  
按需加载

开发中会遇到这样的场景: 页面上有一个按钮,点击按钮之后会执行一些代码.这些代码只有点击了才会执行,所以第一次加载的时候不需要加载,点击按钮之后再加载,从而减少第一次加载时间,这个就是按需加载

按需加载可以通过动态导入语法import()实现:

import(url).then((res) => {}).catch((err) => {})
​
import()返回一个promise,成功时会将导出的模块传递给then的参数

动态导入语法特点: 打包时将导入的文件拆分成单独的bundle,在需要的时候自动加载

修改一个示例代码:

1.js文件夹新增一个js: import.js2. main.js:
document.querySelector(".btn").onclick = () => {
  import("./js/import.js")
    .then((res) => {
      console.log(res);
    })
    .catch((err) => {
      console.log(err);
    });
};

打包后,可以看到将动态语法导入的文件单独打了一个包349.js:

dist           
├─ js          
│  ├─ 10.js    
│  ├─ 349.js   
│  ├─ app.js   
│  └─ main.js  
└─ index.html  
单入口情况

针对SPA单页应用时,只有一个入口文件,我们依然可以做一些代码分割和按需加载的优化:

webpack.prod.js:
splitChunks: {
  chunks: "all",
}
单页面应用只有一个入口文件,minChunks就不用设置了.node_modules打包的组配置是默认的也不用设置了,只需要设置all即可,其他使用默认值.

这样设置打包后有以下的效果:

  • node_modules中使用的文件单独打包
  • 动态导入语法引入的文件单独打包
动态导入的bundle命名

观察上述打包结果可以发现,除了和入口文件相对应的输出文件,其他chunk文件都是随机命名.这不方便我们进行追踪.webpack提供了一种给额外chunk命名的方式:

  1. 第一步:引入的时候,使用webpack允许的特殊的注释起个名字

    import(/* webpackChunkName: "文件名"*/ "文件路径")
    
  2. 第二步:在配置文件的output中给使用:

    output: {
        path: ...,
        filename: '',
        chunkFilename: './js/[name].js'
    }
    
统一命名规范

之前打包输出了很多类型的文件,我们来统一下各个类型文件的命名:

  • 入口文件,输出的chunk文件和使用type:asset处理的资源文件

    output: {
        filename: "js/[name].js", // 入口文件打包输出文件名,使用name: 兼容多入口文件打包
        chunkFilename: "js/[name].chunk.js", // 加个chunk后缀,与主文件区分开来 
        assetModuleFilename: "./static/media/[hash:10][ext][query]", // asset类型资源统一命名,对应的下面的文件命名就可以不要了
    }
    
  • css文件

    plugins: 
        new MiniCssExtractPlugin({
          // 统一css文件命名
          filename: "./css/[name].css", // 同时兼容多入口
          chunkFilename: "./css/[name].chunk.js", // 动态导入css加上chunk标记
        }),
    

Preload/Prefetch

之前我们已经做了chunk分割和按需加载,但还不够好,按需加载时,只有用到了才开始加载,如果加载文件过大,用户就会有卡顿的感觉,我们希望当前需要的文件加载完毕后,在浏览器空闲时间加载后续所需的资源,

preload/prefecth就可以做到这一点

  • preload:告诉浏览器立刻加载,使用as属性指定资源类型:stylescriptfontimagefetch,告诉浏览器加载页面的时候应当立即优先加载指定资源,即使用不到,所以在解析HTML的时候就会请求对应资源,主要用来提高首屏渲染速度
  • prefetch:告诉浏览器空闲时加载,没有as属性,存储在浏览器缓存中,以备后续使用,主要用来提高切换页面时的加载速度
共同点:
  • 不会阻塞页面渲染,只加载不执行
  • 都有缓存,执行时直接使用缓存
区别:
  • preload优先级高,prefetch优先级低
  • preload只能加载当前页面需要使用的资源,prefetch可以加载当前页面的资源,也可以加载下一个页面所需的资源
总结:
  • 当前页面优先级高的资源使用preload加载
  • 下一个页面使用的资源用prefetch加载
问题:

兼容性较差

preload: ie完全不支持

image-20240305231310354.png

prefetch:

image-20240305231334000.png

使用:

@vue/preload-webpack-plugin该插件可以在html中插入带有preload和prefetch标签的链接,以指定哪些js需要预加载

  • 安装,配置文件中引入

    npm install --save-dev @vue/preload-webpack-plugin
    ​
    const PreloadWebpackPlugin = require("@vue/preload-webpack-plugin");
    
  • 配置preload

    // 预加载 prelaod js以preload方式加载,as指定资源类型
    new PreloadWebpackPlugin({
      rel: "preload",
      as: "script",
    }),
    

    打包查看效果,link已经加上了preload

image-20240305233915213.png

打开页面查看网络请求,可以看到这个chunk由于优先级高,加载的次序还是很靠前的

image-20240305234107495.png

  • 配置prefetch

    new PreloadWebpackPlugin({
      rel: "prefetch",
    }),
    

    打包查看效果:

image-20240305234509670.png

打开页面查看网络请求:可以看到加载顺序明显靠后了

image-20240305235005911.png

Network cache

缓存现存的两个问题:

  • 缓存资源变化时,加载新的资源
  • 哪个文件变化,哪个文件缓存失效,其他文件不要受到影响

现在我们打包输出的文件是固定的名字,项目上线后,当我们修改了一个文件,但文件名没有变化,那么浏览器可能继续使用缓存的资源,不会请求新资源,因此我们需要给chunk都加上一个唯一值: .[contenthash:10],这个hash是根据文件内容计算的,所以文件内容变化,文件名就会变化

所有没有hash值的输出文件的地方都加上.[contenthash:10]
output: {
    filename: "js/[name].[contenthash:10].js", // 入口文件打包输出文件名,使用name: 兼容多入口文件打包
    chunkFilename: "js/[name].[contenthash:10].chunk.js", // 加个chunk后缀,与主文件区分开来
},

但是这样修改后会引发另一个问题:

上述示例中,import.js作为公共模块,被main.js引入.这时如果我们修改import.js然后重新打包,会发生什么呢? 猜测import.jsbundle名会发生变化,,这是当前的打包结果:

js                                 
├─ import.99b91a63c8.chunk.js      
├─ import.99b91a63c8.chunk.js.map  
├─ main.c04f70486d.js              
└─ main.c04f70486d.js.map          

修改一下import.js,并重新打包,这是重新打包的结果:

js                                 
├─ import.9065f5f0a8.chunk.js      
├─ import.9065f5f0a8.chunk.js.map  
├─ main.72ad80eb9b.js              
└─ main.72ad80eb9b.js.map          

可以看到,不仅import.jsbundle名变了,main.js也变了,那在实际生产环境,两个文件都会重新请求,包括未变化的main.js文件,这里就需要优化一下:

  1. 为什么main.jsbundle名变化了?

    hash值是根据内容来生成的,hash变了,说明内容变化了,来看一下打包产物中main.js的内容:

image-20240306090514495.png

发现main.jsbudle中引用了import.jsbundle,当import.jsbundle名变化时,main.js也需要变化,故而对应的bundle名也比变化了

  1. 解决方案

    main.js文件中使用的import.jshash值单独放在runtime文件中做映射,就像这样: a:映射import的hash,那么import.js变化时,只会导致runtime文件变化,而runtime文件相当小,重新请求影响也不大

具体配置
  optimization: {
    // 运行时,文件依赖映射
    runtimeChunk: {
      name: (entrypoint) => `runtime~${entrypoint.name}.js`,
    },
  },

配置完成后,打包一下,可以看到会多一个runtime文件,再次修改import.js打包,main.js就不会变化了

core-js

一个专门做ES6+语法向下兼容的工具,babel只能做ES6的语法转换,两者结合才能彻底解决js兼容性问题.当浏览器不支持某个语法是,core-js会采用社区的代码对浏览器进行polyfill

  1. 安装

    npm i core-js -S
    
  2. 非自动引入

    全部引入:
    import 'core-js'
    ​
    手动按需引入:在node_modules中找路径 core/es/对应模块名
    import "core-js/es/promise"
    
  3. 使用babel的智能预设自动按需引入,参考地址

    .babelrc.js中新增配置:
    module.exports = {
      presets: [
        [
          "@babel/preset-env",
          { useBuiltIns: "usage", corejs: 3.36 }, // corejs通过babel智能预设,打包时自动按需导入corejs
        ],
      ],
    };
    ​
    配置后,会根据browserslist配置的环境来自动做兼容处理,打包时没有core对应的bundle可能不是配置错了,检查一下browserslist吧
    

PWA

渐进式网络应用程序(progress web application),可以使web网站提供离线服务,就像app一样即使断网了也可以访问一些基本功能.

webpack中提供已经封装好了pwa的插件workbox

  1. 安装

    npm install workbox-webpack-plugin --save-dev
    
  2. 引入webpack.prod.js并配置

    const WorkboxPlugin = require("workbox-webpack-plugin");
    ​
    ...
    // service worker pwa离线缓存技术
        new WorkboxPlugin.GenerateSW({
          // 这些选项帮助快速启用 ServiceWorkers
          // 不允许遗留任何“旧的” ServiceWorkers
          clientsClaim: true,
          skipWaiting: true,
        }),
    
  3. 在入口文件中注册生成service work

    // 兼容性原因,需要判断一下
    if ("serviceWorker" in navigator) {
      window.addEventListener("load", () => {
        navigator.serviceWorker
          .register("/service-worker.js")
          .then((registration) => {
            console.log("SW 注册成功: ", registration);
          })
          .catch((registrationError) => {
            console.log("SW 注册失败: ", registrationError);
          });
      });
    }
    
  4. 重新打包就能看到多打包了一些文件其中包括service-worker.js,此时直接打开index.html是会注册失败的,

image-20240306213014886.png

图中可以看到,请求service-work.js是从根目录下开始的,因此我们的项目也要保证是在dist目录中运行的,通过serve库可以实现:

serve库用来本地部署静态资源服务器,可以直接讲dist部署上去

npm i serve -g

serve dist // 部署diat文件夹

再打开部署好的地址,就可以看到pwa注册成功,把网络断掉就可以看到pwa的效果了

  1. 查看缓存文件

    • 控制台 Applocationservice worker可以看到对应的service-worker
    • cach storage下可以看到pwa缓存的文件