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

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

代码示例均来自我自己写的练习项目(phase-1phase-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.jsabout.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 的关键特性:pluginsrules 等数组字段是追加(append)而不是覆盖,modedevtool 等标量字段以后者为准。

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-mapfalse
  2. 企业级 loader 里的 api-version-loaderenv-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 扩展点。这种设计思路本身就值得学习。

相关推荐
whuhewei4 天前
Webpack5构建效率优化
前端·webpack
xiaotao1314 天前
Vite 与 Webpack 开发/打包时环境变量对比
前端·vue.js·webpack
netkiller-BG7NYT4 天前
yoloutils - Openclaw Agent Skill
前端·webpack·node.js
qq_385999085 天前
Win7 64 位 + MinGW64 + CMake + OpenCV 之二
人工智能·opencv·webpack
小小弯_Shelby7 天前
webpack优化:Vue配置compression-webpack-plugin实现gzip压缩
前端·vue.js·webpack
色空大师8 天前
网站搭建实操(十)前端搭建
前端·webpack·vue·网站·论坛
辻戋10 天前
从零手写mini-vite
webpack·vite·esbuild
辻戋13 天前
从零开始手写mini-webpack
前端·webpack·node.js
妮妮喔妮14 天前
webpack中plugin和loader的区别
webpack