一文学会webpack:妈妈再也不用担心我不会工程化了

从入门到能写自定义 loader 和 plugin,大概花了将近一周。这篇文章是我整个学习过程的总结,尽量把我觉得真正有用的、踩过坑的部分都写进来,而不是把官网文档重新翻译一遍。

代码示例均来自我自己写的练习项目(phase-1 到 phase-7),可以配合着看。


目录


为什么学 webpack

说实话,现在 Vite 已经很流行了,很多人会问"还有必要学 webpack 吗"。我的答案是:对于理解前端工程化体系,webpack 仍然是绕不开的。

一方面,大量生产项目还在用 webpack,迁移成本不低。另一方面,即使你用 Vite,当遇到构建问题需要深入排查时,你会发现对打包原理的理解是通用的------Rollup、esbuild、webpack 解决的是同一类问题,只是取舍不同。

更重要的是,学 webpack 让我真正理解了:模块系统是怎么工作的、Tree Shaking 为什么需要 ES Module、Code Splitting 的 chunk 是怎么切分的、浏览器缓存和文件 hash 之间的关系。这些知识不会因为换了构建工具就过时。


一、基础:它到底做什么

webpack 的核心工作可以用一句话描述:从 entry 出发,递归分析所有 import/require 依赖,把整个依赖树打包成若干 chunk 文件,输出到 dist 目录。

arduino 复制代码
src/index.js
  └── import './utils/math.js'
        └── import 'lodash'

webpack 会把这三个模块(加上 lodash 的所有文件)
打成一个(或多个)bundle,浏览器只需要加载这个 bundle。

三个模式的差异

最开始容易忽略的是 mode 的影响。它不只是个标签,会直接控制 webpack 内部开启哪些优化:

js 复制代码
// development:不压缩,保留变量名,便于调试
// production:压缩混淆,Tree Shaking,所有优化全开
// none:什么优化都不做,适合研究打包原理

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

我建议初学时用 none 模式看一遍产物,再切到 production 对比,会对 webpack 做了什么有很直观的感受。

最简配置

js 复制代码
// webpack.config.js
const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
};

就这三个字段,已经可以处理 90% 的基础场景。其他配置都是在这之上的扩展。


二、核心配置:五个你必须搞清楚的字段

entry:从哪里开始

四种写法,我按照使用频率排序:

js 复制代码
// 最常用:单入口
entry: './src/index.js'

// 多页应用:对象写法,每个 key 是一个独立的 chunk
entry: {
  home:  './src/home.js',
  about: './src/about.js',
}

// 少用:把多个文件打进同一个 chunk
entry: ['./src/polyfills.js', './src/index.js']

// 高级:dependOn 共享依赖(避免重复打包)
entry: {
  app:    { import: './src/app.js', dependOn: 'shared' },
  shared: ['lodash', 'react'],
}

对象写法是多页应用的标准做法,[name] 占位符会被替换成 key 名,生成 home.bundle.js、about.bundle.js。

output:打到哪里

重点是 hash 策略,这直接影响浏览器缓存的效果:

js 复制代码
output: {
  path: path.resolve(__dirname, 'dist'),

  // [contenthash] 是最推荐的:文件内容不变则 hash 不变
  // 用户浏览器可以一直缓存没有变化的文件
  filename: '[name].[contenthash:8].js',

  // 异步 chunk(动态 import)的命名
  chunkFilename: '[name].[contenthash:8].chunk.js',

  // webpack 5 内置清空,不需要 CleanWebpackPlugin 了
  clean: true,
}

三种 hash 的区别经常被问到:

hash 类型 变化时机 适用场景
[hash] 任意文件变化,所有 chunk 的 hash 都变 几乎不用
[chunkhash] 当前 chunk 内容变化时变 旧写法
[contenthash] 当前文件内容变化时变 推荐,精度最高

module.rules:每种文件怎么处理

这是 webpack 可扩展性的核心。一个 rule 的基本结构:

js 复制代码
{
  test: /\.scss$/,        // 匹配哪些文件
  include: path.resolve(__dirname, 'src'),  // 只处理 src 下的(性能优化)
  use: [
    'style-loader',   // 第三步:注入 <style> 标签
    'css-loader',     // 第二步:处理 @import 和 url()
    'sass-loader',    // 第一步:把 SCSS 编译成 CSS
  ],
  // use 数组从右到左执行,数据流:scss → css → 模块 → DOM
}

一个让我困惑了很久的问题是:loader 的执行顺序是从右到左,但为什么 thread-loader 要放在最左边(最后执行)?

thread-loader 不是普通的转换 loader,它是一个"任务调度器"。它的 normal 阶段最后执行,但此时它会接管右边所有 loader 的执行,把它们派发到 worker 进程中运行。所以"耗时的 loader 放在 thread-loader 右边"的意思是:让它们被 thread-loader 接管并放入线程池。

js 复制代码
use: [
  { loader: 'thread-loader' },  // 最后执行,但此时接管 babel-loader 到 worker
  'babel-loader',               // 实际在 worker 进程里跑
]

resolve:怎么找文件

最常用的两个配置:

js 复制代码
resolve: {
  // 路径别名:消灭 ../../../ 地狱
  alias: {
    '@':       path.resolve(__dirname, 'src'),
    '@utils':  path.resolve(__dirname, 'src/utils'),
    '@components': path.resolve(__dirname, 'src/components'),
  },

  // 省略后缀名:import './utils' 会依次尝试 .js .jsx .ts .tsx
  extensions: ['.js', '.jsx', '.ts', '.tsx'],
}

配合 IDE 的路径提示,@/utils/math 比 ../../utils/math 好维护太多了。

devtool:Source Map

调试体验的关键。不同场景的推荐值:

js 复制代码
// 开发环境:快速重建 + 能定位到源文件
devtool: 'eval-cheap-module-source-map'

// 生产环境:独立文件,只在出错时用
devtool: 'source-map'

// CI 构建(不需要调试)
devtool: false

eval-cheap-module-source-map 这个名字看起来很长,拆开理解:eval 用 eval 执行(快)、cheap 只映射到行不映射列(更快)、module 显示源文件而非 loader 处理后的内容。


三、Loader:文件怎么被处理的

执行机制:pitch + normal 双阶段

这是理解 loader 系统最重要的概念,官网说得不够直观,我用图来表示:

perl 复制代码
配置:use: ['loader-a', 'loader-b', 'loader-c']

pitch 阶段(从左到右):
  loader-a.pitch → loader-b.pitch → loader-c.pitch

normal 阶段(从右到左):
  loader-c.normal → loader-b.normal → loader-a.normal

如果某个 pitch 返回了值,后续 pitch 和所有 normal 都被跳过:
  loader-a.pitch → loader-b.pitch(返回值!)→ loader-a.normal

大多数时候只有 normal 阶段,pitch 是高级用法,style-loader 用 pitch 是因为它要把 CSS 注入到 <head> 里,需要提前介入。

常用 loader 组合

CSS/SCSS 处理链:

js 复制代码
{
  test: /\.scss$/,
  use: [
    'style-loader',     // 开发环境:注入 style 标签(支持 HMR)
    // MiniCssExtractPlugin.loader  // 生产环境:提取独立 CSS 文件
    'css-loader',
    {
      loader: 'postcss-loader',     // 自动加 -webkit- 等前缀
      options: {
        postcssOptions: {
          plugins: ['autoprefixer'],
        },
      },
    },
    'sass-loader',
  ],
}

Babel(JS 语法兼容):

js 复制代码
{
  test: /\.js$/,
  include: path.resolve(__dirname, 'src'),
  use: {
    loader: 'babel-loader',
    options: {
      presets: [
        ['@babel/preset-env', { targets: 'defaults' }],
        '@babel/preset-react',  // 如果用 React
      ],
      // 缓存编译结果,第二次快很多
      cacheDirectory: true,
      cacheCompression: false,
    },
  },
}

自定义 loader

写 loader 其实不难,本质是一个函数:

js 复制代码
// loaders/remove-console-loader.js
module.exports = function(source) {
  // source 是文件的原始内容(字符串)
  // 返回处理后的内容
  return source.replace(/console\.(log|warn|error)\(.*?\);?/g, '');
};

异步 loader 用 this.async():

js 复制代码
module.exports = function(source) {
  const callback = this.async();  // 告诉 webpack 这是异步 loader

  someAsyncOperation(source).then(result => {
    callback(null, result);  // 第一个参数是 error,第二个是处理结果
  });
};

几个做项目时真正有价值的自定义 loader 场景:

  • security-audit-loader:扫描代码中的硬编码密钥、eval 使用、HTTP 明文请求,构建时作为 warning 或 error 输出,比 ESLint 更接近最终产物
  • i18n-loader :构建时把 $t('key') 替换成对应语言的字面量,适合语言确定、不需要运行时切换的场景(比如政府网站、特定版本构建)
  • style-inject-loader :给所有 SCSS 文件自动 prepend 公共变量文件,省去每个组件手动 @import

四、Plugin:如何介入构建过程

Loader 和 Plugin 的根本区别

这个问题面试经常被问,但很多回答只说了表面:

Loader Plugin
处理对象 单个文件的内容 整个构建过程
能做什么 转换文件内容(SCSS→CSS、TS→JS) 修改输出结构、注入资源、分析依赖、改变构建行为
介入方式 module.rules 配置,形成处理链 通过 Tapable 钩子,在特定时机执行
执行时机 make 阶段(构建模块时) 构建生命周期的任意阶段

一句话:loader 处理单个文件的内容转换,plugin 处理整个构建过程中的事件。

Tapable:webpack 的事件系统

webpack 内部所有的扩展点都基于 Tapable,理解它才能写出真正有用的 plugin。

五种核心钩子类型:

js 复制代码
const { SyncHook, SyncBailHook, SyncWaterfallHook,
        AsyncSeriesHook, AsyncParallelHook } = require('tapable');

// SyncHook:同步,所有监听者都执行,忽略返回值
const hook1 = new SyncHook(['arg1']);

// SyncBailHook:同步,返回非 undefined 则中断后续
// 应用:HtmlWebpackPlugin 的 alterAssetTagGroups,可以让某个 plugin 阻断后续
const hook2 = new SyncBailHook(['result']);

// SyncWaterfallHook:同步,上一个的返回值传给下一个
// 应用:处理链,每个 plugin 对结果做一步加工
const hook3 = new SyncWaterfallHook(['value']);

// AsyncSeriesHook:异步串行,一个完成才执行下一个
// 应用:emit 钩子,文件写入前的处理
const hook4 = new AsyncSeriesHook(['compilation']);

// AsyncParallelHook:异步并行,同时执行
const hook5 = new AsyncParallelHook(['compilation']);

注册和触发:

js 复制代码
hook1.tap('MyPlugin', (arg) => { /* 同步 */ });
hook4.tapAsync('MyPlugin', (compilation, callback) => { callback(); });
hook4.tapPromise('MyPlugin', (compilation) => Promise.resolve());

hook1.call('value');
hook4.callAsync(compilation, callback);

webpack 的构建生命周期

一次完整构建,关键钩子的触发顺序:

erlang 复制代码
compiler.hooks.environment     → 初始化,plugin 最早介入的地方(修改 options 用这里)
compiler.hooks.entryOption     → 解析完 entry 配置
compiler.hooks.beforeCompile   → 编译开始前
compiler.hooks.make            → 开始构建模块(从 entry 递归分析依赖)
  compilation.hooks.buildModule  → 处理每一个模块
  compilation.hooks.finishModules→ 所有模块处理完毕
compiler.hooks.afterCompile    → 编译完成(含 assets 已确定)
compiler.hooks.emit            → 文件写入 dist 之前(最后修改机会)
compiler.hooks.afterEmit       → 文件已写入
compiler.hooks.done            → 构建完全结束(读取 stats、发通知用这里)

常用内置 Plugin

js 复制代码
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

plugins: [
  // 生成 index.html,自动注入所有 chunk 的 <script> 和 <link>
  new HtmlWebpackPlugin({
    template: './public/index.html',
    minify: { removeComments: true, collapseWhitespace: true },
  }),

  // 提取 CSS 为独立文件(生产必备,才能 CSS 压缩)
  new MiniCssExtractPlugin({
    filename: 'css/[name].[contenthash:8].css',
  }),

  // 构建时的全局常量替换(注意必须 JSON.stringify)
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
    __APP_VERSION__: JSON.stringify('1.0.0'),
  }),

  // 把 public/ 下的静态文件原样复制到 dist/
  new CopyWebpackPlugin({
    patterns: [{ from: 'public', to: '.', globOptions: { ignore: ['**/index.html'] } }],
  }),
]

DefinePlugin 是做构建时字符串替换的,不是环境变量注入。__APP_VERSION__ 在代码里出现的地方,编译后会被直接替换成 "1.0.0"。必须用 JSON.stringify 是因为替换是字面量替换,JSON.stringify('1.0.0') 得到 '"1.0.0"'(带引号的字符串),替换到代码里才是合法的 JS 字符串。

自定义 Plugin:三个有意思的例子

构建质量监控(BuildStatsPlugin):

js 复制代码
class BuildStatsPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('BuildStatsPlugin', (stats) => {
      // 遍历所有 chunk,统计体积
      for (const chunk of stats.compilation.chunks) {
        for (const file of chunk.files) {
          const size = stats.compilation.assets[file].size();
          if (size > this.options.maxSize) {
            // 向 webpack 注入 warning(会在控制台显示)
            stats.compilation.warnings.push(
              new Error(`${file} 体积 ${size} 超过阈值`)
            );
          }
        }
      }
    });
  }
}

自动 CDN 外链(AutoExternalPlugin):

把手动维护 externals 和 HTML 中的 CDN script 这两件事合并成一个 plugin 自动完成:

js 复制代码
class AutoExternalPlugin {
  apply(compiler) {
    // 最早的钩子,在这里修改 externals
    compiler.hooks.environment.tap('AutoExternalPlugin', () => {
      compiler.options.externals = { lodash: '_', dayjs: 'dayjs' };
    });

    // 与 HtmlWebpackPlugin 协作,在 <head> 里插入 CDN script
    compiler.hooks.compilation.tap('AutoExternalPlugin', (compilation) => {
      HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync(
        'AutoExternalPlugin',
        (data, callback) => {
          data.headTags.unshift({ tagName: 'script', attributes: { src: CDN_URL } });
          callback(null, data);
        }
      );
    });
  }
}

循环依赖检测(ModuleDependencyGraphPlugin):

循环依赖是大型项目的常见隐患:A 依赖 B、B 依赖 C、C 又依赖 A。webpack 不报错,但运行时某个模块可能拿到 undefined。

js 复制代码
compiler.hooks.done.tap('ModuleDependencyGraphPlugin', (stats) => {
  // 构建有向图 edges: Map<moduleId, Set<depId>>
  const edges = new Map();
  for (const mod of stats.compilation.modules) {
    const deps = mod.dependencies.map(dep =>
      stats.compilation.moduleGraph.getResolvedModule(dep)
    ).filter(Boolean);
    edges.set(mod.resource, new Set(deps.map(d => d.resource)));
  }

  // DFS + 颜色标记法检测环
  // white(0) → 未访问  grey(1) → 访问中  black(2) → 完成
  // 发现 grey 节点 = 发现环
  const cycles = detectCycles(edges);
  if (cycles.length) {
    stats.compilation.warnings.push(new Error(
      `检测到循环依赖:\n${cycles.map(c => c.join(' → ')).join('\n')}`
    ));
  }
});

五、开发体验:HMR 和多环境配置

HMR 的工作原理

Hot Module Replacement(热模块替换)是开发体验的核心。它的完整流程:

markdown 复制代码
1. 文件保存
2. webpack 增量编译变更的模块(不是全量重编)
3. 生成 hot-update 补丁文件:*.hot-update.js + *.hot-update.json
4. 通过 WebSocket 通知浏览器有更新
5. 浏览器下载补丁
6. 调用旧模块的 dispose 回调(清理副作用,比如取消定时器)
7. 替换模块代码
8. 调用 module.hot.accept 回调(恢复状态、重渲染)

关键是第 8 步,需要模块自己声明如何处理更新:

js 复制代码
// 声明"如果 counter.js 更新了,这样处理"
if (module.hot) {
  module.hot.accept('./counter', () => {
    // 保存当前状态
    const prevCount = getCount();
    // 重新初始化
    cleanup();
    initCounter();
    // 恢复状态
    setCount(prevCount);
  });
}

如果没有 module.hot.accept,更新会沿依赖树向上冒泡,直到顶层仍没有 accept 处理,就降级为整页刷新。这就是为什么修改某些文件会触发整页刷新。

多环境配置分离

一个项目通常有三套配置,用 webpack-merge 合并:

csharp 复制代码
config/
├── webpack.base.js   # 公共:entry、HTML plugin、DefinePlugin、通用 loader
├── webpack.dev.js    # 开发专属:devServer、eval source map、不 hash
└── webpack.prod.js   # 生产专属:contenthash、CSS 提取、压缩、splitChunks
js 复制代码
// webpack.dev.js
const { merge } = require('webpack-merge');
const base = require('./webpack.base');

module.exports = merge(base, {
  mode: 'development',
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    port: 3000,
    hot: true,
    proxy: {
      '/api': { target: 'http://localhost:8080', changeOrigin: true },
    },
    historyApiFallback: true,  // SPA 路由回退
  },
});

merge 的关键特性:plugins、rules 等数组字段是追加(append)而不是覆盖,mode、devtool 等标量字段以后者为准。

devServer 常用配置

js 复制代码
devServer: {
  port: 3000,
  hot: true,

  // 解决跨域:/api/* → http://localhost:8080/api/*
  proxy: {
    '/api': {
      target: 'http://localhost:8080',
      changeOrigin: true,          // 修改请求头的 Host
      pathRewrite: { '^/api': '' }, // 可选:去掉 /api 前缀
    },
  },

  // SPA 必须:所有路径回退到 index.html
  historyApiFallback: true,

  // 额外的静态文件目录(mock JSON、public 资源)
  static: [
    { directory: path.join(__dirname, 'public') },
    { directory: path.join(__dirname, 'mock') },
  ],

  // 编译错误时页面覆盖层
  client: {
    overlay: { errors: true, warnings: false },
  },
}

六、性能优化:构建速度和产物体积

性能优化分两个维度,方向完全不同:构建速度 (让 webpack 编译得更快)和产物体积(让 dist 里的文件更小)。

构建速度优化

持久化文件缓存(最有效):

js 复制代码
cache: {
  type: 'filesystem',     // 写到磁盘,重启 webpack 仍然有效
  cacheDirectory: path.resolve(__dirname, '.webpack-cache'),
  buildDependencies: {
    // 配置文件变更时自动使缓存失效(很重要,别漏)
    config: [__filename],
  },
  version: '1.0',         // 需要手动失效所有缓存时,改版本号
}

第一次构建正常,第二次命中缓存后通常能快 60-80%。CI 环境可以持久化 .webpack-cache 目录,效果尤其显著。

thread-loader(多进程编译):

js 复制代码
{
  test: /\.js$/,
  use: [
    {
      loader: 'thread-loader',
      options: {
        workers: require('os').cpus().length - 1,
        poolTimeout: 2000,  // worker 空闲多久后销毁
      },
    },
    'babel-loader',  // 在 worker 进程里运行
  ],
}

有几个注意点:

  • thread-loader 有约 600ms 的进程启动开销,模块数少的小项目反而会变慢
  • 只适合 CPU 密集型 loader(babel-loader、ts-loader),IO 密集型没有效果
  • 进程间通信有序列化开销,某些返回复杂对象的 loader 可能不兼容

缩小编译范围:

js 复制代码
{
  test: /\.js$/,
  include: path.resolve(__dirname, 'src'),  // 只处理 src 目录
  // 不加 include 的话,babel 会尝试处理 node_modules 里的所有 js
}

// noParse:跳过已知没有依赖的大文件(不会递归分析它的 import)
module: {
  noParse: /jquery|lodash|moment/,
}

产物体积优化

Tree Shaking:删除未使用的导出

前提条件:

  1. ES Module(import/export),CommonJS require() 不支持
  2. optimization.usedExports: true(production 模式自动开启)
  3. package.json 中配置 sideEffects 字段
json 复制代码
// package.json
{
  "sideEffects": [
    "./src/side-effects.js",  // 这个文件有副作用,不能删
    "*.css",                  // CSS 文件 import 后不需要返回值,但有副作用
    "*.scss"
  ]
}

如果某个文件 import 了但没有使用任何导出,webpack 需要 sideEffects 来判断是否可以安全删除这个 import。sideEffects: false 意味着所有模块都没有副作用,Tree Shaking 最激进。

一个常见误区:Tree Shaking 删的是未使用的 export,不是未使用的代码行。如果整个模块都没有 export 被使用,且没有副作用,整个模块才会被删。

Code Splitting:按需加载

有两种方式触发 code splitting:

  1. splitChunks 配置(处理同步 import)
  2. 动态 import()(处理异步按需加载)

动态 import 加上魔法注释:

js 复制代码
// webpackChunkName → chunk 文件名(不加的话是数字 id,难以追踪)
// webpackPrefetch  → 浏览器空闲时预拉取(<link rel="prefetch">)
// webpackPreload   → 与父 chunk 并行加载(<link rel="preload">)

const { renderChart } = await import(
  /* webpackChunkName: "chart" */
  /* webpackPrefetch: true */
  './modules/chart'
);

Prefetch 和 Preload 的区别经常搞混:

  • Prefetch:下次可能用到(空闲时拉取,低优先级)
  • Preload:当前页就要用(并行拉取,高优先级,避免过度使用)

splitChunks 的合理配置:

js 复制代码
optimization: {
  // runtime 单独打包,避免它的变化影响业务 chunk 的 contenthash
  runtimeChunk: 'single',

  splitChunks: {
    chunks: 'all',        // 同步 + 异步都参与拆分
    minSize: 20_000,      // chunk 小于 20KB 不拆(太小的 chunk 反而增加请求数)
    cacheGroups: {
      vendors: {
        test: /node_modules/,
        name: 'vendors',  // 第三方包独立打包
        priority: 10,
        // 第三方包变更少,独立后用户不需要因为业务代码变化而重新下载它
      },
      common: {
        minChunks: 2,     // 被至少 2 个 chunk 引用才提取成公共 chunk
        priority: -10,
        reuseExistingChunk: true,
      },
    },
  },
}

压缩优化:

JS 压缩(TerserPlugin 是 webpack 5 默认的,production 模式自动用):

js 复制代码
new TerserPlugin({
  parallel: true,
  terserOptions: {
    compress: {
      drop_console: true,    // 生产环境删掉所有 console.*
      drop_debugger: true,
    },
    format: {
      comments: false,       // 不保留注释(减少体积)
    },
  },
  extractComments: false,    // 不生成 *.LICENSE.txt 文件(很烦)
})

CSS 压缩(需要先用 MiniCssExtractPlugin 提取成独立文件):

js 复制代码
new CssMinimizerPlugin({ parallel: true })

Scope Hoisting:

production 模式自动开启(optimization.concatenateModules: true)。把多个 ES Module 内联到同一作用域,消除模块包装函数:

js 复制代码
// 打包前:每个模块有包装函数
(function(module, exports) { exports.add = n => n + 1; })

// Scope Hoisting 后:直接内联
const add = n => n + 1;

体积减少幅度不大,但消除了模块包装的运行时开销,理论上执行速度有微小提升。要求必须是 ES Module,CommonJS 不受益。

externals + CDN:

大型第三方库走 CDN,不打入 bundle:

js 复制代码
externals: {
  lodash: '_',      // import _ from 'lodash' → 运行时读取 window._
  react:  'React',
  'react-dom': 'ReactDOM',
}

HTML 中需要手动加 CDN script(或者用 AutoExternalPlugin 自动化)。适合 lodash(~72KB gzip)、dayjs(~7KB)等变更不频繁、有可靠 CDN 的库。

缓存策略总结:

这几个配置配合使用,才能让浏览器缓存最大化发挥:

bash 复制代码
[contenthash:8]  → 内容不变则文件名不变 → 浏览器永久缓存
runtimeChunk     → runtime 单独打包,业务代码 hash 稳定
vendors chunk    → 第三方包几乎不变,hash 长期不变

七、高级特性:模块联邦和打包库

Module Federation:微前端的核心技术

这是 webpack 5 最重磅的新特性。它解决了一个问题:多个独立部署的前端应用之间,如何在运行时共享代码,而不是各自重复打包。

css 复制代码
传统方式:
  应用 A 打包了 react + lodash + 自己的代码
  应用 B 打包了 react + lodash + 自己的代码
  用户访问 A 后再访问 B,需要重新下载 react 和 lodash

Module Federation:
  应用 A 提供(exposes)部分组件
  应用 B 消费(remotes)应用 A 的组件
  react 和 lodash 只需要加载一次(shared singleton)

配置结构:

js 复制代码
// Remote(提供方)
new ModuleFederationPlugin({
  name: 'remote',
  filename: 'remoteEntry.js',       // 入口清单文件
  exposes: {
    './Button': './src/components/Button',
    './utils':  './src/utils/math',
  },
  shared: {
    lodash: { singleton: true },    // 强制单例
  },
})

// Host(消费方)
new ModuleFederationPlugin({
  name: 'host',
  remotes: {
    // 'remote' 是别名,对应代码里 import('remote/Button') 的前缀
    remote: 'remote@http://localhost:3011/remoteEntry.js',
  },
  shared: {
    lodash: { singleton: true },
  },
})

Host 的入口必须异步:

js 复制代码
// index.js --- 必须这样写
import('./bootstrap');  // 不能直接写业务代码

// bootstrap.js --- 实际的业务代码
import { createButton } from 'remote/Button';

原因是 webpack 需要先完成"模块协商"------获取 remoteEntry.js,对比双方 shared 的版本,确定最终加载哪个实例------然后才能执行业务代码。同步加载的话协商还没完成就开始执行,remote 模块会是 undefined。

singleton: true 对 React 和 Vue 是必须的:如果 Host 和 Remote 各自加载了一份 React,会有两个 React 实例,很多功能会报错(比如 hooks 的上下文无法跨越两个 React 实例)。

require.context:自动化注册

这个功能平时很少被提到,但在 Vue 项目里用处很大:

js 复制代码
// 替代手动 import 每个组件
const ctx = require.context('./components', false, /\.js$/);

ctx.keys().forEach(key => {
  const name = key.replace(/^\.\/(.*)\.js$/, '$1');  // './Button.js' → 'Button'
  Vue.component(name, ctx(key).default);
});

同理可以用于:自动聚合 Vuex modules、自动注册路由、自动加载 i18n 语言包。新增一个文件就自动注册,不需要改任何其他文件。

Asset Modules:统一资源处理

webpack 5 用四种内置类型统一了之前 file-loader/url-loader/raw-loader 的工作:

js 复制代码
// 选型思路:
// 大文件(图片/字体)→ asset/resource(输出独立文件,HTTP 缓存)
// 小图标(< 8KB)    → asset/inline(base64 内联,减少请求数)
// 文本/模板/shader   → asset/source(原始文本字符串)
// 不确定大小        → asset(按 maxSize 阈值自动选择)

{
  test: /\.(png|jpg|gif)$/,
  type: 'asset',
  parser: {
    dataUrlCondition: { maxSize: 8 * 1024 },
  },
  generator: {
    filename: 'images/[name].[hash:8][ext]',
  },
}

打包为库

开发工具库时,output.library 配置 UMD 格式,让用户可以用任何模块系统引用:

js 复制代码
output: {
  filename: 'my-utils.min.js',
  library: {
    name: 'MyUtils',        // 浏览器全局变量名:window.MyUtils
    type: 'umd',            // CommonJS + AMD + 全局变量 三合一
    export: 'default',
    umdNamedDefine: true,
  },
  globalObject: 'globalThis',  // 兼容 Node.js 和浏览器
}

外部化 peer dependencies(不要把 React 打进组件库里):

js 复制代码
externals: {
  react: {
    commonjs:  'react',
    commonjs2: 'react',
    amd:       'React',
    root:      'React',
  },
}

webpack 4 → 5 迁移

主要变化的对应关系:

webpack 4 webpack 5 说明
file-loader type: 'asset/resource' 内置,少一个依赖
url-loader type: 'asset/inline' 或 'asset' 内置
raw-loader type: 'asset/source' 内置
CleanWebpackPlugin output.clean: true 内置
cache-loader cache: { type: 'filesystem' } 内置,效果更好
HashedModuleIdsPlugin 默认行为 默认就是 deterministic
Node polyfill 自动注入 需要 resolve.fallback 避免无谓的体积膨胀

迁移时最容易踩的坑:Node core polyfill 报错 。webpack 4 会自动为 path/crypto/stream 等注入 polyfill,webpack 5 不再这样做。遇到报错时,要么安装 path-browserify 等 polyfill 包并在 resolve.fallback 里配置,要么确认代码是否真的需要这些 Node API。


总结:我的学习路径回顾

回头看这一周,走了一些弯路,也有一些节省时间的地方,记录下来给后来者参考。

先理解的,后来都受益:

  1. Loader 的 pitch + normal 双阶段:理解了这个之后,看任何 loader 的文档都更容易搞清楚它在链路里的位置
  2. Tapable 钩子类型:写 plugin 之前先把 tapable-demo.js 跑一遍,比直接看源码效率高很多
  3. contenthash 和 runtimeChunk:这两个配合使用才能让缓存策略真正生效,单独配一个效果减半

浪费时间的地方:

  1. 一开始花了很多时间研究 devtool 的各种选项,其实就记两个:开发用 eval-cheap-module-source-map,生产用 source-map 或 false
  2. 企业级 loader 里的 api-version-loader 和 env-inject-loader 实际项目里几乎不会用这种模式,偏学术性,可以跳过
  3. Module Federation 需要两个 devServer 同时运行才能看到效果,建议先把 Remote 和 Host 各自构建成静态文件,用 npx serve dist 启动来测试,比 devServer 更稳定

真正值得深挖的:

  1. 循环依赖检测的 DFS 实现(ModuleDependencyGraphPlugin),既复习了图算法,也理解了 webpack 内部怎么遍历模块
  2. splitChunks 的 cacheGroups 配置,直到自己手动调过 priority 和 minChunks 才真正理解每个字段的作用
  3. Tree Shaking 的 sideEffects 字段,这个很容易配错------漏掉 CSS 文件会导致样式消失

webpack 本身确实复杂,但复杂在于它的可扩展性和通用性。当你需要在构建过程中做任何自定义事情的时候,总能找到对应的钩子或者 loader 扩展点。这种设计思路本身就值得学习。

相关推荐
liangshanbo12152 天前
Webpack vs Vite 底层原理高频追问的问题
前端·webpack·node.js
liangshanbo12152 天前
面试题:AI 对话上下文超限怎么办?
面试·webpack·devops
liangshanbo12153 天前
Webpack 文件指纹面试题整理
前端·webpack·node.js
liangshanbo12153 天前
Webpack 常见 Plugin 面试题
前端·webpack·node.js
liangshanbo12153 天前
Webpack Tree Shaking 面试题整理
前端·webpack·node.js
陪我一起学编程3 天前
vue3笔记
前端·webpack·vue3·vite·脚手架·vue3笔记·组件式
liangshanbo12153 天前
Webpack的分包策略面试题
前端·webpack·node.js
liangshanbo12155 天前
面试题:如何优化 Webpack 的打包速度?
前端·webpack·node.js
晚风醉蝶6 天前
webpack 模块提取
前端·webpack·node.js·ast·逆向分析
晚风醉蝶16 天前
保姆级教程:用 AST 自动提取 webpack / rspack 打包后的模块
webpack·web逆向