前端工程化-系列
Webpack
Rollup
Monorepo 单仓库多应用
集成 lint 代码规范工具
Git lint 相关

前言

从Webpack开始的前端工程化探索

模块化

随着前端应用的日益复杂,程序员需要更高效的代码组织形式,以便提高可维护性并提升开发效率。

模块化将复杂的代码按功能的不同,分为不同的模块,单独维护,提高开发效率。

模块:

  1. 将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起。
  2. 块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信。

演变

1、文件划分
将不同的功能及其状态数据存放在单独的JS文件中。约定一个文件就是一个模块,以单独的script标签引入至HTML。

虽然实现了功能的划分,但缺点也十分明显:

  1. 污染全局作用域
  2. 命名冲突
  3. 无法管理模块的依赖关系

2、命名空间
在文件划分的基础上,约定每个模块只暴露一个全局的对象,对象包裹着模块的方法和状态。

虽然避免了命名冲突,但仍然没有私有空间,

3、IIFE
通过立即执行函数实现私有空间,使用window暴露模块的成员,通过参数声明依赖。

1
2
3
4
5
6
7
8
9
10
11
(($)=>{
const name = 123;
const fun = () => {
$('body');
console.log(name); // 闭包
}
window.modelA = {
name
}
})(JQuery) // 通过参数声明依赖

以上是在没有工具和相关规范的早期,以约定的形式,实践模块化思想的方式。

仍然存在问题:

  1. 依赖管理混乱
  2. 不同开发者、不同项目,模块化的实现有差异
  3. 模块的导入不受代码控制

现代化

  1. 模块化规范:对模块代码书写格式和交互规则的详细描述
  2. 模块加载器:使用代码的方式,自动控制模块的导入,管理模块的依赖。

在ES6模块化出现之前,为了解决模块化的需求,出现了众多的模块化机制,CommonJS(NodeJS内置)、AMD(require.js)、CMD(Sea.js)

ES6模块化出现后的最佳实践

  1. 浏览器环境:ES Module
  2. Node环境:CommonJS

ES Module

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量,而 CommonJS 和 AMD 模块,都是运行时的

1
2
3
4
<script type='module'>
import { name } from './modules/index.js';
export name;
</script>

特性:

  1. 自动采用严格模式,忽略’use strict’
  2. 每个ESM模块都是单独的私有作用域
  3. ESM通过CORS请求外部JS模块
  4. ESM的script标签会延迟执行脚本(相当于defer)

详见ES6查缺补漏 #Module模块化MDN-JavaScript模块

兼容性问题

ES6仍然有兼容性问题,早期的浏览器,特别是国产和手机上的浏览器

使用 caniuse-cmd 检查兼容性
1
2
npm install -g caniuse-cmd
caniuse import

可以引入 Polyfill 兼容

使用 webpack、vite 后,有更多的兼容插件可以安装使用,如 @vitejs/plugin-legacy

script添加nomodule属性,仅在不支持ESM的浏览器上执行该脚本

ESM in Node

文档:
Node.js 如何处理 ES6 模块-阮一峰
Node.js文档-ECMAScript模块

ESM在Node.js的v8.5.0中作为实验性功能被引入,v12.17.0为所有Node.js应用程序提供了ESM支持

使用 .mjs.cjs 文件后缀区分 ESM 和 CommonJS 模块

原生Node环境中的ESM与CommonJS:

  1. ES Module中可以导入CommonJS模块
  2. CommonJS中不能导入ES Module模块
  3. CommonJS始终只会导出一个默认成员

ESM得到CommonJS全局成员的值:

index.mjs
1
2
3
4
5
6
7
8
9
10
11
// console.log(__dirname)
// ReferenceError: __dirname is not defined in ES module scope
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
console.log(__filename)
console.log(__dirname)
// c:\***\esm-in-node\index.mjs
// c:\***\esm-in-node

前端打包工具

ESM仍然存在一些问题:

  1. 存在兼容性问题
  2. 模块文件过多,网络请求频繁
  3. 不仅是JS,前端所有的资源,包括CSS、HTML都需要模块化

前端需要更好的工具和规范,让开发者继续享受模块化带来的便利,而不需要担心对生产环境产生的影响。

需求推动技术的进步,打包工具顺势出现了。打包工具解决的是前端整体的模块化,不只是局限于JS的模块化。

如今前端项目的代码组织,已经走上了编辑代码最终运行文件完全两样的形式,一系列工具链和自动化的思想也融入进了打包工具中,打包也逐渐从一个技术问题,转变为了生态和管理问题。打包工具现在也可称为构建工具,支撑着前端工程化。

常见的打包工具:WebpackViteRollupesbuild

Webpack

Webpack是一个用于现代 JavaScript 应用程序的静态模块打包工具。

学习webpack大体上就是学习webpack.config.js的配置、各种loaders和plugins的使用,所以,多看文档,广泛了解,取所需使用文档配置指南loadersplugins

快速上手

安装
1
2
npm init -y
npm install webpack webpack-cli --save-dev
package.json
1
2
3
4
5
6
{
"private": true, // 防止意外发布
"scripts": {
"build": "webpack" // 打包命令
},
}

创建index.html和src目录,并写两个模块化文件:

index.html
1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="module" src="src/index.js"></script>
</body>
</html>
src/index.js
1
2
import { createTitle } from "./module.js";
document.body.append(createTitle('Hello World'));
src/module.js
1
2
3
4
5
6
7
8
export const createTitle = (title) => {
const element = document.createElement('h2')
element.textContent = title
element.addEventListener('click', () => {
alert(title)
})
return element
}

直接打开 index.html 可以看到页面上的 Hello World

接着使用webpack进行打包

1
2
3
npx webpack
#
npm run build

webpack自动创建了 dist 目录,存放了打包好的 main.js

dist/main.js
1
(()=>{"use strict";document.body.append((e=>{const t=document.createElement("h2");return t.textContent=e,t.addEventListener("click",(()=>{alert(e)})),t})("Hello World"))})();

改为引入main.js

1
2
3
<!-- <script type="module" src="src/index.js"></script> -->
<!-- 打包好的main.js已经是ES5语法,可以去掉type="module" -->
<script src="dist/main.js"></script>

打开 index.html 仍然可以看到 Hello World

Webpack4以后,支持这样0配置的方式,快速打包项目,src是默认打包入口,dist是输出,index.js -> main.js

配置

根目录的 webpack.config.js 是webpack默认的配置文件

可以手动创建配置文件,或在 VSCode 中下载webpack插件,通过 Webpack Create 命令,初始化 webpack

插件默认创建的webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
const path = require('path');
module.exports = {
// 设置模式为开发模式
mode: 'development',
// 应用程序的入口点
entry: path.join(__dirname, 'src', 'index'),
// 启用监视模式,以在文件更改时进行自动重新编译
watch: true,
// 输出配置
output: {
// 存储输出文件的目录
path: path.join(__dirname, 'dist'),
// 打包文件的公共路径(由浏览器用于加载资源)
publicPath: '/dist/',
// 主要包的输出文件名
filename: "bundle.js",
// 动态加载的代码的输出文件名(代码分割)
chunkFilename: '[name].js'
},
// 用于处理不同文件类型的模块配置
module: {
// 使用Babel处理JavaScript和JSX文件的规则
rules: [{
// 用于匹配以.jsx或.js结尾的文件
test: /.jsx?$/,
// 只处理src目录下的文件
include: [
path.resolve(__dirname, 'src')
],
// 排除node_modules目录下的文件
exclude: [
path.resolve(__dirname, 'node_modules')
],
// 使用babel-loader进行转译
loader: 'babel-loader',
options: {
// 使用指定的Babel预设
presets: [
["@babel/env", {
// 指定目标浏览器版本为最近的两个Chrome版本
"targets": {
"browsers": "last 2 chrome versions"
}
}]
]
}
}]
},
// 配置模块解析的文件扩展名
resolve: {
extensions: ['.json', '.js', '.jsx']
},
// 生成源映射以方便调试
devtool: 'source-map',
// 配置开发服务器
devServer: {
// 提供内容的基本目录
contentBase: path.join(__dirname, '/dist/'),
// 启用内联模式,自动注入脚本以处理实时更新
inline: true,
// 服务器主机
host: 'localhost',
// 服务器端口
port: 8080,
}
};

JS配置文件运行在node环境中,所以需要使用CommonJS写法

插件还会自动安装这些插件:

1
2
3
4
5
6
7
8
"devDependencies": {
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"@babel/core": "^7.18.13",
"@babel/preset-env": "^7.18.10",
"babel-loader": "^8.2.5",
"webpack-dev-server": "^4.10.0"
}

基本概念

  1. entry: webpack打包的入口起点,从这里开始根据各个文件之间的依赖来对文件进行打包。单页面应用只有一个入口起点,多页面应用则存在多个入口起点
  2. output: 打包文件输出定义的地方,定义打包后输出文件的名字以及输出路径等
  3. mode: 模式,webpack打包的模式,分为三种 development、production、none
  4. loader: 对javascript等文件进行预处理的,可以通过loader来构建包含javascript在内的任何静态资源
  5. plugin: 插件是用来解决loader解决不了的问题,它可以在webpack构建过程中任何一个节点来调用

createapp.dev 是一个创建自定义 webpack 配置的在线工具

使用不同的配置文件:

package.json
1
2
3
"scripts": {
"build": "webpack --config prod.config.js"
}

入口和上下文

入口对象是用于 webpack 查找开始构建 bundle 的地方。上下文是入口文件所处的目录的绝对路径的字符串。文档

基础目录context

context 基础目录,绝对路径,解析入口点(entry point)和加载器(loader)

1
context: path.resolve(__dirname, 'src'),

配置了context后,entry路径相对于context

1
2
3
4
entry: './src/index.js',
// 配置context后
context: path.resolve(__dirname, 'src'),
entry: './index.js',

入口entry

entry指示webpack使用一个或多个模块,来作为构建应用的入口,webpack会找出哪些模块和是入口起点的直接或者间接的依赖,并将其打包到一起。默认值 ./src/index.js

value类型:string、array、object

入口分为单入口(单页应用SPA)和多入口(多页面应用)

1、单入口
单入口主要使用string、array为值,应用于单页应用SPA

1
2
3
entry: './index.js',
// 将这两个文件以及其中所依赖的代码打包到同一个文件中
entry: ['./index.js', './main.js'],

这种写法默认的chunkname是main,是object的简略写法

object完整写法
1
2
3
4
5
6
entry: {
main: './index.js',
},
entry: {
main: ['./index.js', './main.js'],
},

2、多入口
多入口即有多个html,分别需要不同的打包好的js

1
2
3
4
5
6
7
8
9
10
11
{
entry: {
main: './src/main.js',
bundle: './src/index.js'
},
// 这里的[name]相当于是个占位符,值就是上面入口的key值,单入口时可以写死,不过不建议
output: {
// 多入口需要对应多出口,通常用[name]占位,不然打包会报错
filename: '[name].js'
}
}

可以看到dist目录下打包好了 main.js 和 index.js 两个文件

3、更多配置
打包入口不仅仅是写一个入口文件地址就可以,它还有额外的配置:

  1. dependOn: 指当前入口文件所依赖的模块,这些模块必须在入口文件被加载前加载
  2. filename: 指定要输出的文件名称(优先级高于output中的filename和path)
  3. import: 启动时要加载的模块(入口文件地址)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
entry: {
index: './index.js',
main: './main.js',
catalog: {
import: './catalog.js',
filename: 'pages/log.js',
dependOn: 'main',
},
},
output: {
filename: '[name].js',
}
}

出口output

output配置的作用是告知webpack如何向硬盘写入打包好的文件。

注意:entry可以存在多个入口,但output只有一个出口配置

配置项:

  1. filename 打包文件名,默认main.js
  2. path 打包文件输出路径,默认dist
  3. clean 输出包前清空输出目录,默认false

1、filename

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// [name]对应entry的key
output: {
filename: '[name].js',
}
// 使用内部chunk id占位
output: {
filename: '[id].bundle.js',
// main-0.js
},
// 使用由生成的内容产生的 hash,通常和[name]组合使用
output: {
filename: '[name]-[contenthash].js',
// main-8bc05850732530fe321c.js
},

2、path 和 clean

1
2
3
4
5
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name]-[contenthash].js',
clean: true,
}

工作模式mode

mode 用于设置webpack的工作模式,告知 webpack 使用相应模式的内置优化,文档

webpack.config.js
1
2
3
module.exports = {
mode: 'development',
};

三种工作模式:

  1. production 默认,生产模式,启用自动压缩代码、去除业务无关代码等
  2. development 开发模式
  3. none 不使用任何默认优化选项

打包结果分析

将 mode 设为 none,查看输出文件

整个打包好的模块是一个立即执行函数

折叠后
1
2
3
/******/ (() => { // webpackBootstrap
/******/ })()
;

展开一层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ([
/******/ ]);
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ }
/******/
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ })();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ })();
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other entry modules.
(() => {
})();

// This entry need to be wrapped in an IIFE because it need to be isolated against other entry modules.
(() => {
})();

/******/ })()
;

先看最末尾的两个IIFE,两个入口文件 index.js 和 main.js,分别被打包为了立即执行函数,以此实现私有作用域

1
2
3
4
5
6
7
8
9
10
11
12
// This entry need to be wrapped in an IIFE because it need to be isolated against other entry modules.
(() => {
var __webpack_exports__ = {};
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _module_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);

document.body.append((0,_module_js__WEBPACK_IMPORTED_MODULE_0__.createTitle)('Hello World'));
})();

// This entry need to be wrapped in an IIFE because it need to be isolated against other entry modules.
(() => {
})();

再看其它代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 这个数组包含了Webpack构建过程中生成的模块信息。
// 数组的每个元素对应一个模块,具体模块的内容在数组索引位置的函数中定义
var __webpack_modules__ = ([/******/]);
// 缓存已经加载的模块,以避免重复加载
var __webpack_module_cache__ = {};
// 模块加载函数,用于加载模块并执行其中的代码。
// 它接受一个模块ID作为参数,检查是否已经缓存了该模块,如果没有,则执行模块代码,并将模块的导出结果缓存起来
function __webpack_require__(moduleId) {/******/}
// 三个立即执行函数,往__webpack_require__上挂载了一些数据和工具函数
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ })();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ })();
// 用于存储模块的导出内容
var __webpack_exports__ = {};

loader

loader是webpack实现前端模块化的核心,用于将指定格式的资源文件按一定格式进行转换输出

例如,可以使用 loader 告诉 webpack 加载 CSS 文件,或者将 TypeScript 转为 JavaScript。

官方loaders

特点:

  1. 单一职责:一个Loader只做一件事情,正因为职责越单一,所以Loaders的组合性强,可配置性好
  2. loader支持链式调用,上一个loader的处理结果可以传给下一个loader接着处理,上一个Loader的参数options可以传递给下一个loader,直到最后一个loader,返回Webpack所期望的JavaScript

webpack内部的default loader只能处理JavaScript,想要处理如css、ts等其它类型文件,就要安装对应的loader

loader可以分为三类:

  1. 编译转换型:如css-loader
  2. 文件操作型:如file-loader
  3. 代码检查型:如eslint-loader

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 用于处理不同文件类型的模块配置
module: {
// 使用Babel处理JavaScript和JSX文件的规则
rules: [{
// 用于匹配以.jsx或.js结尾的文件
test: /.jsx?$/,
// 只处理src目录下的文件
include: [
path.resolve(__dirname, 'src')
],
// 排除node_modules目录下的文件
exclude: [
path.resolve(__dirname, 'node_modules')
],
// 使用babel-loader进行转译
loader: 'babel-loader',
options: {
// 使用指定的Babel预设
presets: [
["@babel/env", {
// 指定目标浏览器版本为最近的两个Chrome版本
"targets": {
"browsers": "last 2 chrome versions"
}
}]
]
}
}]
},

Module的文档:module

加载css

查看官方文档指南-管理资源-加载CSS

安装所需的loader

1
npm install --save-dev style-loader css-loader

添加配置,css-loader将css文件打包为js模块,style-loader把 CSS 插入到 DOM 中(css-loader将css push到一个数组中,style-loader将数组中的css通过style标签追加到html-head中)

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
module: {
rules: [
{
// 正则匹配loader要处理的资源
test: /\.css$/i,
// 逆序执行,从右往左
use: ['style-loader', 'css-loader'],
},
],
},
}

src目录下创建index.css

src/index.css
1
2
3
body{
background-color: #ccc;
}

打包入口中使用该css

src/index.js
1
import './index.css'

现在样式已经生效

webpack推荐我们使用import根据JS代码的需要动态导入资源,就像刚刚import css一样,这样的代码与资源的关系,更符合模块化的依赖思想

加载其它资源

webpack5使用资源模块Asset Modules来加载图片、字体等资源,webpack4则使用file-loaderurl-loader

file-loader

安装file-loader

1
npm i file-loader -D

在代码所需的地方导入图片并使用

src/index.js
1
2
3
4
import avatar from './avatar.png' // 导入打包后资源的路径
const img = new Image();
img.src = avatar;
document.body.append(img);

配置规则

1
2
3
4
5
6
7
8
9
module: {
rules: [
/******/
{
test: /\.png$/,
use: 'file-loader',
}
],
},

打包后,在dist目录下生成了871132b331c17257fcba75273b57f9fe.png,这是将文件的hash值作为了打包后的文件名,当然,这也是可以自定义的。

1
2
3
4
5
6
7
8
9
10
{
test: /\.png$/,
use: {
loader: 'file-loader',
options: {
// 默认[hash].[ext]
name: '[path][name].[ext]',
}
},
}

这样就能保留图片的原始相对路径和名称。

url-loader

file-loader拷贝文件到输出目录,而url-loader通过durl的形式表示文件

1
npm i url-loader -D

durl即Data URLs,可以通过url直接去表示文件的内容,不会产生任何请求

1
2
3
4
// 一个html类型的文件内容,编码是utf-8
data:text/html;charset=UTF-8,<h1>html content</h1>
// 如果是图片这种无法直接通过文本表示的文件,则可以将文件内容进行base64编码
data:image/png;base64,iDAHAidhbaIADHA...AHiDAd

最佳实践:配置小文件使用url-loader,大文件则使用file-loader

1
2
3
4
5
6
7
8
9
10
11
{
test: /\.(png|ico)$/,
use: {
loader: "url-loader",
options: {
name: "[path][name]_[hash:6].[ext]",
limit: 50 * 1024, //小于50kb的进行编码
// 超过这个大小,url-loader会自动调用file-loader
},
},
}
1
2
3
4
5
import { createImage } from "./module.js";
import avatar from './avatar.png'
import icon from './icon.ico'
document.body.append(createImage(avatar));
document.body.append(createImage(icon));

Asset Modules

webpack5使用资源模块Asset Modules来加载图片、字体等资源。

在webpack5之前,通常使用:raw-loader将文件导入为字符串,url-loader将文件作为durl内联到bundle中,file-loader将文件发送到输出目录

资源模块类型(asset module type),通过添加 4 种新的模块类型,来替换所有这些 loader:

  1. asset/resource 发送一个单独的文件并导出 URL。之前通过使用 file-loader 实现。
  2. asset/inline 导出一个资源的 data URI。之前通过使用 url-loader 实现。
  3. asset/source 导出资源的源代码。之前通过使用 raw-loader 实现。
  4. asset 在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用 url-loader,并且配置资源体积限制实现。

案例:

asset/resource
1
2
3
4
5
6
7
8
9
{
test: /\.(png|jpg|svg|gif|ico)$/,
// type选择类型
type: "asset/resource",
// 通过generator属性进行配置
generator: {
filename: "img/[name]_[hash:6][ext]",
},
},
asset/inline
1
2
3
4
{
test: /\.(png|jpg|svg|gif|ico)$/,
type: "asset/inline",
},

最佳实践:type设为asset,添加一个parser属性,并且制定dataUrl的条件,添加maxSize属性;

1
2
3
4
5
6
7
8
9
10
11
12
{
test: /\.(png|jpg|svg|gif|ico)$/,
type: "asset",
generator: {
filename: "img/[name]_[hash:6][ext]",
},
parser: {
dataUrlCondition: {
maxSize: 50 * 1024,//小于50kb进行编码,转为base64
},
},
},

babel-loader

webpack由于打包需要,会去处理import和export,但对于其它ES6新特性,则不会去做兼容处理

如果需要将代码中的ES6进行转换,则需要babel-loader

1
2
// 安装babel-loader及其依赖的核心库和特性转换集合
npm install -D babel-loader @babel/core @babel/preset-env

注意:babel只是转换JS代码的一个平台,还需要用其它的插件,如@babel/preset-env,通过该平台来转换ES6特性

进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module: {
rules: [
{
test: /\.js$/i,
include: [
path.resolve(__dirname, 'src')
],
exclude: [
path.resolve(__dirname, 'node_modules')
],
loader: 'babel-loader',
options: {
presets: ["@babel/preset-env"]
}
},
]
}

开启ESM转CommonJS(会导致Tree Shaking失效,不推荐开启)

1
2
3
4
5
presets: [
["@babel/preset-env", {
modules: "commonjs", // 开启ESM转CommonJS,默认:"auto"
}]
]

这样就完成了简单的ES6转换,更完善的使用core-js@3兼容,后面再说吧。

资源加载方式

除了在js文件中使用import加载资源,webpack还会自动处理其它加载资源的方式,如css文件中的url()、@import

例如:当css-loader在处理css文件时遇到url()时,会找到符合的规则对所需的资源进行处理,如使用asset/resource对图片资源处理

1
2
3
4
5
6
7
8
9
10
11
/* main.css */
body{
background-image: url(avatar.png);
background-size: auto;
}

/* index.css */
@import './main.css';
body{
background-color: #ccc;
}

开发loader

尝试开发一个markdown-loader,深入了解loader的工作过程

文档:编写loader

功能:将模块中所需的markdown资源转为html内容导入

在根目录新建markdown-loader.js,一个最简单的loader是一个函数,接收传入的资源内容,若该loader是最后一个执行的,返回结果必须是JS代码

1
2
3
4
module.exports = source => {
console.log(source)
return 'console.log(source)'
}

使用该loader

1
2
3
4
{
test: /\.md$/i,
use: path.resolve(__dirname, 'markdown-loader.js'),
}

在模块中导入markdown,webpack只会处理模块所依赖的资源

src/main.js
1
2
import md from './01.md'
console.log(md)

打包时控制台输出了markdown的内容。查看打包结果,loader返回的js也在其中,被一个IIFE包裹。

下面继续完成功能:

安装解析markdown内容的模块,使用marked

1
npm i marked -D

修改 markdown-loader.js

1
2
3
4
5
6
7
8
const marked = require('marked');

module.exports = source => {
console.log(source)
const html = marked.parse(source)
console.log(html)
return 'console.log(source)'
}

输出如下,现在loader已经能解析markdown文件了

1
2
3
4
# 简介
这是一个**markdown**
<h1>简介</h1>
<p>这是一个<strong>markdown</strong></p>

完善loader,将html暴露给模块使用,会作为模块中import markdown文件的default值

1
2
3
4
5
6
7
const marked = require('marked');

module.exports = source => {
const html = marked.parse(source)
// html中存在一些字符,使用JSON.stringify进行转译
return `export default ${JSON.stringify(html)}`
}

现在,markdown-loader就完成了,模块导入的就是解析好的html内容

src/main.js
1
2
3
4
import md from './01.md'
console.log(md)
// <h1>简介</h1>
// <p>这是一个<strong>markdown</strong></p>

当然,markdown-loader也可以直接返回解析好的html内容,再交给loader管道中下一个loader进行处理,webpack只要求最后一个loader返回的需要是JS代码

处理html就需要安装html-loader,npm i html-loader -D

修改代码:

1
2
3
4
5
6
7
{
test: /\.md$/i,
use: [
'html-loader',
path.resolve(__dirname, 'markdown-loader.js'),
]
}
1
2
3
4
5
6
const marked = require('marked');

module.exports = source => {
const html = marked.parse(source)
return html
}

实现的功能也是一样的

plugin

loader用于处理资源的加载,而插件plugin用于实现各种自动化操作,如压缩代码、替换内容、处理资源

官方plugins

打包分析插件

webpack-bundle-analyzer是一个打包分析插件,使用交互式可缩放树形地图可视化,并输出文件的大小。可以方便开发人员检查打包后的文件拆分、分析文件大小。

每次打包时,会自动打开浏览器,访问127.0.0.1:8888查看项目结构

安装:npm i webpack-bundle-analyzer -D

webpack中,插件都需要导入后使用,且通常插件导出的都是一个class,需要new实例。配置项plugins是一个数组,保存插件的实例。

1
2
3
4
5
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
/*****/
plugins: [
new BundleAnalyzerPlugin(),
],

自动生成HTML

手动在根目录创建index.html,并配置打包好的JS等资源的路径,这样硬编码过于麻烦且易出错

可以使用html-webpack-plugin简化HTML文件的创建,自动引入打包好的JS模块,这对于那些文件名中包含哈希值,并且哈希值会随着每次编译而改变的 webpack 包特别有用。

安装:npm i html-webpack-plugin --D

1
2
3
4
5
6
7
8
9
10
const HtmlWebpackPlugin = require('html-webpack-plugin')
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, './index.html'), // 自定义模板
inject: 'body', // 插入到body
filename: 'index.html', // 输出文件名,默认index.html
title: 'webpack测试', // 自定义title,通过<%= htmlWebpackPlugin.options.title %>在html中使用
minify: true, // 压缩
}),
]

修改根目录下的index.html,使其作为一个模板

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<!-- 去掉js文件的引入,插件会自动引入 -->
<!-- <script src="dist/main.js"></script> -->
</body>
</html>

查看打包后的index.html

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>webpack测试</title>
</head>
<body>
<script defer src="main_0cc0be.js"></script>
</body>
</html>

html-webpack-plugin还有其它的配置项,查看官方仓库文档:配置项

若是多页面、多个html文件,则创建多个插件实例加入到plugins数组中

拷贝文件

通常项目中还有一些无需打包的静态文件存放于public目录,这些资源同样需要输出到dist

copy-webpack-plugin

安装:npm i copy-webpack-plugin -D

1
2
3
4
5
6
7
8
9
const CopyPlugin = require("copy-webpack-plugin");
plugins: [
new CopyPlugin({
patterns: [
// 复制public文件夹内的文件到{output}中
{ from: path.resolve(__dirname, 'public'), to: "" },
],
}),
]

开发plugin

相较于loader只作用于模块加载,plugin的作用范围更广。plugin通过常见的钩子机制实现,就像Vue生命周期提供的钩子一样。

webpack提供了很多打包过程中的钩子,plugin向这些钩子上挂载任务,并获取上下文,来实现对资源的操作等功能。

钩子相关文档:compiler-hooks

webpack要求plugin必须是一个函数,或一个包含apply方法的对象,通常是定义一个包含apply方法的类

新建myPlugin.js

1
2
3
4
5
export default class {
// apply接收一个compiler对象参数
// 这个对象包含了打包过程中所有信息以及用于注册钩子函数
apply(compiler) {}
}

现在开发一个用于清除webpack生成的/******/注释,以方便阅读打包后的JS代码

明确了功能,考虑需要用到哪些钩子,显然,清除注释要在输出文件前执行,对要输出的内容进行处理。emit钩子符合需求,这个钩子在输出 asset 到 output 目录之前执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default class {
// apply接收一个compiler对象参数
// 这个对象包含了打包过程中所有信息以及用于注册钩子函数
apply(compiler) {
// 通过hooks属性访问钩子,tap方法注册钩子函数
// tap方法第一个参数为plugin名,第二个参数是接收了compilation对象的钩子函数
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation是此次打包过程中的上下文,存放了打包过程的信息和结果
// compilation.assets获取即将输出的资源文件信息
for (const name in compilation.assets) {
console.log(name); // 输出文件名
}
})
}
}

使用插件

1
2
3
4
const MyPlugin = require('./myPlugin.js');
plugins: [
new MyPlugin(),
]

输出:

1
2
3
4
5
main_28990539038fea465479.js
img/avatar_871132b331c17257fcba.png
img/icon_36fa45932bf38a34e9af.ico
favicon.ico
index.html

插件已经能读取到打包后的文件名,接下来通过正则替换来处理JS文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
module.exports = class {
#isJSFile(filename) {
// 使用正则表达式检查文件名是否以 .js 结尾
return /\.js$/i.test(filename);
}

// apply接收一个compiler对象参数
// 这个对象包含了打包过程中所有信息以及用于注册钩子函数
apply(compiler) {
// 通过hooks属性访问钩子,tap方法注册钩子函数
// tap方法第一个参数为plugin名,第二个参数是接收了compilation对象的钩子函数
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation是此次打包过程中的上下文,存放了打包过程的信息和结果
// compilation.assets获取即将输出的资源文件信息
for (const name in compilation.assets) {
// console.log(name);
// 使用source方法获取文件内容
if(this.#isJSFile(name)){
let content = compilation.assets[name].source();
// 使用正则去除webpack生成的/******/
content = content.replace(/\/\*{3,}\//g, '');
// 覆盖文件信息
compilation.assets[name] = {
// 覆盖内容
source: () => content,
// webpack要求指定大小
size: () => content.length
}
}
}
})
}
}

现在,打包的JS文件内容已经去除了/******/

但控制台有警告信息:

1
2
3
4
5
(node:76292) [DEP_WEBPACK_COMPILATION_ASSETS] DeprecationWarning: Compilation.assets will be frozen in future, all modifications are deprecated.
BREAKING CHANGE: No more changes should happen to Compilation.assets after sealing the Compilation.
Do changes to assets earlier, e. g. in Compilation.hooks.processAssets.
Make sure to select an appropriate stage from Compilation.PROCESS_ASSETS_STAGE_*.
(Use `node --trace-deprecation ...` to show where the warning was created)

这是因为Webpack5将在未来版本冻结compilation.assets,需在compiler.hooks.thisCompilation钩子中使用 Compilation 中的 processAssets hook 来对资源进行再处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
module.exports = class {
#isJSFile(filename) {
// 使用正则表达式检查文件名是否以 .js 结尾
return /\.js$/i.test(filename);
}

// apply接收一个compiler对象参数
// 这个对象包含了打包过程中所有信息以及用于注册钩子函数
apply(compiler) {
// 使用thisCompilation钩子,在 compilation 对象创建时执行一些自定义逻辑
compiler.hooks.thisCompilation.tap('MyPlugin', compilation => {
// processAssets钩子用于在 webpack 编译完成后,但在最终资源输出之前,处理资源文件的阶段执行插件代码
compilation.hooks.processAssets.tap(
{
name: 'MyPlugin',
// https://webpack.docschina.org/api/compilation-hooks/#list-of-asset-processing-stages
stage: compilation.PROCESS_ASSETS_STAGE_OPTIMIZE, // 以通用的方式优化已有asset
},
(assets) => {
for (const name in assets) {
if (this.#isJSFile(name)) {
// 使用正则表达式去除块注释
const content = assets[name].source().replace(/\/\*{3,}\//g, '');
// 覆盖文件信息
assets[name] = {
source: () => content,
size: () => content.length,
};
}
}
}
);
})
}
}

优化开发过程

项目打包过程已经自动化了,但开发过程仍然在手动操作

编写代码->命令打包->运行应用->刷新浏览器,这个繁琐的过程也需要自动化,以提高开发效率

提出下面的需求:

  1. 以 HTTP Server 运行,而不是打开文件浏览
  2. 自动编译 + 自动刷新
  3. 提供 Source Map 支持,方便调试

watch工作模式

处于watch工作模式时,webpack会监听文件变化,自动重新打包

添加watch配置:

1
2
3
module.exports = {
watch: true,
}

DevServer

webpack dev server提供了HTTP Server,集成了自动编译和自动刷新浏览器的功能。

该插件会将将打包结果暂时存放于内存,而不输出于硬盘,以提高性能。

安装:npm i webpack-dev-server -D

添加配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
// 配置开发服务器
devServer: {
// 服务器主机
host: 'localhost',
// 服务器端口
port: 8080,
// 启用Gzip
compress: true,
// CopyPlugin通常只在项目上线时去使用,开发时通常将静态资源目录配置给devServer,以提高性能
// 使用static配置从目录提供静态文件的选项,默认public
static: {
// 告诉服务器从哪里提供内容
directory: path.join(__dirname, 'public'),
},
}
}

添加命令脚本

1
2
3
4
5
{
"scripts": {
"serve": "webpack serve"
}
}

使用命令运行npm run serve

代理API服务

前后端同源部署时,本地开发在请求api时可能有cors问题,可以使用开发服务器代理api请求,服务器间通信就不存在cors了

DevServer就支持proxy配置api代理,文档

1
2
3
4
5
6
7
8
9
10
11
12
13
devServer: {
proxy: {
// 代理api路径
'/api': {
// localhost:8080/api/user -> api.github.com/api/user
target: 'https://api.github.com',
// 请求路径重写 /api/user -> /user
pathRewrite: { '^/api': '' },
// 将 host 请求头修改为 target 的 URL
changeOrigin: true,
},
},
}

Source Map

前端工程化后,源代码和运行代码几乎完全不同,调试和报错都是基于运行代码,调试源代码就成了问题

Source Map用于映射源代码和运行代码之间的关系

一个Source Map的组成:

1
2
3
4
5
6
{
"version": 3, // 当前Map使用的Source Map标准版本
"sources": ["main.js"], // 记录源文件的名称,可以是多个文件
"names": ["global",/****/], // 源代码使用的成员名称,如变量的原名
"mappings": ";/****/" // 核心,记录源码和运行代码一些字符的映射关系
}

通过一行特定格式的注释引入Source Map

1
//# sourceMappingURL=main.map

如果Source Map不起作用,需在浏览器控制台-设置-偏好设置中启用JavaScript源代映射

使用Devtool在webpack中配置Source Map:

1
2
3
module.exports = {
devtool: 'source-map', // 值为Source Map工作模式
}

相关文章:一文搞懂SourceMap以及webpack devtool

Source Map工作模式:

1
[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
  1. inline- 将SourceMap内联到原始文件中,而不是创建一个单独的文件。
  2. hidden- 仍然会生成.map文件,但是打包后的代码中没有sourceMappingURL,即浏览器不会加载.map文件,控制台中看不到源代码。Map生成后只供服务端分析使用,前端将出错的行列传给服务端。
  3. eval- 通过eval包裹每个模块打包后代码以及对应生成的SourceMap(不实际生成),因为eval中为字符串形式,进行字符串处理会提升rebuild的速度。
  4. nosources- 不包含 sourcesContent 内容,调试时只能看到文件信息和行信息,无法看到源码。
  5. cheap-[module-] 只定位到源码所在的行,不定位至具体的列,构建速度有所提升。如果只用 cheap ,显示的是 loader 编译之后的源代码,加上 module 后会显示编译之前的源代码。

如何选择devtool:

  1. production:none,source-map,hidden-source-map,nosources-source-map
  2. development:eval,eval-source-map,eval-cheap-source-map,eval-cheap-module-source-map

开发环境下,需要频繁的修改代码,更多地考虑的开发效率和调试效率,所以更多关注 performance 中 rebuild 的性能。生产环境下,不必过多关注打包性能,主要考虑 quality 代码的保护性、出错的定位速度已经安全性

热替换HMR

监视模块变动后重新打包、自动刷新会导致页面的一些状态丢失(输入的文本内容),如果能让页面不刷新,模块也能更新,这样的开发体验会好很多

模块热替换HMR(Hot Module Replacement)可以实现无刷更新模块,「webpack 核心特性」模块热替换(HMR)

HMR作用:

  1. 保留在完全重新加载页面期间丢失的应用程序状态。
  2. 只更新变更内容,以节省宝贵的开发时间。
  3. 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。
使用HMR
1
2
3
4
5
devServer: {
hot: true, // 开启HMR
// 在构建失败时不刷新页面作为回退
// hot: 'only',
}

从webpack-dev-server v4开始,HMR已默认启用。会自动应用HotModuleReplacementPlugin插件

注意:HMR并不是开箱即用,还需要使用HMR-API手动处理模块的热替换逻辑,否则还会自动刷新,部分loader和插件如style-loader已经处理好了css的热更新逻辑,在Vue等框架下开发,框架本身也处理好了HMR

使用HMR-API手动处理JS模块热替换:

通常在入口模块统一做处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (module.hot) {
module.hot.accept('./library.js', function() {
// 对更新过的 library 模块做些事情...
});
}
// or
if (import.meta.webpackHot) {
import.meta.webpackHot.accept('./library.js', function () {
// Do something with the updated library module…
});
}

// accept方法
module.hot.accept(
dependencies, // 可以是一个字符串或字符串数组
callback // 用于在模块更新后触发的函数
errorHandler // (err, {moduleId, dependencyId}) => {}
);

案例:

src/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
import { appendMarkdown } from "./module.js";
import md from './01.md'
let mde = appendMarkdown(md);

if (module.hot) {
// 处理01.md的更新
module.hot.accept('./01.md', () => {
// 热重载,先移除原来的
document.body.removeChild(mde);
// 再创建新的
mde = appendMarkdown(md);
});
}

热重载的需要根据自己的业务逻辑去实现,没有通用的方法,这也是webpack没有提供JS模块HMR的原因。

打包后,HMR相关代码会被自动去除

不同环境的配置

不同的环境需要不同的webpack配置,主要是区分生产和开发环境,文档

区分环境有两种方式

  1. 配置函数中判断env,返回不同的配置信息
  2. 创建多个配置文件对应不同的环境(推荐)

判断env

webpack配置导出一个函数而非对象,导出函数环境变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
/**
*
* @param {string} env 环境名参数
* @param {array} argv cli传递的所有参数
* @returns {object} webpack配置
*/
module.exports = (env, argv) => {
console.log(env);
// 默认的通用配置
const config = {
mode: 'none',
// 生成源映射以方便调试
devtool: 'eval-source-map',
// watch: true,
context: path.resolve(__dirname, 'src'),
entry: {
main: ['./index.js', './main.js'],
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name]_[contenthash].js',
clean: true,
},
module: {
rules: [
{
// 正则匹配loader要处理的资源
test: /\.css$/i,
// 逆序执行,从右往左
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpg|svg|gif|ico)$/,
type: "asset",
generator: {
filename: "img/[name]_[contenthash][ext]",
},
parser: {
dataUrlCondition: {
maxSize: 50 * 1024,//小于50kb进行编码,转为base64
},
},
},
{
test: /\.js$/i,
include: [
path.resolve(__dirname, 'src')
],
exclude: [
path.resolve(__dirname, 'node_modules')
],
loader: 'babel-loader',
options: {
presets: ["@babel/preset-env"]
}
},
{
test: /\.md$/i,
use: path.resolve(__dirname, 'markdown-loader.js'),
}
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, './index.html'), // 自定义模板
inject: 'body', // 插入到body
filename: 'index.html', // 输出文件名,默认index.html
title: 'webpack测试', // 自定义title,通过<%= htmlWebpackPlugin.options.title %>在html中使用
minify: true, // 压缩
}),
],
// 配置开发服务器
devServer: {
// 服务器主机
host: 'localhost',
// 服务器端口
port: 8080,
// 使用HMR
hot: true,
// 启用Gzip
compress: true,
// CopyPlugin通常只在项目上线时去使用,开发时通常将静态资源目录配置给devServer,以提高性能
// 使用static配置从目录提供静态文件的选项,默认public
static: {
// 告诉服务器从哪里提供内容
directory: path.join(__dirname, 'public'),
},
proxy: {
// 代理api路径
'/api': {
// localhost:8080/api/user -> api.github.com/api/user
target: 'https://api.github.com',
// 请求路径重写 /api/user -> /user
pathRewrite: { '^/api': '' },
// 将 host 请求头修改为 target 的 URL
changeOrigin: true,
},
},
}
}
// 判断环境,修改配置
if (env.production) { // 生产环境
config.mode = 'production';
config.devtool = false;
config.plugins = [
...config.plugins,
new CopyPlugin({
patterns: [
// 复制public文件夹内的文件到{output}中
{ from: path.resolve(__dirname, 'public'), to: "" },
],
}),
];
} else if (env.development) { // 开发环境
config.mode = 'development';
}

return config
}
1
2
npx webpack --env production
npx webpack --env development

多配置文件

若项目较大配置复杂,就不适合用判断env的方式,写多个配置文件更清晰明了,文档

通常有三个配置文件:

  1. webpack.common.js 通用配置文件,写一些项目的通用基础配置
  2. webpack.dev.js 开发配置文件
  3. webpack.prod.js 生产配置文件

安装webpack-merge合并配置对象:npm i webpack-merge -D

webpack.common.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
mode: 'none',
// 生成源映射以方便调试
devtool: 'source-map',
// watch: true,
context: path.resolve(__dirname, 'src'),
entry: {
main: ['./index.js', './main.js'],
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name]_[contenthash].js',
clean: true,
},
//警告 webpack 的性能提示
performance: {
hints: 'warning',
//入口起点的最大体积
maxEntrypointSize: 1024 * 1024 * 10,
//生成文件的最大体积
maxAssetSize: 1024 * 1024,
//只给出 js 文件的性能提示
assetFilter: function (assetFilename) {
return /\.js$/.test(assetFilename);
}
},
module: {
rules: [
{
// 正则匹配loader要处理的资源
test: /\.css$/i,
// 逆序执行,从右往左
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpg|svg|gif|ico)$/,
type: "asset",
generator: {
filename: "img/[name]_[contenthash][ext]",
},
parser: {
dataUrlCondition: {
maxSize: 50 * 1024,//小于50kb进行编码,转为base64
},
},
},
{
test: /\.js$/i,
include: [
path.resolve(__dirname, 'src')
],
exclude: [
path.resolve(__dirname, 'node_modules')
],
loader: 'babel-loader',
options: {
presets: ["@babel/preset-env"]
}
},
{
test: /\.md$/i,
use: path.resolve(__dirname, 'markdown-loader.js'),
}
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, './index.html'), // 自定义模板
inject: 'body', // 插入到body
filename: 'index.html', // 输出文件名,默认index.html
title: 'webpack测试', // 自定义title,通过<%= htmlWebpackPlugin.options.title %>在html中使用
minify: true, // 压缩
}),
],
// 配置开发服务器
devServer: {
// 服务器主机
host: 'localhost',
// 服务器端口
port: 8080,
// 使用HMR
hot: true,
// 启用Gzip
compress: true,
// CopyPlugin通常只在项目上线时去使用,开发时通常将静态资源目录配置给devServer,以提高性能
// 使用static配置从目录提供静态文件的选项,默认public
static: {
// 告诉服务器从哪里提供内容
directory: path.join(__dirname, 'public'),
},
proxy: {
// 代理api路径
'/api': {
// localhost:8080/api/user -> api.github.com/api/user
target: 'https://api.github.com',
// 请求路径重写 /api/user -> /user
pathRewrite: { '^/api': '' },
// 将 host 请求头修改为 target 的 URL
changeOrigin: true,
},
},
}
}
webpack.dev.js
1
2
3
4
5
6
7
const common = require("./webpack.common");
const { merge } = require("webpack-merge");

module.exports = merge(common, {
mode: 'development',
devtool: 'eval-source-map',
});
webpack.prod.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const common = require("./webpack.common");
const path = require('path');
const CopyPlugin = require("copy-webpack-plugin");
const { merge } = require("webpack-merge");

module.exports = merge(common, {
mode: 'production',
devtool: false,
plugins: [
new CopyPlugin({
patterns: [
// 复制public文件夹内的文件到{output}中
{ from: path.resolve(__dirname, 'public'), to: "" },
],
}),
],
});

通过 —config 标志使用不同的配置文件

1
2
3
4
5
6
"scripts": {
"build": "webpack --config webpack.prod.js",
"build-dev": "webpack --config webpack.dev.js",
"prod": "webpack serve --config webpack.prod.js",
"dev": "webpack serve --config webpack.dev.js"
},

内置插件

webpack本身内置了很多插件对bundle进行优化,且一些插件在mode: production时会自动开启,进行一些通用的优化操作,优化(Optimization)

DefinePlugin

DefinePlugin用来注入全局成员,在编译时将代码中的变量替换为其他值或表达式

mode: production时,DefinePlugin默认启用,并注入了process.env.NODE_ENV,许多第三方的模块使用这个常量来判断当前环境

DefinePlugin接收一个对象,对象中的值若为字符串,将被作为代码片段使用,

使用API_BASE_URL区分生产和开发环境API接口
1
2
3
4
5
6
7
8
9
10
const webpack = require('webpack');

plugins: [
new webpack.DefinePlugin({
// 使用JSON.stringify作为表示字符串的代码片段
API_BASE_URL: JSON.stringify('http://api.github.com'),
}),
],

console.log(API_BASE_URL)

Tree Shaking

Tree Shaking用于移除JS上下文中的未引用代码(dead-code),基于ESM

mode: production时Tree Shaking功能自动开启,也可通过配置开启

1
2
3
4
5
6
module.exports = {
optimization: {
// 只导出使用了的成员
usedExports: true,
},
}

测试代码:

src/utils.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const info = {
name: 'chuckle',
age: '20',
}
export function getName(){
return info.name
}
export function getAge(){
return info.age
}
export function logName(){
console.log(info.name);
}
export function logAge(){
console.log(info.age);
}

打包结果,仍然存在未使用的代码片段,这是因为usedExports只是标记了未引用代码,而optimization.minimize才是用于压缩bundle,并去除未引用代码,两者搭配才实现了Tree Shaking

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ logName: () => (/* binding */ logName)
/* harmony export */ });
/* unused harmony exports getName, getAge, logAge */
var info = {
name: 'chuckle',
age: '20'
};
function getName() {
return info.name;
}
function getAge() {
return info.age;
}
function logName() {
var _console;
/* eslint-disable */(_console = console).log.apply(_console, _toConsumableArray(oo_oo("3634127370_12_2_12_24_4", info.name)));
}
function logAge() {
var _console2;
/* eslint-disable */(_console2 = console).log.apply(_console2, _toConsumableArray(oo_oo("3634127370_15_2_15_23_4", info.age)));
}

压缩代码去除未引用

optimization.minimize压缩bundle并去除未引用代码,mode: production默认开启

1
2
3
4
5
6
module.exports = {
//...
optimization: {
minimize: true,
},
};

打包后,未使用过的代码已经去除

1
e.d(_,{logName:()=>d});var t={name:"chuckle",age:"20"};

副作用

将文件标记为side-effect-free(无副作用)安全地删除未用到的export,目的是为了给Tree Shaking更大的优化空间

副作用:模块执行时,除了导出成员之外所作的事情

optimization.sideEffects告知webpack去辨识package.json中的副作用标记或规则,以跳过那些当导出不被使用且被标记不包含副作用的模块。

1
2
3
optimization: {
sideEffects: true, // 开启
},
1
2
3
"sideEffects": false, // 项目所有模块都无副作用
// or
"sideEffects": ["*.css"], // 使用css-loader且在css文件中使用import时很有必要

常见副作用代码:

src/pad.js
1
2
3
4
5
6
7
8
9
10
11
// 数字前补全0
Number.prototype.pad = function (size) {
let result = String(this);
while (result.length < size) {
result += '0';
}
return result;
}

// 导入使用
import './pad'

若没有标记副作用,打包会排除该代码片段

1
2
3
4
5
Uncaught TypeError: 8.pad is not a function
at ./main.js (main.js:17:72)
at __webpack_require__ (bootstrap:24:1)
at startup:7:1
at startup:7:1

标记副作用

1
"sideEffects": ["*.css", "./src/pad.js"],

模块分包

webpack会将所有小颗粒度的模块,从入口模块开始打包到一个JS模块,若项目较大,bundle也会很大,一些模块可以分包出来,减小bundle的体积,文档

模块分包办法:

  1. 多入口打包
  2. 动态导入

多入口打包

多入口打包通常用于多页面应用,但也可以一个页面应用多个bundle,实现分包

同事可以使用dependOn指定依赖的公共模块,并在html中引入公共模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
entry: {
main: {
import: ['./index.js', './main.js'],
dependOn: 'shared',
},
about: {
import: ['./about.js'],
dependOn: 'shared',
},
shared: './module.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name]_[contenthash].js',
clean: true,
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, './index.html'), // 自定义模板
inject: 'body', // 插入到body
filename: 'index.html', // 输出文件名,默认index.html
title: 'webpack测试', // 自定义title,通过<%= htmlWebpackPlugin.options.title %>在html中使用
minify: true, // 压缩
chunks: ['main', 'shared'], // 公共模块也要引入
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, './about.html'),
inject: 'body',
filename: 'about.html',
title: '关于页',
minify: true, // 压缩
chunks: ['about', 'shared'],
}),
],

如果想要在一个 HTML 页面上使用多个入口,还需设置 runtimeChunk

1
2
3
4
optimization: {
// 用于指定运行时(runtime)代码的拆分策略
runtimeChunk: 'single',
},

自动提取

当多个模块引入了同一个模块,可以使用splitChunks将其自动提取为独立的chunk

1
2
3
4
5
splitChunks: {
chunks: 'all',
minSize: 20 * 1024, // 设置最小分包大小,默认20000
minSizeReduction: 50 * 1024, // 需要分包的bundle最小大小
},

minSizeReduction:设置需要分包的bundle最小大小,这意味着如果分割成一个 chunk 并没有减少主 chunk(bundle)的给定字节数,它将不会被分割,即使它满足 splitChunks.minSize

这样就不用使用dependOn指定依赖的公共模块了

动态导入

动态导入实现按需加载,需要某个模块再加载该模块,所有动态导入的模块都会被自动分包

使用ESM的import()实现动态导入

下面是一个hash路由的小demo

1
2
3
4
5
6
7
<body>
<header>
<a href="#Home">首页</a>
<a href="#List">列表</a>
</header>
<div id="main"></div>
</body>
src/blog.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import home from './home';
import list from './list';

const render = ()=>{
const hash = window.location.hash || "#Home";
const mainEle = document.querySelector('#main');
mainEle.innerHTML = "";
if(hash === "#List"){
mainEle.appendChild(list());
}else if(hash === "#Home"){
mainEle.appendChild(home());
}
}
render();

window.addEventListener("hashchange", render)
src/home/index.js
1
2
3
4
import { renderMarkdown } from "../module";
import './index.css'
import md from './index.md'
export default () => renderMarkdown(md, "home");
src/list/index.js
1
2
3
4
import { renderMarkdown } from "../module";
import './index.css'
import md from './index.md'
export default () => renderMarkdown(md, 'list');

若不使用动态导入,不同路由页引入的css都同时影响样式,导致样式冲突,下面使用import()改造

src/blog.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const render = () => {
const hash = window.location.hash || "#Home";
const mainEle = document.querySelector('#main');
mainEle.innerHTML = "";
if (hash === "#List") {
import('./list').then(({ default: list }) => {
mainEle.appendChild(list());
})
} else if (hash === "#Home") {
import('./home').then(({ default: home }) => {
mainEle.appendChild(home());
})
}
}
render();

window.addEventListener("hashchange", render)

魔法注释

在动态导入过程中可以加入魔法注释,控制分包命名、合并、开启预加载

1
2
3
4
5
6
7
8
// 使用chunkName设置分包命名
import(/* webpackChunkName: "home" */'./home')
import(/* webpackChunkName: "list" */'./list')
// 相同chunkName会被打包到一起
import(/* webpackChunkName: "components" */'./home')
import(/* webpackChunkName: "components" */'./list')
// 开启预加载
import(/* webpackPrefetch: true */'./list');

css处理进阶

css这东西吧,还得琢磨琢磨

css-loader模块化

开启options.modules,css-loader会将样式中的类名进行转换,根据模块路径和类名生成转换为一个唯一的hash值。文档

作用:CSS的规则都是全局的,任何一个组件的样式规则,都对整个页面有效。产生局部作用域的唯一方法,就是使用一个独一无二的class的名字,不会与其他选择器重名

1
2
3
4
5
6
7
8
9
rules: [
{
test: /\.css$/i,
loader: "css-loader",
options: {
modules: true,
},
},
],

通过导出对象访问类名来应用样式

1
2
3
.list{
background-color: #2f59b4
}
1
2
3
4
import { renderMarkdown } from "../module";
import css from './index.css'
import md from './index.md'
export default () => renderMarkdown(md, css.list);

提取css

之前css通过style-loader直接应用到style标签内,而css则保存在js模块中,若css体积较大,还是提取css为一个单独的文件好

MiniCssExtractPlugin将 CSS 提取到单独的文件中,为每个包含 CSS 的 JS 文件创建一个 CSS 文件,并且支持 CSS 和 SourceMaps 的按需加载

安装:npm i mini-css-extract-plugin -D

配置
1
2
3
4
5
6
7
8
9
10
11
12
13
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
plugins: [new MiniCssExtractPlugin()],
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
};

压缩css

webpack本身只能压缩JS模块,需要压缩CSS等其它类型文件需要安装对应的插件

使用CssMinimizerWebpackPlugin优化和压缩独立的CSS模块

安装:npm i css-minimizer-webpack-plugin -D

配置
1
2
3
4
5
6
7
8
9
10
11
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");

optimization: {
minimize: true,
// 压缩类的插件应配置在minimizer,受minimize控制
minimizer: [
// 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`)
`...`,
new CssMinimizerPlugin(),
],
},

文件hash

开启静态资源的客户端缓存后,为了能及时更新资源,资源文件就需要带上hash,文档

绝大多数插件都支持使用filename配置输出的文件名

三种hash:

1
2
3
4
5
6
// [hash]项目级hash,项目中一个模块有变化,该hash就变化
filename: '[name]_[hash].js',
// [chunkhash]chunk级hash,同一路的打包相同hash
filename: '[name]_[chunkhash].js',
// [contenthash]文件级hash,根据输出文件内容的hash
filename: '[name]_[contenthash].js',

指定hash长度

1
filename: '[name]_[contenthash:8].js',

控制缓存最佳实践:8位contenthash

总结

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
"name": "webpack01",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"build": "webpack --config webpack.prod.js",
"build-dev": "webpack --config webpack.dev.js",
"prod": "webpack serve --config webpack.prod.js",
"dev": "webpack serve --config webpack.dev.js"
},
"sideEffects": [
"*.css",
"./src/pad.js"
],
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.23.3",
"@babel/preset-env": "^7.23.3",
"babel-loader": "^8.3.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.1",
"file-loader": "^6.2.0",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.3",
"marked": "^9.1.6",
"mini-css-extract-plugin": "^2.7.6",
"style-loader": "^3.3.3",
"url-loader": "^4.1.1",
"webpack": "^5.74.0",
"webpack-bundle-analyzer": "^4.9.1",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.15.1",
"webpack-merge": "^5.10.0"
}
}

配置TS环境

安装TS相关依赖:

  1. 编译TS npm install ts-loader -D
  2. TS环境 npm install typescript -D
配置webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");

module.exports = {
mode: 'none',
// 生成源映射以方便调试
devtool: 'source-map',
// watch: true,
context: path.resolve(__dirname, 'src'),
entry: {
main: {
import: ['./index.ts'],
}
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name]_[contenthash].js',
clean: true,
},
optimization: {
usedExports: true,
// minimize: true,
// 用于指定运行时(runtime)代码的拆分策略
// runtimeChunk: 'single',
// splitChunks: {
// chunks: 'all',
// minSize: 10 * 1024, // 设置最小分包大小
// // minSizeReduction: 50 * 1024, // 需要分包的bundle最小大小
// },
minimizer: [
// 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`)
`...`,
new CssMinimizerPlugin(),
],
},
//警告 webpack 的性能提示
performance: {
hints: 'warning',
//入口起点的最大体积
maxEntrypointSize: 1024 * 1024 * 10,
//生成文件的最大体积
maxAssetSize: 1024 * 1024,
//只给出 js 文件的性能提示
assetFilter: function (assetFilename) {
return /\.ts$/.test(assetFilename);
}
},
module: {
rules: [
{
// 正则匹配loader要处理的资源
test: /\.css$/i,
// 逆序执行,从右往左
use: [
// {
// loader: 'style-loader',
// },
{
loader: MiniCssExtractPlugin.loader
},
{
loader: 'css-loader',
// options: {
// modules: true // css-loader会将样式中的类名进行转换,根据模块路径和类名生成转换为一个唯一的hash值。
// },
},
],
},
{
test: /\.(png|jpg|svg|gif|ico)$/,
type: "asset",
generator: {
filename: "img/[name]_[contenthash][ext]",
},
parser: {
dataUrlCondition: {
maxSize: 50 * 1024,//小于50kb进行编码,转为base64
},
},
},
{
test: /\.js$/i,
include: [
path.resolve(__dirname, 'src')
],
exclude: [
path.resolve(__dirname, 'node_modules')
],
loader: 'babel-loader',
options: {
presets: ["@babel/preset-env"]
// presets: [
// ["@babel/preset-env", {
// modules: "commonjs", // 开启ESM转CommonJS
// }]
// ]
}
},
{
test: /\.ts$/i,
loader: "ts-loader",
include: [
path.resolve(__dirname, 'src')
],
exclude: [
path.resolve(__dirname, 'node_modules')
],
}
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, './index.html'), // 自定义模板
inject: 'body', // 插入到body
filename: 'index.html', // 输出文件名,默认index.html
title: 'webpack测试', // 自定义title,通过<%= htmlWebpackPlugin.options.title %>在html中使用
minify: true, // 压缩
chunks: ['main'],
}),
new webpack.DefinePlugin({
API_BASE_URL: JSON.stringify('http://api.github.com'),
}),
new MiniCssExtractPlugin(),
],
resolve: {
extensions: ['.ts', '.js'],
alias: {
'@': path.resolve(__dirname, './src')
}
},
// 配置开发服务器
devServer: {
// 服务器主机
host: 'localhost',
// 服务器端口
port: 8080,
// 使用HMR
hot: true,
// 启用Gzip
compress: true,
// CopyPlugin通常只在项目上线时去使用,开发时通常将静态资源目录配置给devServer,以提高性能
// 使用static配置从目录提供静态文件的选项,默认public
static: {
// 告诉服务器从哪里提供内容
directory: path.join(__dirname, 'public'),
},
proxy: {
// 代理api路径
'/api': {
// localhost:8080/api/user -> api.github.com/api/user
target: 'https://api.github.com',
// 请求路径重写 /api/user -> /user
pathRewrite: { '^/api': '' },
// 将 host 请求头修改为 target 的 URL
changeOrigin: true,
},
},
}
}
tsconfig.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
{
"compilerOptions": {
"incremental": false, // TS编译器在第一次编译之后会生成一个存储编译信息的文件,第二次编译会在第一次的基础上进行增量编译,可以提高编译的速度
// "tsBuildInfoFile": "./buildFile", // 增量编译文件的存储位置
"diagnostics": true, // 打印诊断信息
"target": "esnext", /* 指定 ECMAScript 目标版本:'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */
"module": "esnext", /* 输出的代码使用什么方式进行模块化: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"lib": [ /* 指定引用的标准库 */
"esnext",
"dom",
"dom.iterable",
], // TS需要引用的库,即声明文件,es5 默认引用dom、es5、scripthost,如需要使用es的高级版本特性,通常都需要配置,如es8的数组新特性需要引入"ES2019.Array",
"allowJs": true, // 允许编译器编译JS,JSX文件
"checkJs": true, // 允许在JS文件中报错,通常与allowJS一起使用
"outDir": "./dist", // 指定输出目录
"rootDir": "./src", // 指定输出文件目录(用于输出),用于控制输出目录结构
"declaration": true, // 生成声明文件,开启后会自动生成声明文件
"declarationDir": "./dist/typings", // 指定生成声明文件存放目录
// "emitDeclarationOnly": true, // 只生成声明文件,而不会生成js文件
"sourceMap": false, // 生成目标文件的sourceMap文件
// "inlineSourceMap": true, // 生成目标文件的inline SourceMap,inline SourceMap会包含在生成的js文件中
"declarationMap": false, // 为声明文件生成sourceMap
// "typeRoots": [], // 声明文件目录,默认时node_modules/@types
"types": [], // 加载的声明文件包
"removeComments": true, // 删除注释
"noEmit": false, // 不输出文件,即编译后不会生成任何js文件
"noEmitOnError": true, // 发送错误时不输出任何文件
"noEmitHelpers": true, // 不生成helper函数,减小体积,需要额外安装,常配合importHelpers一起使用
"importHelpers": true, // 通过tslib引入helper函数,文件必须是模块
"downlevelIteration": true, // 降级遍历器实现,如果目标源是es3/5,那么遍历器会有降级的实现
"strict": true, // 开启所有严格的类型检查
"alwaysStrict": true, // 在代码中注入'use strict'
"noImplicitAny": true, // 不允许隐式的any类型
"strictNullChecks": true, // 不允许把null、undefined赋值给其他类型的变量
"strictFunctionTypes": true, // 不允许函数参数双向协变
"strictPropertyInitialization": true, // 类的实例属性必须初始化
"strictBindCallApply": true, // 严格的bind/call/apply检查
"noImplicitThis": true, // 不允许this有隐式的any类型
"noUnusedLocals": true, // 检查只声明、未使用的局部变量(只提示不报错)
"noUnusedParameters": true, // 检查未使用的函数参数(只提示不报错)
"noFallthroughCasesInSwitch": true, // 防止switch语句贯穿(即如果没有break语句后面不会执行)
"noImplicitReturns": true, //每个分支都会有返回值
"esModuleInterop": true, // 允许export=导出,由import from 导入
"allowUmdGlobalAccess": true, // 允许在模块中全局变量的方式访问umd模块
"moduleResolution": "node", // 模块解析策略,ts默认用node的解析策略,即相对的方式导入
"baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
"paths": { // 路径映射,相对于baseUrl
// 如使用jq时不想使用默认版本,而需要手动指定版本,可进行如下配置
// "jquery": [
// "node_modules/jquery/dist/jquery.min.js"
// ],
"@/*": [
"src/*"
]
},
"rootDirs": [
"src"
], // 将多个目录放在一个虚拟目录下,用于运行时,即编译后引入文件的位置可能发生变化,这也设置可以虚拟src和out在同一个目录下,不用再去改变路径也不会报错
"listEmittedFiles": true, // 打印输出文件
"listFiles": true, // 打印编译的文件(包括引用的声明文件)
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
},
// 指定一个匹配列表(属于自动指定该路径下的所有ts相关文件)
"include": [
"src/**/*",
],
// 指定一个排除列表(include的反向操作)
// "exclude": [
// "demo.ts"
// ],
// 指定哪些文件使用该配置(属于手动一个个指定文件)
// "files": [
// "demo.ts"
// ]
}