Webpack系列-编译过程

上一篇我们介绍了 Webpack 的基本概念和基础使用,了解到它是一个静态模块打包工具,通过入口、出口、Loader 和插件四大核心概念完成构建工作。这一篇我们将深入 Webpack 的编译过程,按照「初始化→构建依赖图→代码转换→Chunk 分割→优化处理→资源输出」的完整流程,深入拆解 Webpack 的编译细节。

初始化

此阶段将完成环境准备和配置整合,为后续流程奠定基础。

1. 配置收集与合并

启动webpack的方式主要分为Node APICLI两种方式。

1. Node.js API方式

js 复制代码
const webpack = require('webpack');
let  config = require('./webpack.config.js') // 配置文件
// 通过使用yargs等工具解析命令行参数
// config= mergeConfig({...config, ...{命令行参数}})
const compiler = webpack(config)

2. CLI方式

在输入命令时(如webpack --mode=development),webpack-cli将开始运行,其内部处理流程为:

2. 执行webpack函数

js 复制代码
// lib/webpack.js 简化版本
const webpack = (options, callback) => {
  const create = () => {
    if(!Array.isArray(options)) {
      getValidateSchema(options) // 通过schema-utils库配置验证
    }
    const compiler = createCompiler(options) // 创建编译器对象 编译核心对象
    return compiler
  }
  // 判断回调函数
  if(callback) {
    const compiler = create()
    compiler.run((err, stats) => {
      // 编译器关闭时执行回调函数
      compiler.close((err2) => {
          callback(err || err2, stats)
      })
    })
  } else {
      return create() // 返回编译器对象
  }
}

核心初始化 createCompiler函数

js 复制代码
// lib/webpack.js 简化版本
const createCompiler = (rawOptions, compilerIndex) => {
  // 将配置转换为标准配置
  const options = getNormalizedWebpackOptions(rawOptions);
  applyWebpackOptionsBaseDefaults(options);
  // 创建compiler对象 它是webpack的核心控制中枢
  // 它继承了Tapable库 具有强大的发布订阅能力,贯穿着Webpack整个构建过程
  const compiler = new Compiler(options);
  // 初始化 NodeEnvironmentPlugin
  // 这个插件为 compiler 挂载了 Node.js 环境下的文件系统(fs)和输入输出系统
  new NodeEnvironmentPlugin({
    infrastructureLogging: options.infrastructureLogging,
  }).apply(compiler);
  // 遍历配置中的plugins数组,并调用每个插件的apply方法,从而挂载插件
  if (Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      if (typeof plugin === "function") {
        plugin.call(compiler, compiler);
      } else if (plugin) {
        plugin.apply(compiler);
      }
    }
  }
  // 根据归一化后的 options,调用 applyWebpackOptionsDefaults 函数。
  // 这个函数非常重要,它根据 `mode`(development/production/none)等设置,
  // 为所有未指定的配置项填充智能默认值。
  // 例如,在生产模式下,会自动开启 TerserPlugin 进行代码压缩。
  const resolvedDefaultOptions = applyWebpackOptionsDefaults(
    options,
    compilerIndex
  );
  if (resolvedDefaultOptions.platform) {
    compiler.platform = resolvedDefaultOptions.platform;
  }
  // 这是插件生命周期中的早期钩子,表示环境已经准备就绪。
  compiler.hooks.environment.call();
  compiler.hooks.afterEnvironment.call();
  // 根据配置决定启用哪些内置功能
  new WebpackOptionsApply().process(options, compiler);
  compiler.hooks.initialize.call();
  return compiler;
};

createCompiler函数是初始化的心脏。主要做了如下几件事情:

  • 配置归一化: 调用getNormalizedWebpackOptions将配置转换为Webpack内部标准配置
  • 应用默认配置 执行applyWebpackOptionsDefaults对配置集中管理,根据mode值的不同设置不同的默认配置,这就是为什么生产环境下会自动开启 TerserPlugin 进行代码压缩的原因
  • 应用内置插件 根据配置,决定启用哪些内置功能

构建依赖图

构建依赖图是整个构建过程中的核心,依赖图是 Webpack 在打包过程中建立的一个内部图谱,它清晰地描述了项目中所有模块(文件)之间的依赖关系。底层逻辑如下:

1. 入口点开始

Webpack根据配置的entry字段的值开始

js 复制代码
module.exports = {
  entry: './src/index.js' // 解析的起点
}

1. 解析入口文件

根据配置entry的入口路径读取入口文件内容,使用JS解析器(如acorn)将文件内容转换为AST(抽象语法树),遍历AST识别依赖声明,找到所有ImportDeclarationCallExpression(callee.name为require)的节点。

2. 依赖解析

获取入口所有依赖声明节点后,将依赖模块路径转换为绝对路径,这个过程由enhanced-resolve包完成,遵循如下策略:

  • 解析绝对路径: 如果是绝对路径,直接使用。

  • 解析相对路径: Webpack依据当前所在文件的目录为上下文与相对路径拼接,形成绝对路径

  • 解析第三方路径: 当遇到类似import _ from lodash这样的情况,Webpack会模拟Node.js的模块解析策略:

    1. 查找node_modules目录:从当前目录开始,向上递归查找,直到文件系统的根目录。例如在src/index.js中引入import _ from lodash,查找顺序:
    • src/node_modules/lodash

    • /project_name/node_modules/loadsh

    • .../.../node_modules/lodash

    1. 处理package.json:在找到第三方库目录后,查看package.jsonmainmodule字段,以确定入口文件。如没有package.json则尝试查找index.js等默认文件。
  • 别名: 在解析过程中,Webpack还会检查配置中的resolve.alias。如匹配后将别名替换别名指向的路径。

js 复制代码
resolve: {
  alias: {
    '@': path.resolve(__dirname, 'src/'), // 将 @ 映射到 src 目录的绝对路径
  }
}
// 之后就可以使用:import '@/utils' 来代替 './src/utils'
  • 扩展名处理: 如果路径中没有文件扩展名,Webpack会按照resolve.extensions配置的列表依次尝试添加扩展名,直到找到存在的文件。
js 复制代码
resolve: {
  extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'] // 默认值包含这些
}
// 当遇到 `import './utils'` 时,会依次查找 `./utils.js`, `./utils.jsx`, ... 直到找到 `./utils.js` 文件。

3. 文件类型识别与 Loader 匹配

确定了文件的绝对路径后,Webpack会读取文件内容。在转化为AST之前,会根据文件扩展名(.css,.scss,.png,.ts,.jsx)来判断是否需要使用Loader进行预处理转换为JS模块。

⚠️ 注意

此时Loader尚未真正执行转换工作。此阶段会根据配置的module.rules确定文件需要哪些Loader,为后续做好准备。

4. 递归依赖收集

Webpack会依次根据入口文件的所有依赖路径,按照解析入口类似操作,Webapck会:

  • 将内容转换AST(抽象语法树)
  • 遍历AST,找出所有的importrequire等依赖声明
  • 对找到每个依赖声明,重复步骤2(依赖解析)和步骤3(文件类型识别与 Loader 匹配) 即递归地解析路径、文件类型识别、收集依赖。 在上述过程会持续进行,直到从项目入口文件出发找到所有的模块和解析即依赖收集。形成一棵完整的模块树即依赖图。

5. 创建模块对象

在递归收集依赖的同时会对每个解析到的模块,创建一个Module对象。此对象包含了如下信息:

  • id:通常为模块的绝对路径
  • dependencies:该模块直接依赖的其他模块的路径列表
  • request:原始的依赖路径
  • resource:解析后的绝对路径
  • loaders:需要应用到的Loader列表;结构类似
js 复制代码
{
 id: `/src/style.css`,
 dependenices: ['/src/global.css'],
 request: './style.css',
 resource: 'src/style/css',
 loaders: [
   {loader: /project_name/node_modules/style-loader/dist/index,js, options: {}},
   {loader: /project_name/node_modules/css-loader/dist/index,js, options: {}}
 ]
}

6. Loader处理

在递归收集依赖中,会创建Module对象,匹配到了需要执行的loader。它们放入到了Module对象中的loaders数组中。通过Module.loaders数组进行loader的执行。流程如下:

  • 首先进入pitch 阶段,从左到右执行。 若某个pitch返回非undefined值,则触发短路逻辑
    • 跳过后续所有loaderpitch
    • 跳过模块本身的读取文件内容
    • 直接返回值作为模块内容交给当前loaderNormal阶段处理。
  • 读取模块原始内容:如果在pitch阶段没有发生短路,Webpack将读取模块原始内容,作为后续处理的输入
  • 进入Normal阶段,从右到左执行(最后一个loader最先处理,第一个loader最后处理)
  • 输出转化后的JS模块代码

经过上述以递归遍历找到所有依赖,构建一个有向图数据结构的依赖图,以便后续处理。

代码转换

此阶段Webpack将一个个独立的、遵循各种规范的模块源文件,进行转换、封装成能够在一个作用域中协同工作并能在浏览器中执行的代码。

Webpack不会执行运行ES Module的import/export,而是将其替换成自身实现的模块系统。大致代码如下:

js 复制代码
// 这是一个简化到极致的示例,用于说明原理
(function(modules) { // webpackBootstrap 启动函数
  // 1. 模块缓存
  var installedModules = {};

  // 2. Webpack 自定义的 require 函数
  function __webpack_require__(moduleId) {
    // 检查缓存
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 创建新模块(并放入缓存)
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false, // 是否已加载
      exports: {} // 模块的导出对象
    };
    // 执行模块函数
    // modules[moduleId] 就是我们下面看到的被封装后的函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true; // 标记为已加载
    // 返回该模块的 exports
    return module.exports;
  }
  // 3. 加载入口模块
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
  //  modules 对象:一个字典,key 是模块ID(通常是路径),value 是一个函数
  "./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    // 1. 标记 __webpack_exports__ 为 ES Module
    // 2. 通过 __webpack_require__ 加载依赖模块 './greet.js'
    var _greet_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/greet.js");
    // 3. 使用被导入的内容
    console.log(_greet_js__WEBPACK_IMPORTED_MODULE_0__["greet"]('World'));
  }),

  "./src/greet.js": (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    // 1. 将 greet 函数挂载到 __webpack_exports__ 上
    __webpack_exports__["greet"] = (function(name) {
      return `Hello, ${name}!`;
    });
  })
});

该阶段主要是将多种模块规范组成的依赖图中每个模块的导入/导出,重写为Webpack自定义的__webpack_require____webpack_exports__语句,并将每个模块包裹成一个函数。形成一个完整的、自包含的、键值对形式的 modules 对象,以及驱动这个对象的运行代码。

Chunk分割

Chunk分割阶段从"模块化"思维转向"交付"思维的关键一步,直接决定了最终生成的资源文件(Bundle)的数量和内容。

什么是Chunk?

Chunk是什么呢?理清几个概念:

  • Module(模块) :你的源代码文件,无论是 ESM、CommonJS 还是 AMD,经过 Loader 转换后都成为 Webpack 内部的模块。它是构建依赖图的基本单位
  • Chunk 一个或多个模块(Module)的集合。它是分割和合并的中间产物
  • Bundle 最终被写入输出目录的物理文件。通常情况下,一个 Chunk 会对应一个 Bundle,但也有例外(如 devtool 配置为 'source-map' 时,一个 Chunk 会对应一个 .js Bundle 和一个 .js.map Bundle)

Chunk分割处理

Chunk 分割不是随机进行的,在Chunk分割阶段主要处理入口动态导入

  • 入口点分割: 这是基础分割方式,在webpack.config.js中定了几个入口就有几个Chunk。 这指的是初始Chunk,后续可能通过SplitChunksPlugin进一步分割。
js 复制代码
modules.exports = {
  entry: {
    app: './src/app.js',
    user: './src/user.js'
  }
}

在这种情况下,Webpack 会创建两个 Chunk:

  • app Chunk: 包含./src/app.js以及所有依赖(除非有其他的规则分割出去)
  • user Chunk:包含./src/user.js以及所有依赖
  • 动态导入分割: 这是实现代码分割最常用、最有效的方式。通过 ES2020 的 import() 语法或 Webpack 特定的 require.ensure,可以显式地告诉 Webpack:"这里应该分割出一个新的 Chunk"。

优化处理

优化处理阶段主要根据Webpack内部的optimizationPluginswebpack.config.js中配置的optimization的选项进行优化处理。 当所有模块都被编译、转换并组织到不同的 Chunk 中后,就进入了 优化处理 阶段。这个阶段的核心目标是:通过各种智能手段,减小最终输出文件的体积、提升应用程序在浏览器中的运行性能。

常见的优化手段:

  • Tree Shaking(摇树优化):移除 JavaScript 上下文中未引用(dead code)的代码
  • Scope Hoisting(提升作用域):将模块打包到一个函数作用域内,减少函数声明和闭包的数量,从而减小包体积并提升运行速度
  • Code Splitting(代码分割):在Chunk分割阶段进行初步的分割,在优化阶段会进行更加智能的合并和拆分。主要依靠Webpack**SplitChunksPlugin**内置插件处理。
  • Minification(代码压缩):通过删除空白符、注释、缩短变量名等方式,极小化 JavaScript 和 CSS 代码的体积
  • Module and Chunk IDS:为模块和 Chunk 分配合适的 ID,以优化长期缓存
  • Side Effects:跳过整个未使用的模块/库,实现更极致的 Tree Shaking。在package.json中声明sideEffects:false

注意 📢

不管webpackmode是生产还是开发模式都会进入优化处理阶段,只是优化的策略不同:

  • 开发模式:以构建速度和调试体验优先
  • 生成模式:以代码体积和运行性能优先

资源处理

资源输出 发生在 Webpack 完成了所有模块的编译、转换、依赖图构建、代码分割和优化之后。此时,在内存中已经存在一个或多个准备就绪的 Chunk (代码块)。这个阶段的任务就是将这些内存中的 Chunk 转换成最终的 Asset(资源文件),并按照配置将它们写入到项目的输出目录中。

资源输出基本流程:

  1. 确定输出路径和文件名 Webpack 需要知道把文件写到哪里,以及叫什么名字。这由 webpack.config.js 中的 output 配置项决定
  2. 模板替换和文件生成 Webpack 会根据上一步的文件名模板,结合每个 Chunk 的具体信息,进行占位符替换,最终生成确定的文件名。

例如,一个名为 main 的入口 Chunk,其内容哈希为 a1b2c3d4,那么根据 [name].[contenthash].js 模板,最终文件名就是 main.a1b2c3d4.js。 3. 文件发射 这是将内存中的 Chunk 内容真正写入到磁盘的步骤。在 Webpack 的源码中,这个动作由 compiler 对象上的 emit 钩子触发。

  1. 资源写入磁盘 Webpack 通过 Node.js 的 fs 模块,将每个 Chunk 和 Asset 的内容(已经是字符串或 Buffer 形式)写入到指定的文件路径。

小结

Webpack 的编译过程是一个精密的工业化流水线。它从初始化 配置开始,通过构建依赖图 精准地描绘出项目的模块脉络。接着,Loader 作为"翻译官"将各类资源转换为 JavaScript,代码转换 阶段则用统一的模块系统将其封装。随后,Chunk 分割 根据入口和动态导入策略,将模块组织成利于交付的代码块,再经过优化处理 (如 Tree Shaking、压缩等)剔除冗余、提升性能。最终,在资源输出阶段,将所有处理完毕的 Chunk 转换为最终的 Bundle 文件并写入磁盘。

理解这套从"模块"到"资源"的完整转换流程,是进行高效配置优化和性能调优的基石

相关推荐
米诺zuo1 天前
nextjs文件路由、路由组
前端·next.js
加个鸡腿儿1 天前
锚点跳转-附带CSS样式 & 阻止页面刷新技术方案
前端·javascript·css
dragon7251 天前
FutureProvider会刷新两次的问题研究
前端·flutter
天蓝色的鱼鱼1 天前
Next.js路由全解析:Pages Router 与 App Router,你选对了吗?
前端·next.js
xun_xing1 天前
基于Nextjs15的学习手记
前端·javascript·react.js
有意义1 天前
Vibe Coding:人机共生时代的开发革命 —— 从概念到 Chrome 扩展实战
前端·ai编程·vibecoding
梅梅绵绵冰1 天前
SpringMVC快速入门
前端
kirkWang1 天前
HarmonyOS 6.0 服务卡片实战:把「轻食刻」装进桌面,让轻断食一眼可控
前端
1024小神1 天前
VNBarcodeObservation的结果中observation.boundingBox 是什么类型?
前端
xun_xing1 天前
Javascript的Iterator和Generator
前端·javascript