文章

Webpack核心概念深度解析

Webpack核心概念深度解析

一句话概括

Webpack 通过将 entry、output、loader、plugin、module 五个核心概念有机组合,以”一切皆模块”的哲学统摄 JavaScript、CSS、图片、字体等资源,构建出一个高度可扩展的现代化前端打包体系。

1. 背景与意义

1.1 前端模块化演进的六个阶段

Webpack 的诞生不是偶然,它是前端模块化演进到一定阶段的必然产物。

1
2
3
4
5
6
7
┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐
│ 全局     │  │ IIFE    │  │ CommonJS│  │ AMD     │  │ ES      │  │ Bundle  │
│ Script   │→│ (自执行  │→│ (Node)  │→│ (浏览   │→│ Modules │→│ (Webpack)│
│ 污染     │  │ 函数)   │  │ 同步    │  │ 器异步) │  │ 标准    │  │ 一统    │
│ 全局     │  │ 隔离    │  │ 服务器  │  │ require │  │ import  │  │ 天下    │
└─────────┘  └─────────┘  └─────────┘  └─────────┘  └─────────┘  └─────────┘
2005        2008         2009         2011          2015         2018

关键转折点

  • 2009年:Node.js 诞生,CommonJS 规范广为接受,但浏览器端无法直接使用
  • 2011年:Browserify 出现,第一个将 Node 模块打包到浏览器的工具
  • 2012年:RequireJS + r.js 优化器,AMD 规范在浏览器端率先普及
  • 2014年:Webpack 1.0 发布,”一切皆模块”的理念开启了前端构建的新纪元
  • 2015年:ES6 草案定稿,原生 import/export 进入规范
  • 2022年至今:Turbopack、Vite 等新一代构建工具崛起,但 Webpack 仍是生态最成熟的方案

1.2 Webpack 的生态地位

截至 2026 年,Webpack 依然是:

  • React 生态默认构建工具(通过 Create React App)
  • Next.js 底层构建引擎(Webpack 5 + Turbopack 混合)
  • Angular CLI 构建管道(基于 Webpack)
  • 几乎所有企业级项目的首选构建方案

虽然 Vite 在开发体验上有极大优势,但在生产构建的可配置性、插件生态、老项目迁移等方面,Webpack 的地位依然稳固。

2. 概念与定义

2.1 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
                  Webpack 运行流程
                  
    entry(入口)                   output(输出)
        │                              │
        ▼                              │
   ┌────────────┐                      │
   │ 依赖图解析  │                      │
   │ (Dependency │                      │
   │  Graph)     │                      │
   └─────┬──────┘                      │
         │                             │
         ▼                             ▼
   ┌────────────┐   ┌────────────┐   ┌────────────┐
   │  loader 链  │──▶│  module    │──▶│ chunk      │
   │ {test: re}  │   │ (AST变换)  │   │ (代码分割) │
   └────────────┘   └────────────┘   └─────┬──────┘
         │                                 │
         │                                 ▼
         │                           ┌────────────┐
         └──────────────────────────▶│  plugin    │
                                     │ (hooks)    │
                                     └────────────┘
                                              │
                                              ▼
                                     output(bundle.js)

2.2 五个核心概念的定义

Entry(入口)

Webpack 构建依赖图的起点。可以是单入口(SPA 应用)或多入口(多页应用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 单入口(最常见的 SPA 格式)
module.exports = {
  entry: './src/index.js'
};

// 多入口(MPA 或多应用)
module.exports = {
  entry: {
    main: './src/main.js',
    admin: './src/admin.js',
    vendor: ['react', 'react-dom']
  }
};

// 动态入口(返回 Promise)
module.exports = {
  entry: () => new Promise((resolve) => {
    // 异步获取入口路径
    fetch('/config').then(config => {
      resolve(config.entry);
    });
  })
};

Output(输出)

告诉 Webpack 如何以及在哪里输出打包后的文件。

1
2
3
4
5
6
7
8
9
module.exports = {
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    publicPath: 'https://cdn.example.com/assets/',
    clean: true  // Webpack 5 新增,构建前清理 dist
  }
};

filename 中的占位符

  • [name] — 入口名称(如 main)
  • [contenthash] — 内容哈希(文件内容变化时才变化)
  • [chunkhash] — 代码块哈希
  • [id] — 内部块 ID
  • [ext] — 文件扩展名(用于资源文件)

Module(模块)

在 Webpack 的语境中,”模块”不仅仅是 JavaScript,还包括 CSS、图片、字体等一切可以被 import 的资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Webpack 中的模块概念:
// JS 模块
import App from './App.jsx';

// CSS 模块
import styles from './styles.module.css';

// 图片作为模块
import logo from './logo.svg';

// JSON 模块
import config from './config.json';

// WASM 模块(Webpack 5 原生支持)
import wasmModule from './example.wasm';

Loader(加载器)

Loader 是模块转换器。Webpack 原生只理解 JavaScript 和 JSON,loader 将这些非 JS 资源转换为 Webpack 可以处理的模块。

Loader 的本质是一个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Loader 的函数签名
// content: 源文件内容(字符串或 Buffer)
// sourceMap: 可选的 source map
// meta: 额外的元数据

/**
 * @param {string|Buffer} content 源文件内容
 * @param {object} [sourceMap] Source map
 * @param {any} [meta] 元数据
 * @returns {string|Buffer} 转换后的内容
 */
function myLoader(content, sourceMap, meta) {
  // 对 content 进行转换
  const transformed = transform(content);
  return transformed;
}

Loader 的执行顺序(从右到左,从下到上):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 配置中的 loader
module.exports = {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          'style-loader', // 3. 将 CSS 注入到 DOM 中
          'css-loader',   // 2. 解析 CSS 中的 @import 和 url()
          'sass-loader'   // 1. 将 SCSS 编译为 CSS
        ]
      }
    ]
  }
};

// 执行顺序:sass-loader → css-loader → style-loader
// 👆 从右到左,先处理 SCSS,再处理 CSS,最后注入 DOM

Plugin(插件)

Plugin 在 Webpack 的生命周期中注入自定义行为。与 Loader 不同(Loader 只处理模块转换),Plugin 可以访问 Webpack 编译器的整个生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Plugin 的本质是一个具有 apply 方法的类
class MyPlugin {
  constructor(options) {
    this.options = options;
  }

  apply(compiler) {
    // compiler 是 Webpack 的核心编译器实例
    // 包含了完整的配置和生命周期 hooks
    
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('构建完成!', this.options.message);
    });
  }
}

2.3 Webpack 5 vs 4 的关键差异

特性Webpack 4Webpack 5
模块联邦✅ Module Federation
持久化缓存需要 cache-loader内置 cache: { type: 'filesystem' }
资源模块需要 file-loader/url-loader内置 type: 'asset'
输出 clean需要 CleanWebpackPlugin内置 output.clean: true
默认目标web自动检测(node/web)
代码生成ES5支持 output.ecmaVersion

3. 最小示例

3.1 从零搭建完整的 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
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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
// webpack.config.js
// 这是目前项目中最常见的一种 Webpack 5 配置模板

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const isProduction = process.env.NODE_ENV === 'production';

module.exports = {
  // ─── Entry ───
  entry: './src/index.js',

  // ─── Output ───
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: isProduction ? 'js/[name].[contenthash:8].js' : 'js/[name].js',
    chunkFilename: isProduction ? 'js/[name].[contenthash:8].chunk.js' : 'js/[name].chunk.js',
    assetModuleFilename: 'assets/[name].[hash:8][ext]',
    publicPath: '/',
    clean: true,
  },

  // ─── Mode ───
  mode: isProduction ? 'production' : 'development',

  // ─── Devtool ───
  devtool: isProduction ? 'source-map' : 'eval-cheap-module-source-map',

  // ─── Module(Loader 配置在这里)───
  module: {
    rules: [
      // 1. JavaScript 处理(Babel 编译)
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', { targets: '> 0.25%, not dead' }],
              '@babel/preset-react',
              '@babel/preset-typescript'
            ]
          }
        }
      },

      // 2. 样式处理
      {
        test: /\.css$/,
        use: [
          isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: {
                auto: /\.module\./,
                localIdentName: isProduction ? '[hash:base64:8]' : '[name]__[local]--[hash:base64:5]'
              },
              importLoaders: 1
            }
          },
          'postcss-loader'
        ]
      },
      {
        test: /\.(scss|sass)$/,
        use: [
          isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
          'css-loader',
          'postcss-loader',
          'sass-loader'
        ]
      },

      // 3. 图片和字体(Webpack 5 内置资源模块)
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/i,
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 8 * 1024 // 8KB 以下转为 Data URL
          }
        },
        generator: {
          filename: 'images/[name].[hash:8][ext]'
        }
      },
      {
        test: /\.(woff2?|eot|ttf|otf)$/i,
        type: 'asset/resource',
        generator: {
          filename: 'fonts/[name].[hash:8][ext]'
        }
      },

      // 4. 其他资源
      {
        test: /\.(pdf|txt)$/i,
        type: 'asset/resource'
      }
    ]
  },

  // ─── Resolve ───
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@components': path.resolve(__dirname, 'src/components'),
    }
  },

  // ─── Plugins ───
  plugins: [
    // 生成 HTML(自动注入打包后的 JS/CSS)
    new HtmlWebpackPlugin({
      template: './public/index.html',
      filename: 'index.html',
      favicon: './public/favicon.ico',
      minify: isProduction ? {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true
      } : false
    }),

    // 提取 CSS 为独立文件
    ...(isProduction ? [new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].chunk.css'
    })] : [])
  ],

  // ─── Optimization ───
  optimization: {
    minimize: isProduction,
    minimizer: [
      // 使用 terser-webpack-plugin(Webpack 5 内置)
      '...',
    ],
    // 代码分割
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          name: 'vendor',
          test: /[\\/]node_modules[\\/]/,
          priority: 10,
          chunks: 'initial'
        },
        common: {
          name: 'common',
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true
        }
      }
    },
    // 运行时分离
    runtimeChunk: 'single'
  },

  // ─── Dev Server ───
  devServer: {
    port: 3000,
    hot: true,
    open: true,
    historyApiFallback: true,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  }
};

3.2 对应的简单项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
my-app/
├── public/
│   └── index.html
├── src/
│   ├── index.js
│   ├── App.jsx
│   ├── styles
│   │   └── app.module.scss
│   └── components
│       ├── Header.jsx
│       └── Header.module.scss
├── webpack.config.js
├── babel.config.js
├── postcss.config.js
└── package.json
1
2
3
4
5
6
7
8
9
10
11
12
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

4. 核心知识点拆解

4.1 Entry 的深入理解

4.1.1 Entry 的本质不是文件路径,而是依赖图的根

1
2
3
4
5
6
// Webpack 内部将 entry 编译为 Chunk
// 每个 entry 对应至少一个 Chunk
// Chunk 是 Webpack 内部代码分割的基本单位

// 单入口 → 一个 entry chunk
// 多入口 → 多个 entry chunk(可能共享模块)

4.1.2 分离 vendor 的正确姿势

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
// ❌ 错误方式:通过手写多入口分离 vendor
entry: {
  app: './src/index.js',
  vendor: ['react', 'react-dom'] // 不要这样做!
}
// 问题:vendor 和 app 中的代码是独立的,
// app 中仍包含 react 的引用
// 用户访问时仍会加载两份 react

// ✅ 正确方式:使用 splitChunks 自动分离
optimization: {
  splitChunks: {
    cacheGroups: {
      vendor: {
        name: 'vendor',
        test: /[\\/]node_modules[\\/]/,
        chunks: 'all',
        priority: 10
      }
    }
  }
}
// Webpack 自动分析依赖关系
// 将 node_modules 中的模块提取到 vendor chunk
// app chunk 中的 import react 会被替换为对 vendor chunk 的引用

4.2 Output 的深入理解

4.2.1 ContentHash 的作用机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// contenthash 只会在文件内容变化时变化
// 利用浏览器的长期缓存

// 构建输出示例
// dist/
//   js/
//     main.a1b2c3d4.js       ← app 代码
//     vendor.e5f6g7h8.js     ← 第三方库(不变)
//     common.i9j0k1l2.js     ← 共享模块

// 如果只修改了 app 中的业务代码:
// main.a1b2c3d4.js → main.x1y2z3w4.js (hash 变化 ✅)
// vendor.e5f6g7h8.js → vendor.e5f6g7h8.js (hash 不变 ✅)
// common.i9j0k1l2.js → common.i9j0k1l2.js (hash 不变 ✅)

// 用户只需重新下载 main.xxx.js(约 50KB)
// vendor 和 common 可以从缓存中读取(约 500KB 免下载)

4.2.2 PublicPath 的三种模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 相对路径
output: { publicPath: '' }
// 生成的资源路径: <script src="js/main.abc.js">
// 适用于:开发环境、部署到域名根目录

// 2. 绝对路径
output: { publicPath: '/' }
// 生成的资源路径: <script src="/js/main.abc.js">
// 适用于:部署到域名根目录

// 3. CDN 路径
output: { publicPath: 'https://cdn.example.com/static/' }
// 生成的资源路径: <script src="https://cdn.example.com/static/js/main.abc.js">
// 适用于:静态资源 CDN 分离

// 4. 动态 publicPath
// 也可以在实际运行时设置
__webpack_public_path__ = window.CDN_BASE_URL || '/';

4.3 Loader 的深入理解

4.3.1 Loader 的执行顺序规则

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
30
// 规则1:从右到左
use: ['a-loader', 'b-loader', 'c-loader']
// 执行顺序:c-loader → b-loader → a-loader
// 理解为:c 先处理原始内容,结果传给 b,b 再传给 a

// 规则2:从上到下(rules 数组中)
module: {
  rules: [
    { test: /\.js$/, use: ['loader-x'] },    // 先匹配
    { test: /\.jsx$/, use: ['loader-y'] }     // 后匹配
  ]
}
// 如果同时匹配,先匹配的先执行(但注意,每个文件只会匹配第一个匹配的 rule)

// 规则3:normal loader vs pitch loader
// Loader 可以定义 pitch 方法,执行顺序相反
use: [
  'a-loader',
  {
    loader: 'b-loader',
    options: {}
  }
]

// 正常执行顺序(从右到左):
// 1. b-loader.pitch  → 2. a-loader.pitch
// 3. a-loader(normal) → 4. b-loader(normal)

// loader.pitch 有"熔断"机制:
// 如果某个 loader 的 pitch 有返回值,跳过后续 loader 的正常执行

4.3.2 自定义 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
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
// remove-console-loader.js
// 一个自定义 loader:在生产环境移除 console 语句

module.exports = function removeConsoleLoader(source) {
  const isProduction = this.mode === 'production' || process.env.NODE_ENV === 'production';
  
  if (!isProduction) {
    return source; // 开发环境不处理
  }

  // 使用正则移除 console 语句
  const result = source.replace(/console\.(log|info|warn|error|debug)\([^)]*\);?/g, '');
  
  // 记录处理信息
  console.log(`[remove-console] 处理文件: ${this.resourcePath}`);
  console.log(`[remove-console] 移除了 ${(source.length - result.length) / 2} 个字符`);
  
  return result;
};

// 使用
// {
//   test: /\.(js|jsx)$/,
//   exclude: /node_modules/,
//   use: [
//     'babel-loader',
//     { loader: './loaders/remove-console-loader.js' }
//   ]
// }

// 更复杂的 loader:国际化文本替换
// i18n-loader.js

const fs = require('fs');
const path = require('path');

module.exports = function i18nLoader(source) {
  const options = this.getOptions();
  const locale = options.locale || 'zh-CN';
  const translationsPath = path.resolve(
    this.rootContext,
    options.translationsPath || 'src/locales',
    `${locale}.json`
  );
  
  let translations = {};
  try {
    translations = JSON.parse(fs.readFileSync(translationsPath, 'utf-8'));
  } catch (e) {
    console.warn(`[i18n-loader] 未找到翻译文件: ${translationsPath}`);
  }

  // 替换 __("key") 为实际翻译文本
  const result = source.replace(/__\("([^"]+)"\)/g, (match, key) => {
    return JSON.stringify(translations[key] || key);
  });

  return result;
};

// Loader 的 options schema 验证
const schema = {
  type: 'object',
  properties: {
    locale: { type: 'string' },
    translationsPath: { type: 'string' }
  },
  additionalProperties: false
};

module.exports.schema = schema;

4.4 Plugin 的深入理解

4.4.1 Compiler 和 Compilation

Plugin 的核心是两个对象:

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
// compiler:完整的 Webpack 环境配置
// 包含:options、loaders、plugins 等
// 在整个构建过程中只创建一次

// compilation:单次构建的产物
// 包含:modules、chunks、assets 等
// 监听文件变化后重新构建会创建新的 compilation

// compiler 生命周期 hooks(关键部分):
compiler.hooks = {
  // 初始化配置
  environment: new SyncHook(),
  afterEnvironment: new SyncHook(),
  entryOption: new SyncBailHook(['context', 'entry']),
  
  // 编译阶段
  beforeRun: new AsyncSeriesHook(['compiler']),
  run: new AsyncSeriesHook(['compiler']),
  
  // 编译开始
  compile: new SyncHook(['params']),
  thisCompilation: new SyncHook(['compilation', 'params']),
  compilation: new SyncHook(['compilation', 'params']),
  
  // 生成输出
  make: new AsyncParallelHook(['compilation']),
  afterCompile: new AsyncSeriesHook(['compilation']),
  
  // 输出
  emit: new AsyncSeriesHook(['compilation']),
  afterEmit: new AsyncSeriesHook(['compilation']),
  
  // 完成
  done: new AsyncSeriesHook(['stats']),
  failed: new SyncHook(['error']),
};

// compilation 生命周期 hooks(关键部分):
compilation.hooks = {
  // 模块处理
  buildModule: new SyncHook(['module']),
  succeedModule: new SyncHook(['module']),
  failedModule: new SyncHook(['module', 'error']),
  
  // 资源处理
  optimize: new SyncHook(),
  optimizeChunks: new SyncHook(['chunks']),
  
  // 生成资源
  processAssets: new AsyncSeriesHook(['assets']),
  afterProcessAssets: new SyncHook(['assets']),
  
  // 模块 ID 和哈希
  optimizeModuleIds: new SyncHook(['modules']),
  optimizeChunkIds: new SyncHook(['chunks']),
  recordHash: new SyncHook(['compilation']),
};

4.4.2 自定义 Plugin 实战

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
// build-stats-plugin.js
// 一个自定义 plugin:构建完成后输出各项统计信息

class BuildStatsPlugin {
  constructor(options = {}) {
    this.options = {
      filename: 'build-stats.json',
      includeAssets: true,
      includeModules: false,
      includeChunks: true,
      ...options
    };
  }

  apply(compiler) {
    // 使用 compilation 的 processAssets hook
    // 在资源处理完成后,输出之前收集信息
    compiler.hooks.thisCompilation.tap('BuildStatsPlugin', (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: 'BuildStatsPlugin',
          stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL
        },
        (assets) => {
          const stats = {
            buildTime: Date.now(),
            compilationHash: compilation.hash,
            
            // 模块统计
            moduleCount: compilation.modules.size,
            moduleTypes: this.collectModuleTypes(compilation),
            
            // 代码块统计
            chunkCount: compilation.chunks.size,
            chunks: this.options.includeChunks 
              ? this.collectChunkInfo(compilation) 
              : undefined,
            
            // 资源统计
            assetCount: Object.keys(assets).length,
            assets: this.options.includeAssets 
              ? this.collectAssetInfo(assets) 
              : undefined,
            
            // 错误和警告
            errors: compilation.errors.length,
            warnings: compilation.warnings.length,
            
            // 文件大小
            totalSize: this.calculateTotalSize(assets),
          };

          // 将统计信息作为资源文件添加到输出
          const content = JSON.stringify(stats, null, 2);
          compilation.emitAsset(
            this.options.filename,
            new compiler.webpack.sources.RawSource(content)
          );
        }
      );
    });

    // 构建完成时输出到控制台
    compiler.hooks.done.tap('BuildStatsPlugin', (stats) => {
      const jsonStats = stats.toJson({
        all: false,
        assets: true,
        chunks: true,
        modules: false,
        errors: true,
        warnings: true,
      });

      console.log('\n📊 构建统计:');
      console.log(`  模块数: ${jsonStats.modules?.length || 0}`);
      console.log(`  代码块: ${jsonStats.chunks?.length || 0}`);
      console.log(`  资源文件: ${jsonStats.assets?.length || 0}`);
      console.log(`  总大小: ${this.formatSize(jsonStats.assets?.reduce(
        (sum, a) => sum + a.size, 0) || 0)}`);
      
      if (compilation.errors.length > 0) {
        console.log(`  错误: ${compilation.errors.length} ❌`);
      }
      if (compilation.warnings.length > 0) {
        console.log(`  警告: ${compilation.warnings.length} ⚠️`);
      }
    });
  }

  collectModuleTypes(compilation) {
    const types = {};
    compilation.modules.forEach(module => {
      const type = module.type || 'unknown';
      types[type] = (types[type] || 0) + 1;
    });
    return types;
  }

  collectChunkInfo(compilation) {
    return Array.from(compilation.chunks).map(chunk => ({
      id: chunk.id,
      name: chunk.name,
      size: chunk.size,
      files: Array.from(chunk.files),
      modules: chunk.sizeOfModules,
      entry: chunk.hasEntryModule(),
      initial: chunk.canBeInitial(),
    }));
  }

  collectAssetInfo(assets) {
    return Object.entries(assets).map(([name, source]) => ({
      name,
      size: source.size(),
      type: name.split('.').pop(),
    }));
  }

  calculateTotalSize(assets) {
    return Object.values(assets).reduce(
      (sum, source) => sum + source.size(), 0
    );
  }

  formatSize(bytes) {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
  }
}

// 使用
// plugins: [new BuildStatsPlugin({ filename: 'stats.json' })]

5. 实战案例

案例一:Module Federation(模块联邦)实现微前端

Webpack 5 中最具革命性的特性之一:

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
// app-shell/webpack.config.js
// 宿主应用(消费方)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        // 远程模块地址(由其他应用暴露)
        app1: 'app1@http://localhost:3001/remoteEntry.js',
        app2: 'app2@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      }
    })
  ]
};

// app1/webpack.config.js
// 子应用(提供方)
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      filename: 'remoteEntry.js',
      exposes: {
        './Header': './src/components/Header',
        './Footer': './src/components/Footer',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
      }
    })
  ]
};

// app2/webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/pages/ProductList',
        './Cart': './src/pages/Cart',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
      }
    })
  ]
};

// shell/src/App.jsx
// 宿主应用中使用远程模块
import React, { Suspense } from 'react';

// 远程组件(懒加载)
const Header = React.lazy(() => import('app1/Header'));
const ProductList = React.lazy(() => import('app2/ProductList'));

function App() {
  return (
    <div>
      <Suspense fallback={<div>加载中...</div>}>
        <Header />
        <ProductList />
      </Suspense>
    </div>
  );
}

export default App;

案例二: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
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
// webpack.prod.config.js
// 生产环境的性能优化配置(大项目适用)

const path = require('path');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');

module.exports = merge(baseConfig, {
  mode: 'production',
  devtool: 'source-map',

  output: {
    filename: 'js/[name].[contenthash:8].js',
    chunkFilename: 'js/[name].[contenthash:8].chunk.js',
  },

  // ├── 1. 缓存策略 ──
  cache: {
    type: 'filesystem',
    cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'),
    buildDependencies: {
      config: [__filename]
    }
  },

  // ├── 2. 模块处理优化 ──
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        // 使用 thread-loader 开启多线程编译
        use: [
          {
            loader: 'thread-loader',
            options: {
              workers: require('os').cpus().length - 1,
              workerParallelJobs: 50,
            }
          },
          'babel-loader?cacheDirectory=true'
        ],
        exclude: /node_modules/,
      },
      {
        test: /\.(png|jpg|gif)$/,
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 4 * 1024 // 4KB 以内转 base64
          }
        }
      }
    ]
  },

  // ├── 3. 代码分割优化 ──
  optimization: {
    // 使用 deterministic 而不是 size
    // 保证 module id 稳定,不影响缓存
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    
    // 深层 splitChunks 配置
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: 25,
      minSize: 20000,
      maxSize: 250000,
      cacheGroups: {
        // React 全家桶
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
          name: 'react-core',
          priority: 40,
          chunks: 'all',
        },
        // Ant Design
        antd: {
          test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
          name: 'antd',
          priority: 30,
          chunks: 'all',
        },
        // 其他第三方库
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          priority: 10,
          chunks: 'initial',
        },
        // 共享的业务组件
        common: {
          name: 'common',
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true,
        },
        // 样式文件
        styles: {
          name: 'styles',
          test: /\.(css|scss|less)$/,
          chunks: 'all',
          enforce: true,
          priority: 20,
        }
      }
    },

    // Tree Shaking 增强
    sideEffects: true,
    usedExports: true,

    // 最小化
    minimize: true,
    minimizer: [
      '...',
      // JS 压缩
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true,
            drop_debugger: true,
            pure_funcs: ['console.log', 'console.info'],
          },
          output: { comments: false },
        },
        extractComments: false,
      }),
      // CSS 压缩
      new CssMinimizerPlugin(),
    ],
  },

  // ├── 4. 构建体积分析 ──
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html',
      openAnalyzer: false,
    })
  ],

  // ├── 5. 资源压缩 ──
  // 需要在 nginx 配置 gzip
  // Webpack 也可以做预压缩
  plugins: [
    new CompressionPlugin({
      test: /\.(js|css|html|svg)$/,
      algorithm: 'gzip',
      minRatio: 0.8,
      threshold: 10240, // 10KB 以上才压缩
      deleteOriginalAssets: false, // 保留原始文件
    })
  ],
});

6. 底层原理

6.1 Webpack 的打包核心流程

Webpack 的整个构建流程可以概括为三个阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
阶段1: 初始化
┌────────────────────────────────────┐
│ 读取配置          合并命令行参数    │
│ 注册所有 Plugin   实例化 Compiler  │
│ 找到 Entry        开始编译         │
└────────────────────────────────────┘

阶段2: 编译构建
┌────────────────────────────────────┐
│ 从 Entry 出发     解析依赖         │
│ 对每个模块调用对应 Loader 进行转换  │
│ 生成 AST          分析依赖         │
│ 递归构建所有依赖  生成 ModuleGraph  │
│ 收集所有 Chunk                      │
└────────────────────────────────────┘

阶段3: 输出
┌────────────────────────────────────┐
│ 对 Chunk 进行优化(分组合并)       │
│ 生成 Chunk Graph                    │
│ 生成最终代码                        │
│ 输出到指定目录                      │
└────────────────────────────────────┘

6.2 模块依赖图的构建过程

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
// Webpack 内部的核心是 ModuleGraph
// 以下是一个简化版的依赖图构建过程

// 1. 从 entry 开始,创建根模块
const entryModule = new NormalModule({
  request: './src/index.js',
  rawRequest: './src/index.js',
});

// 2. 解析入口模块的源代码,创建 AST
// Webpack 使用 acorn 或 terser 的解析器
// 生成 AST 后,遍历 AST 找到所有 import 声明

// 简化版 AST 遍历:
function parseDependencies(source) {
  // 假设我们已经用 acorn 解析了源代码
  const ast = acorn.parse(source, { sourceType: 'module' });
  
  const dependencies = [];
  
  // 遍历 AST 节点
  function walk(node) {
    if (node.type === 'ImportDeclaration') {
      // 找到 import 语句
      dependencies.push({
        source: node.source.value,  // 如 './App.jsx'
        specifiers: node.specifiers.map(s => s.local.name),
        type: 'esm'
      });
    }
    
    if (node.type === 'CallExpression' && 
        node.callee.name === 'require') {
      // 找到 require 调用
      dependencies.push({
        source: node.arguments[0].value,
        type: 'cjs'
      });
    }
    
    // 递归遍历所有子节点
    for (const key in node) {
      if (node[key] && typeof node[key] === 'object') {
        walk(node[key]);
      }
    }
  }
  
  walk(ast);
  return dependencies;
}

// 3. 对每个依赖创建新的模块,并递归解析
// 形成一个完整的 ModuleGraph
class ModuleGraphBuilder {
  constructor() {
    this.modules = new Map();
    this.graph = new Map(); // module → dependencies[]
  }

  build(entryPath) {
    const queue = [path.resolve(entryPath)];
    
    while (queue.length > 0) {
      const absolutePath = queue.shift();
      
      if (this.modules.has(absolutePath)) continue;
      
      // 读取文件内容
      const source = fs.readFileSync(absolutePath, 'utf-8');
      
      // 应用 loader 链
      const transformedSource = this.applyLoaders(source, absolutePath);
      
      // 解析依赖
      const dependencies = parseDependencies(transformedSource);
      
      // 存储模块
      const moduleId = this.getModuleId(absolutePath);
      this.modules.set(moduleId, transformedSource);
      this.graph.set(moduleId, dependencies);
      
      // 处理子依赖
      for (const dep of dependencies) {
        const depPath = this.resolvePath(dep.source, path.dirname(absolutePath));
        queue.push(depPath);
      }
    }
  }
}

// 4. 所有模块解析完成后
// Webpack 生成一个从 entry 开始的"依赖图"
// 这个图包含了整个应用的模块关系
// 然后基于这个图生成 chunk 和最终代码

6.3 Tapable 插件机制

Webpack 的整个插件系统建立在 Tapable 库之上。Tapable 提供了类似 Node.js EventEmitter 但更强大的 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
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
// Tapable 的几种 Hook 类型
const {
  SyncHook,          // 同步钩子
  SyncBailHook,      // 同步熔断钩子(返回非 undefined 时停止)
  SyncWaterfallHook, // 同步瀑布钩子(前一个返回值传给下一个)
  AsyncSeriesHook,   // 异步串行钩子
  AsyncParallelHook, // 异步并行钩子
} = require('tapable');

// 以 SyncHook 为例
class MyPluginSystem {
  constructor() {
    // 定义钩子
    this.hooks = {
      beforeBuild: new SyncHook(['compilation']),
      afterBuild: new SyncHook(['compilation']),
      
      // 可以熔断的钩子
      shouldBuild: new SyncBailHook(['filename']),
      
      // 异步串行
      emit: new AsyncSeriesHook(['compilation']),
    };
  }

  fireHooks() {
    const compilation = { name: 'test' };
    
    // 触发同步钩子
    this.hooks.beforeBuild.call(compilation);
    
    // 触发同步熔断钩子
    const result = this.hooks.shouldBuild.call('app.js');
    if (result === false) return; // 如果插件阻止了构建
    
    // 触发异步钩子
    this.hooks.emit.callAsync(compilation, (err) => {
      if (err) console.error(err);
      console.log('Emit done');
    });
  }
}

// 插件如何注册
const system = new MyPluginSystem();

// 使用 tap 注册同步插件
system.hooks.beforeBuild.tap('MyPlugin', (compilation) => {
  console.log('构建前:', compilation);
});

// 使用 tapAsync 注册异步插件
system.hooks.emit.tapAsync('MyPlugin2', (compilation, callback) => {
  setTimeout(() => {
    console.log('异步插件');
    callback();
  }, 100);
});

// Webpack 的 Compiler 和 Compilation 钩子都基于 Tapable
// 这就是 Webpack 插件系统的底层实现

7. 高频面试题解析

面试题 1:Webpack 中 loader 和 plugin 的区别是什么?分别举一个常见的例子说明。

答案

核心区别

维度LoaderPlugin
职责模块转换(文件级别)构建流程干预(编译级别)
作用对象单个文件(.js, .css, .scss 等)整个编译过程
运行时机模块解析时(编译阶段)生命周期各阶段
接口形式函数 (content) → content类 (apply(compiler))
配置位置module.rulesplugins 数组中

常见示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Loader 示例:babel-loader
// 作用:将 ES6+ / JSX 代码转换为浏览器兼容的 ES5 代码
// 工作方式:接收 .js 文件内容 → Babel 转换 → 返回转换后的内容
{
  test: /\.jsx?$/,
  use: {
    loader: 'babel-loader',
    options: {
      presets: ['@babel/preset-env', '@babel/preset-react']
    }
  }
}

// Plugin 示例:HtmlWebpackPlugin
// 作用:自动生成 HTML 文件,并注入打包后的资源
// 工作方式:在 compilation 完成、emit 阶段,
// 读取模板 HTML,注入所有 chunk 的 JS/CSS 链接
plugins: [
  new HtmlWebpackPlugin({
    template: './public/index.html',
    title: 'My App'
  })
]

两者协作的工作流

1
2
3
4
5
6
7
文件 → [Loader: sass-loader] → [Loader: css-loader] → [Loader: style-loader] → DOM
         ↑ 每个 Loader 处理单个文件                          
                                                             
Plugin 可以在这个流程的任何阶段拦截:
[编译开始] ──→ [模块解析] ──→ [Loader 处理] ──→ [模块编译完成] ──→ [优化] ──→ [输出]
         ↑                    ↑                       ↑                    ↑
    DefinePlugin         ProvidePlugin          MiniCssExtractPlugin  HtmlWebpackPlugin

面试题 2:Webpack 的 Tree Shaking 是如何工作的?哪些情况下会导致 Tree Shaking 失效?

答案

Tree Shaking 的原理

Tree Shaking 依赖于 ES Module 的静态结构import/export 在编译时确定),利用 Terser 等 minifier 将未被使用的导出从最终 bundle 中移除。

1
2
3
4
5
6
7
8
9
10
11
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; } // 未使用

// main.js
import { add, subtract } from './math';
console.log(add(1, 2)); // 只用了 add 和 subtract

// Tree Shaking 后:
// multiply 被移除,不会出现在 bundle 中

Webpack 中启用 Tree Shaking 的条件

1
2
3
4
5
6
7
8
9
// 1. mode 设置为 production(默认启用)
module.exports = {
  mode: 'production',
  // 等价于:
  optimization: {
    usedExports: true,
    minimize: true
  }
};

Tree Shaking 失效的 5 种常见情况

情况 1:使用 CommonJS(不是 ESM)

1
2
3
4
5
6
// ❌ 失效:CommonJS 模块
const { add } = require('./math');
// require 是动态的,Webpack 无法静态分析

// ✅ 有效:ES Module
import { add } from './math';

情况 2:Side Effects 未正确标注

1
2
3
4
5
6
7
8
9
10
// package.json 中未设置 "sideEffects": false
// Webpack 认为模块可能有副作用,保留所有导出

// ✅ 正确设置
// package.json
{
  "sideEffects": false
  // 或者只排除 CSS:
  // "sideEffects": ["*.css"]
}

情况 3:Babel 配置不当

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 失效:@babel/preset-env 配置了 modules: 'commonjs'
{
  presets: [
    ['@babel/preset-env', { modules: 'commonjs' }]
  ]
}
// Babel 将 import 转成了 require,Webpack 无法做 Tree Shaking

// ✅ 有效:不要转换模块语法
{
  presets: [
    ['@babel/preset-env', { modules: false }] // 保持 ESM
  ]
}

情况 4:使用动态导入的变量

1
2
3
4
5
6
// ❌ 失效:动态导入
const moduleName = 'math';
const math = await import(`./${moduleName}`);

// ✅ 有效:静态导入
import { add } from './math';

情况 5:模块使用全局副作用

1
2
3
4
5
6
7
8
9
10
11
// ❌ 失效:模块有全局副作用
// polyfill.js
Array.prototype.customFn = function() {}; // 副作用

// 即使 main.js 没有 import polyfill
// 但如果有其他文件 import 了,整个 polyfill.js 都不会被 tree shaking

// ✅ 在 package.json 中声明
{
  "sideEffects": ["./src/polyfill.js"] // 这些文件有副作用,保留
}

面试题 3:Webpack 5 的 Module Federation 是什么?它是如何实现微前端应用之间共享代码的?

答案

Module Federation(模块联邦)是 Webpack 5 最核心的新特性,它允许多个独立构建的应用程序在运行时动态加载对方的模块,实现”微前端”级别的代码共享。

核心概念

1
2
3
// 提供方(Exposes):暴露部分模块供其他应用使用
// 消费方(Remotes):从其他应用加载模块
// 共享方(Shared):声明共享的第三方库(如 react)

运行机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
应用 A 构建时:
1. 将所有 exposes 中的模块打包为单独的 chunk
2. 生成 remoteEntry.js(包含映射表)
3. remoteEntry.js 中包含:
   - 所有暴露模块的下载地址
   - 共享模块的版本信息
   - 初始化引导代码

应用 B 运行时:
1. 首次引用 remote/xxx 时加载 A 的 remoteEntry.js
2. remoteEntry.js 执行,注册所有暴露模块
3. 应用 B 动态加载需要的模块
4. 如果 react 是 shared,检查版本是否兼容
5. 版本兼容 → 复用已有实例;不兼容 → 各自加载

底层实现

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
// Webpack 内部生成的 remoteEntry.js 简化版
// 这是一个自执行函数,创建了应用 A 的模块容器

var moduleMap = {
  "./Header": function() {
    return Promise.resolve().then(function() {
      return __webpack_require__("./src/components/Header.js");
    });
  },
  "./Footer": function() {
    return Promise.resolve().then(function() {
      return __webpack_require__("./src/components/Footer.js");
    });
  }
};

var get = function(module, getScope) {
  return moduleMap[module]();
};

var init = function(shareScope) {
  // 初始化共享作用域
  // 版本协商逻辑在这里
  __webpack_require__.S = shareScope;
};

// 应用 B 通过 container 引用
// import('app1/Header') 实际上等价于:
// container.get('./Header').then(module => module.default)

与传统的微前端方案对比

特性iframeqiankun/single-spaModule Federation
通信成本高(postMessage)中(自定义事件)低(直接 import)
样式隔离完全隔离有沙箱无(需 CSS Modules)
共享依赖各自加载可配置共享自动版本协商
构建方式独立部署独立部署独立部署
技术栈要求必须 Webpack 5

Module Federation 的最佳实践

  1. 共享依赖:将 react/react-dom 设为 singleton: true,避免多实例
  2. 版本协商:使用 requiredVersion 确保版本兼容
  3. 懒加载Suspense 包裹远程组件,提供加载态
  4. 错误边界:远程应用可能宕机,需要 Error Boundary 兜底

8. 总结与扩展

核心要点回顾

  1. 五个核心概念:entry(入口)、output(输出)、module(模块)、loader(加载器)、plugin(插件),构成了 Webpack 完整打包体系
  2. Loader 和 Plugin:Loader 处理文件级别的转换(从右到左),Plugin 插桩到生命周期各个阶段
  3. Module Federation:Webpack 5 的微前端方案,实现跨应用的运行时模块共享
  4. 优化策略:splitChunks 代码分割、Tree Shaking 死代码移除、contenthash 持久缓存、多线程编译、文件系统缓存

值得继续深挖的方向

  • Webpack 5 vs Turbopack:Next.js 新一代构建引擎的性能优势
  • Vite vs Webpack:esbuild + Rollup 的构建思路对比
  • Parcel 2:零配置构建工具的架构设计
  • Rspack:Rust 重写的 Webpack 兼容方案
  • unplugin:跨构建工具的通用插件规范

思考题

  1. Webpack 5 的 cache: { type: 'filesystem' } 是如何实现跨次构建的持久化缓存的?它的缓存键由哪些因素决定?
  2. 当使用 splitChunks 切分代码后,如果不同入口之间有相同的共享模块,Webpack 如何保证这些模块只被加载一次?
  3. Module Federation 中 shared 配置的 singleton: true 意味着什么?如果各个应用中的 react 版本不同(如 17.x 和 18.x),会发生什么?

参考资源:

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量