[万字总结]深度理解Webpack

297 阅读29分钟

模块化

概念

将一个复杂的程序依据一定的规则,封装成几个块(文件),并进行组合在一起;

块的内部数据和实现是私有的,只是向外部暴露了一些接口(方法)与外部其它模块通信

作用

在实际开发过程中,会遇到变量 函数 对象等名字的冲突,还可能会造成全局变量的污染

所以模块化的作用有:

  • 避免全局变量被污染

  • 便于代码编写和维护

  • 利于封装可重用逻辑

ES6之前,最主要是有**CommonJS和AMD/CMD.**前者用于服务器,后者用于浏览器.

ES6在语言标准的层面上实现了模块功能,也就是**ESM,**成为浏览器和服务器通用的模块解决方案.

CommonJS

Node.js是commonjs规范的主要实践者,实际使用时,用module.exports定义当前模块对外输出的接口,用require加载模块

// 定义模块math.js
var basicNum = 0;
function add(a, b) {
  return a + b;
}
module.exports = { //在这里写上需要向外暴露的函数、变量
  add: add,
  basicNum: basicNum
}

// 引用自定义的模块时,参数包含路径,可省略.js
var math = require('./math');
math.add(2, 5);

// 引用核心模块时,不需要带路径
var http = require('http');
http.createService(...).listen(3000);

CommonJS用同步的方式加载模块.

在服务端,模块文件都存在本地磁盘,读取快速,但是在浏览器,限于网络原因,更合理的方案是使用异步加载

module.exports和exports

CommonJS规范仅定义了exports,但exports存在被重写而丢失的问题,所以创造了module.exports

在CommonJS中,每个js文件都是一个模块,每个模块里都有一个全局module对象

这个module对象的exports属性用来导出接口.当外部模块导入当前模块时,使用的也是module对象.

// hello.js
var s = 'hello world!'
module.exports = s;
console.log(module);

img

其他模块导入时

// main.js
var hello = require('./hello.js'); // hello = hello world!

当在hello.js中这样写时:

// hello.js
var s = 'hello world!'
exports = s;

这里的exports被重写了

更清除的代码

var module = {
  exports: {}
}
var exports = module.exports;
console.log(module.exports === exports); // true

var s = 'hello world!'
exports = s; // module.exports 不受影响
console.log(module.exports === exports); // false
  1. 在模块初始化时,exportsmodule.exports指向同一块内存,因此有exports===module.exports
  2. exports被重新赋值时,重新指向了新的内存地址,切断了和原来内存地址的联系

exports的规范使用

// hello.js
exports.s = 'hello world!';

// main.js
var hello = require('./hello.js');
console.log(hello.s); // hello world!

特点

  • 所有代码都运行在模块作用域,不会污染全局作用域

  • 模块是同步加载的,即只有加载完毕,才能执行后面的操作

  • 模块在首次执行后就会缓存,再次加载只返回缓存结果

  • CommonJS输出是值的拷贝,模块内部的变化不会影响导出的值

AMD和require.js

AMD规范采用异步方式加载模块,模块的加载不影响它后面语句的运行.

所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完毕之后这个回调才执行

RequireJS用法

通过define来定义一个模块,使用require导入

//a.js
//define可以传入三个参数,分别是字符串-模块名、数组-依赖模块、函数-回调函数
define(function(){
    return 1;
})

// b.js
//数组中声明需要加载的模块,可以是模块名、js文件路径
require(['a'], function(a){
    console.log(a);// 1
});

特点

对于依赖的模块,AMD推崇**依赖前置,提前执行.**也就是在define方法里面传入的依赖模块,会在一开始就下载执行

define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) { 
    // 等于在最前面声明并初始化了要用到的所有模块
    if (false) {
      // 即便没用到某个模块 b,但 b 还是提前执行了
      b.foo()
    } 
});

CMD和sea.js

和require.js在声明依赖的模块时就直接初始化不一样,CMD推崇依赖就近,延迟执行

/** AMD写法 **/
define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) { 
     // 等于在最前面声明并初始化了要用到的所有模块
    a.doSomething();
    if (false) {
        // 即便没用到某个模块 b,但 b 还是提前执行了
        b.doSomething()
    } 
});

/** CMD写法 **/
define(function(require, exports, module) {
    var a = require('./a'); //在需要时申明
    a.doSomething();
    if (false) {
        var b = require('./b');
        b.doSomething();
    }
});

/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
    var $ = require('jquery.js');
    var add = function(a,b){
        return a+b;
    }
    exports.add = add;
});
// 加载模块
seajs.use(['math.js'], function(math){
    var sum = math.add(1+2);
});

ESM

特性

script标签执行时立即执行加载脚本, defer 属性会在页面渲染完之后执行脚本

给script标签 设置type = module 来告知当前script标签中的代码采用ESM的规范来执行

1.自动采用严格模式

  <script type="module">
    console.log(this) // undefined
  </script>

2.每个EMS模块都是单独的私有作用域

  <script type="module">
    var a = 111
    console.log(a)
  </script>

  <script type="module">
    console.log(a) // ref Error
  </script>

3.ESM的script标签会延迟执行脚本,默认加上了defer属性`

4.ESM通过CORS跨域请求

<!-- 下面地址去请求js库会报跨域,是因为这个地址不支持 CORS, 如果要请求外部地址服务端必须支持 CORS -->
  <script type="module" src="https://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
  <!-- 下面这个地址就没有问题,因为服务端支持 CORS -->
  <script type="module" src="https://unpkg.com/jquery@3.4.1/dist/jquery.min.js"></script>

export

export导出接口有以下方式

// config.js
// 直接导出
export const hello = 'hello world!';
export const api = `${prefix}/api`;

// 集中导出
const hello = 'hello world!';
const api = `${prefix}/api`;
export {
  hello,
  api,
}

上面都是为了把hello和api分别导出

// foo.js
export default function foo() {}

// 等同于:
function foo() {}
export {
  foo as default
}

export default用来导出模块的默认接口,它等同于导出一个名为default的接口.

import

根据导出的方式,有相应的导入方式

import { api, hello } from './config.js';

// 配合`import`使用的`as`关键字用来为导入的接口重命名。
import { api as myApi } from './config.js';

对于export default导出的模块

import foo from './foo.js';

// 等同于:
import { default as foo } from './foo.js';

ESM和CommonJS

输出拷贝和输出引用

commonJS

// a.js
let a = 1;
let b = { num: 1 }
setTimeout(() => {
    a = 2;
    b = { num: 2 };
}, 200);
module.exports = {
    a,
    b,
};

// main.js
// node main.js
let {a, b} = require('./a');
console.log(a);  // 1
console.log(b);  // { num: 1 }
setTimeout(() => {
    console.log(a);  // 1
    console.log(b);  // { num: 1 }
}, 500);

exports对象是模块内外的唯一关联,commonjs输出的内容,就是exports对象的属性,模块运行结束,属性就确定了

esm

// a.mjs
let a = 1;
let b = { num: 1 }
setTimeout(() => {
    a = 2;
    b = { num: 2 };
}, 200);
export {
    a,
    b,
};

// main.mjs
// node --experimental-modules main.mjs
import {a, b} from './a';
console.log(a);  // 1
console.log(b);  // { num: 1 }
setTimeout(() => {
    console.log(a);  // 2
    console.log(b);  // { num: 2 }
}, 500);

模块内部引用的变化,会反应在外部

总结

1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

  • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
  • ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

  • 运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
  • 编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。

基础配置

简单配置

依赖安装

npm install webpack webpack-cli -D

工作模式

新建 ./src/index.js 文件,编写简单代码

const a = 'Hello ITEM'
console.log(a)
module.exports = a;

此时目录结构

webpack_work                  
├─ src                
│  └─ index.js         
└─ package.json       

运行npx webpack

img

提示未进行mode(模式)配置

模式:供mode配置选项,告知webpack使用相应模式的内置优化,默认值为production,另外还有development``none

  • production:开发模式,打包更加快速,省略了代码优化的阶段

  • development:生产模式,打包速度慢,会开启tree-shaking和压缩代码

  • none:不适用任何默认优化选项

在配置对象中加入下列配置

module.exports = {
  mode: 'development',
};

或者在cli参数中传递

webpack --mode=development

配置文件

在根路径下新建一个配置文件webpack.config.js,编写基本配置信息

const path = require('path')

module.exports = {
  mode: 'development', // 模式
  entry: './src/index.js', // 打包入口地址
  output: {
    filename: 'bundle.js', // 输出文件名
    path: path.join(__dirname, 'dist') // 输出文件目录
  }
}

Loader

新增CSS文件

body {
  margin: 0 auto;
  padding: 0 20px;
  max-width: 800px;
  background: #f4f8fb;
}

修改配置文件中的入口文件为新的CSS文件

const path = require('path')

module.exports = {
  mode: 'development', // 模式
  entry: './src/main.css', // 打包入口地址
  output: {
    filename: 'bundle.css', // 输出文件名
    path: path.join(__dirname, 'dist') // 输出文件目录
  }
}

打包之后会发现不成功

因为webpack默认支持处理JS与JSON文件,其他类型处理不了,这里必须借助Loader来对不同类型的文件进行处理。

安装对应的loader来处理CSS

npm install css-loader

配置文件

const path = require('path')

module.exports = {
  mode: 'development', // 模式
  entry: './src/main.css', // 打包入口地址
  output: {
    filename: 'bundle.css', // 输出文件名
    path: path.join(__dirname, 'dist') // 输出文件目录
  },
  module: { 
    rules: [ // 转换规则
      {
        test: /\.css$/, //匹配所有的 css 文件
        use: 'css-loader' // use: 对应的 Loader 名称
      }
    ]
  }
}

再次运行可以打包成功

结论:Loader就是将Webpack不认识的内容转化为认识的内容

Plugin

与loader用于转换特定类型的文件不同,Plugin可以贯穿Webpack打包的生命周期,执行不同的任务

例如,希望js或css文件可以自动引入到Html中,就需要使用插件html-webpack-plugin

新建src/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ITEM</title>
</head>
<body>
  
</body>
</html>

安装插件

npm install html-webpack-plugin

配置插件

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development', // 模式
  entry: './src/index.js', // 打包入口地址
  output: {
    filename: 'bundle.js', // 输出文件名
    path: path.join(__dirname, 'dist') // 输出文件目录
  },
  module: { 
    rules: [
      {
        test: /\.css$/, //匹配所有的 css 文件
        use: 'css-loader' // use: 对应的 Loader 名称
      }
    ]
  },
  plugins:[ // 配置插件
    new HtmlWebpackPlugin({
      template: './src/index.html'
    })
  ]
}

执行打包命令后可以看到dist目录下生成的index.html中已经引入了js文件

自动清空打包目录

每次打包时,打包目录都会遗留上次打包的文件,为了保持打包目录的纯净,需要在打包前将打包目录清空

可以使用clean-webpack-plugin插件实现

区分环境

本地环境:

  • 需要更快的构建速度

  • 需要打印debug信息

  • 需要hot reload功能

  • 需要sourcemap方便定位问题

生产环境:

  • 需要更小的包体积,代码压缩+tree-shaking

  • 需要进行代码分割

  • 需要压缩图片体积

针对不同需求,首先要做的就是做好环境区分

安装cross-env

npm install cross-env -D

配置启动命令

"scripts": {
    "dev": "cross-env NODE_ENV=dev webpack serve --mode development", 
    "test": "cross-env NODE_ENV=test webpack --mode production",
    "build": "cross-env NODE_ENV=prod webpack --mode production"
  },

启动devServer

安装webpack-dev-server

npm intall webpack-dev-server@3.11.2 -D

webpack版本大于4之后需要用devServer.static进行配置,不再有devServer.contentBase选项

配置本地服务

// webpack.config.js
const config = {
  // ...
  devServer: {
    contentBase: path.resolve(__dirname, 'public'), // 静态文件目录
    compress: true, //是否启动压缩 gzip
    port: 8080, // 端口号
    // open:true  // 是否自动打开浏览器
  },
 // ...
}

contentBase:

因为在webpack打包时,对静态文件的处理,例如图片都是直接copy到dist目录下的。但是对于本地开发来说这个过程太费时,所以在设置了contentBase之后就直接到对应的静态目录下去读取文件,不需要对文件进行迁移

执行命令就可以启动服务

npm run dev

引入CSS

单靠一个css-loader是没有方法将样式加载到页面上的,这个时候需要安装style-loader

style-loader的作用是把处理好的css通过style标签的形式添加到页面上

安装依赖

npm install style-loader

配置loader

const config = {
  // ...
  module: { 
    rules: [
      {
        test: /\.css$/, //匹配所有的 css 文件
        use: ['style-loader','css-loader']
      }
    ]
  },
  // ...
}

Loader的执行顺序是固定从后往前的,即按css-loader --> style-loader

在入口文件中引入样式文件

// ./src/index.js

import './main.css';


const a = 'Hello ITEM'
console.log(a)
module.exports = a;

重新启动一下服务,可以看到样式已经通过style标签插入到了html中

img

style-loader的核心逻辑相当于

const content = `${样式内容}`
const style = document.createElement('style');
style.innerHTML = content;
document.head.appendChild(style);

CSS兼容性

可以使用postcss-loader,自动添加部分属性的浏览器前缀

npm install postcss postcss-loader postcss-preset-env -D
const config = {
  // ...
  module: { 
    rules: [
      {
        test: /\.css$/, //匹配所有的 css 文件
        use: [
          'style-loader',
          'css-loader', 
          'postcss-loader'
        ]
      }
    ]
  }, 
  // ...
}

添加postcss的配置文件postcss.config.js

// postcss.config.js
module.exports = {
  plugins: [require('postcss-preset-env')]
}

创建postcss-preset-env的配置文件.browserslistrc

# 换行相当于 and
last 2 versions # 回退两个浏览器版本
> 0.5% # 全球超过0.5%人使用的浏览器,可以通过 caniuse.com 查看不同浏览器不同版本占有率
IE 10 # 兼容IE 10

运行

img

autoprefixer在线转化器:autoprefixer.github.io/

分离样式文件

如果不想要样式文件通过style标签引入,可以进行文件形式的引入

安装mini-css-extract-plugin

npm install mini-css-extract-plugin

修改配置,记得去掉先前的style-loader,因为它是标签形式引入的

// ...
// 引入插件
const MiniCssExtractPlugin = require('mini-css-extract-plugin')


const config = {
  // ...
  module: { 
    rules: [
      // ...
      {
        test: /\.(s[ac]|c)ss$/i, //匹配所有的 sass/scss/css 文件
        use: [
          // 'style-loader',
          MiniCssExtractPlugin.loader, // 添加 loader
          'css-loader',
          'postcss-loader',
          'sass-loader', 
        ] 
      },
    ]
  },
  // ...
  plugins:[ // 配置插件
    // ...
    new MiniCssExtractPlugin({ // 添加插件
      filename: '[name].[hash:8].css'
    }),
    // ...
  ]
}

// ...

打包结果

dist                     
├─ bundle.js            
├─ index.html              
└─ main.3bcbae64.css # 生成的样式文件  

img

sourceMap

sourceMap就是一个信息文件,里面存储了代码打包转换后的位置信息,实质上是一个json描述文件,维护了打包前后的代码映射关系。

常见的源码转换主要是以下三种情况:

  • 压缩减小体积

  • 多个文件合并,减少http请求数

  • 其他语言编译成JavaScript

转换后的实际运行代码不同于开发代码,debug变得困难,所以才需要sourceMap.

例如用webpack打包编译下面这段代码

console.log('source map!!!')
console.log(a); //这一行肯定会报错

img

点击进入报错文件之后

img

无法定位到具体位置.

于是在webpack配置中开启sourceMap

img

重新构建后打开浏览器

img

可以定位到具体的错误位置.

文件指纹

Webpack的文件指纹是指文件名后面加上hash值,作用是为了缓存

例如在基础配置中用到的:filename:"[name][hash:8][ext]"

  • ext:文件后缀名

  • name:文件名

  • path:文件相对路径

  • folder:文件所在文件夹

  • hash:每次构建生成的唯一 hash 值

  • chunkhash:根据 chunk 生成 hash 值

  • contenthash:根据文件内容生成hash 值

hash:任何一个文件改动,整个项目的构建hash值都会变

chunkhash:文件的改动只会影响其所在chunk的hash值

contenthash:每个文件都有独立的hash值,文件的改动只影响自身的hash值

js的文件指纹

设置output的filename,用chunkhash

css的文件指纹

设置minicssextractPlugin的filename,用contenthash

优化构建速度

简化模块引用

const path = require('path')
...
// 路径处理方法
function resolve(dir){
  return path.join(__dirname, dir);
}

 const config  = {
  ...
  resolve:{
    // 配置别名
    alias: {
      '~': resolve('src'),
      '@': resolve('src'),
      'components': resolve('src/components'),
    }
  }
};

配置完成之后,可以在项目中使用别名

// 使用 src 别名 ~ 
import '~/fonts/iconfont.css'

// 使用 src 别名 @ 
import '@/fonts/iconfont.css'

// 使用 components 别名
import footer from "components/footer";

多进程配置

小型项目一般不用,因为启动进程和进程通信都有一定开销

安装thread-loader

配置在thread-loader之后的loader都会在一个单独的worker池中运行

npm i -D thread-loader

配置

const path = require('path');

// 路径处理方法
function resolve(dir){
  return path.join(__dirname, dir);
}

const config = {
  //...
  module: { 
    noParse: /jquery|lodash/,
    rules: [
      {
        test: /\.js$/i,
        include: resolve('src'),
        exclude: /node_modules/,
        use: [
          {
            loader: 'thread-loader', // 开启多进程打包
            options: {
              worker: 3,
            }
          },
          'babel-loader',
        ]
      },
      // ...
    ]
  }
};

缓存

利用缓存可以大幅提升重复构建的速度

babel-loader开启缓存

  • babel在转译js过程中时间开销比较大,将babel-loader的执行结果缓存起来,重新打包时直接读取缓存
  • 缓存位置:node_modules/.cache/babel-loader

配置

const config = {
 module: { 
    noParse: /jquery|lodash/,
    rules: [
      {
        test: /\.js$/i,
        include: resolve('src'),
        exclude: /node_modules/,
        use: [
          // ...
          {
            loader: 'babel-loader',
            options: {
              cacheDirectory: true // 启用缓存
            }
          },
        ]
      },
      // ...
    ]
  }
}

cache-loader

  • 缓存一些性能开销比较大的loader处理结果

安装

npm i -D cache-loader

配置

const config = {
 module: { 
    // ...
    rules: [
      {
        test: /\.(s[ac]|c)ss$/i, //匹配所有的 sass/scss/css 文件
        use: [
          // 'style-loader',
          MiniCssExtractPlugin.loader,
          'cache-loader', // 获取前面 loader 转换的结果
          'css-loader',
          'postcss-loader',
          'sass-loader', 
        ]
      }, 
      // ...
    ]
  }
}

DLLPlugin

DLLPlugin可以打包常用的且不经常更新的模块,生成JS和json文件,一般放进public目录中,项目打包时不会再对这些依赖进行编译,而是通过在html中插入script标签来读取.

比如vue,echarts等常用框架和资源库,这些项目依赖包达到一定规模时速度的提升尤为明显.

详情使用可参考:juejin.cn/post/691502…

个人认为可以用externals代替

Code Splite

分析

webpack默认配置下会把所有的依赖和插件都打包到vendors.js中.所以对于大量引入第三方依赖的项目,这个文件会非常大.所以它会带来以下的问题:

  • 打包文件很大,首屏加载时间长
  • 第三方库,一般不会改动,但是我们的业务代码是经常需要改动的,假设我改动了业务逻辑接着重新打包,这个时候会重新生成一个大的文件

解决

第三方库可以打包到另一个文件中

  • 业务代码放到main.js
  • vendors~main.js放第三方库

这里采用Code Splitting代码分割

module.exports = {
    optimization: {
        splitChunks: {
            chunks: 'all'
        }
    },
}

配置

splitChunks: {
    chunks: "all",
    minSize: 30000,
    maxSize: 50000,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
        vendors: {
            // 引入的库是否在node_modules里
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        },
        default: {
                minChunks: 2,
                priority: -20,
                reuseExistingChunk: true
            }
        }
}
  • chunks的意思是对那些类型的代码进行分割,all是全部、async是异步、initial是同步;

  • minSize的意思是引入的模块必须大于30kb,才会进行代码分割;

  • maxSize的意思是如果引入的模块,如果大于50kb,进行二次分割,分成多个文件;

  • minChunks当模块引入次数大于1时,才会进行代码分割;

  • maxAsyncRequests同时加载5个以上的js文件,并行请求的最大数目为5;

  • maxInitialRequests 入口文件中,如果加载的模块大于3个时,不再进行代码分割;

  • automaticNameDelimiter表示拆分出的chunk的名称连接符。默认为~。如vendors~main.js;

  • name 可重新定义打包后的文件名称,cacheGroups的vendors的filename生效;

  • priority当一个文件的打包条件同时满足vendors以及default中的条件时,会根据priority的大小优先选择应用那个,数值越大优先级越高;

  • reuseExistingChunk如果a模块引用的模块b已经打包过,再打包时b模块不再进行打包,直接使用;

优化构建结果

构建结果分析

借助插件webpack-bundle-analyzer可以直观看到打包结果中的文件体积大小和各模块的依赖关系

安装

npm i -D webpack-bundle-analyzer

配置

// 引入插件
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin


const config = {
  // ...
  plugins:[ 
    // ...
    // 配置插件 
    new BundleAnalyzerPlugin({
      // analyzerMode: 'disabled',  // 不启动展示打包报告的http服务器
      // generateStatsFile: true, // 是否生成stats.json文件
    })
  ],
};

启动命令

 "scripts": {
    // ...
    "analyzer": "cross-env NODE_ENV=prod webpack --progress --mode production"
  },

执行命令之后访问端口为8888的地址可以看到

img

如果不想启动web服务

new BundleAnalyzerPlugin({
   analyzerMode: 'disabled',  // 不启动展示打包报告的http服务器
   generateStatsFile: true, // 是否生成stats.json文件
})

压缩CSS

安装optimize-css-assets-webpack-plugin

npm install -D optimize-css-assets-webpack-plugin 

修改配置

// ...
// 压缩css
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')
// ...

const config = {
  // ...
  optimization: {
    minimize: true,
    minimizer: [
      // 添加 css 压缩配置
      new OptimizeCssAssetsPlugin({}),
    ]
  },
 // ...
}

// ...

打包结果

img

压缩JS

在生产环境下默认会开启js压缩,但是我们手动配置optimization选项之后,就不再默认对js进行压缩,需要手动配置

因为webapck5中内置了terser-webpack-plugin,所以直接引用

const TerserPlugin = require('terser-webpack-plugin');

const config = {
  // ...
  optimization: {
    minimize: true, // 开启最小化
    minimizer: [
      // ...
      new TerserPlugin({})
    ]
  },
  // ...
}

清除无用的CSS

安装插件purgecss-webpack-plugin

npm i -D purgecss-webpack-plugin

添加配置

// ...
const PurgecssWebpackPlugin = require('purgecss-webpack-plugin')
const glob = require('glob'); // 文件匹配模式
// ...

function resolve(dir){
  return path.join(__dirname, dir);
}

const PATHS = {
  src: resolve('src')
}

const config = {
  plugins:[ // 配置插件
    // ...
    new PurgecssPlugin({
      paths: glob.sync(`${PATHS.src}/**/*`, {nodir: true})
    }),
  ]
}

再次打包会把没用到的css样式自动去除,不再打包进最终的文件中

Externals

将第三方的类库放到CDN上,能够大幅度减少生产环境中的项目体积,另外CDN能够实时地根据网络流量和各节点的连接 负载状况以及到用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上

另外因为CDN和服务器的域名一般不是同一个,可以缓解同一域名并非http请求的数量限制,有效分流以及减少多余的cookie发送


Vue在使用webpack构建之后,chunk-vendors.js这个文件巨大无比,加载时间长,这是首屏加载时间长的罪魁祸首之一

首先通过插件webpack-bundle-analyzer查看chunk-vendors.js的文件内容

img

从上面的依赖图可以看到,最大的两个js文件里面都是一些第三方的依赖包,那么只要把这些依赖包提取即可.

externals就可以用来防止这些依赖包被打包

externals的值是个对象

module.exports={
    configureWebpack:congig =>{
        externals:{
            key: value
        }
    }
}

其中key是第三方依赖库的名称,同package.json文件中的dependcies对象的key一样

img

value值是第三方依赖编译打包后生成的js文件,然后js文件执行后赋值给window的全局变量名称

例:

在public/index.html中用cdn引入各需要剥离的依赖

<body>
    <div id="app"></div>
    <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.runtime.min.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/vue-router/3.2.0/vue-router.min.js"></script>
    <script src="https://unpkg.com/element-ui@2.10.1/lib/index.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/xlsx/0.16.1/xlsx.min.js"></script>
</body>

在vue.config.js中的配置如下

module.exports={
    configureWebpack:{
        externals: {
            'element-ui': 'ELEMENT',
            'vue': 'Vue',
            'vue-router': 'VueRouter',
            'xlsx': 'XLSX'
        }
    }
}

externals提取第三方依赖之后,代码原先引入依赖的地方是可以不用更改的.

比如main.js中

import ElementUI from 'element-ui';
Vue.use(ElementUI);

除非<script src="https://unpkg.com/element-ui@2.10.1/lib/index.js"></script>被放置在了app.js之后

比如

<body>
    <div id="app"></div>
    <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.runtime.min.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/vue-router/3.2.0/vue-router.min.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/xlsx/0.16.1/xlsx.min.js"></script>
</body>
<script src="https://unpkg.com/element-ui@2.10.1/lib/index.js"></script>

那么打包编译之后,会变成

img

此时就会报错了

img

这个时候就得在main.js中把依赖引入和use的部分去掉

原因:

前面的element-ui的CDN链接放在了app.js后面引入,但是main.js里面的代码都会编译打包到app.js中,执行app.js时就会遇到需要import对应的element-ui模块,但是此时因为还没引入,所以报错

提取完之后再执行一次打包,观察分析图

img

之前的依赖都已消失,优化成效明显

路由懒加载

传统路由配置

import VueRouter from 'vue-router'
import Login from '@/views/login/index.vue'
import Home from '@/views/home/home.vue'
Vue.use(VueRouter)
const router = new VueRouter({
 routes: [
    { path: '/login', component: Login },
    { path: '/home', component: Home }
  ]
export default router

路由懒加载写法

import VueRouter from 'vue-router'
 
Vue.use(VueRouter)
const router = new VueRouter({
 routes: [
    { path: '/login', component: () => import('@/views/login/index.vue') },
    { path: '/home',  component: () => import('@/views/home/home.vue') }
  ]
 
export default router

Gzip压缩

前提是服务器那边需要开启Gzip

通常开启gzip压缩能够有效缩小传输资源的大小,利用compression-webpack-plugin让webpack在打包的时候输出.gz后缀的压缩文件

//cnpm install compression-webpack-plugin --save-dev 

// vue.config.js
const CompressionPlugin = require("compression-webpack-plugin")

module.exports = {
  configureWebpack: () => {
    if (process.env.NODE_ENV === 'production') {
      return {
        plugins: [
          new CompressionPlugin({
            test: /.js$|.html$|.css$|.jpg$|.jpeg$|.png/, // 需要压缩的文件类型
            threshold: 10240, // 归档需要进行压缩的文件大小最小值,我这个是10K以上的进行压缩
            deleteOriginalAssets: false, // 是否删除原文件
            minRatio: 0.8
          })
        ]
      }
    }
  }
}

这样就不需要服务器主动压缩我们就可以得到gzip文件,只要把.gz的文件放在服务器上,就可以让服务器优先返回.gz文件,在面对高流量时也能一定程度减轻服务器的压力,属于是空间换时间

图片压缩

使用image-webpack-loader

   module: {
      rules: [
        {
          test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
          use: [
            {
              loader: "image-webpack-loader", // 压缩图片
              options: {
                bypassOnDebug: true,
              },
            },
          ],
        },
      ],
    },

Prefetch

实例:

img

列表第一次展开时,优惠券背景有一个逐渐显示的过程,体验上不是很好.

原因是因为优惠券列表展开时需要去加载图片,当网速慢时该问题会比较明显.

如果能在优惠券列表渲染前就加载好背景图,这个问题就不会出现

**prefetch(链接预取)**是一种浏览器机制,其利用浏览器空闲时间来下载或预取用户在不久的将来可能访问的文档.

网页向浏览器提供一组预取提示,并在浏览器完成当前页面的加载后开始静默地拉取指定的文档并将其存储在缓存中

具体来说,浏览器通过标签来实现预加载

<head>
    ...
    <link rel="prefetch" href="static/img/ticket_bg.a5bb7c33.png">
    ...
</head>

查看现在优惠券列表的加载效果

img

达到了期望,观察一下network面板

img

img

在首屏的请求中,请求列表已经出现了优惠券背景图ticket_bg.png的加载请求;

展开优惠券列表之后,network中增加了一个新的ticket_bg.png的访问请求,但有一个特殊标记 prefetch cache,表明这次请求用的是prefetch请求.

即浏览器在空闲时间预先加载资源,真正使用时直接从浏览器缓存中快速获取

Preload

preload为预加载

元素的rel属性的属性值preload能够让你的html页面指明哪些资源是在页面加载完成后立即需要的.

这些即刻需要的资源,我们希望在页面加载的生命周期的早期阶段就开始获取,在浏览器的主渲染机制介入前就进行预加载.

简单来说,就是通过标签显示声明一个高优先级资源,强制浏览器提前请求,同时不阻塞文档正常onload.

img

上面的页面使用了自定义字体.

但是页面首次加载时文字会出现短暂的字体样式闪动,在网络较差时情况比较明显.

原因是 字体文件由css引入,在css解析之后才会加载,加载完成之前浏览器只能使用降级字体.

也就是说字体加载的时机太迟,需要浏览器提前告知进行加载

<head>
    ...
    <link rel="preload" as="font" href="<%= require('/assets/fonts/AvenirNextLTPro-Demi.otf') %>" crossorigin>
    <link rel="preload" as="font" href="<%= require('/assets/fonts/AvenirNextLTPro-Regular.otf') %>" crossorigin>
    ...
</head>

img

对比一下network面板

前:

img

后:

img

可以发现字体文件的加载时机明显提前了

preload link必须设置as属性来声明资源类型(font/image/style/script)

vue-cli3中默认配置

  • preload

默认情况下,vue-cli会为所有初始化渲染需要的文件自动生成preload提示

  • prefetch

默认情况下,vue-cli会为所有作为async chunk生成的JavaScript自动生成prefetch提示

module,chunk,bundle区别

目录结构

src/
├── index.css
├── index.html # 这个是 HTML 模板代码
├── index.js
├── common.js
└── utils.js

webpack配置

{
    entry: {
        index: "../src/index.js",
        utils: '../src/utils.js',
    },
    output: {
        filename: "[name].bundle.js", // 输出 index.js 和 utils.js
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    MiniCssExtractPlugin.loader, // 创建一个 link 标签
                    'css-loader', // css-loader 负责解析 CSS 代码, 处理 CSS 中的依赖
                ],
            },
        ]
    }
    plugins: [
        // 用 MiniCssExtractPlugin 抽离出 css 文件,以 link 标签的形式引入样式文件
        new MiniCssExtractPlugin({
            filename: 'index.bundle.css' // 输出的 css 文件名为 index.css
        }),
    ]
}

运行打包,结果如下

img

index.csscommon.js在index.js中被引入,打包生成的index.bundle.css和index.bundle.js都属于chunk0,因为utils独立打包,它生成的utils.bundle.js属于chunk1

示例图

img

  • 对于一份代码,当我们手写下一个一个的文件,无论它们是ESM还是commonJS或AMD,它们都是module

  • 当我们写的module源文件传到webpack打包时,webpack会根据文件引用关系生成chunk文件

  • webpack处理好chunk文件之后,最后会输出bundle文件,这个bundle文件包含了经过加载和编译的最终结果源源文件,所以它可以直接在浏览器运行

一般来说一个chunk对应一个bundle

比如:utils.js => chunks 1 => utils.bundle.js

但也有例外,比如用MiniCssExtractPluginchunks 0中抽离了index.bundle.css文件

总结

module``chunk``bundle其实就是同一份逻辑代码在不同转换场景下取的三个名字.

我们直接写出来的是module,webpack处理时是chunk,最后生成浏览器可以直接运行的是bundle.

loader

概念

Loader本质上就是一个函数,loader用于对模块的源代码进行转换.

loader可以使你在import模块时预处理文件.

loader可以将文件从不同的语言如ts转换成JavaScript或将内联图像转换为data URL

webpack中通过compliation对象进行模块编译时,会首先进行匹配loader处理文件得到结果(string/buffer),之后才会输出给webpack进行编译

简单来说,loader就是一个函数,通过它我们可以在webpack处理我们的特定资源之前进行提前处理

比如说,webpack只能识别js模块,而我们在使用typescript编写代码时可以提前通过babel-loader.ts后缀文件提前编译成JavaScript代码,之后交给webpack处理

基础配置参数

示例配置

module.exports = {
  module: {
    rules: [
      { test: /.css$/, use: 'css-loader',enforce: 'post' },
      { test: /.ts$/, use: 'ts-loader' },
    ],
  },
};

我们通过module中的rules属性来配置loader

test

test是一个正则表达式,根据test的规则去匹配文件,匹配到的就会交给对应的loader处理

use

use表示匹配到test中的文件时,应该用哪个loader的规则去处理.

use可以是一个字符串,也可以是一个数组

如果use为一个数组时表示有多个loader依次处理匹配的资源,按照从右往左的顺序处理

enforce

loader中存在一个enforce参数标志loader的顺序

示例

module.exports = {
  module: {
    rules: [
      { test: /.css$/, use: 'sass-loader', enforce: 'pre' },
      { test: /.css$/, use: 'css-loader' },
      { test: /.css$/, use: 'style-loader', enforce: 'post' },
    ],
  },
};

针对.css结尾的资源文件,我们在打包过程中module.rules分别有三条规则匹配到,也就是对于同一个.css文件我们需要使用匹配到的三个loader分别进行处理.

enforce可以决定处理的顺序

  • 默认值为normal loader

  • 配置enforce:'pre'参数时,我们称之为pre loader(前置loader)

  • 配置enforc:'post'参数时,我们称之为post loader(后置loader)

三种loader的执行顺序

img

loader的路径

通常配置时都是直接使用loader的名称,如

// webpack.config.js
module.exports = {
    ...
    module: {
        rules: [
            {
                test:/\.js$/,
                loader: 'babel-loader'
            }
        ]
    }
}

上面的配置相当于告诉webpack关于js结尾的文件使用babel-loader去处理,那么问题是它是如何寻找到babel-loader的真实内容的

绝对路径

第一种方式在项目内部存在一些未发布的自定义loader时比较常见,直接使用绝对路径地址的形式指向loader文件所在的地址

const path = require('path')
// webpack.config.js
module.exports = {
    ...
    module: {
        rules: [
            {
                test:/\.js$/,
                loader: path.resolve(__dirname,'../loaders/babel-loader.js')
            }
        ]
    }
}

resolveLoader.alias

第二种方式使用webpack中的resolveLoader的别名alias方式进行配置

const path = require('path')
// webpack.config.js
module.exports = {
    ...
    resolveLoader: {
        alias: {
            'babel-loader': path.resolve(__dirname,'../loaders/babel-loader.js')
        }
    },
    module: {
        rules: [
            {
                test:/\.js$/,
                loader: 'babel-loader'
            }
        ]
    }
}

此时,当webpack在解析到loader中使用babel-loader时,查找到alias中定义了babel-loader的文件路径,就会按这个路径去寻找

但是每次一个loader都要配一个别名太麻烦了

resolveLoader.modules

我们可以通过resolveLoader.modules定义webpack在解析loader时应该查找的目录

const path = require('path')
// webpack.config.js
module.exports = {
    ...
    resolveLoader: {
        modules: [ path.resolve(__dirname,'../loaders/') ]
    },
    module: {
        rules: [
            {
                test:/\.js$/,
                loader: 'babel-loader'
            }
        ]
    }
}

resolveLoader.modules的默认值是['node_modules'],也就是我们的第三方依赖

Loader种类

enforce参数将loader分成了三类

  • pre loader

  • normal loader

  • post loader

同时webpack还支持一种内联的方式配置loader,比如

import Styles from 'style-loader!css-loader!./styles.css';

上述在引用style.css时,调用了css-loaderstyle-loader进行提前处理文件

所以综上,loader的四种类型分别是

  • pre loader

  • normal loader

  • inline loader

  • post loader

Loader的执行顺序

默认顺序

默认的loader执行阶段,四种loader会按照下面的顺序执行

img

webpack进行编译文件之前,资源文件匹配对应的loader:

  • 执行pre loader前置处理文件

  • 将pre loader执行后的资源链式传递给normal loader正常的loader处理

  • normal loader处理结束后交给inline loader处理

  • 最终通过post loader处理文件,将处理的结果交给webpack进行模块编译

以上的loader的默认处理顺序

loader的pitch阶段

关于loader的执行阶段其实分为两种阶段:

  • 在处理资源文件之前,首先会经历pitch阶段

  • pitch结束后,读取资源文件内容

  • 经过pitch处理后,读取到了资源文件,此时才会将读取到的资源文件内容交给正常阶段的loader进行处理

简单来说就是所谓的loader在处理文件资源时分为两个阶段:pitch阶段和normal阶段

示例:

// webpack.config.js

module.exports = {
  module: {
    rules: [
      // 普通loader
      {
        test: /\.js$/,
        use: ['normal1-loader', 'normal2-loader'],
      },
      // 前置loader
      {
        test: /\.js$/,
        use: ['pre1-loader', 'pre2-loader'],
        enforce: 'pre',
      },
      // 后置loader
      {
        test: /\.js$/,
        use: ['post1-loader', 'post2-loader'],
        enforce: 'post',
      },
    ],
  },
};
// 入口文件中
import something from 'inline1-loader!inline2-loader!./title.js';

配置中的loader的执行顺序如下图

img

webpack在使用loader处理资源时首先会经过loader.pitch阶段,pitch阶段结束后才会读取文件而后进行正常阶段处理

  • **Pitch阶段:**loader上的pitch方法,按照post,inline,normal,pre的顺序调用
  • **Normal阶段:**loader上的常规方法,按照pre,normal,inline,post执行.模块源码的转换,发生在这个阶段

pitch-loader

熔断机制

上述提到webpack编译资源时首先经过loader的处理,会经过两个阶段分别是pitchnormal阶段

而在pitch阶段有一个重要的特性:

pitch loader中如果存在非undefined的返回值的话,那么上述图中的整个loader chain会发生熔断效果.

img

假设在inline-loaderpitch阶段返回了一个字符串,那么此时loader的执行会打破原有的顺序

它会立马掉头,去执行该阶段之前的loader,如上图所示

  • pitch阶段返回的非undefined的值会造成loader打破原有的顺序掉头执行,这就是熔断效果
  • 正常执行时是会读取资源文件内容交给normal loader去处理,但是pitch存在返回值发生熔断并不会读取文件内容了.此时pitch函数的返回值会交给将要执行的normal loader

作用

示例:

style-loader是常用的一个loader

它要做的事情很简单: 获得对应的样式文件内容,然后通过在页面创建style标签.将样式内容赋给style节点然后将节点加入head标签即可

简单的逻辑代码:

function styleLoader(source) {
  const script = `
    const styleEl = document.createElement('style')
    styleEl.innerHTML = ${JSON.stringify(source)}
    document.head.appendChild(styleEl)
  `;
  return script;
}

style-loader一般需要和css-loader进行配合.

而如果按上面style-loader的逻辑,css-loadernormal阶段会将样式文件处理成为js脚本并且返回给style-loader的函数中,也就是source

source的内容是一个js脚本,我们将js脚本的内容插入到styleEl中去,样式当然是不会生效的.

这也就意味着,如果style-loader设计成normal-loader的话,我们需要执行上一个loader返回的js脚本,并且获取它导出的内容才可以得到对应的样式内容.

那么我们此时需要在style-loader的normal阶段实现一系列js方法才可以达到目的

更好的处理方法:将style-loader设计成pitch-loader

如果在style-loaderpitch阶段直接返回值的话,就会发生熔断效应

img

我们可以在style-loaderpitch阶段通过require语句 引入css-loader处理文件返回后的js脚本,得到导出的结果,整个阶段是由webpack帮我们完成的.

意思就是 style-loader的pitch方法里面调用了require('!!.../x.css'),这就会把require的css文件当作新的入口文件,重新链式调用剩余的loader函数进行处理。(值得注意的是'!!'是一个标志,表示不会再重复递归调用style-loader,而只会调用css-loader处理了)

function styleLoader(source) {}

// pitch阶段
styleLoader.pitch = function (remainingRequest, previousRequest, data) {
  //pitch阶段的remainingRequest表示剩余还未处理loader的绝对路径以"!"拼接(包含资源路径)的字符串
  const script = `
  import style from "!!${remainingRequest}"

    const styleEl = document.createElement('style')
    styleEl.innerHTML = style
    document.head.appendChild(styleEl)
  `;
  return script;
};

module.exports = styleLoader

总结

如果loader的开发中,需要依赖其他loader的处理结果,但是上一个loader的函数返回的并不是处理后的资源文件而是一段js脚本,那么将loader的逻辑设计在pitch阶段是一种更好的方式.

normal loader & pitch loader参数

normal loader

normal loader默认接受一个参数,这个参数是需要处理的文件内容,若存在多个loader时,它的参数会受上一个loader的影响

同时normal loader存在一个返回值,这个返回值会链式调用给下一个loader作为入参,当最后一个loader处理完成之后,会将这个返回值返回给webpack进行编译.

// source为需要处理的源文件内容 
function loader(source) {
    // ...
    // 同时返回本次处理后的内容
    return source + 'hello !'
}

normal loader中会有很多属性挂载在函数的this上,比如我们通常使用loader时会在外部传递一些参数,此时这些参数就可以通过函数内部的 this.getOptions()方法获取.

所以loader不能是一个箭头函数!!

img

pitch loader

// normal loader
function loader(source) {
    // ...
    return source
}

// pitch loader
loader.pitch = function (remainingRequest,previousRequest,data) {
    // ...
}

Loader的pitch阶段也是一个函数,它接受三个参数,分别是

  • remainingRequest

  • previousRequest

  • data

remainingRequest

表示剩余需要处理的loader,这些loader会以它们的绝对路径加上!进行分割,组织成一个字符串

img

loader2pitch函数中,它的remainingRequest的值就是xxx/loader3.js的字符串.如果后续还有其他loader,则以!进行分割

previousRequest

表示**pitch**阶段已经迭代过的**loader**,表示方法和remainingRequest形式一样

data

该参数默认是一个空对象{}

  • 当我们在loader2.pitch函数中给data赋值时,比如data.name="front"
  • 此时在loader2函数中可以通过this.data.name获取到自身pitch方法中传递的front

img

raw

loader中还有一个raw属性

在默认情况下,normal loader的参数是一个字符串.但是当我们要处理图片资源时,将图片变成字符串明显是不合理的.所以针对图片的操作通常我们需要的是读取图片资源的Buffer类型而非字符串类型.

通过loader.raw可以标记传递的参数是Buffer还是String

function loader2(source) {
    // 此时source是一个Buffer类型 而非模型的string类型
}

loader2.raw = true

module.exports = loader2

自定义loader

去除打印信息

loader内容

// source:表示当前要处理的内容
const reg = /(console.log()(.*)())/g;
module.exports = function(source) {
    // 通过正则表达式将当前处理内容中的console替换为空字符串
    source = source.replace(reg, "")
    // 再把处理好的内容return出去,坚守输入输出都是字符串的原则,并可达到链式调用的目的供下一个loader处理
    return source
}

webpack配置中引入

module: {
    rules:[
        {
            test: /.js/,
            use: [
               {
                loader: path.resolve(__dirname, "./dropConsole.js"),
               }
            ]
        },
      {
   ]
}

正常运行之后 调试台不会打印console的信息

babel-loader

babel-loader实现的功能比较简单,本质上是通过babel-loader将js文件进行转化

loader内容

const core = require('@babel/core');

/**
 *
 * @param {*} source 源代码内容
 */
function babelLoader(source) {
  // 获取loader参数
  const options = this.getOptions() || {};
  // 通过transform方法进行转化
  const { code, map, ast } = core.transform(source, options);
  // 调用this.callback表示loader执行完毕
  // 同时传递多个参数给下一个loader
  this.callback(null, code, map, ast);
}

module.exports = babelLoader;
  • 通过core.transform将源js代码进行ast转化 同时传递外部参数处理ast节点的转化,从而可以按照外部传入的规则将js代码转化为想要的目标代码
  • 通过this.getOptions获得外部传递的参数

Tapable

webpack的编译过程,本质上通过Tapable实现了在编译过程中的一种发布订阅模式的plugin机制

通过Tapable我们可以注册事件,从而在不同时机去触发注册的事件进行执行.

Webpack中的plugin机制正是基于这种机制实现在不同编译阶段调用不同插件从而影响编译的结果.

Tapable官方提供了九种钩子

const {
	SyncHook,
	SyncBailHook,
	SyncWaterfallHook,
	SyncLoopHook,
	AsyncParallelHook,
	AsyncParallelBailHook,
	AsyncSeriesHook,
	AsyncSeriesBailHook,
	AsyncSeriesWaterfallHook
 } = require("tapable");

SyncHook为例

// 初始化同步钩子
const hook = new SyncHook(["arg1", "arg2", "arg3"]);

// 注册事件
hook.tap('flag1', (arg1,arg2,arg3) => {
    console.log('flag1:',arg1,arg2,arg3)
})

hook.tap('flag2', (arg1,arg2,arg3) => {
    console.log('flag2:',arg1,arg2,arg3)
})

// 调用事件并传递执行参数
hook.call('19Qingfeng','wang','haoyu')

// 打印结果
flag1: 19Qingfeng wang haoyu
flag2: 19Qingfeng wang haoyu
  • 通过new关键字实例不同种类的Hook

  • 通过tap监听对应的事件

    • 第一个参数是一个标识位,类似于id的作用
    • 第二个参数是本次注册的函数,在调用时执行
  • call方法传入对应的参数,调用注册在hook内部的事件函数进行执行

更多内容:juejin.cn/post/704098…

Plugin

本质上在Webpack编译阶段会为各个编译对象初始化不同的Hook,开发者可以在自己编写的Plugin中监听到这些Hook,在打包的某个特定时间段触发对应Hook注入特定的逻辑从而实现自己的行为.

常用的对象

以下对象可以注册Hook:

  • compiler Hook

  • compilation Hook

  • ContextModuleFactory Hook

  • JavascriptParser Hook

  • NormalModuleFactory Hook

基本构成

示例一个简单的插件,它会在编译完成时执行输出done:

class DonePlugin {
  apply(compiler) {
    // 调用 Compiler Hook 注册额外逻辑
    compiler.hooks.done.tap('Plugin Done', () => {
      console.log('compilation done');
    });
  }
}

module.exports = DonePlugin;

可以看到一个Webpack Plugin主要由以下几方面组成:

  • 首先一个Plugin应该是一个class,当然也可以是一个函数

  • Plugin的原型对象上存在一个apply方法,当webpack创建compiler对象时会调用各个插件实例上的apply方法,并传入compiler对象作为参数

  • 同时指定一个绑定在compiler对象上的Hook,比如compiler.hooks.done.tap,在传入的compiler对象上监听done事件

  • 在Hook的回调中处理插件自身的逻辑

  • 根据Hook的种类,在完成逻辑后通知webpack继续进行

插件的构建对象

compiler

class DonePlugin {
  apply(compiler) {
    // 调用 Compiler Hook 注册额外逻辑
    compiler.hooks.done.tapAsync('Plugin Done', (stats, callback) => {
      console.log(compiler, 'compiler 对象');
    });
  }
}

module.exports = DonePlugin;

在compiler对象中保存着完整的Webpack环境配置

这个对象会在首次启动Webpack的时候创建,通过compiler对象能够访问到Webpack的主环境配置,比如loader和plugin等配置信息.

可以把compiler理解为一个单例,每次启动webpack构建时都是一个独一无二,仅创建一次的对象

compiler对象存在以下属性

  • compiler.options

该属性存储着本次启动webpack时的所有配置文件,包括但不限于loader entry等信息

  • compiler.hooks

扩展了来自tapable的不同种类的Hook,监听这些hook从而可以在compiler生命周期中植入不同逻辑

compilation

class DonePlugin {
  apply(compiler) {
    compiler.hooks.afterEmit.tapAsync(
      'Plugin Done',
      (compilation, callback) => {
        console.log(compilation, 'compilation 对象');
      }
    );
  }
}

module.exports = DonePlugin;

compilation对象代表一次资源的构建,实例可以访问所有的模块和它们的依赖

在compilation对象中我们可以获取/操作 本次编译的当前模块资源 编译生成的资源 变化的文件以及被跟踪的状态信息.

在devServer下每次修改代码都会进行重新编译,此时可以理解为每次构建都会创建一个新的compilation

compilation对象存在的属性

  • modules

  • chunks

    • chunk是多个modules组成而来的一个代码块,当Webpack进行打包时首先会根据项目入口文件分析对应的依赖关系,将入口依赖的多个modules组合成一个大的对象,这个对象就是chunk.
    • 多个chunk组成chunks
  • assets

    • assets对象上记录了本次打包生成的所有文件的结果
  • hooks

总结

webpack plugin的核心机制就是基于tapable的发布订阅模式,在不同的周期触发不同的hook从而影响最终的打包结果.

自定义plugin

需求分析

前端每次需要把打包好的dist文件发送给后端部署,这时需要开发者每次build之后再次进行压缩,变成压缩包再发给后端.

此时,如果可以在build的时候把所有的资源打包成一个zip包,就很方便

webpack配置

const path = require('path');
const CompressAssetsPlugin = require('./plugins/CompressAssetsPlugin');

module.exports = {
  mode: 'development',
  entry: {
    main: path.resolve(__dirname, './src/entry1.js'),
  },
  devtool: false,
  output: {
    path: path.resolve(__dirname, './build'),
    filename: '[name].js',
  },
  plugins: [
    new CompressAssetsPlugin({
      output: 'result.zip',
    }),
  ],
};

这里自定义的CompressAssetsPlugin,它只接收一个output参数,表示生成的压缩包的名字

原理分析

围绕Webpack打包过程的两个核心对象:

  • compiler

    • compiler在Webpack启动打包时创建,保存着本次打包的所有初始化配置信息
    • 在每一次进行打包过程中它会创建compilation对象进行模块打包.
  • compilation

    • compilation代表这一次资源构建的过程,在compliation对象我们可以通过一系列API访问/修改本次打包生成的module assets chunks

需要用到的库和hook

  • JSZIP

    • 这是一个JS生成zip压缩包的库
  • compiler Emit Hook

    • compiler对象上的Emit Hook会在输出asset到output目录之前执行,简单来说就是每次即将打包完成生成文件时调用这个钩子
  • compilation对象方法

    • 在打包过程中需要获取本次打包即将生成的资源,可以使用compilation.getAssets()方法获取原始打包的资源源文件内容,之后通过compilation.emitAssets()输出生成的zip到打包结果去

基础内容

任何一个Webpack Plugin都是一个模块,这个模块导出了一个类或者说是函数,该函数必须存在一个名为apply的原型方法.

在调用webpack()方法开始打包时,会将compiler对象传递给每一个插件的apply方法并且调用他们注册的Hook

const pluginName = 'CompressAssetsPlugin';

class CompressAssetsPlugin {
  // 在配置文件中传入的参数会保存在插件实例中
  constructor({ output }) {
    // 接受外部传入的 output 参数
    this.output = output;
  }

  apply(compiler) {
    // 注册函数 在webpack即将输出打包文件内容时执行
    compiler.hooks.emit.tapAsync(pluginName, (compilation,callback) => {
        // dosomething
    })
  }
}

module.exports = CompressAssetsPlugin;

通过 tapAsync 注册的事件函数中接受两个参数:

  • 第一个参数为 compilation 对象表示本次构建的相关对象
  • callback 参数对应我们通过 tapAsync 注册的异步事件函数,当调用 callback() 时表示注册事件执行完成。

完善

const JSZip = require('jszip');
const { RawSource } = require('webpack-sources');
/* 
  将本次打包的资源都打包成为一个压缩包
  需求:获取所有打包后的资源
*/
 
const pluginName = 'CompressAssetsPlugin';

class CompressAssetsPlugin {
  constructor({ output }) {
    this.output = output;
  }

  apply(compiler) {
    // AsyncSeriesHook 将 assets 输出到 output 目录之前调用该钩子
    compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
      // 创建zip对象
      const zip = new JSZip();
      // 获取本次打包生成所有的assets资源
      const assets = compilation.getAssets();
      // 循环每一个资源
      assets.forEach(({ name, source }) => {
        // 调用source()方法获得对应的源代码 这是一个源代码的字符串
        const sourceCode = source.source();
        // 往 zip 对象中添加资源名称和源代码内容
        zip.file(name, sourceCode);
      });
      // 调用 zip.generateAsync 生成 zip 压缩包
      zip.generateAsync({ type: 'nodebuffer' }).then((result) => {
        // 通过 new RawSource 创建压缩包
        // 并且同时通过 compilation.emitAsset 方法将生成的 Zip 压缩包输出到 this.output
        compilation.emitAsset(this.output, new RawSource(result));
        // 调用 callback 表示本次事件函数结束
        callback();
      });
    });
  }
}

module.exports = CompressAssetsPlugin;

Webpack全流程

img

  1. 初始化参数阶段

这一步会从我们配置的webpack.config.js中读取对应的配置参数和shell命令中传入的参数进行合并,得到最终打包的配置参数

  1. 开始编译准备阶段

这一步会通过调用webpack()方法返回一个compiler方法,创建compiler对象,并且注册各个Plugin.找到配置入口的entry代码,调用compiler.run()进行编译

  1. 模块编译阶段

从入口模块进行分析,调用匹配的loader对文件进行处理.同时分析模块的依赖,递归进行模块编译工作

  1. 完成编译阶段

使用loader翻译完所有模块后,得到每个模块被翻译后的最终内容以及他们直接的依赖关系

  1. 输出文件阶段

根据入口和模块之间的依赖关系,组装成一个一个包含多个模块的chunk,再把每个chunk转换成一个单独的文件加入到输出列表中.最后确定输出内容后,根据配置的路径和文件名写到文件系统

简单回答

  • 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler

  • 编译:从 Entry 出发,针对每个 Module 串行调用对应的 Loader 去翻译文件的内容,再找到该 Module 依赖的 Module,递归地进行编译处理

  • 输出:将编译后的 Module 组合成 Chunk,将 Chunk 转换成文件,输出到文件系统中

HMR

概念

HMR即模块热更新.

刷新分为两种:一种是页面刷新,不保留状态,简单粗暴;另一种是基于webpack-dev-server的模块热替换,只需要局部刷新页面上变化的模块,同时保留当前页面状态,比如复选框的选中状态 输入框的输入等

使用方式

webpack的配置文件中,将deServerhot开启

+ devServer: {
+  hot: true,   // 启动模块热更新 HMR
+   open: true,  // 开启自动打开浏览器页面
+ },

在入口文件使用module.hot监听待更新的模块

if (module.hot) {
  module.hot.accept('./library.js', function() {
    // 使用更新过的 library 模块执行某些操作...
  })
}

webpack5中不需要再继续手动的HotModuleReplacementPlugin插件配置,开启hot之后会自动配置,同时要在配置文件中把target设置为web.

配置完成之后就可以在浏览器的控制台看见

img

webpack构建

项目启动之后,会进行首次构建打包,控制台会输出整个构建的过程,可以观察到一个hash值

img

在每次代码的修改之后,保存时都会在控制台上出现compiling字样,可以在控制台观察到:

img

  • Hash值更新

  • 生成了hot-update.json

  • 生成了hot-update.js

如果没有任何改动,则不会输出以上的新文件,Hash值也不会改变

再次修改代码保存.可以观察到上次输出的Hash值被作为本次编译新生成的HMR文件标识

img

实现原理

注入

执行npx webpack server命令后,WDS调用HotModuleReplacementPlugin插件向应用的主Chunk注入一系列HMR Runtime:

  • 用于建立WebSocket连接

  • 初始化**RuntimeGlobals.hmrDownloadManifest****RuntimeGlobals.hmrDownloadUpdateHandlers**接口

  • 初始化**module.hot.accept**接口

增量构建

HotModuleReplacementPlugin插件借助Webpack的watch能力,在代码文件发生变化后执行增量构建,生成:

  • mainfest文件:JSON格式文件,包含所有发生变更的模块列表
  • 模块变更文件:js格式,包含编译后的模块代码

增量构建完毕后,Webpack将触发compilation.hooks.done钩子.

WDS监听done钩子,在回调中通过WebSocket发生模块更新消息

img

加载更新

客户端接收到hash消息后,发出mainfest请求获取本轮热更新涉及的chunk

img

注意,在 Webpack 4 及之前,热更新文件以模块为单位,即所有发生变化的模块都会生成对应的热更新文件; Webpack 5 之后热更新文件以 chunk 为单位,如上例中,main chunk 下任意文件的变化都只会生成 main.[hash].hot-update.js 更新文件。

mainfest请求完毕后,客户端HMR运行时开始下载发生变化的chunk文件,将最新的模块代码加载到本地

module.hot.accept回调

浏览器加载最新模块代码后,HMR运行时继续触发module.hot.accept回调,将最新代码替换到运行环境中

总结

  • 使用WDS托管资源,同时注入HMR客户端代码

  • 浏览器加载页面后,与WDS建立WS连接

  • Webpack监听到文件变化后,增量构建变更的模块,通过WS发送hash事件

  • 浏览器接收到hash事件后,请求manifest资源文件,确定增量变更的范围

  • 浏览器加载变更的模块

  • 触发accept回调,执行代码变更逻辑

  • done

img

Tree Shaking

概念

Tree Shaking是一种基于ES Module规范的Dead Code Elimination技术,它会在运行过程中静态分析模块之间的导入导出,确定ESM模块中哪些导出值未曾被其他模块使用,并将其删除,以此实现打包产物的优化

启动

Tree Shaking只支持ESM的引入方式,不支持Common JS的引入方式。

如果要对一段代码做Tree Shaking处理,那么就要避免引入整个库到一个JS对象上,如果这么做了,Webpack就会认为你是需要这整个库的,这样就不会做Tree Shaking处理。

下面是引入lodash的例子,如果引入的是lodash中的一部分,则可以Tree Shaking

// Import everything (NOT TREE-SHAKABLE)
import _ from 'lodash';

// Import named export (CAN BE TREE SHAKEN)
import { debounce } from 'lodash';

// Import the item directly (CAN BE TREE SHAKEN)
import debounce from 'lodash/lib/debounce';

在Webpack中启动,需要满足三个条件:

  • 使用ESM规范编写模块代码

  • 配置optimization.usedExportstrue,开启标记功能

  • 启动代码优化功能,可以通过如下方式实现:

    • 配置mode=production,生产环境下默认启动Tree Shaking配置
    • 配置optimization.minimize=true
// webpack.config.js
module.exports = {
  entry: "./src/index",
  mode: "production",
  devtool: false,
  optimization: {
    usedExports: true,
  },
};

示例

对于下述代码

// index.js
import {bar} from './bar';
console.log(bar);

// bar.js
export const bar = 'bar';
export const foo = 'foo';

示例中,bar.js模块导出了bar foo,但只有bar导出值被其他模块使用,经过Tree Shaking处理后,foo变量会被视作无用代码删除

原理

Webpack中,Tree-shaking的实现是先通过标记出模块导出值中哪些值没有被使用过,然后通过Terser删掉这些没有被用过的导出语句

标记过程分为三个步骤

  • 编译(Make)阶段,收集模块导出变量并记录到模块依赖关系图ModuleGraph变量中

  • 输出资源(Seal)阶段,遍历ModuleGraph标记模块导出变量有没有被使用

  • 生成产物时,若变量没有被其他模块使用则删除对应的导出语句