20分钟速学webpack

125 阅读9分钟
本文会从webpack文件结构入手,梳理出webpack核心的用法和概念。
你将会收获:
  1. webpack的entryoutput配置
  2. webpack的loader配置、原理、以及实战
  3. webpack的plugin配置、原理、以及实战
  4. webpack的HMR配置、原理
  5. webpack的source map配置
  6. webpack原理(是如何运行的?)
  7. 杂项:

一、Webpack文件结构


下面是一个webpack.config.js文件基本的配置:

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'development', // 模式
  entry: './src/index.js', // 打包入口地址
  output: {
    filename: 'bundle.js', // 输出文件名
    path: path.join(__dirname, 'dist') // 输出文件目录
    clean: true, // 清理 dist 目录
  },
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /.(png|svg|jpg|jpeg|gif)$/i,
        type: 'asset/resource',
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html', // 指定模板文件
      filename: 'index.html', // 输出的文件名
    }),
  ],
}

1. webpack的entryoutput配置

  • entry:入口路径
  • output.filename:输出文件名称
  • output.path:输出目录名称

上面的例子是单入口写法,还有多入口打包,生成多个js的方式。
例如:页面index/ 和 页面about/ 是两个独立的入口。

...
  entry: {
    index: './src/index/index.js', // 第一个入口
    about: './src/about/about.js', // 第二个入口
  },
  output: {
    filename: '[name].bundle.js', // 使用 [name] 占位符生成动态文件名
    path: path.resolve(__dirname, 'dist'), // 输出目录
    clean: true, // 清理 dist 目录
  },
...

当然,filename是可以使用hash值命名的。例如

...
  output: {
    filename: '[name].[contenthash].bundle.js',
    path: path.resolve(__dirname, 'dist'), 
    clean: true, 
  },
...

Webpack 提供了多种哈希值占位符:

  • [hash]:基于整个项目的构建生成哈希值,所有文件共享同一个哈希值。
  • [chunkhash]:基于每个 chunk 的内容生成哈希值,适用于多入口打包。
  • [contenthash]:基于文件内容生成哈希值,通常用于 CSS 或静态资源。

推荐使用 [contenthash],因为它可以更精确地反映文件内容的变化。好处是:

  • 当文件内容变化时,哈希值会改变,浏览器会加载新文件。
  • 当文件内容不变时,哈希值不变,浏览器会使用缓存。

注意:一般只在生产阶段使用hash,开发阶段无需使用。

2. webpack的loader配置、原理、以及实战

截取 Loader 代码如下:

...
module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'], // Babel 配置
          },
        },
      },
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /.(png|svg|jpg|jpeg|gif)$/i,
        type: 'asset/resource',
      },
    ],
  },
...

可见 Loader 通过 module.rules 进行配置。每个规则(Rule)通常包括以下属性:

  • test:匹配文件的正则表达式。
  • use:指定使用的 Loader,可以是字符串、对象或数组。
  • exclude:排除不需要处理的文件或目录。
  • include:指定需要处理的文件或目录。
  • loader:指定单个 Loader(use 的简写形式)。
  • options:传递给 Loader 的配置选项。

Loader 是一个函数,它接收源文件内容作为输入,经过处理后返回新的内容。Webpack 会按照配置的顺序依次调用 Loader。

2.1 Loader 的执行流程和原理

  1. 匹配文件:Webpack 根据 module.rules 中的 test 正则表达式匹配文件。
  2. 链式调用:如果匹配成功,Webpack 会按照 use 数组中 Loader 的顺序依次调用。
  3. 处理文件:每个 Loader 接收上一个 Loader 的处理结果,进行进一步处理。
  4. 返回结果:最后一个 Loader 返回 JavaScript 代码或可处理的模块。

值得一提的是 Laoder 的执行顺序是从后往前的。

2.2 Loader 的实战

以下是一个简单的自定义 Loader,这个 Loader 的功能是自动移除 JavaScript 文件中的 console.log 语句,适用于生产环境打包时清理调试代码。

/**
 * 自定义 Loader:移除 JavaScript 文件中的 console.log 语句
 * @param {string} source - JavaScript 文件内容
 * @returns {string} - 处理后的 JavaScript 内容
 */
module.exports = function (source) {
  // 使用正则表达式匹配并移除 console.log 语句
  const cleanedSource = source.replace(/console.log(.*?);?/g, '');

  // 返回处理后的内容
  return cleanedSource;
}

在 Webpack 配置中使用自定义 Loader:

...
rules: [
      {
        test: /.js$/, // 匹配 .js 文件
        use: [
          {
            loader: path.resolve(__dirname, 'remove-console-loader.js'), // 使用自定义 Loader
          },
          'babel-loader', // 其他 Loader(如 Babel)
        ],
      },
    ],
...

至此,你应该对 Loader 的工作流程有一个初步的认识了,在文章末尾,会将 Loader 的原理串联进webpack的原理之中。

3. webpack的plugin配置、原理、以及实战

plugin的配置简单直接,是直接通过在plugins字段里面新建对象。

const HtmlWebpackPlugin = require('html-webpack-plugin');
...
    plugins: [
        new HtmlWebpackPlugin({
          template: './src/index.html', // 指定模板文件
          filename: 'index.html', // 输出的文件名
        }),
      ],
...

可见:每一个plugin都是一个js对象,首先require进来构造函数,再new出这个实例对象。
那么问题来了,plugin的构造函数是怎么写的呢?下面展示一个简单的plugin类。

class MyPlugin {
  apply(compiler) {
    // 监听 'done' 钩子(构建完成时触发)
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('构建完成!');
    });

    // 监听 'emit' 钩子(生成资源到输出目录之前触发)
    compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
      console.log('正在生成资源...');
      // 可以访问 compilation.assets 修改输出内容
      callback();
    });
  }
}

module.exports = MyPlugin;

这是一个在构建的不同阶段打印出对应文字的plugin。可以看到其实plugin类的写法非常简单:

  1. 首先内部实现了一个apply方法,传入的参数是compiler。(这一步是固定的)
  2. compiler.hooks.[不同生命周期].tap('插件名称',(不同生命周期会有不同的参数)=>{})

注意callback并非由我们自定义,而是用来通知webpack我们的钩子执行结束,可以进行下一步操作了,所以无论成功与否,都需要去执行callback()/callback(err)

3.1 Plugin 的执行流程和原理

好了,上面我们知道了plugin的配置,以及plugin是如何编写的。但是一直到现在为止,我觉得把plugin的详细原理讲出来还是不太妥当。不妨我们卖个关子,先讲个大概。

  1. webpack的构建过程其实有很多个生命周期。
  2. webpack提供给plugin监听各个生命周期的hooks,让开发者在不同的构建阶段介入,做一些自定义的操作。
  3. 这些操作包括但不限于对产物的操作,例如:压缩js代码,提供热更新,显示构建进度等等。

粗略的说就是这些,具体的生命周期,和执行流程,我们放到最后的原理中详细阐述。

3.2 Plugin 的实战

就用上面那个简单的例子来说明好了。如下:

class MyPlugin {
  apply(compiler) {
    // 监听 'done' 钩子(构建完成时触发)
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('构建完成!');
    });

    // 监听 'emit' 钩子(生成资源到输出目录之前触发)
    compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
      console.log('正在生成资源...');
      // 可以访问 compilation.assets 修改输出内容
      callback();
    });
  }
}

module.exports = MyPlugin;

在配置文件中,可以这样写:

// webpack.config.js
const MyPlugin = require('./MyPlugin'); // 引入自定义 Plugin

module.exports = {
...
  plugins: [
    new MyPlugin(), // 使用自定义 Plugin
  ],
...
};

4.HMR配置、原理


HMRwebpack的核心功能之一,拥有热更新的项目给开发者带来的极大的便利。 通过设置devServer:{hot:true}就可以开启热更新,开启后可以根据代码的改动局部更新相应的页面内容,无需刷新整个页面。
配置如下:

...
  devServer: {
    hot: true, // 启用HMR
...
  },
    // webpack 5+不需要手动添加 HotModuleReplacementPlugin

4.1 HMR的流程及原理。

阶段 1:启动阶段
  1. npm run dev 触发

    • 启动 webpack-dev-server,同时创建两个实例:

      • Webpack 实例:监听文件变化、执行增量编译。
      • Server 实例:托管静态资源、提供 WebSocket 服务(默认端口 8080)。
  2. 注入客户端运行时

    • 自动注入以下代码到打包结果中

      // 1. WebSocket 客户端(通信层)
      import 'webpack-dev-server/client?http://localhost:8080';
      // 2. HMR 核心逻辑(控制层)
      import 'webpack/hot/dev-server';
      
    • 补充说明

      • 实际注入的代码由 HotModuleReplacementPlugin 生成,包含 module.hot API 实现。
      • 生产环境构建时会自动剔除这些代码。

阶段 2:文件修改与编译
  1. 监听文件变化

    • Webpack 通过 chokidar 库监听文件系统,触发重新编译。

    • 仅重新编译变更的模块,生成新 hash 和增量更新文件:

      • [hash].hot-update.json(变更清单)
      • [chunkId].[hash].hot-update.js(增量模块代码)
  2. 推送变更通知

    • Server 通过 WebSocket 向浏览器发送消息:

      // 消息示例
      { type: 'hash', data: 'a1b2c3' }  // 新编译的 hash 值
      { type: 'still-ok' }              // 编译完成但无更新
      { type: 'error', errors: [...] }  // 编译错误
      

阶段 3:浏览器处理更新
  1. 接收通知并拉取更新

    • 浏览器 HMR 运行时收到 hash 事件后:

      1. 请求 Manifest
        GET a1b2c3.hot-update.json(确认哪些 chunk 需要更新)。
      2. 下载增量模块
        GET 1.a1b2c3.hot-update.js(动态加载变更的模块代码)。
  2. 应用更新

    • 关键逻辑判断

      flowchart LR
          A[接收新模块] --> B{目标模块或其父模块\n是否调用 module.hot.accept?}
          B -->|是| C[执行 accept 回调]
          B -->|否| D[整页刷新]
      
    • 具体行为

      • 有 accept 回调:执行回调并局部更新(如重新渲染 React 组件)。
      • 无 accept:回退到 window.location.reload()
      • 更新失败(如模块执行报错):自动回退到整页刷新。

阶段 4:容错与优化
  1. 错误处理

    • 编译错误:通过 WebSocket 推送 error 类型消息,浏览器展示 overlay 错误遮罩。
    • 运行时错误:HMR 自动回退到整页刷新。
  2. 性能优化

    • Webpack 5 改进

      • 按需注入 HMR 运行时(未使用的 API 不生成代码)。
      • 可能合并 hash 和 manifest 消息,减少请求次数。

5. source-map

介绍source-map之前,想先问读者一个问题。你们有仔细观察过发布到线上的代码吗?能否直接通过控制台查看网页源码?如果能查看源码,那么是否会带来安全问题呢?

我们在dev调试的时候,是不需要对源代码进行代码压缩和代码混淆的,因为我们需要查看程序的执行,和错误堆栈。但是当我们的代码发布到生产环境,代码压缩可以有效减少代码体积,带来性能提升,代码混淆又能更好的保护我们代码逻辑的安全性,不容易被外部发现漏洞进行攻击。

比如:查看本地加密逻辑,构建爬虫工具不断爬取企业有价值的接口信息,构建黑客私有的信息库。

基于此source map的功能会便捷的解决以上问题。 5.1什么是source-map Source Map 是一个信息文件,它存储了源代码和转换后代码(如压缩、合并、编译后的代码)之间的位置映射关系。它就像一个“翻译字典”或“地图”,允许浏览器或调试工具将运行时的代码“映射”回原始的、人类可读的源代码。 现代前端开发流程中,源代码通常会经过一系列复杂的处理:

  1. 编译:  将 TypeScript、SCSS/Less、ES6+ 等高阶语言转换为浏览器能理解的 JavaScript 和 CSS。
  2. 合并:  将多个模块文件打包成一个或几个 bundle 文件以减少 HTTP 请求。
  3. 压缩:  删除空格、注释、缩短变量名(丑化)以减小文件体积。
  4. 优化:  进行 Tree Shaking、代码分割等。

最终部署到生产环境的是这些转换后的文件。如果代码在浏览器中运行时出现错误,调试工具(如 Chrome DevTools)只能定位到转换后代码的位置。试想一下,在压缩后的单行代码中看到一个错误,变量名都是 abc,这几乎是无法调试的。

5.2如何使用source-map

可以通过devtool或者sourceMapDevToolPlugin来配置sourcemap,常用的可以设置成eval、eval-source-map、eval-cheap-source-map等,具体可根据项目情况选择不同的soucemap模式。可以参考官网。 工作流程:

  1. Webpack 在打包时,根据你的配置生成(或不生成) Source Map。

  2. 在生成的 bundle 文件末尾,会添加一行特殊注释(或通过其他方式关联):

    javascript

    //# sourceMappingURL=bundle.js.map
    

    这行注释告诉浏览器:“这个文件的 Source Map 在哪里”。

  3. 当你在浏览器 DevTools 中开启 JavaScript 源映射功能后(默认开启),浏览器会下载并解析这个 .map 文件。

  4. 当你在 DevTools 的 Sources 面板中查看代码或遇到错误时,浏览器会利用这个映射关系,将错误堆栈和显示的代码反向映射到原始的源代码上。