Webpack 基础入门

前言

webpack 相关知识较为琐碎,学习起来需要静心。

本文基于 Webpack v5 文档和一些视频资料,记录一些基础配置、概念,完成 webpack 入门。

概念

本质上,webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图 (dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。

功能

webpack 本体功能是有限的:

  • 开发模式:仅能编译 JS 中的 ES module 语法
  • 生产模式:能编译 JS 中的 ES module 语法,还能压缩 JS 代码

创建简单工程

在一个空的目录下,执行命令:

1
npm init -y

创建以下结构的项目:

1
2
3
4
5
6
7
8
9
10
11
├─📁 dist
├─📁 src
│ ├─📁 css
│ ├─📁 font
│ ├─📁 img
│ ├─📄 asset.d.ts
│ └─📄 index.ts
├─📄 index.html
├─📄 package-lock.json
├─📄 package.json
└─📄 webpack.config.js

index.html 简单导入 index.ts

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>webpack-ts</title>
</head>
<body>
<div class="container"></div>
<script src="./src/index.ts"></script>
</body>
</html>

安装 webpack 和 webpack-cli:

1
npm i webpack webpack-cli -D

webpack 是核心本体,而 webpack-cli 则是命令行工具,用来和人类进行交互的;-D 表示安装到开发环境 development(在 package.jsondevDependencies 项可以找到)。

CLI(Command-Line Interface)GUI(Graphical User Interface)是两种不同的交互,前者为命令行式,后者则是可视化界面。

对于什么时候安装到开发环境,什么时候安装到生产环境,区分原则就是:运行时是否需要该依赖

对于 ts,我们还需要安装 ts:

1
npm install -D typescript

核心概念

  1. entry(入口)
    这将指定 webpack 从哪个文件开始分析依赖,进行打包
  2. output(输出)
    这将指定 webpack 打包完的文件输出到哪里去
  3. loader(加载器)
    webpack 本体只能处理 js、json,其他资源需要借助 loader 才能解析
  4. plugins(插件)
    扩展 webpack 的功能
  5. mode(模式)
    主要分为两种模式:developmentproduction

基础配置

入口起点 (entry point) 指示 webpack 应该使用哪个模块,来作为构建其内部 依赖图 (dependency graph) 的开始。这就是说,你需要给 webpack 指定一个分析入口,webpack 就会从这个入口文件中分析得到一张依赖关系图,webpack 本身不会运行你的代码,此阶段只专注于分析依赖。

入口起点,其默认值为:./src/index.js,一般通过 webpack 的配置文件进行配置,可以指定一个或多个不同的入口。对于我们目前的项目结构,入口文件就是./src/index.ts

在项目根目录下新建文件:webpack.config.js

1
2
3
4
5
const path = require('path'); // 引入node.js的path模块

module.exports = {
entry: './src/index.ts', // 入口文件,webpack从入口文件开始解析依赖关系
}

这样就做好了入口文件的标识。

Webpack 的配置文件运行在 Node.js 环境 下的,所以默认采用 CommonJS(CJS) 模块语法(require / module.exports),而不是 ECMAScript Modules(ESM) 语法(import / export)。

具体应该选哪种语法,其实取决于项目结构以及所依赖的生态环境:

1、CJS(默认 & 最常见)

  • 历史原因,目前绝大多数 Webpack 插件和 Loader 的文档与示例仍然使用 CJS。
  • 兼容性最好,几乎所有 Node.js 版本都支持。

2、ESM(现代写法)

  • 在 Node.js 12+ 支持 ES Modules 后,可以把 Webpack 配置文件写成 ESM。
  • 使用时需要在 package.json 里加 "type": "module",或者把文件命名为 webpack.config.mjs
  • 更适合想要在前后端统一用 import/export 的项目。

如果是学习 / 小项目,用 CJS 更省心。

如果整个项目已经全局采用 ESM(比如 Vue 3 + Vite 生态),可以考虑 Webpack 配置也写 ESM。

output 属性告诉 webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。主要输出文件的默认值是 ./dist/main.js,其他生成文件默认放置在 ./dist 文件夹中。

你可以通过在配置中指定一个 output 字段,来配置这些处理过程:

webpack.config.js

1
2
3
4
5
6
7
8
9
10
const path = require('path'); // 引入node.js的path模块

module.exports = {
entry: './src/index.ts', // 入口文件,webpack从入口文件开始解析依赖关系
output: {
filename: 'main.js', // 输出文件名
path: path.resolve(__dirname, 'dist'), // __dirname是node.js的全局变量,代表当前执行脚本所在的目录
clean: true // 每次打包前清空dist目录
},
}

再就是加载器和插件了,当前我们的项目比较简单干净,所以目前并不需要,可以留空先看看 webpack 运行效果。

你还可以根据 mode 字段给 webpack 指定运行模式,有 development 和 production 两种,默认是 production。所以,最后一个简单的 webpack 配置文件如下:

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const path = require('path'); // 引入node.js的path模块

module.exports = {
entry: './src/index.ts', // 入口文件,webpack从入口文件开始解析依赖关系
output: {
filename: 'main.js', // 输出文件名
path: path.resolve(__dirname, 'dist'), // __dirname是node.js的全局变量,代表当前执行脚本所在的目录
clean: true // 每次打包前清空dist目录
},
// loader配置
module: {
rules: []
},
// 插件配置
plugins: [],
// mode表示webpack的运行模式,有development和production两种,默认是production
mode: "development"
}

开发模式与生产模式概念

Webpack 通过 mode 选项来区分运行环境,常见的有 development(开发模式) 和 production(生产模式)。

  1. 开发模式(mode: "development"
  • 优化点:速度优先,帮助开发调试。
  • 特点:
    • 不压缩、不混淆代码。构建更快。
    • 自动启用 eval-cheap-module-source-map,方便定位源码。
    • 保留有用的警告和错误信息。
    • 通常配合 webpack-dev-server 实现热更新。
  1. 生产模式(mode: "production"
  • 优化点:体积优先,帮助上线运行更快。
  • 特点:
    • 自动压缩(TerserPlugin)并混淆 JS。
    • Tree Shaking(删除未使用代码)。
    • 自动分离 runtime,优化缓存。
    • scope hoisting(作用域提升),减少函数包装。
    • 输出的 bundle 尽可能小、加载更快。

本文前半段的相关配置基本关注于开发模式。

在这个模式下,我们先前提到 webpack 本身并不能处理除了 js 和 json 以外的文件,所以我们需要配置一系列东西帮助 webpack 完成工作。

处理 TS

现在基本项目都需要用到 TS,那么优先处理 TS 是必要的,否则无法输出正确的 js。

写一段简单的 hello, world 式的 ts 代码如下:src/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义一个类型
type User = {
id: number;
name: string;
};

// 定义一个函数
const greet = (user: User): string => `Hello, ${user.name}!`;

// 使用函数
const me: User = { id: 1, name: "Webpack + TS" };
console.log(greet(me));

// 操作 DOM
const el = document.createElement("div");
el.textContent = greet(me);
el.className = "greeting";
document.body.appendChild(el);

因为我们之前已经在 index.html 中引入了 index.ts,所以我们可以直接运行 webpack 看看没有经过任何配置的 webpack 将输出什么样的 js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 执行命令
npx webpack

# 这将输出:
npx webpack
asset main.js 1.51 KiB [emitted] (name: main)
./src/index.ts 424 bytes [built] [code generated] [1 error]

ERROR in ./src/index.ts 2:5
Module parse failed: Unexpected token (2:5)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
| // 定义一个类型
> type User = {
| id: number;
| name: string;

webpack 5.101.3 compiled with 1 error in 56 ms

可以看到,webpack 无法处理该文件,并贴心提示你:You may need an appropriate loader to handle this file type => 你或许需要一个合适的loader来处理该文件类型

ts-loader

于是我们来安装 ts-loader 帮 webpack 处理 ts 文件:

1
npm install ts-loader --save-dev

有的同学可能会问,我们之前不是用 npm 已经安装了 ts 吗?那直接配置 tsconfig.json 不就好了?

是的,但这不是说两者只需要一个,而是他们完全可以搭配干活,完成更精细的控制。

对于 ts 编译器,它只会做 语法检查 + 转换成 JS,不会做打包(不处理 import 的依赖、不打包 CSS / 图片);而 Webpack 是个打包工具,他本身不懂 TypeScript。有了 ts-loader 后,不用单独跑 tsc,Webpack 在打包时就会自动把 TS 转成 JS。

这就是说,整个工作流程如下:

当我们安装了 ts-loader 之后,并且配置了 tsconfig.json 文件,再执行 npx webpack 时:

  • ts-loader → 发现要处理 .ts 文件
  • 它调用 TypeScript 编译器 → 读取 tsconfig.json
  • 按配置(比如转成 ES5)输出 JS → 再交给 Webpack 打包

于是,我们编写 webpack.config.js 的 module 如下:

1
2
3
4
5
6
7
8
9
10
// loader配置
module: {
rules: [
{
test: /\.tsx?$/, // 匹配文件后缀名为.ts或.tsx的文件
use: 'ts-loader', // 当use中有多个loader时,webpack会从右到左依次执行
exclude: /node_modules/ // 排除node_modules目录,提升编译速度
}
]
},

并且在根目录下创建 tsconfig.json

1
2
3
4
5
6
7
8
9
10
{
"compilerOptions": {
"target": "es5",
"module": "ESNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["DOM", "ES2022"]
},
"include": ["src/**/*"], // 编译范围,指定编译的文件目录
}

我们来逐项说明:

  1. target:指定编译之后的 js 语法版本,取决于项目需要兼容多旧的浏览器,比如 es5 最终代码能在老旧的 IE11 上运行,现在一般是 es2016+,支持 async/await 等常用特性。

  2. module:告诉编译器生成的 JS 模块化方案,这里是最新的 ES Modules 语法(import/export)。

    详细解释

    常见取值

    1. 现代 ES 模块
    • ESNext
      • 始终使用 TS 支持的最新 ES Module 语法。
      • 输出 import / export
      • 场景:现代打包工具(Webpack、Vite、Rollup)、Node.js 14+(支持 ES Modules)。
    • ES6 / ES2015
      • "ESNext" 类似,但只保证符合 ES2015 版本。
      • 场景:需要固定在 ES6 标准,不跟随 TS 的更新。
    1. CommonJS 系列
    • CommonJS
      • 输出 require() / module.exports
      • 场景:Node.js 里最常见的模块系统;老旧工具链。
      • Webpack 默认配置文件就是 CJS。
    • AMD
      • 输出 AMD 风格模块,依赖 require.js
      • 场景:老项目,前端按需加载。
    • UMD
      • 通用模块定义,兼容 CommonJS + AMD + 全局变量。
      • 场景:要发布一个库给不同环境使用时。
    • System
      • 输出 SystemJS 模块。
      • 场景:用 SystemJS 动态加载模块的老项目。
    1. Node.js 特定
    • "Node16" / "NodeNext"
      • TypeScript 4.7+ 引入的新选项。
      • 模拟 Node.js 的真实模块解析方式(支持 package.json 里的 "type": "module")。
      • 场景:写 Node.js 服务端项目,特别是用 ESM 时推荐
  3. esModuleInterop:允许用 import 语法导入 CommonJS 模块。
    比如 import express from "express";(否则你要写 import * as express from "express";)。

  4. forceConsistentCasingInFileNames:在导入路径时,强制区分大小写,防止在大小写不敏感的文件系统(如 Windows)上编译通过,但在 Linux 上运行失败。

  5. lib:指定编译时要包含的库声明文件,影响类型检查和代码补全。

    • "DOM" → 提供 documentwindow 等类型。
    • "ES2022" → 提供最新 ECMAScript 2022 的内置对象类型(比如 Object.hasOwn

这里的 lib 中我写 ES2022 主要是为了给后面的高版本语法转换预留的。

现在写好了配置文件也配置好了 ts-loader,可以运行试试了:

1
2
# 执行命令
npx webpack

不出意外,肯定是编译成功的。但是我们也无法通过实际效果亲眼看到,这是因为我们的 index.html 是引入的 srcindex.ts,我们需要给它改成 dist 下的 main.js

1
2
3
4
<div class="container"></div>
- <script src="./src/index.ts"></script>
+ <script src="./dist/main.js"></script>
</body>

此时页面就能正常看到效果了:

image-20250920021730265

js 兼容性问题

但是,这就完了吗?其实,上述操作编译过后的 js 文件是有可能存在一定的兼容性问题的。

对于老旧浏览器,例如 IE11:TypeScript 只会降级 TS 语法(比如 interface → 删除,class → 函数),但不会 polyfill 新的 JS 特性。比如:PromiseArray.includes 还需要 Babel + core-js 来处理。

Babel

Babel 是一个 JavaScript 编译器。对于 webpack 来说,babel 是它的一个 loader。

核心功能

  1. 语法转换
    • let/constvar
    • 箭头函数 → 普通函数
    • class → 构造函数 + 原型
  2. Polyfill(垫片)
    • 新 API(PromiseArray.includesObject.assign 等)自动引入 core-js 来兼容老环境。
  3. 插件和预设
    • 插件(plugins):处理具体语法,比如 @babel/plugin-transform-arrow-functions
    • 预设(presets):插件集合,比如 @babel/preset-env@babel/preset-react@babel/preset-typescript

常见应用场景

  • 前端:保证新语法在旧浏览器可运行。
  • React/Vue 项目:转换 JSX / TS。
  • Node 项目:用最新语法但运行在老版本 Node。
  • 库开发:发布前打包成多种模块格式(ESM / CJS)。

应用 Babel

我们首先来重写一下 src/index.ts 方便展示转换效果:

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
// 定义类型
type Task = {
id: number;
name: string;
done: boolean;
};

// 模拟异步请求
function fetchTasks(): Promise<Task[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, name: "Learn TypeScript", done: true },
{ id: 2, name: "Play with Webpack", done: false },
]);
}, 500);
});
}

// 使用 ES6+ 的 includes
const tags: string[] = ["ts", "webpack", "babel"];
console.log("是否包含 babel? ->", tags.includes("babel"));

// DOM 操作
fetchTasks().then((tasks) => {
const list = document.createElement("ul");
for (const t of tasks) {
const item = document.createElement("li");
item.textContent = `${t.name} - ${t.done ? "yes" : "no"}`;
list.appendChild(item);
}
document.querySelector(".container").appendChild(list);
});

接着我们安装 Babel

1
npm install -D babel-loader @babel/core @babel/preset-env

安装完成之后,在 webpack.config.js 中进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// loader配置
module: {
rules: [
{
test: /\.tsx?$/, // 匹配文件后缀名为.ts或.tsx的文件
- use: 'ts-loader', // 使用ts-loader进行编译,当use中有多个loader时,webpack会从右到左依次执行
+ use: [
+ {
+ loader: "babel-loader",
+ },
+ {
+ loader: "ts-loader", // 使用ts-loader进行编译
+ options: {
+ transpileOnly: true // 跳过类型检查,提升编译速度
+ }
+ }
+ ], // 使用ts-loader进行编译,当use数组中有多个loader时,webpack会从右到左依次执行
exclude: /node_modules/ // 排除node_modules目录,提升编译速度
}
]
},

因为 babel 处理的是 js,所以它需要运行在 ts-loader 之后,等待 ts 被转换为 js。

这里的 babel 需要我们提供一个配置文件(也可以直接写在 webpack.config.js 中):babel.config.js

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
presets: [
[
"@babel/preset-env", // 使用预设
{
targets: "> 0.25%, not dead, ie 11",
useBuiltIns: "usage",
corejs: 3,
}, // 这里是options而不是预设名
],
],
};

这里的 presets 如果存在多个预设,处理顺序也是从右到左。解释如下:

1、@babel/preset-env

Babel 的核心预设,用来 根据目标环境决定要转换哪些语法和 API

它包含了大量插件,例如:

  • 箭头函数转换插件
  • 模板字符串转换插件
  • ES6+ 新方法 polyfill 插件

2、targets

指定代码需要兼容哪些环境 / 浏览器。

  • > 0.25% : 使用率大于 0.25% 的浏览器
  • not dead :不包含停止更新的浏览器
  • ie 11 :强制兼容 IE11

3、useBuiltIns: "usage"

告诉 Babel 按需注入 polyfill,而不是全量引入。例如 const arr = [1,2].includes(2),Babel 会检测到你用到了 Array.prototype.includes,自动引入对应 polyfill,而不是把所有 ES6/ES2015 API 都打包进去。

4、corejs: 3

  • 指定 polyfill 的版本
  • Babel 会用 core-js@3 提供缺失的 API

为什么 ts-loader 需要跳过类型检查

这里为什么 ts-loader 需要跳过类型检查(transpileOnly: true)呢?

这是因为编译过程中 包含类型检查,构建速度较慢(因为 Webpack 编译时要等类型检查完成)。如果项目较小,是可以的;如果项目是中大型项目,完全可以新开独立进程交给 fork-ts-checker-webpack-plugin 完成。

fork-ts-checker-webpack-plugin

ts-loader(transpileOnly: true) + fork-ts-checker-webpack-plugin + babel-loader 的工作流程:

  • ts-loader:仅转译 TS 到 JS(跳过类型检查)
  • babel-loader:做语法转换(比如转成 ES5,polyfill 等)
  • fork-ts-checker-webpack-plugin:在单独的进程里做 类型检查

需要注意的是,fork-ts-checker-webpack-pluginNodeWebpackTS 三个东西的版本都有要求(更多要求详见其 README 文档):

1
This plugin requires Node.js >=14.0.0+, Webpack ^5.11.0, TypeScript ^3.6.0

目前我们的项目中,可以在 package.json 中找到 webpack 和 TS:

1
2
3
4
5
6
7
8
9
"devDependencies": {
"@babel/core": "^7.28.4",
"@babel/preset-env": "^7.28.3",
"babel-loader": "^10.0.0",
"ts-loader": "^9.5.4",
"typescript": "^5.9.2",
"webpack": "^5.101.3",
"webpack-cli": "^6.0.1"
}

它们都是高于要求的,所以可以使用 fork-ts-checker-webpack-plugin:

1
npm install --save-dev fork-ts-checker-webpack-plugin

配置 webpack.config.js 如下:

1
2
3
4
5
+ // 引入插件
+ const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');

- plugins: [],
+ plugins: [new ForkTsCheckerWebpackPlugin()],

此时再进行编译:

1
npx webpack

这将报错。这是因为我们没有安装 core.js 无法进行 polyfill。这也是 webpack 文档的一个小问题了,有时候不会指明全部需要的依赖项。

执行安装命令,安装 core.js 到生产环境

1
npm install core-js

为什么是生产依赖呢?前面说过,安装到生产还是开发环境,主要看它需不需要被运行。这里我们为了追求编译打包后的代码体积小,所以采用的是按需使用 polyfill(useBuiltIns: "usage" 而不是 useBuiltIns: "entry"),Babel 会在编译过后的代码文件中依然引用 core.js

如果是全量引入,那就要在入口文件中手动引入:

1
2
3
// 在入口文件手动引入
import "core-js/stable";
import "regenerator-runtime/runtime";

全量引入情况下 Babel 不再插入单独模块,而是依赖入口文件的全量导入,也就可以不在生产环境使用 core.js 了。

此时再次执行编译命令 npx webpack 就可以到 dist/index.js 查找 index.ts 是否在转换后使用 polyfill 了:

1
2
3
4
5
6
7
8
9
/***/ "./src/index.ts":
/*!**********************!*\
!*** ./src/index.ts ***!
\**********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("{__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var core_js_modules_es_array_concat_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! core-js/modules/es.array.concat.js */ \"./node_modules/core-js/modules/es.array.concat.js\");\n/* harmony import */ var core_js_modules_es_array_concat_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_array_concat_js__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var core_js_modules_es_array_includes_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! core-js/modules/es.array.includes.js */ \"./node_modules/core-js/modules/es.array.includes.js\");\n/* harmony import */ var core_js_modules_es_array_includes_js__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_array_includes_js__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var core_js_modules_es_function_name_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! core-js/modules/es.function.name.js */ \"./node_modules/core-js/modules/es.function.name.js\");\n/* harmony import */ var core_js_modules_es_function_name_js__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_function_name_js__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var core_js_modules_es_object_to_string_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! core-js/modules/es.object.to-string.js */ \"./node_modules/core-js/modules/es.object.to-string.js\");\n/* harmony import */ var core_js_modules_es_object_to_string_js__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_object_to_string_js__WEBPACK_IMPORTED_MODULE_3__);\n/* harmony import */ var core_js_modules_es_promise_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! core-js/modules/es.promise.js */ \"./node_modules/core-js/modules/es.promise.js\");\n/* harmony import */ var core_js_modules_es_promise_js__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_promise_js__WEBPACK_IMPORTED_MODULE_4__);\n\n\n\n\n\n// 模拟异步请求\nfunction fetchTasks() {\n return new Promise(function (resolve) {\n setTimeout(function () {\n resolve([{\n id: 1,\n name: \"Learn TypeScript\",\n done: true\n }, {\n id: 2,\n name: \"Play with Webpack\",\n done: false\n }]);\n }, 500);\n });\n}\n// 使用 ES6+ 的 includes\nvar tags = [\"ts\", \"webpack\", \"babel\"];\nconsole.log(\"是否包含 babel? ->\", tags.includes(\"babel\"));\n// DOM 操作\nfetchTasks().then(function (tasks) {\n var list = document.createElement(\"ul\");\n for (var _i = 0, tasks_1 = tasks; _i < tasks_1.length; _i++) {\n var t = tasks_1[_i];\n var item = document.createElement(\"li\");\n item.textContent = \"\".concat(t.name, \" - \").concat(t.done ? \"yes\" : \"no\");\n list.appendChild(item);\n }\n document.body.appendChild(list);\n});\n\n//# sourceURL=webpack://webpack-code/./src/index.ts?\n}");

/***/ })

能看到 core.js 相关的导入语句:

1
2
3
4
5
/* harmony import */ var core_js_modules_es_array_concat_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! core-js/modules/es.array.concat.js */ "./node_modules/core-js/modules/es.array.concat.js");
/* harmony import */ var core_js_modules_es_array_includes_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! core-js/modules/es.array.includes.js */ "./node_modules/core-js/modules/es.array.includes.js");
/* harmony import */ var core_js_modules_es_function_name_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! core-js/modules/es.function.name.js */ "./node_modules/core-js/modules/es.function.name.js");
/* harmony import */ var core_js_modules_es_object_to_string_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! core-js/modules/es.object.to-string.js */ "./node_modules/core-js/modules/es.object.to-string.js");
/* harmony import */ var core_js_modules_es_promise_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! core-js/modules/es.promise.js */ "./node_modules/core-js/modules/es.promise.js");

可以说明 polyfill 已经发生了。

处理 CSS

前面提到,webpack 本身不能处理除了 JS 和 json 以外的资源,所以 css 也要借助 loader 来处理,即 css-loader

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

注意,文档中可能没有提到安装 style-loader,自己在安装时需要一起安装。

接着我们在 src/css 中创建 index.css

1
2
3
.container {
background-color: rebeccapurple;
}

然后在入口文件 index.ts 中进行引用:

1
import "./css/index.css"

最后,在 webpack 配置文件中配置 css-loader:

1
2
3
4
5
6
7
8
9
10
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
};

此时就可以编译试试了。

处理 sass

对于 sass、less 和 stylus,如果你会了其中一种,那么其他的大差不差,本文在此介绍 sass 的处理方法。

安装 sass-loader

1
npm i sass sass-loader --save-dev

配置 webpack:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: [
// 将 JS 字符串生成为 style 节点
'style-loader',
// 将 CSS 转化成 CommonJS 模块
'css-loader',
// 将 Sass 编译成 CSS
'sass-loader',
],
},
],
},
};

那么,我们在 css 目录下删除 index.css(别忘记移除入口文件中对它的引入),然后创建一个 index.sassindex.scss 文件来检验:

index.sass

1
2
3
.container ul li:first-child
background: blue
color: #ff8a6a

index.scss

1
2
3
.container ul li:not(:first-child) {
background: lightpink;
}

当然,这两个文件也需要到入口文件 index.ts 中进行引入。之后执行编译命令之后应该就能看到效果了。

处理图片资源

在 webpack4 时,处理图片资源通过的是 file-loader(将资源原封不动地输出)和 url-loader(将图片转换为 Base64 格式,可指定大小阈值)。现在 webpack5 已经将这两个 loader 功能内置了,我们只需要简单配置一下即可。

要处理图片,首先要有图片资源,我将下面这张《浪客剑心:追忆篇》的海报和《风骚律师》的海报放入到 src/img 中(如果你没有图片资源,可以下载下面这两张图片测试):

final 风骚律师

按照文档进行配置 webpack(可以通过搜索 asset 找到,文档详见资源模块):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
module: {
rules: [
{
test: /\.(png|jpe?g|gif|svg|webp)$/i,
type: 'asset', // 打包成静态资源
parser: {
dataUrlCondition: {
maxSize: 40 * 1024 // 图片大小小于40kb的会被转为base64格式
}
}
}
]
}
}

配置好了,但是我们还没指定图片放在哪,于是在 index.ts 中给页面增加两个 div 用来放图:

1
2
3
4
5
6
const img1 = document.createElement("div")
img1.classList.add("img1")
const img2 = document.createElement("div")
img2.classList.add("img2")
document.querySelector(".container").appendChild(img1)
document.querySelector(".container").appendChild(img2)

接着在 index.sassindex.scss 中写样式:

index.sass

1
2
3
4
5
6
7
8
9
10
.container
display: flex
flex-direction: column
.img1
height: 320px
background-size: contain !important
background: url("../img/追忆.webp") no-repeat
ul li:first-child
background: blue
color: #ff8a6a

index.scss

1
2
3
4
5
6
7
8
9
10
.container {
.img2 {
height: 320px;
background-size: contain !important;
background: url("../img/风骚律师.webp") no-repeat;
}
ul li:not(:first-child) {
background: lightpink;
}
}

执行编译命令之后,可以看到追忆.webp 被输出为原数据格式,而风骚律师.webp 则被输出为 Base64 格式。

分类输出资源

观察打包目录 dist:

image-20250920045248407

可以看到输出结果是没有分类的,我们最好对各类资源进行分类,这样方便管理。修改 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
module.exports = {
output: {
- filename: 'main.js', // 输出文件名
+ filename: 'static/js/main.js', // 输出文件名
path: path.resolve(__dirname, 'dist'), // __dirname是node.js的全局变量,代表当前执行脚本所在的目录
clean: true // 每次打包前清空dist目录
},
// loader配置
module: {
rules: [
{
test: /\.css$/, // 匹配文件后缀名为.css的文件
use: ['style-loader', 'css-loader'],
+ generator: {
+ filename: 'static/css/[name].[contenthash:8].css' // 输出文件名
+ }
},
{
test: /\.s[ac]ss$/i, // 匹配文件后缀名为.scss或.sass的文件
use: ['style-loader', 'css-loader','sass-loader'],
+ generator: {
+ filename: 'static/css/[name].[contenthash:8].css' // 输出文件名
+ }
},
{
test: /\.(png|jpe?g|gif|svg|webp)$/i, // 匹配文件后缀名为.png、.jpg、.jpeg、.gif、.svg的文件
type: 'asset', // 打包成静态资源
parser: {
dataUrlCondition: {
maxSize: 40 * 1024 // 图片大小小于40kb的会被转为base64格式
}
},
+ generator: {
+ filename: 'static/img/[name].[contenthash:8][ext]' // 输出文件名
+ }
}
]
},
}

[name].[contenthash:8][ext] 中:

  • [name]:使用文件原名
  • [contenthash:8]:使用文件指纹,取生成的 hash 值的前 8 位
  • [ext]:使用原扩展名
contenthash 和 hash

需要说明的是,contenthashhash 是两个不同的占位符:

  1. [hash]
  • 整个 项目构建的 hash 值(一次构建一个值)
  • 缺点:只要有一个文件改动,所有文件的 [hash] 都会变 (缓存不友好

  1. [chunkhash]
  • 根据 chunk 内容 生成的 hash
  • 不同 entry 的 chunk 值不同
  • 优点:只改动某个入口文件时,其他 chunk 的文件名不变(更适合持久缓存)

  1. [contenthash]
  • 根据 文件内容 生成 hash
  • 最精细化的方式:只有内容变化时,才会生成新的 hash
  • 适合生产环境:比如 CSS 抽离文件(MiniCssExtractPlugin)常用 [contenthash]

处理字体图标资源

要处理字体图标资源,首先得要有这些资源。推荐从阿里巴巴矢量图标库中下载这些资源:https://www.iconfont.cn/

随便点开一个图标库,将一些图标加入到项目(需要登录):

image-20250920195241433

image-20250920195337094

如果此前没有项目就需要创建项目,如果已经有项目直接选择一个项目加入到项目即可。加入之后将会跳转到项目详情页,我们将这些图标资源下载到本地(仅作演示,以后图标资源也可能是通过服务器得到的):

image-20250920195551617

下载到本地的图标资源是一个压缩包的形式,我们将其剪切到 src/font 目录下,并将其解压:

image-20250920195834494

解压后的目录如下:

image-20250920195853412

这之中的 demo_index.html 也可以指导你如何使用图标资源:

image-20250920200019039

这三种方式主要是兼容性的区别:

  • Unicode 在 IE6 以上可以使用
  • Font class 在 IE8 以上可以用
  • Symbol 在 IE9 以上可以用

我们一般使用 Font class 的方式进行使用:

image-20250920200226113

依照指导文档,我们将 iconfont.css 剪切到 src/css 目录中。

需要注意的是,iconfont.css 中引入的路径需要进行修改:

image-20250920200520028

我们将字体文件从 font 的深层目录移动出来,剪切到 src/font 目录下:

image-20250920200720663

然后将 iconfont.css 中的路径修改如下:

image-20250920201139842

别忘了,我们还得导入才能被 webpack 打包。在 index.ts 中引入 iconfont.css

image-20250920201333398

此时就可以执行打包命令看看效果了:

image-20250920201528709

不过默认打包的结构肯定不是我们期望的,我们可以像配置图片资源一样配置它们(webpack.config.js):

1
2
3
4
5
6
7
{
test: /\.(ttf|woff2?)$/i,
type: 'asset/resource', // 原味输出静态资源
generator: {
filename: 'static/media/[contenthash:8][ext]' // 输出文件名
}
}

asset/resource 表示将资源原封不动的输出,这里为了输出字体文件名短点,所以 filename 不保留原名了。

为了在页面中能显示这三个图标,我们还需要按照 demo_index.html 的指导加入 span 标签(index.html):

1
2
3
4
5
<span class="iconfont icon-a-028-medicalapp"></span>
<span class="iconfont icon-a-004-bones"></span>
<span class="iconfont icon-webpack"></span>
<!--别忘记更新main.js的路径-->
<script src="./dist/static/js/main.js"></script>

icon 名和图标名一致:

image-20250920202957685

这样执行打包命令就可以在 index.html 看到效果了。

处理其他资源

其实上述资源已经是我们经常会使用到的资源了,处理其他资源,如视频资源等,我们也只需要原封不动将其输出就可以了,所以就扩大一下 test 的匹配范围就行:

1
2
3
4
5
6
7
{
test: /\.(ttf|woff2?|mp4|avi|mkv)$/i, // 扩大匹配范围
type: 'asset/resource', // 原味输出静态资源
generator: {
filename: 'static/media/[contenthash:8][ext][query]' // 输出文件名
}
}

处理 Html 资源

安装 html-webpack-plugin

1
npm i html-webpack-plugin -D

接着在 webpack 配置文件中进行引入并配置:

1
2
3
4
5
6
7
8
9
10
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 引入

module.exports = {
entry: 'index.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'index_bundle.js',
},
plugins: [new HtmlWebpackPlugin()], // 启用插件
};

并且在 index.html 中取消手动引入编译后的 main.js

1
2
3
4
5
6
<div class="container"></div>
<span class="iconfont icon-a-028-medicalapp"></span>
<span class="iconfont icon-a-004-bones"></span>
<span class="iconfont icon-webpack"></span>
<!--不再需要手动引入-->
<!--<script src="./dist/static/js/main.js"></script>-->

此时执行打包命令,你会发现打包输出的 dist/index.html 中:

image-20250920205559943

所以我们还需要给这个 html 插件配置 template 字段:

1
2
3
4
5
6
7
8
// 插件配置
plugins: [
new HtmlWebpackPlugin({
// template参数指定生成的html以哪个文件为参考生成结构
template: path.resolve(__dirname, './index.html')
}),
new ForkTsCheckerWebpackPlugin(),
],

template 参数指定生成的 html 以哪个文件为参考生成一致的元素结构

搭建开发服务器

每次写完代码都需要手动执行打包命令才能同步代码,太麻烦了,我们安装 webpack-dev-server 来自动化执行:

1
npm install --save-dev webpack-dev-server

然后在 webpack 的配置文件中进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 插件配置
plugins: [
new HtmlWebpackPlugin({
// template参数指定生成的html以哪个文件为参考生成结构
template: path.resolve(__dirname, './index.html')
}),
new ForkTsCheckerWebpackPlugin(),
],
devServer: {
host: 'localhost', // 主机名
port: 3000, // 端口号
static: "./dist", // 静态文件目录
open: true, // 自动打开浏览器
},

此时,打包命令需要执行的是:

1
npx webpack serve

需要注意的是,如果使用 serve 开发服务器,那么实际是不会输出文件的,编译打包结果存在内存之中。

ESLint

ESLint,可组装的 JavaScript 和 JSX 检查工具。主要被用来检测 js 和 jsx 语法,可以配置各种功能。

安装 ESLint

在 webpack4 中,ESLint 是一个 loader;而在 webpack5 中,ESLint 是一个插件。

执行命令安装 EslintWebpackPlugin 和 ESLint 到开发环境:

1
npm install eslint-webpack-plugin eslint --save-dev

在 webpack 配置文件中进行引入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const ESLintPlugin = require('eslint-webpack-plugin');

module.exports = {
// ...
plugins: [
new ESLintPlugin({
// 指定要约束的文件位置
context: path.resolve(__dirname, './src'),
// 指定要检查的文件后缀名
extensions: ['ts', 'tsx', 'js', 'jsx'],
// 插件没完全兼容eslint >= 8的版本带来的 Flat Config,需要手动指定路径
eslintPath: require.resolve('eslint/use-at-your-own-risk'),
}),
],
// ...
};

ESLint 配置

和 webpack、babel 一样,eslint 也是依照配置文件工作。eslint 配置文件有很多种写法:

  • .eslintrc.*.eslintrc.eslintrc.js.eslintrc.json
  • eslint.config.js
  • 写在 package.json 中:eslintConfig 配置项(适合简单项目)

ESLint 会自动查找读取它们,配置文件只需要存在一个

其中,eslint.config.js 写法是 ESLint 9 推出的新标准,叫做 Flat Config(平面配置),逐步替代 .eslintrc.*。它必须是一个 JavaScript/ESM 文件

如果你的项目用的工具链都比较新(比如 webpack5、Vite、TS 最新版),建议直接用 eslint.config.js
如果你需要兼容老工具,暂时用 .eslintrc.js 更省心。

我们这里使用 eslint.config.js 写法。需要注意的是,我们的项目环境默认在 Node 环境,而 Node 环境下,.js 文件默认是 CommonJS (CJS) 模块,我们需要将 ESLint 配置文件后缀改为 mjs,Node 就会自动按 ESM 方式解析。

你也可以在 package.json 中声明 type: "module"
但是,一旦加了 "type": "module",项目里的所有 .js 文件都变成 ESM,会影响 Webpack 等其他配置。
所以,除非项目的相关生态能统一所有配置文件全为一种语法(要么全是 CJS 要么全是 ESM),否则还是只改个别文件的后缀让 Node 自己识别为好。

于是,我们在根目录下创建 eslint.config.mjs。由于 ESLint 的配置项十分的多,所以我们一般不会自己手写全部规则,而是使用官方推荐的规则,然后在官方的规则下面写一些自定义的规则覆盖冲突规则即可。例如:

  • @eslint/js:官方维护的 JavaScript 推荐规则集,代替了过去 .eslintrc 里常用的 "eslint:recommended"
  • typescript-eslint:官方的 TypeScript 插件 + 解析器(集成了 @typescript-eslint/parser@typescript-eslint/eslint-plugin),Flat Config 推荐用法。
  • globals:提供常见的全局变量集合(比如 window, document, process 等),让 ESLint 知道这些名字是合法的,不会误报 no-undef

上面三个基本够用了,执行安装命令:

1
npm install --save-dev @eslint/js typescript-eslint globals

接着我们配置完整的 config.eslint.mjs

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
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";

/** @type {import('eslint').Linter.Config[]} */
export default [
// 全局基础规则(JS和TS 推荐规则)
pluginJs.configs.recommended,
...tseslint.configs.recommended,

// src 下的前端代码(浏览器环境)
{
files: ["src/**/*.{js,mjs,cjs,ts,tsx}"],
languageOptions: {
globals: globals.browser,
},
},

// config 下的 webpack 配置(Node 环境,允许 require)
{
files: ["config/**/*.js"],
languageOptions: {
globals: globals.node,
},
rules: {
"@typescript-eslint/no-require-imports": "off", // 允许 require
},
},

// 忽略不需要检查的文件
{
ignores: [
"**/*.d.ts",
"**/node_modules/**",
"src/font",
"**/dist/**",
"**/*.config.*",
],
},
];

如果是旧的 ESLint 配置,应当是:.eslintrc.js+.eslintignore
其中 eslintrc 中的 rc 指的是 run commands 或者 runtime configuration,前者是历史渊源,后者是通俗解释

生产模式

在生产模式下,我们主要关注:代码运行速度、代码打包速度。

在根目录下创建 config 目录,将根目录下的 webpack.config.js 移动到 config 目录下,重命名为 webpack.dev.js,并复制一份重命名为 webpack.prod.js

dev 环境

我们对 webapck.dev.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
const path = require('path'); // 引入node.js的path模块
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');

module.exports = {
entry: './src/index.ts', // 入口文件,webpack从入口文件开始解析依赖关系
output: {
filename: 'static/js/main.js', // 输出文件名
- path: path.resolve(__dirname, 'dist'), // __dirname是node.js的全局变量,代表当前执行脚本所在的目录
+ path: undefined, // 开发模式无输出
- clean: true // 每次打包前清空dist目录
},
// loader配置
module: {
rules: [
// ......
]
// 插件配置
plugins: [
new ESLintPlugin({
// 指定要约束的文件位置
- context: path.resolve(__dirname, './src'),
+ context: path.resolve(__dirname, '../src')
extensions: ['ts', 'tsx', 'js', 'jsx'], // 指定要检查的文件后缀名
// 插件没完全兼容eslint >= 8的版本带来的 Flat Config,需要手动指定路径
eslintPath: require.resolve('eslint/use-at-your-own-risk'),
}),
new HtmlWebpackPlugin({
// template参数指定生成的html以哪个文件为参考生成结构
- template: path.resolve(__dirname, './index.html')
+ template: path.resolve(__dirname, '../index.html')
}),
new ForkTsCheckerWebpackPlugin(),
],
devServer: {
host: 'localhost', // 主机名
port: 3000, // 端口号
static: "./dist", // 静态文件目录
open: true, // 自动打开浏览器
},
+ devtool: "inline-source-map", // 启用源码地图
// mode表示webpack的运行模式,有development和production两种,默认是production
mode: "development"
}

在开发模式下,编译打包发生在内存中,没有输出结果不会写入到磁盘变成文件,所以 output 的 path 我写成 undefined(实际开发模式会忽略此项配置);因为我们改动了 webpack 配置文件所在的路径,需要给使用绝对路径的配置项纠正路径,所以插件部分的 path.resolve 的路径都往上了一级;入口文件为什么不改呢?首先入口文件并非绝对路径,其次因为 webpack 是运行在 Node 环境中的,Node 的运行环境又是相对于执行命令时的路径(也就是根目录),所以入口文件不需要改动也能找到正确的文件。

eslintPath: require.resolve("eslint/use-at-your-own-risk")
它直接走 Node 的模块查找机制,最终解析到 node_modules/eslint 中的绝对路径,和配置文件在哪个目录没关系。

prod 环境

生产环境也需要修改配置文件:

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
const path = require('path'); // 引入node.js的path模块
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');

module.exports = {
entry: './src/index.ts', // 入口文件,webpack从入口文件开始解析依赖关系
output: {
filename: 'static/js/main.js', // 输出文件名
- path: path.resolve(__dirname, 'dist'), // __dirname是node.js的全局变量,代表当前执行脚本所在的目录
+ path: path.resolve(__dirname, '../dist'), // __dirname是node.js的全局变量,代表当前执行脚本所在的目录
clean: true // 每次打包前清空dist目录
},
// loader配置
module: {
rules: [
// ......
]
},
// 插件配置
plugins: [
new ESLintPlugin({
// 指定要约束的文件位置
- context: path.resolve(__dirname, './src'),
+ context: path.resolve(__dirname, '../src'),
extensions: ['ts', 'tsx', 'js', 'jsx'], // 指定要检查的文件后缀名
// 插件没完全兼容eslint >= 8的版本带来的 Flat Config,需要手动指定路径
eslintPath: require.resolve('eslint/use-at-your-own-risk'),
}),
new HtmlWebpackPlugin({
// template参数指定生成的html以哪个文件为参考生成结构
- template: path.resolve(__dirname, './index.html')
+ template: path.resolve(__dirname, '../index.html')
}),
new ForkTsCheckerWebpackPlugin(),
],
- devServer: {
- host: 'localhost', // 主机名
- port: 3000, // 端口号
- static: "./dist", // 静态文件目录
- open: true, // 自动打开浏览器
- },
// mode表示webpack的运行模式,有development和production两种,默认是production
- mode: "development"
+ mode: "production"
}

改写命令脚本

前面我们配置了 ESLint,现在又分离了开发和生产环境,此时执行命令急需改造。修改 package.json,将原来的 scripts 项:

1
2
3
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},

改成下面:

1
2
3
4
5
6
"scripts": {
"lint": "eslint \"src/**/*.{ts,tsx,js}\"",
"dev": "npm run lint && webpack serve --config ./config/webpack.dev.js",
"start": "npm run dev",
"build": "webpack --config ./config/webpack.prod.js"
},

scripts 配置的是一个对象,所以其中的命令顺序没有关系。配置了上述命令脚本,以后我们使用开发模式进行查看,就只需要运行简单的命令:

1
npm run dev

这将先触发 ESLint 的检查然后再启动开发服务器,一旦 ESLint 检测到语法问题触发报错,打包将停止。

生产模式同理,执行简单命令:

1
npm run build

即可打包生成静态结果。

CSS 提取优化

目前我们打包 css 有个问题:css 被打包到 js 文件中,css 样式只能等待到 js 被执行完毕才能被应用,而在 js 执行的过程中,浏览器可能已经渲染出了原始的 DOM 结构,页面表现为裸 HTML 结构,等待到 JS 执行完毕,style 标签终于被插入完毕,样式被应用,浏览器又重新渲染,这在用户看来就是 "闪屏" 现象。

针对这个现象,我们要做的就是把 css 单独提取出来变成 <link> 标签,让 CSS 可以和 JS 并行下载,并且在 首次渲染之前 就能应用样式,避免闪屏。

为什么单独抽取 css 为 link 标签可以避免闪屏?

要知道为什么,需要你对浏览器渲染原理有一定了解。可以看看我之前写的《浏览器渲染原理

简单来说,当浏览器解析 HTML 遇到 <link>,会下载 CSS,启动 CSS 的预解析线程,使得 CSSOM 在较早阶段可用。

浏览器会等待关键 CSSOM 就绪再做首次完全渲染(即使主解析线程继续解析 HTML,渲染会等 CSSOM)。

因此当 CSS 是通过 <link> 提前可用时,首次绘制就包含正确样式,从而避免闪屏出现(代价是阻塞渲染的时间,但通常能改善一点视觉稳定性)。

如果你想验证,可以按照下面的思路进行:

  1. 在本地启动 dev server 并使用 style-loader,在 Chrome DevTools (一般浏览器叫开发者工具)的 Network 设置为 Slow 3G 并禁用 cache,刷新页面:你会看到先是裸 HTML / 文本再有样式。
  2. 在 Performance 面板录制加载过程,查看首次绘制(FP)和后面的 repaint/reflow,能明显看到样式注入引起的重排。
  3. 临时把 JS 放到头部或把 CSS 直接注入 <style>,观察差异。

MiniCssExtractPlugin

安装 MiniCssExtractPlugin 插件:

1
npm install --save-dev mini-css-extract-plugin

安装之后我们就不再需要 style-loader 了,顾名思义,它就是用来动态插入生成 style 标签的,我们将其改为 MiniCssExtractPlugin(webpack.prod.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
plugins: [new MiniCssExtractPlugin()],
module: {
rules: [
{
test: /\.css$/, // 匹配文件后缀名为.css的文件
use: [ MiniCssExtractPlugin.loader, 'css-loader'],
generator: {
filename: 'static/css/[name].[contenthash:8].css' // 输出文件名
}
},
{
test: /\.s[ac]ss$/i, // 匹配文件后缀名为.scss或.sass的文件
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
generator: {
filename: 'static/css/[name].[contenthash:8].css' // 输出文件名
}
},
],
},
};

注意改动的是生产环境的 webpack 配置文件,别搞错了。

这之后可以使用打包命令看看效果了:

image-20250920231526029

可以看到默认是打包到了 dist 目录下,我们自然希望它在 static 目录下,所以继续配置插件:

1
2
3
new MiniCssExtractPlugin({
filename: "static/css/[name].[contenthash:8].css" // 输出文件名
}),

这样就好了。

CSS 兼容性

css 兼容性是老生常谈的问题了,我们一般使用 postcss 来解决。postcss 在 webpack 中是一个 loader:postcss-loader

安装命令:

1
npm install --save-dev postcss-loader postcss postcss-preset-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
{
test: /\.css$/, // 匹配文件后缀名为.css的文件
use: [ MiniCssExtractPlugin.loader, 'css-loader', {
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
"postcss-preset-env", // 预设
]
}
}
}],
generator: {
filename: 'static/css/[name].[contenthash:8].css' // 输出文件名
}
},
{
test: /\.s[ac]ss$/i, // 匹配文件后缀名为.scss或.sass的文件
use: [MiniCssExtractPlugin.loader, 'css-loader', {
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
"postcss-preset-env", // 能解决大部分的兼容性问题
]
}
}
}, 'sass-loader'],
generator: {
filename: 'static/css/[name].[contenthash:8].css' // 输出文件名
}
}

请注意,use 中 loader 的执行顺序是从右到左。所以对于普通的 css 应当是将 postcss 配置写在最右,让其第一个被执行,先处理兼容性问题,然后让 css-loader 处理 css 内容,最后让 MiniCssExtractPlugin 提取 css 文件为 link 标签;对于 sass、less、stylus 等,应当是将 postcss 配置写在预编译之后,让 posttcss 处理预编译得到的 css 代码而非 sass、lesss 和 stylus 代码。

除了预设,我们还可以让 postcss 知道兼容性要做到什么程度。在 package.json 中增加配置:

1
2
3
"browserslist": [
"ie >= 8"
]

实际上兼容性不需要做到如此好,此处仅作演示。兼容的浏览器版本越久远,就越需要更多的兼容性代码,文件体积也会一定程度的增加。
一般我们会配置为(注释仅供参考,实际不需要):

1
2
3
4
5
6
7
8
"browserslist": [
// 每个主流浏览器的最近两个版本
"last 2 versions",
// 全球市场份额大于 1% 的浏览器,太旧的不考虑
"> 1%",
// 排除掉已经废弃的浏览器
"not dead"
]

因为我们此前的 css 中使用到了 display: flex,而 flex 布局是存在一定兼容性问题的,所以我们可以直接执行生产打包看看效果:

image-20250920234739011

display: flex; 变成了 -ms-flexbox,这就说明 postcss 正确配置了。

优化 css 的 loader 结构

目前我们的 webpack 配置中,关于 css 的相关 loader 是存在一定重复代码的(因为我没有处理每一种 css 所以较少,如果你们写了 css、sass、less、stylus,那就很多重复代码了),我们可以将它们抽离为函数,精简代码结构:

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
const path = require('path'); // 引入node.js的path模块
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

const getStyleLoaders = (...loaders) => {
return [
MiniCssExtractPlugin.loader,
'css-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
"postcss-preset-env",
]
}
}
},
...loaders
].filter(Boolean);
}

module.exports = {
// loader配置
module: {
rules: [
{
test: /\.css$/, // 匹配文件后缀名为.css的文件
use: getStyleLoaders(),
generator: {
filename: 'static/css/[name].[contenthash:8].css' // 输出文件名
}
},
{
test: /\.s[ac]ss$/i, // 匹配文件后缀名为.scss或.sass的文件
use: getStyleLoaders('sass-loader'),
generator: {
filename: 'static/css/[name].[contenthash:8].css' // 输出文件名
}
},
]
}

这里函数的参数是剩余参数,所以传入多个参数时,需要理解 use 的处理顺序(从右到左)传入合适的参数。

例如:

1
2
3
4
5
6
7
const getStyleLoaders = (...loaders) => [
'css-loader',
...loaders
];

console.log(getStyleLoaders('postcss-loader', 'sass-loader'));
// 输出: ['css-loader', 'postcss-loader', 'sass-loader']

此时 use 也能正确处理。

CSS 压缩

CSS 压缩需要使用到插件:CssMinimizerWebpackPlugin

安装命令:

1
npm install css-minimizer-webpack-plugin --save-dev

引入插件(webpack.prod.js):

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

module.exports = {
// optimization与output等配置项平级
optimization: {
minimizer: [
new CssMinimizerPlugin(), // 压缩css文件
]
},
}

执行生产打包命令之后如果看到 dist/static/css/ 目录下的 css 文件如果是一行,那就说明压缩生效了。

html 和 js 压缩

目前,html 打包后会自动压缩,但是 js 并不会(说实话这很奇怪,按理来说是都会被压缩的)。翻看了官方文档:

Webpack v5 comes with the latest terser-webpack-plugin out of the box. If you are using Webpack v5 or above and wish to customize the options, you will still need to install terser-webpack-plugin.

译:Webpack v5 自带最新的 terser-webpack-plugin 。如果你使用的是 Webpack v5 或更高版本,并且希望自定义选项,你仍然需要安装 terser-webpack-plugin

说实话,我没看懂这左右脑互搏的说明,自带!= 安装过了?

安装 terser-webpack-plugin

1
npm install terser-webpack-plugin --save-dev

引入并应用(webpack.prod.js):

1
2
3
4
5
6
7
8
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
optimization: {
minimize: true,
minimizer: [new TerserPlugin()],
},
};

这样执行生产打包就能看到效果了。

我后来想了想,文档的意思应该是:如果你覆盖了默认的 minimizer(比如加了 CssMinimizerPlugin),就必须自己安装并手动添加 TerserPlugin,否则 JS 不会压缩。

至此,webpack 基础篇完结撒花。

最终代码地址:webpack5-tutorial-basics