【Webpack学习笔记】三、性能优化

本文详细介绍了Webpack的性能优化技巧,包括热模块替换(HMR)及其在JS、CSS、HTML文件中的应用,SourceMap的配置与选择,缓存策略(babel缓存、文件缓存)以及chunkhash和contenthash的使用。此外,还探讨了Tree Shaking的概念和启用方法,代码分割的实现,动态加载(懒加载、预加载)以及PWA(渐进式网页应用)的配置。最后,提到了多进程打包、Dll优化和externals的使用策略,全面解析了Webpack性能优化的各种策略和实践方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

【Webpack学习笔记】三、性能优化

更新:2021年10月17日19:58:13

参考课程:尚硅谷最新版Webpack5实战教程(从入门到精通)_哔哩哔哩_bilibili

更新:2021年10月17日19:58:48

参考:【Webpack学习笔记】一、基本使用_赖念安的博客-优快云博客

参考:【Webpack学习笔记】二、生产环境下的使用_赖念安的博客-优快云博客

一、HMR

HMR(Hot Module Replacement),意为:热模块替换 / 模块热替换。

此前在【【Webpack学习笔记】一、基本使用】中,我们了解了可以在 webpack.config.js 中配置 devServer 并使用 npx webpack-dev-server 命令来启动项目,从而实现自动监测 src 目录下的代码变动并实时地为我们重新打包编译。这极大的方便了我们的开发调试,但是这也有一个问题,那就是每次重新打包时都是不管三七二十一直接把整个项目进行重新编译,有时我们只是修改了某个模块里的一小部分而已,如果项目中的模块比较多,那么每次都这样重新整体打包就会比较浪费时间。所以就有了 HMR 这个机制。

简单来说,HMR 就是可以只对发生改动的那个模块进行重新编译,而其他地方就不受影响,这就极大的提高了我们重新构建的速度。

webpack.config.js 中的 devServer 中增加一个 hot 配置项并将其值设置为 true 即可。

// webpack.config.js

const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/js/index.js',
  output: {...},
  module: {rules: [...]},
  plugins: [...],
  mode: 'development',

// 上面一些重复的配置就不具体写了,和此前的一样    
    
  devServer: {
    contentBase: resolve(__dirname, 'build'),
    compress: true,
    port: 3000,
    open: true,
    // 增加 hot 配置项并设置为 true 以开启 HMR 功能
    hot: true
  }
};

进行如上修改后,重新运行 npx webpack-dev-server 命令,并尝试修改某个模块的源代码,观察浏览器加载与此前的区别。

注意,每次修改了配置文件后,都要重新运行相关的启动命令才能加载最新修改过的配置。

1、样式文件的HMR

对于项目中的样式文件,我们不需要再做其他配置,因为 style-loader 这个loader内部已经实现了相应的功能。

这也是为什么我们在开发环境下针对样式文件一般是使用 style-loader 来进行打包,因为这样可以提高开发调试时的构建速度。而在生产环境下,因为要考虑到实际运用中的其他性能要求,所以我们会用 mini-css-extract-plugin 这个插件中的loader来代替 style-loader

2、html文件的HMR

对于项目中的html文件,默认是不能使用 HMR 的。因为项目中一般只有一个html文件作为首页入口,如果该html文件发生了改变,那就一定得对整个页面进行重新加载,而页面中的引用的其他资源也是被重新加载一次。所以,不用对html做 HMR 处理

而且要注意的是,如果在 devServer 中设置了 hottrue,那么html文件的实时更新反而就会失效,此时如果我们修改了html文件中的内容,则该修改操作不会被监测到,所以浏览器中的页面也不会重新刷新。要解决这个问题,就要修改 webpack.config.js 中的 entry

// webpack.config.js

const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // 修改前
  // entry: './src/js/index.js',
  // 修改后
  entry: ['./src/js/index.js', './src/index.html'],
  output: {...},
  module: {rules: [...]},
  plugins: [...],
  mode: 'development',

// 上面一些重复的配置就不具体写了,和此前的一样    
    
  devServer: {
    contentBase: resolve(__dirname, 'build'),
    compress: true,
    port: 3000,
    open: true,
    // 增加 hot 配置项并设置为 true 以开启 HMR 功能
    hot: true
  }
};

修改后再次用 npx webpack-dev-server 重启项目。

3、js文件中的HMR

对于项目中的js文件,默认也是不能使用 HMR 的。但是当一个js文件中引用了大量的外部模块,如果其中一个模块发生了变动,那么就会导致与之相关其他模块也一起被重新打包,这不是我们所希望的。所以我们需要针对项目中的js文件做 HMR 处理。具体就是在相应的 index.js 文件中添加支持 HMR 功能的代码来使其实现热模块替换。

// ./src/js/index.js

import xxxx from './xxxx';
import yyyy from './yyyy';
...

xxxx();

// 如果全局的module对象上有hot属性,那说明开启了HMR,此时if判断内部的语句就会生效
if (module.hot) {
  // 通过accept方法,我们可以监测第一个参数中指定的js模块,如果该文件发生了改动,
  // 则只会重新编译这个模块并执行第二个参数中的回调函数参数中的代码,而其他模块不受影响。
  module.hot.accept('./xxxx.js', function() {
    // 如果 xxxx.js 文件发生变化,则会执行下方的 xxxx() 方法,其他模块不会重新打包构建
    // 这个xxxx()函数往往就是被改动的文件中被暴露出来的关键函数
    xxxx();
  });
  // 如果需要对其他的js文件做HMR处理,则继续调用accept函数并制定监视目标和相应回调
}

注意:

我们往往只对非入口的js文件(即 index.js 以外的js文件)做HMR处理。因为入口文件中往往引入了很多的其他模块,如果入口文件发生了改动,那么就会导致所有的模块都被重新编译打包。所以,我们只会对入口文件中引入的这些依赖做 HMR 处理。

二、source-map

1、source-map 的作用与使用方式

项目构建之后,输出文件相较于源文件已经有了比较大的改变了,此时如果出现bug,想要根据输出后的文件来调试代码的话就不是很方便了。而source-map在构建后的代码和源代码之间提供了一种映射关系,根据这种映射关系,我们可以比较方便地在浏览器的开发者工具中进行调试工作。

使用 source-map 的方式也比较简单,那就是在 webpack.config.js 中增加 devtool 配置项。

// webpack.config.js
...

module.exports = {
  ...

// 上面一些重复的配置就不具体写了,和此前的一样    
// 设置devtool可以开启source-map,除了source-map外,还有其他可选值
  devtool: 'source-map'
};

2、devtool 的可选值

这里的区别用文字表述不大形象,建议直接看相应的视频:

更新:2021年10月17日23:32:24

参考:尚硅谷最新版Webpack5实战教程(从入门到精通)-P21_尚硅谷_source-map_哔哩哔哩_bilibili

上面的 devtool 有许多可选值:[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map。它们可以进行各种组合,部分组合的区别可以看下面表格:

可选值形式功能
source-map外部文件提供错误代码准确信息和源代码的错误位置,可以在浏览器
中点击提示信息中的指示从而跳转到对应出错的源文件代码
inline-source-map内联代码同source-map(只有一个source-map信息)
eval-source-map内联代码同source-map(每个引入的文件都会生成一个source-map信息,
且放在eval函数中;同时会在浏览器的文件提示后加上一个hash值)
cheap-source-map外部文件同source-map(但是只会提示错误位置在哪一行,不能精确到列)
cheap-module-source-map外部文件同cheap-source-map(但是会在此基础上加上对模块的映射关系)
hidden-source-map外部文件提示错误代码的错误原因,但是只能提示该错误在构建后代
码(built.js)中的位置,而没有指示该错误在源代码中的位置
nosources-source-map外部文件提示错误代码的错误原因,会指示该错误在源代码中的位置
但是点击相关文件提示后不能查看相关的源代码(无法加载源文件)

“外部文件”指的是该模式下打包输出时会有一个 built.js.map 文件生成来存储映射信息。

“内联代码”指的是该模式下打包输出时不会有 .map 文件生成,而是将映射信息以 base64 的形式直接写入 built.js。这种方式的构建速度更快。

image-20211017230916987

3、source-map的选用

上面提到了很多种可选值,那么我们应该选用哪一种呢?这就看我们的具体需求了,这取决于当前是开发环境还是生产环境。

3.1、开发环境

开发环境下,我们考虑的一般是速度和调试友好度的问题,上面提到的几个参数的速度大致为:eval>inline>cheap>...

如果比较在意速度,可以用 eval-cheap-souce-mapeval-souce-map ,其中前者的速度更快一点。

如果比较在意调试友好度,可以用 souce-mapcheap-module-souce-mapcheap-souce-map,其中 souce-map 的友好度最好。

综合考虑的话,一般会选择用 eval-souce-mapeval-cheap-module-souce-map 来平衡速度和调试友好度两方面的需求。而在 ReactVue 脚手架构建时,默认选用的是前者。

3.2、生产环境

生产环境下,我们考虑的一般是是否需要隐藏源代码和调试友好度的问题。而由于内联形式的 source-map 会导致 built.js 的体积较大,所以在生产环境下不要用内联形式的 source-map

如果需要隐藏源代码,可以用 nosources-source-maphidden-source-map ,其中前者会进行全部隐藏,而后者虽然会隐藏源代码,但是还是可以跳转到构建后的代码。

如果需要考虑调试,可以用 souce-mapcheap-module-souce-map

简单总结的话,就是如果需要隐藏源码,那就从那两种隐藏的方式中选一种;如果不需要的话,可以直接用 source-map 以让调试更友好一些。

三、oneOf

一路学习下来,我们已经在 webpack.config.jsmodule 中的 rules 中配置了许多loader,这些loader会根据我们在 testexclude 中的正则表达式来做对相应的匹配文件类型做转化工作。然而,在Webpack加载这些loader时,如果按照默认的方式,那么无论当前文件是什么类型,它都会试着去匹配 rules 中的所有loader(也就是所有的loader每次都会被遍历一遍),尽管这个loader不是为了该文件设置的,这就会降低性能。

于是可以在这些loader外包装一层 oneOf 属性:

// webpack.config.js
...
// 一些重复的配置就不具体写了,和此前的一样

module.exports = {
  entry: ['./src/js/index.js', './src/index.html'],
  output: {...},
  module: {
    rules: [
      // 这个是针对js文件的eslint-loader
      { test: /\.js$/, ... },
      {
        // 在oneOf中的loader只会匹配其中一个,而不会遍历全部的loader,
        // 所以要注意在oneOf中,不能有两个loader处理同一种类型文件,此前我们有针对js文件的
        // eslint-loader和babel-loader都会对js文件做相应处理,而且还设置了先让eslint-loader
        // 做处理,如果将这两个loader同时放在oneOf中,其中一个就会失效。所以如果需要让这两个
        // loader都生效,就要把其中一个loader放到oneOf之外,这样就能保证两个loader都能生效了
        oneOf: [
          { test: /\.css$/, ... },
          { test: /\.less$/, ... },
           // 这个是针对js文件的babel-loader
          { test: /\.js$/, ... },
          { test: /\.(jpg|png|gif)/, ... },
          { test: /\.html$/, ... },
          { exclude: /\.(js|css|less|html|jpg|png|gif)/, ... }
        ]
      }
  	]
  },
  plugins: [...],
  mode: 'development',    
  devServer: {...}
};

四、缓存

1、babel缓存

在开发时,改动最多的往往是js文件。如果每次改动后都要重新把所有的js文件做兼容性处理,就可能会白白浪费不必要的时间,因为我们改动的可能只是其中一个文件,剩余的文件没有变动,那就没有必要重新做兼容性处理了。所以开启babel缓存的话就可以从缓存中读取原来兼容性处理后的输出结果,那些未做改动的就直接使用缓存中结果。这样就可以提高我们第二次及以后的打包速度。

开启方法也很简单,直接在 babel-loader 的配置项中添加一个 cacheDirectory 属性,并设置为 true

// webpack.config.js
...
// 一些重复的配置就不具体写了,和此前的一样

module.exports = {
  entry: ['./src/js/index.js', './src/index.html'],
  output: {...},
  module: {
    rules: [
      // 这个是针对js文件的eslint-loader
      { test: /\.js$/, ... },
      { oneOf: [{
       		// 这个是针对js文件的babel-loader
          test: /\.js$/,
          loader: 'babel-loader',
          options: {
            preset: [['@babel/preset-env',{
                useBuiltIns: 'usage',
                corejs: {version: 3},
                targets: {chrome: '60',firefox: '60',ie: '9',safari: '10',edge: '17'}
              }]],
            // 开启babel缓存,第二次及以后的打包速度将会提高
            cacheDirectory: true
          }},
          { test: /\.css$/, ... },
          { test: /\.less$/, ... },
          { test: /\.(jpg|png|gif)/, ... },
          { test: /\.html$/, ... },
          { exclude: /\.(js|css|less|html|jpg|png|gif)/, ... }
        ]
      }
  	]
  },
  plugins: [...],
  mode: 'development',    
  devServer: {...}
};

babel缓存一般只是用于提供开发时的打包速度,项目上线时就需要通过下面的“文件缓存”来提高文件读取效率。

2、文件缓存

2.1、文件缓存的效果

首先创建一个本地服务器:

// server.js

/*
  服务器代码
  启动服务器指令:
    npm i nodemon -g
    nodemon server.js
	或
    node server.js
  访问服务器地址:
    http://localhost:3000
*/
const express = require('express');

const app = express();
// express.static向外暴露静态资源(这里可以简单地理解为浏览器会强制缓存这些静态资源)
// maxAge 资源缓存的最大时间,单位ms
app.use(express.static('build', { maxAge: 1000 * 3600 }));

app.listen(3000);

通过 Express 实现了一个小型的本地服务器,并将 build 目录下的静态资源往外暴露。在浏览器中访问 http://localhost:3000 这个地址就能看到浏览器向本地服务器的根目录(也就是 build 目录)请求资源的结果。首次请求的话,通过浏览器的开发者工具中的 Network 选项卡就可以发现资源是来自服务器的,如果刷新页面的话,就可以看到资源来自 memory cache(即本地的资源缓存)。这是因为我们设置了对这些静态资源做了“强制缓存”操作(这里先暂时这样理解,具体的机制,需要了解 Express 的相关知识)。

image-20211018140045685

被缓存过的静态资源将会优先从缓存中读取

image-20211018140327403

请求头字段中的缓存控制

但是这种强制缓存也会带来问题:当我们修改本地的代码后,再次刷新页面就看不到最新的结果。因为浏览器发现所请求的资源已经在缓存里了,所以会优先读取缓存里的数据,而这些数据还是原来未被修改过的数据,自然就无法体现最新的修改效果了。

此时就要通过给打包后的文件增加hash值后缀来标识文件的改动了。共有三种hash值的选取方案:

2.2、三种hash值
2.2.1、webpack打包时的hash值

此前在【【Webpack学习笔记】一、基本使用】中,我们提到了每次运行 webpack 指令进行打包的时候,都会生成一个唯一的hash值来标识当前的打包操作:

$ webpack 
// 每次打包时都会生成一个唯一的hash值
Hash: 68a756570c2d4c8a5886
Version: webpack 4.41.6
Time: 138ms
Built at: 2021/10/13 下午8:19:53
   Asset      Size  Chunks             Chunk Names
built.js  5.39 KiB    main  [emitted]  main
Entrypoint main = built.js
[./src/data.json] 36 bytes {main} [built]
[./src/index.js] 925 bytes {main} [built]

于是我们可以通过给打包输出的文件加上这个hash值后缀来标识当前的打包输出文件:

// webpack.config.js

const {resolve} = require('path');

module.exports = {
  entry: '.src/js/index.js',
  output: {
    // 修改前
    // filename: 'js/built.js',
    // 修改后,表示给打包输出的built.js加上hash值后缀,且只取hash值的前10位
    filename: 'js/built.[hash:10].js',
    // path 用于指示所有输出文件的根目录
    path: resolve(__dirname, 'build')
  },
  module: {rules: [...]},
  plugins: [
    ...
    new MiniCssExtractPlugin({
  		// 修改前
      // filename: 'css/built.css'
  		// 修改前,表示给打包输出的built.css加上hash值后缀,且只取hash值的前10位
      filename: 'css/built.[hash:10].css'
    }),
  ],
  mode: 'development',
  devServer: {...}
}

image-20211018144752703

观察到输出文件多了一个hash值后缀

image-20211018145134247

浏览器根据文件名发现所需的文件不在缓存中,于是重新请求

这样的话,每次我们修改文件后重新打包输出,尽管浏览器会对静态资源做强制缓存,但我们还是能在浏览器中看到最新的效果,因为文件名每次都不一样了,缓存中也就是找不到最新的文件了,所以会重新请求资源。

但是这也引出了一个问题:上面使用webpack打包时生成的唯一hash值来作为静态文件的标识虽然可以让我们查看到服务器根目录下最新的文件变动,但是由于各个静态文件使用的是同一个hash值,这就导致当我们修改项目中的某一个文件并重新打包输出时,其他没有变动的静态资源的文件名也被改变了,这就意味着浏览器会对这些静态资源全部重新请求,这就导致缓存的利用率大大降低了。

2.2.2、chunkhash

为了有效地利用缓存,(即不让浏览器重新请求那些没有变更的资源),我们可以尝试使用另一个hash值来对文件做标识: chunkhash

这个 chunkhash 就是根据 chunk 生成的唯一hash值,如果打包来源于同一个 chunk,那么其生成的 chunkhash 值就一样。

先明白什么是 chunk简单来说,如果一个文件中引入了其他的模块,那么打包时,这个文件本身和那些被引入的文件就会是在一个 chunk 里,所以这些文件的 chunkhash 也就是相同的

所以如果是在入口文件中引入了其他模块(假设为某些静态资源),那么当入口文件被改动并重新打包时,输出的所有静态文件的hash后缀还是一样的,尽管其中的某些静态文件没有被改动。此时 chunkhashhash 就没有什么太大的区别了,因为浏览器都会重新向服务器请求所有的资源,本地的文件缓存还是没有被利用起来。

image-20211018152457622

具有引用关系的两个模块打包后是属于同一个chunk

2.2.3、contenthash

这个 contenthash 就是根据当前文件内容本身生成的hash值,不同文件hash值一定是不一样的,而且如果该文件的内容没有改动,那么其生成的 contenthash 值就不会变。

image-20211018153144994

根据每个文件自身的文件内容生成hash值

浏览器只会重新请求那些被更改的资源,其他未更改的资源就从缓存中读取。

image-20211018153729812

谁被修改,浏览器就重新请求谁

通过维护hash值的变化,我们就能决定哪个文件需要更新,哪个文件从缓存中读取。这种文件缓存机制就能让我们的项目在生产环境中(或者说线上环境)表现得更好,性能也会有提升。

五、treeshaking

1、treeshaking的作用

我们可以把我们的项目想象成一个树,而一个个代码模块就是上面的果子和树叶,当我们摇动这棵树时,上面那些已经变黄的叶子或是不再需要的果子(相当于是我们项目上线时用不到的代码)就会被摇下来,剩在树上的就是那些健壮的叶和果(相当于是我们项目中需要的关键代码)。所以 treeshaking 就是将项目中那些不用的代码给剔除掉,让整个项目更加的轻便简洁且小巧。

所谓“不用的代码”,可能是某个定义了,但是从来没有被引用过的函数或变量之类的。

2、treeshaking的使用

要开启 treeshaking,要注意:

  • 必须设置 modeproduction
  • 想要被 treeshaking 的文件必须使用ES6模块。

只要满足这两个条件,webpack就会自动对代码进行 treeshaking 操作。

但是要注意的是,在某些版本的webpack中,作为外部模块引入的 .css.less 之类的文件可能会被当做无用的代码而被直接剔除,这就会导致输出的文件中会没有相应的 .css 文件,此时就可以在 package.json 中增加 sideEffects 这个配置项:

// 项目根目录下的 package.json 文件

{
  "name": "webpack_code",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {...},
  "devDependencies": {...},
  "dependencies": {...},
  "browserslist": {...},            
  "eslintConfig": { "extends": "airbnb-base"},
  // 设置sideEffects为false就相当于告诉webpack项目中的所有文件都没有副作用,
  // 均可进行treeshaking操作,但是这就可能导致css或less文件被剔除
  // "sideEffects": false
                   
  // 所以需要将那些我们不希望进行treeshaking的文件设置在下面的数组中,
  // 打包时就会不对这些文件treeshaking
  "sideEffects": ["*.css", "*.less"]
}

有关“副作用”的理解,可以参看下方文档:

更新:2021年10月18日20:48:03

参考:import#仅为副作用而导入一个模块 - JavaScript | MDN

六、code split——代码分割

代码分割主要是将一个 chunk 文件划分成多个文件。这样的话就可以减小单个文件的体积,而且还可以并行加载这些文件从而提高加载速度。同时还能实现按需加载。

1、设置多入口

此前我们都是设置 webpack.config.js 中的 entry 为一个指定的入口文件(比如 ./src/js/index.js),而在入口文件中引入的其他模块则会都会被写入 built.js 这个输出文件中。如果想要实现代码分割的话,可以将 entry 设置为多个入口:

// webpack.config.js

const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // 单入口
  // entry: './src/js/index.js',
  // 多入口
  entry: {
    // 设置了多少个入口,最终就会输出多少个对应的打包后的文件
    // 比如下方设置了 index 和 test 这两个入口,那么最后就会输出两个对应的文件
    index: './src/js/index.js',
    test: './src/js/test.js'
  },
  output: {
    // 如果这里将filename写死了(比如built.js),那么上面输出的两个文件的文件名都会是built.js
    // 这就不利于我们观察源文件和打包输出文件之间的对应关系,此时可以用[name] 和 [contenthash]
    // 来做标识。其中,[name] 代表打包前的这个文件的名字,比如上面的index.js,那么其打包后文件名
    // 也会是index.js。而 [contenthash] 就是之前【缓存】中的所提到的contenthash,这样就能根据
    // 文件内容的hash值的前十位生成一个起标识作用的hash后缀

	// 更新:2021年11月5日10:13:26,注意上面我说 [name] 代表打包前的这个文件的名字,
	// 这种说法是错的,[name] 实际上是指我们在entry中设置的chunk名,只不过我刚好把
	// chunk名设置成原文件名了,详情可参看下方的勘误图示
    filename: 'js/[name].[contenthash:10].js',
    path: resolve(__dirname, 'build')
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      minify: {collapseWhitespace: true, removeComments: true}
    })
  ],
  mode: 'production'
};

image-20211018195618240

多入口设置

更新:2021年11月5日10:05:24
勘误
上面我说,[name] 是指原文件名,其实是错误的。name 是指我们在 entry 中设置的chunk名,只不过我们习惯性地会把chunk名设置为和对应的文件名一样,所以我才误以为 [name] 表示的是原文件名。具体可以看下方的图示:

在这里插入图片描述
可以看到,我在 entry 中设置了 test.js 的chunk名为 demo,然后打包生成的文件名就为 demo.xxxxx.js

参考:缓存 | webpack 中文文档

【更新结束】

这种多入口的设置对应于多页应用的场景,而此前的单入口就是单页应用时的使用场景。

2、设置optimization

如果有多个文件中都引入了一个同一个外部模块,比如 index.jstest.js 中都用到了 jQuery 这个第三方库,如果只设置上面的多入口的话,那么就会使得打包输出后的两个chunk文件都会包含 jQuery 这个模块,这就会导致两个文件的体积都比较大。于是我们就要想办法复用这个被公共引用的模块。

可以通过在 webpack.config.js 中增加 optimization 这配置项来实现:

// webpack.config.js

const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // 多入口
  entry: {
    // 设置了多少个入口,最终就会输出多少个对应的打包后的文件
    // 比如下方设置了 index 和 test 这两个入口,那么最后就会输出两个对应的文件
    index: './src/js/index.js',
    test: './src/js/test.js'
  },
  output: {
    filename: 'js/[name].[contenthash:10].js',
    path: resolve(__dirname, 'build')
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      minify: {collapseWhitespace: true, removeComments: true}
    })
  ],
  mode: 'production',
  // 该设置项可以让webpack将 node_moduls 中被引用的第三方模块单独打包为一个chunk
  // 同时,如果设置了多入口,且这些入口chunk共用了同一个外部模块(比如index.js和test.js
  // 都用到了a.js),那么配置该项也可以将那个被共用的模块被单独打包成一个chunk以供复用
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

注意,要想让上面的 optimization 把某些公共模块单独打包为一个chunk,那就要设置多入口,否则的话,该设置只能保证把 node_modules 中被用到的第三方库单独打包成一个chunk。

3、在js文件中配置单独打包某个模块

上面的 optimization 使用还是有些限制,而实际中我们还是开发单页面应用比较多,如果此时想要webpack将某些模块(比如某些公共模块)打包成一个单独的chunk,则要在js文件中做相关的设置。

// .src/js/index.js

function sum(...args) {
  return args.reduce((p, c) => p + c, 0);
}

/*
  通过js代码,让某个文件被单独打包成一个chunk
  import动态导入语法:能将某个文件单独打包
*/
// 使用这种方法单独打包输出的文件名都是以其chunks的编号为名的,这不利于观察,
// 可以在路径参数的前面加上内联注释从而指定打包输出后的文件名,下面的注释就指定将引入
// 的 test.js 文件的打包输出名(准确的说应该是chunk名)设为test,有关描述可参看下方文档
import(/* webpackChunkName: 'test' */ './test')
  .then(({ mul, count }) => {
    // 文件加载成功~
    // eslint-disable-next-line
    console.log(mul(2, 5));
  })
  .catch(() => {
    // eslint-disable-next-line
    console.log('文件加载失败~');
  });

这里我们用到的是“动态import”,即 import(path) 函数。运用该函数,可以将 path 中指定的模块单独打包为一个chunk。该函数的返回值是一个 Promise 对象。且引入成功时的回调函数中的参数就是我们所引入的那个模块对象,我们可以通过对象解构赋值的形式来接收里面的值并直接使用。

默认情况下,这种方法打包输出的chunk文件名是相应的 Chunks 编号,比如下面的 test.js 打包输出后是 1.xxxxxxxxxx.js(其中 xxxxxxxxx 是contenthash的前十位),我们通过“内联注释”的方式来指定引入模块的 Chunk Names ,然后打包输出时就可以此为名。

image-20211018210701865

动态引入时指定chunk名

有关该函数的其他描述,可以看下方的MDN文档和Webpack中文文档:

更新:2021年10月18日20:49:12

参考:import#动态import - JavaScript | MDN

参考:模块方法 | webpack 中文文档

七、动态加载

上面提到的 import() 语句除了可以完成代码分割的工作,如果利用它动态加载的特点,我们还可以实现懒加载和预加载的功能。

1、懒加载(lazy loading)

所谓懒加载就是在真正需要用到某个模块时才将其加载,也可以理解为是按需加载。比如将 import() 函数放在一个按钮点击后的回调函数中:

// ./src/js/index.js

console.log('index.js文件被加载了~');

// 正常加载:正常加载可以认为是并行加载(同一时间加载多个文件)
// import { mul } from './test';

document.getElementById('btn').onclick = function() {
  // 懒加载~:当文件需要使用时才加载~
  // 正常加载可以认为是并行加载(同一时间加载多个文件)  
  // 预加载 prefetch:等其他资源加载完毕,浏览器空闲了,再偷偷加载资源
  import(/* webpackChunkName: 'test' */ './test').then(({ mul }) => {
    console.log(mul(4, 5));
  });
};

只有当用户点击相应的按钮时,才会调用点击行为的回调函数,从而触发 ./test.js 的加载行为。这就将动态加载的行为延迟到用户需求需要被满足的时刻。当然,实现这种效果的前提也是要对该文件进行了代码分割,而 import() 函数就已经可以完成这个工作。

如果用户重复点击这个触发加载的按钮,相应的文件不会被重新加载,而是直接读取缓存中的文件。

简单理解的话,懒加载就是将动态导入语句放入了一个异步调用的函数中,当该函数被调用时才会被触发加载行为

2、预加载(Prefetch)

所谓预加载就是不是非得等到用户需要用到某个模块时才加载这个模块,而是在其他那些正常加载完成之后的资源都已经完成加载后再自动帮我们加载这个模块。这样的话就可以帮用户先准备好可能要用到的模块,等用户真正用到该模块时,就能减少用户等待时间,从而提升用户体验(尤其是被动态加载的模块体积较大时,这种感受就会比较明显)。

开启预加载的方式也很简单,只要在 import() 函数的内联注释中加上 webpackPrefetch: true 即可:

// ./src/js/index.js

console.log('index.js文件被加载了~');

// 正常加载
// import { mul } from './test';

document.getElementById('btn').onclick = function() {
  // 预加载 prefetch:会在使用之前,提前加载js文件,
  // 它是等其他需要资源都加载完毕,此时浏览器空闲下来了,于是再自动偷偷帮我们加载资源
  import(/* webpackChunkName: 'test', webpackPrefetch: true  */ './test')
    .then(({ mul }) => {
    	console.log(mul(4, 5));
  	});
};

3、几点注意与对比

①正常加载时,可以并行加载多个模块,但是可能会受到浏览器的某些限制,比如一次只能同时加载10个文件之类的,而且文件的加载的顺序就是引入语句的顺序(即写在前面的引入语句先起作用),如果某个先加载的模块没有那么快被用到,那么其实就会白白浪费一些后面模块等待的时间,如果某个靠前的模块比较大,甚至还会阻塞后面模块的加载。

②而预加载就可以实现等那些需要用到的资源被加载完了之后在利用浏览器的空闲时间完成相应模块的加载工作,这就就不会浪费时间,且不会阻塞其他模块。懒加载则是非要等到要用的时候才加载,此时也可能会出现一段等待时间。

③预加载看起来是最智能的,事实上也比较好用,但是其兼容性比较差,只能用在一些较高版本的浏览器上。

八、PWA

1、PWA是什么及其功能

PWA(Progressive Web App),意为渐进式网页应用。利用 ServiceWorker 和浏览器的缓存机制(memory cache)就能实现“离线访问”效果。

所谓“离线访问”,就是在断网的情况下刷新原来的线上网页仍能访问一些缓存的网页资源。

要实现“离线访问”,就要使用谷歌开发的 workbox 技术:

更新:2021年10月18日23:45:00

参考:Workbox | Google Developers

2、在Webpack中应用PWA

2.1、在 webpack.config.js 配置 workbox-webpack-plugin 插件

在Webpack中,我们是通过相关的插件来实现这个功能的。使用前先通过npm下载:

$ npm i workbox-webpack-plugin -D

然后在 webpack.config.js 配置:

// webpack.config.js

const {resolve} = require('path');
// 使用插件前先对其引入
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');

module.exports = {
  entry: '.src/js/index.js',
  output: {...},
  module: {rules: [...]},
  plugins: [
    ...
    new MiniCssExtractPlugin({filename: 'css/built.[hash:10].css'}),
    // 使用 GenerateSW() 函数启动ServiceWorker,需要传入一个配置对象,其中传入两个配置项
    // 最终该插件将会生成一个 ServiceWorker 配置文件(即:service-worker.js和workbox.js)
    new WorkboxWebpackPlugin.GenerateSW({
      // clientsClaim 用于帮助ServiceWorker快速启动
      clientsClaim: true,
      // skipWaiting 删除旧的 ServiceWorker,使用最新的SW
      skipWaiting: true
    })
  ],
  mode: 'production',
  devtool: 'source-map'
}
2.2、在入口文件注册 ServiceWorker

然后需要在入口文件中注册 ServiceWorker

// ./src/js/index.js

...

// 在注册ServiceWorker时,还需要解决兼容性问题,如果当前浏览器不支持SW,则不使用它
// 如果navigator中有serviceWorker,则进行注册工作
if ('serviceWorker' in navigator) {
  // 当前全局资源加载完成之后,再进行注册
  window.addEventListener('load', () => {
    // 通过调用register()函数来注册,其中需要传入一个注册文件,该文件就是前面webpack.config.js
    // 中生成的那个文件。register() 函数返回一个Promise对象,可以通过Promise的回调函数处理结果
    navigator.serviceWorker
      .register('/service-worker.js')
      .then(() => {
        // serviceWorker注册成功时的回调
        console.log('sw注册成功了~');
      })
      .catch(() => {
        // serviceWorker注册失败时的回调
        console.log('sw注册失败了~');
      });
  });
}
2.3、处理 babel-loader报错问题

注意,上面的js代码中用到了 windownavigator 这些在浏览器中才有的全局变量,而之前配置的 eslint-loader 不认识这些变量,所以需要在 package.json 中的 eslintConfig 中增加一个配置项,否则 babel-loader 在做js语法检查的时候就会报错。

// 项目根目录下的 package.json 文件

{
  "name": "webpack_code",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {...},
  "devDependencies": {...},
  "dependencies": {...},
  "browserslist": {...},            
  "eslintConfig": { 
    "extends": "airbnb-base",
    // 在eslintConfig中增加env配置项,设置browser为true,表示语法检查时支持浏览器环境下的变量
    "env": {
      "browser": true
    }
  },
  "sideEffects": ["*.css", "*.less"]
}
2.4、在服务器中启动SW代码

另外需要注意的是,ServiceWorker 的相关代码必须运行在服务器上,所以需要将打包输出的目录作为一个服务器的根目录并将其中的资源作为静态资源向外暴露,这里可以参考之前学习【缓存】时所提到的 Express 来搭建一个本地的小型服务器。或者也可以使用 serve 这个模块来启动构建后的项目。

$ npm i serve -g

全局安装 serve 后,就可以使用 serve 命令来将某个目录作为服务器根目录启动:

# 启动服务器,并将build目录下所有资源作为静态资源暴露出去
$ serve -s build

image-20211019003038873

浏览器开发者工具中可以查看ServiceWorker信息

image-20211019003244665

浏览器刚才请求到的资源,交由SW处理

image-20211019003535102

离线访问效果

九、多进程打包(慎用)

众所周知,JavaScript是一门单线程语言,这意味着js代码在一个时间只能干一件事情。但是我们可以通过 thread-loader 这个loader来让多个进程同时进行打包工作,这样就可以加快打包速度。

使用前,先通过 npm 安装:

$ npm i thread-loader -D

使用 thread-loader 就是在处理某个文件的某个loader前加上 thread-loader 的配置,一般花费时间较多的就是 babel-loader,所以可以它前面加上 thread-loader 来让 babel-loader 进行多进程工作:

// webpack.config.js
...
// 一些重复的配置就不具体写了,和此前的一样

module.exports = {
  entry: ['./src/js/index.js', './src/index.html'],
  output: {...},
  module: {
    rules: [
      // 这个是针对js文件的eslint-loader
      { test: /\.js$/, ... },
      { oneOf: [{
       		// 这个是针对js文件的babel-loader
          test: /\.js$/,
       		use: [{
						// 把thread-loader放到某个想要开启多线程工作的loader前
       			loader: 'thread-loader',
       			options: {
              // 开启两个进程(workers)来执行babel-loader
							workers: 2
            }
          }, {
            loader: 'babel-loader',
            options: {
              preset: [['@babel/preset-env',{...}]],
              cacheDirectory: true
            }
          }]},
          // 其他loader
          ...
        ]
      }
  	]
  },
  plugins: [...],
  mode: 'production',    
  devServer: {...}
};

需要注意的是,进程的开启是有时间开销的,而且进程之间的通信也是有时间开销的。所以如果项目中的代码不是很多,那么就没有必要使用多进程打包,否则反而会增加打包的时间。而如果项目中确实有某个loader需要大量运行,那么就可以开启多进程打包从而提升效率。

有关 thread-loader 的其他文章,可以参考下面的链接:

更新:2021年10月19日01:11:11

参考:thread-loader

参考:webpack4打包优化(HappyPack、thread-loader) - 掘金

十、externals

在项目中,我们可能会引进一些第三方库,比如 jQueryVue 等,如果将这些库也进行打包操作,那么就会增加输出文件的总体体积,这时可以通过在 webpack.config.js 中配置 externals 来排除对某些外部库的打包过程。

// webpack.config.js
...
// 一些重复的配置就不具体写了,和此前的一样

module.exports = {
  entry: ['./src/js/index.js', './src/index.html'],
  output: {...},
  module: {rules: [...]},
  plugins: [...],
  mode: 'production',    
  devServer: {...},
  // 配置externals来排除某些模块参与打包过程
  externals: {
    // 拒绝jQuery被打包进来,注意下面的形式为:
    // npm包名(或者说是模块名): '库名'
    jquery: 'jQuery'
  }
};

根据官方中文文档的说法,这样就可以【从输出的 bundle 中排除(指定的)依赖】,有关描述可以参看下方链接:

更新:2021年10月19日01:30:47

参考:外部扩展(Externals) | webpack 中文文档

参考:webpack之深入浅出externals - 前端小豪 - 博客园

既然不将这些指定的依赖进行打包了,那就需要通过别的方法来进行引入了,比如在输出的 html 文件中通过 script 标签进行引入:

<script src="https://cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script>

结束:2021年10月19日01:42:49

十一、dll

1、dll的功能

更新:2021年10月19日09:51:27

漏了一点性能优化的有关内容,在此补上。

dll 是指“动态链接库”(Dynamic Link Library)。它和上面的 externals 一样,用于指示哪些库不参与打包。此前我们通过设置 optimization 可以让 node_modules 中的模块被单独打包,但是是把多个库打包为一个chunk文件,如果库比较多,那么这个chunk文件就会比较大,而 dll 就可以把指定对某些库来进行单独打包,这样有利于性能优化。

上面的 externals 是将一些第三方库进行从输出bundle的依赖中排除,然后手动再引入被排除的库。而 dll 就是事先对某个第三库进行打包,且只需要首次打包,此后便不需要重复打包该第三方库并且也不会将其输出到bundle文件(也就是 built.js)中,而是通过特定的插件自动将该打包好的第三方库引入输出的 html 文件中。

2、dll的使用

2.1、dll配置文件 webpack.dll.js

使用 dll 需要额外创建一个文件:webpack.dll.js(文件名不一定非得叫这个,可以自定义)。它的内容和此前一直在写的 webpack.config.js 非常像。只不过 webpack.config.js 是指定这个项目的打包工作,而 webpack.dll.js 是指定某个库的单独打包。

// webpack.dll.js

/*
 * @Descripttion:
 * @version:
 * @Author: LiarCoder
 * @Date: 2021-10-19 16:28:40
 * @LastEditors: LiarCoder
 * @LastEditTime: 2021-10-19 17:50:11
 */

const { resolve } = require('path');
// 为了使用Webpack中自带的 Dllplugin 插件,先将Webpack引入
const Webpack = require('webpack');

module.exports = {
  entry: {
    // 指明当前配置文件是针对哪个库进行单独打包,
    // jquery:其中前面的属性名是设置单独打包输出后的[name]值是什么(一般用作输出文件名)
    // ['jquery']:而属性值就是指明当前配置文件要打包的库是什么
    // 注意它的值是一个数组,所以我们可以添加多个库,那么这些库就都会打包输出到一个文件中
    // 比如 vue: ['vue', 'vuex', 'vue-router'] 就是将数组中这些有关Vue的库都打包输出到
    // vue.js 这个文件中(当然,这个文件名和下方的output中配置的filename有关)
    jquery: ['jquery']
  },
  output: {
    // filename指示打包生成的文件的文件名,这里的这个name就是上面配置的那个[name]值
    filename: '[name].js',
    // 指示生成的文件的存放目录
    path: resolve(__dirname, 'dll'),
    // 指示当前打包生成的库向外暴露时的标识,name就是上面的那个[name]值,
    // 而[hash]就是本次打包时生成的唯一hash值
    library: '[name]_[hash]'
  },
  // 上面的配置是专门用于单独打包我们指定的库的,运行该配置后就会生成单独打包后的库,而下面的插件配置
  // 则是生成一个映射关系,通过这个映射关系,将来webpack对整个项目进行打包的时候,就知道不要再将映射
  // 关系中的这些库进行打包处理了,因为此前已经单独打包过了,直接引用该打包过的库就行了
  plugins: [
    // 需要用到Webpack中的一个自带的插件来建立打包输出的文件和原本的第三方库的映射关系
    // 这个映射关系存储在一个 manifest.json 文件中,
    new Webpack.DllPlugin({
      // 指定我们想要映射的库的名称,这里可以理解为和上面library对应
      name: '[name]_[hash]',
      // 指定存储的映射关系的文件(即 manifest.json)的存放位置
      path: resolve(__dirname, 'dll/manifest.json')
    })
  ],
  mode: 'production'
}

webpack 命令运行时默认加载的配置是:wepack.config.js 中的配置,如果想让Webpack运行时加载指定的配置(比如我们上面刚写的这个 webpack.dll.js),那么就可以通过以下命令来实现:

$ webpack --config webpack.dll.js

运行该命令后,就会在相应的目录下生成打包输出的库文件(根据上面的配置,就会在项目根目录下的 dll 目录下生成一个 jquery.js 文件)和已经存储映射关系的 manifest.json 文件。

image-20211019181639517

使用dll单独打包某些库时的输出文件

image-20211019182153813

存储映射关系的文件

2.2、在 webpack.config.js 中引入打包好的库

通过运行上面的配置文件生成了相应的单独打包过后的库,此后,我们想要引用相应的库的话就直接向之前那样通过 requireimport 的方法引入即可,但是想要让项目不再去打包那些我们已经打包好的库,那就需要再在 webpack.config.js 中使用对应的插件(Webpack.DllReferencePlugin(),该插件也是Webpack自带的一个插件)来读取上面生成的 manifest.json 映射文件,从而让Webpack知道哪些库已经被单独打包好,不用再对其做打包工作了。

但是我们虽然通过插件告诉Webpack哪些库是不用再打包的,从而不会在 built.js 中写入这些库。但是我们还是得使用相关的库啊,此时就需要再用到一个 add-asset-html-webpack-plugin 插件来自动将我们单独打包好的那些库插入到最后输出的 html 文件中。

// webpack.config.js

const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// 为了使用Webpack中自带的 DllReferencePlugin 插件,先将 Webpack 引入
const Webpack = require('webpack');
const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'built.js',
    path: resolve(__dirname, 'build')
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    }),
    // 通过让Webpack读取 manifest.json 文件来告诉 Webpack 哪些库不参与项目的整体打包,
    // 而且也根据原库和打包后的库(我们此前已经通过一个自定义的library值向外暴露了我们单独
    // 打包的库)的映射关系,同时告诉 Webpack 使用该库时的名称也得变~
    new Webpack.DllReferencePlugin({
      manifest: resolve(__dirname, 'dll/manifest.json')
    }),
    // 将某个文件打包输出去,并在html中自动引入该资源
    new AddAssetHtmlWebpackPlugin({
      // 将 'dll/jquery.js' 再次打包成项目想要的样子并输出到特定文件夹(默认就是在输出目录
      // 的根目录下),同时在html文件中通过script标签的形式引入该资源文件
      filepath: resolve(__dirname, 'dll/jquery.js')
    })
  ],
  mode: 'production'
};

所以说,Webpack.DllReferencePlugin 这个插件只是告诉Webpack不打包某个库,

相当于是你向别人问路,别人只告诉你面前的这条路不是你要找的,而没有告诉你正确的路该怎么走。

add-asset-html-webpack-plugin 插件则是在Webpack没有打包某个库时,满足了我们想要用该库的需求。

相当于是你向前面那个人问了路,虽然知道了当前这条路不通,但是还是不知道该怎么走,而这时恰好有个热心市民在旁边给你指了正确的路。

dllexternals 都可以排除对某些库的打包过程。如果需要将某些库放在公司服务器中,可以使用 dll ,如果选择使用 CDN 的方式引入外链,那么就可以选择 externals

同时,要注意,此前在【code split——代码分割】中提到了 optimization 可以将 node_modules 中的第三方库打包成一个chunk文件,那么我们就可以配合此处的 dll 来按自己的需求将第三方库做一个定制化的打包。

十二、总结

上面提到的这些性能优化手段有的是针对开发时的,有些是针对生产上线的;有些是针对构建速度的优化,有些则是针对上线运行速度的优化。在此做一个总结:

image-20211019204133326

当然,上面提到的这些优化手段可以灵活组合,从而达到最佳的优化效果。

结束:2021年10月19日20:41:41

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值