webpack

Youky ... 2021-10-11 前端
  • 前端工程化
  • Webpack
About 15 min

# webpack

# 0. webpack 解决了什么问题

如何在前端项目中高效的管理和维护项目中的每一个资源。

对于 webpack 的自定义设置,通过修改根目录的webpack.config.js文件

# 1. 模块化的演进过程

# 第一阶段、文件划分

将不同模块放在不同的 js 文件中,再在全局依次引入

存在的问题:

  • 模块直接在全局工作,污染全局作用域,容易产生命名冲突
  • 没有私有空间,模块成员可能在外部被修改
  • 无法管理模块间的依赖关系
  • 难以分辨某个成员所属的模块

# 第二阶段、命名空间方式

将不同模块挂载到 window 的一个属性上

解决了命名空间问题,其他问题依旧存在

# 第三阶段、IIFE

利用立即执行函数

  • 实现了私有变量
  • 通过给立即执行函数传入参数实现依赖,可以在日后方便的判断模块的依赖关系

仍存在的问题:模块的加载

# 2. 核心概念

# entry

打包的入口

打包项目时,entry 为指定的打包入口,从入口文件开始构建依赖图,来将所有被引用的文件进行打包。

entry 的取值分为两种情况:

  • 单入口:取值为字符串
  • 多入口:取值为对象

# output

打包的输出

对于单入口和多入口打包:都只指定一个输出的filenamepath

当具有多个入口时,默认输出到 dist 目录下,文件名同入口文件名。

可以通过文件占位符([name])来表示入口文件名,并在此基础上进行修改,来区分打包结果的名称

# mode

用来指定当前的环境,取值有:

  • production
  • development
  • none

**作用:**自动开启 webpack 内置的函数,在不同模式下开启不同的功能

# loader

webpack 默认的 loader 只能加载jsJSON文件,要加载 css 等其他文件,就要给文件设置对应的 loader。

# 设置方法

在配置添加module.rules字段,取值为一个数组。

每一项是针对某类文件使用的loader,包含两个属性:

  • test:取值为正则表达式,表示匹配的文件类型
  • use:使用的 loader。
    • 如果只使用一个 loader,取值为一个字符串
    • 如果使用多个 loader,取值为一个数组。数组中的所有 loader从后向前执行

# 常用的 loader

  • 样式:
    • css-loader
    • style-loader
    • stylus-loader
  • 编译:
    • babel-loader
    • ts-loader
    • eslint-loader
  • 文件:
    • file-loader
    • url-loader

# plugin

目的:增强 webpack自动化构建各方面的能力

# 配置方法

  • 引入插件(一般是一个构造函数)
  • 在配置对象的plugins数组中添加(用 new 实例化)

# 常见 plugin

  • 打包前清除 dist 目录
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
module.exports = {
  plugins: [new CleanWebpackPlugin()],
};
1
2
3
4
  • 自动生成 HTML
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      // 对于输出html的自定义配置对象
      title: "", // 页面title
      meta: {
        // meta信息
      },
    }),
    new HtmlWebpackPlugin({
      // 打包输出多个html
      filename: "", // 输出的html文件名,默认是index.html
    }),
  ],
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • 将静态资源文件复制到打包后的目录
const CopyWebpackPlugin = require('copy-webpack-plugin);
module.exports = {
  plugins:[
    new CopyWebpackPlugin([
      'public'  // 要打包的静态资源的目录
    ])
  ]
}
1
2
3
4
5
6
7
8
  • 热更新插件:HotModuleReplacementPlugin
  • 压缩打包后的输出文件:terser-webpack-plugin
const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};
1
2
3
4
5
6
7
  • 注入一些会发生变化的值,如 API 地址:webpack.DefinePlugin
// webpack.config.js
const { DefinePlugin } = require("webpack");
module.exports = {
  plugins: [
    new DefinePlugin({
      // 注入的值会作为字符串直接替换到代码中使用到的地方
      FOO_BAR: "something",
    }),
  ],
};
1
2
3
4
5
6
7
8
9
10
// src/main.js
console.log(FOO_BAR); // 'something'
1
2

# loader 和 plugin 的区别

  • loader 是加载器,用于将 A 文件编译为 B 文件,即进行文件转换。loader 是一个函数,接收源文件为参数,返回目标文件。
  • plugin 用于扩展 webpack 的功能,可以控制打包的每个环节。是基于事件机制工作,会监听打包过程中的某些节点,并执行任务

# 编写 loader 和 plugin 的思路

# loader

loader 的本质是一个函数,接受源码并返回转换后的代码。

其 this 会被 webpack 填充(因此不能使用箭头函数);接受一个参数,为 webpack 提供的源码。

返回结果可以是同步或异步的,异步返回是使用 this.callback 进行。

# plugin

webpack 基于发布订阅模式,运行的生命周期中会发布很多事件,plugin 通过监听具体的事件可完成自己的需求。

  • plugin 需要是一个函数或是含有 apply 方法的对象
  • 根据具体的钩子实现自己功能。异步操作后要调用回调函数通知 webpack 进入下一流程

# 3. 常见文件的解析

# ES6

webpack 可以加载 JS 代码,但对于 ES6 的高级语法,需要使用babel-loader进行转换。

  1. 首先,在module.rules中添加{ test: /.js$/, use: "babel-loader" }
  2. 创建.babelrc文件:
{
  "presets": ["@babel/preset-env"]
}
1
2
3

# 样式

# CSS

解析 css 需要至少两个 loader,必须的是css-loader,负责加载.css 文件,并转换为 commonjs 对象。此时样式并没有被真正应用,还需要以下二者之一

  • style-loader:将样式通过 style 标签插入head标签中(JS 代码中完成)
{
    test: /.css$/,
    use: [
        'style-loader',
        'css-loader',
    ]
},
1
2
3
4
5
6
7
  • MiniCssExtractPlugin.loader:将 css 提取到独立的 css 文件
{
    module:{
        rule:[{ test: /.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] },]
    },
    plugins: [new MiniCssExtractPlugin()],
}
1
2
3
4
5
6

# sass/stylus/less

对于sass等预处理器代码,需在末尾再添加一个对应处理器的 loader:

{
    test: /.sass$/,
    use: [
        'style-loader',
        'css-loader',
        'sass-loader'
    ]
}
1
2
3
4
5
6
7
8

# 前缀自动补齐

使用postcss-loader可以自动生成如-webkit-等兼容性样式前缀

{
    test: /.sass$/,
    use: [
        'style-loader',
        'css-loader',
        'sass-loader',
        {
            loader: 'postcss-loader',
            options: {
                plugins: () => require('autoprefixer')({
                    browsers: ['>1%', 'IOS 7']	// 需要兼容的浏览器类型
                })
            }
        }
    ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 图片 / 字体

对于非代码文件,可以使用file-loader

{
    test: /.(jpg|png)$/,
    use: "file-loader",
},
{
    test: /.woff$/,
    use: "file-loader",
}
1
2
3
4
5
6
7
8

也可以使用url-loader,其作用和 file-loader 类似,但是可以将小图片转换为 base64 格式:

{
  test: /.(jpg|png)$/,
  use: {
    loader: "url-loader",
    options: {
      limit: 10240, // 10KB以下的图片会转为base64格式
    },
  },
},
1
2
3
4
5
6
7
8
9

# 4. 文件监听

# 原理

持续轮询,判断文件的最后编辑时间是否发生变化。检测到变化后不会立即告诉监听者,而是先进行缓存,等待一个aggregateTimeout 后统一处理。

缺点:需要手动刷新浏览器才能更新。

解决办法:热更新(dev-server

# 配置

{
    watch: true,					// 开启文件监听,默认false
    watchOptions: {
    	ignored: /node_module/,		// 忽略这部分的变化
    	poll: 1000,					// 每秒轮询次数,默认1000次
    	aggregateTimeout: 300,		// 监听到变化后的延迟,默认300ms
  	},
}
1
2
3
4
5
6
7
8

# 5. webpack-dev-server

官方提供的开发服务器,提供了自动编译、热更新等功能

为了提高构建速度,webpack-dev-server 并不是将打包结果写入磁盘,而是存到内存中。

配置对象的 devServer 属性用来提供配置,详细配置文档 (opens new window)

// ./webpack.config.js
const path = require("path");
module.exports = {
  devServer: {
    static: {
      directory: path.join(__dirname, "public"), // 静态资源路径
    },
    compress: true, // 是否开启压缩
    port: 9000, // 端口号
  },
};
1
2
3
4
5
6
7
8
9
10
11

# 使用

  • 安装依赖:
npm install -D webpack-dev-server
1
  • 启动服务:
npx webpack serve
1

# 静态资源访问

除了构建过程中会被打包的资源,要让其它资源可访问,就要向 contentBase 传入静态资源的路径。

为什么不通过copy-webpack-plugin将静态资源加入打包结果?

使用copy-webpack-plugin会进行磁盘 IO,在开发过程中影响效率。

# Proxy 代理

由于本地应用是运行在 localhost 的临时服务器上,一些生产环境下能直接访问的 API 在开发时会产生跨域问题。

在 devServer 中添加 proxy 属性,其取值为一个对象。

http://localhost:8080/api/foo代理到https://api.github.com/foo

// ./webpack.config.js
module.exports = {
  devServer: {
    proxy: {
      "/api": {
        // 代理/api开头的请求。即为了代理所有请求,将所有请求的开头添加一个/api
        target: "https://api.github.com",
        pathRewrite: {
          "^/api": "", // 替换掉代理地址中的 /api
        },
        changeOrigin: true, // 确保请求 GitHub 的主机名就是:api.github.com
      },
    },
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 热更新

对于文件的热更新,提供了开箱即用的 plugin

devServer: {
  hot: true,      // 开启 HMR 特性,如果资源不支持或代码报错,会回退到直接刷新
  hotOnly: true   // 只使用 HMR,不会回退
},
1
2
3
4

回退到直接刷新会怎么样?

直接刷新后,将无法看到代码中的报错

# 6. 文件指纹

打包后输出文件名的后缀

# 目的

  • 便于版本管理
  • 未修改的文件名称不变,便于使用浏览器缓存

# 常见类型

  • hash:和整个项目的构建相关。只要项目中有文件修改,hash 值就会改变
  • chunkhash:和打包的 chunk 相关。不同的 entry 会生成不同的 chunk。即一个入口内的文件改变后,该入口下引用的文件的 chunkhash 会变
  • contenthash:根据文件内容定义 hash。文件内容不变则 contenthash 不变

# 使用

对于 JS 文件:修改 output 选项即可

  • [name]表示文件名
output: {
    filename: "[name]_[chunkhash].js"
},
1
2
3

**对于图片/字体等:**修改 loader 的 option

  • [ext]表示文件后缀
  • [hash:8]表示取前八位的文件 hash 值(由 md5 生成)
{
  test: /.(jpg|png)$/,
  use: {
    loader: "file-loader",
    options: {
      name: "[name]_[hash:8].[ext]"
    },
  },
},
1
2
3
4
5
6
7
8
9

# 7. 页面公共资源的分离

module、bundle、chunk 分别是什么含义?

Webpack 的作用是将任意模块依赖(js 和非 js 资源如 css、图片等)打包成静态资源,其中:

  • module:js 文件、css 文件、jpg 文件等打包前的文件
  • bundle:打包后生成的产物
  • chunk:对于 bundle 进行拆分,bundle 的一部分

# 基础库分离(CDN 形式引入)

默认情况下,使用 React/Vue 等框架开发的项目打包时会将框架代码一并打包到结果中。

使用 html-webpack-externals-plugin 可以将公共库提取出来,单独通过 CDN 引入,以减少打包结果的大小。

  • module:公共库的名字、
  • entry:库的 CDN 地址
  • global:公共库全局导出的变量名
const HtmlWebpackExternalsPlugin = require("html-webpack-externals-plugin");
module.exports = {
  plugins: [
    new HtmlWebpackExternalsPlugin({
      externals: [
        {
          module: "react", // 公共库的名字
          entry: "https://unpkg.com/react@16/umd/react.production.min.js",
          global: "React", // 公共库全局导出的变量名
        },
        {
          module: "react-dom",
          entry:
            "https://unpkg.com/react-dom@16/umd/react-dom.production.min.js",
          global: "ReactDOM",
        },
      ],
    }),
  ],
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 提取公共资源

使用 optimization.splitChunks 设置。可以自定义要提取的公共包的大小、类型等

{
	optimization: {
      splitChunks: {
      	chunks: "all", 					// 对所有引入方式的包都做分包
      	minSize: 20000, 				// 打包前体积小于20k的包不会做分包处理
      	minChunks: 1, 					// 最小被引用次数
      	enforceSizeThreshold: 50000, 	// 如果某个包超过了50K则强制进行拆分
      },
  	},
}
1
2
3
4
5
6
7
8
9
10

# 8. Tree Shaking

在打包结果中筛选掉未引用的代码(不是整个模块,其中的副作用代码仍会生效

production模式下会自动开启。在非生产模式下设置optimization

  • usedExports设置为 true,表示只导出使用到的成员
  • minimize设置为 true,表示压缩代码
module.exports = {
  optimization: {
    usedExports: true,
    minimize: true,
  },
};
1
2
3
4
5
6

# 使用 babel-loader 后失效的问题

在某些版本的babel-loader中,会将 ESModule 代码编译为 CommonJS 形式。

最新版的 babel-loader 中,会根据环境禁用对 ES Module 的转换,因此不会带来这个问题

而 webpack 中 Tree Shaking 的前提是 ES Module 代码,因此可能导致其失效

# 9. SideEffects

完整移除整个没有使用的模块

production模式下会自动开启。在非生产模式下:

  • 设置optimization.sideEffects:true
  • package.json中添加sideEffects属性
    • 取值为 false,表示所有模块都没有副作用
    • 取值为数组,每个元素表示需要保留副作用的路径

# 10. SourceMap

提供源代码和打包后代码间的映射关系

目的:在有报错时能找到源码中对应的位置

通常 sourceMap 文件以.map 结尾,是一个 JSON 格式的对象。

在打包后的代码中,通过注释的形式引入 SourceMap 文件

//# sourceMappingUrl=xxx
1

配置方法:修改配置对象的devtool属性决定选用的模式。不同的模式决定了构建速度和品质的不同。

# eval

将模块放在 eval 函数中,通过添加注释的方法来判断属于哪个源文件。

优点:没有生成 sourceMap,构建速度最快

缺点:只能定位到文件,不能定位到具体的行列

# eval-source-map

生成了 sourceMap,可以反推到源码中的行列信息

# cheap-eval-source-map

只能定位到行,不能定位到列。构建速度比eval-source-map更快

# cheap-module-eval-source-map

定位到的是未经转换的源码

# nosources-source-map

能看到错误位置,但点进去看不到源代码

# 总结

  • 带 source-map 的是生成了 sourceMap 文件的
  • 带 cheap 的构建更快,但只能定位到行,不能定位到列
  • 带 module 的是定位到未经过 Loader 加工的源码,不带 module 的是经过 loader 加工的

在开发中,优先选用cheap-module-eval-source-map

  • 对于 React 和 Vue 等框架,经过 loader 转换后的代码会有很大的变化,无法定位问题
  • 养成良好的编码习惯,一行不会有太多内容,定位到行足够找出问题了

在上线版本中,选用none,即不生成 sourceMap:

  • 调试应该是开发中的事,不应在上线代码中 debug
  • 生成 sourceMap 会暴露源代码,带来安全隐患

# 11. Code Splitting(分块打包)

# 分包的原因

打包结果太过集中的弊端:

  • 打包结果的体积太大
  • 不能做到按需加载,影响首屏时间

打包结果太过分散的弊端:

  • 多次请求本身会有延迟
  • 每次请求的请求头带来额外的带宽消耗

webpack 实现分包的方式有两种:

  1. 多入口,多出口
  2. 根据 ES Module 的动态导入特性,按需加载模块

# 多入口打包

多用于传统的多页应用,一个页面对应一个打包入口。对于不同页面的公共部分,提取到公共结果中。

  1. 首先,在 entry 中设置多个入口
  2. 修改output[name]作为占位符表示入口名称
  3. 设置HtmlWebpackPluginchunks属性,使其只加载对应的文件
  4. 设置optimization.splitChunks.chunks'all',表示将公共部分自动分包
module.exports = {
  entry: {
    index: "./src/index.js",
    foo: "./src/foo.js",
  },
  output: {
    filename: "[name].bundle.js", // [name] 是入口名称
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: ["index.html"],
      chunks: ["index"],
    }),
    new HtmlWebpackPlugin({
      filename: ["foo.html"],
      chunks: ["foo"],
    }),
  ],
  optimization: {
    splitChunks: {
      chunks: "all",
    },
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 动态导入

使用 ES Modules 的import函数,无需其他配置即可完成。

# 魔法注释

如果需要给动态导入的模块命名,可以在 import 函数中添加行内注释。格式为/* webpackChunkName: 'name' */

import(/* webpackChunkName: 'foo' */ "./foo/foo").then(({ default: foo }) => {
  // do something with foo...
});
1
2
3

如果两个模块的注释一样的话,会打包到一个文件中,借助这个特点可以自己更加细致的控制分包的结果。

# 12. Scope hoisting

# 要解决的问题

构建后的代码中存在大量的闭包代码,导致:

  1. 打包产物的体积增大
  2. 运行时创建的函数作用域变多,增大内存开销

# 原理

只被引用了一次的模块按引用顺序放在一个函数作用域里。

# 使用

  • production模式下会默认开启
  • 手动添加new webpack.optimize.ModuleConcatenationPlugin()

# 13. 打包基础库

对于基础库的打包,一般会打包出未压缩和压缩后的两种版本。

  • entry 中设置两个入口,指向同一个文件
  • output 中,library属性指明库的导出情况
  • mode设为 none,利用TerserPlugin选择性进行压缩
module.exports = {
  mode: "none",
  entry: {
    sum: "./src/index.js",
    "sum.min": "./src/index.js",
  },
  output: {
    filename: "[name].js",
    library: {
      name: "sum", // 暴露的库的名称
      type: "umd", // 允许在所有模块定义下暴露本库
      export: "default", // 使用默认导出,否则引入时需要使用.default获取默认导出的内容
    },
  },
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin({ include: /\.min\.js$/ })],
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

package.json中的main字段指向 index.js,在其中通过判断环境参数,返回 dist 中对应的文件

# 14. 构建过程分析

# 初级分析:内置的 stats

在 package.json 中添加命令:

webpack --config src/ --json > stats.json
1

构建后会在目录下生成 stats.json 文件。

# 速度分析:speed-measure-webpack-plugin

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap({
  // ...
});
1
2
3
4
5

# 体积分析:webpack-bundle-analyzer

打包后默认在 8888 端口打开页面显示各个 bundle 大小情况

# 15. 构建优化

# 多进程并行构建:thread-loader

# 原理

将打包任务划分为多个 node 进程,将任务依次分配给这些进程。

# 使用

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ["thread-loader", "expensive-loader"],
      },
    ],
  },
};
1
2
3
4
5
6
7
8
9
10

# 多进程并行压缩:TerserWebpackPlugin

webpack5 开箱即带,但如果需要自定义配置,仍需自己安装:

npm install terser-webpack-plugin -D
1
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};
1
2
3
4
5
6
7
8

# 利用缓存:提升二次构建速度

# babel-loader

module: {
    rules: [{ test: /\.js$/, use: ["babel-loader?cacheDirectory"] }],
},
1
2
3

# HardSourceWebpackPlugin

var HardSourceWebpackPlugin = require("hard-source-webpack-plugin");
module.exports = {
  // ...
  plugins: [new HardSourceWebpackPlugin()],
};
1
2
3
4
5

# 优化查找策略

通过resolve字段优化模块解析的策略:

const path = require("path");

module.exports = {
  resolve: {
    alias: {
      Utilities: path.resolve(__dirname, "src/utilities/"), // 引用时可以使用别名
    },
    mainFields: ["main"], // 使用package.json中的main字段作为入口
    modules: [path.resolve(__dirname, "node_modules")], // 以node_modules作为查找入口
  },
};
1
2
3
4
5
6
7
8
9
10
11
Last update: October 16, 2022 21:28
Contributors: youky7 , Youky