返回首页

Webpack底层分析

布莱克2026-05-14 19:13已编辑
Tip:文章封面与内容无关,作者旅游时拍摄,因为没什么值得把四季都错过!

Webpack 是如何打包的?(核心流程)

Webpack 的运行过程可以概括为:从一个“起点”出发,顺藤摸瓜,最后通过“翻译官”和“插件”输出成品。

  1. 初始化 (Initialization): 读取配置文件,加载各种插件(Plugins),确定构建环境。
  2. 寻找入口 (Entry): 找到你在配置中定义的 main.js(通常是 Vue 项目的起点)。
  3. 构建依赖图 (Dependency Graph): * Webpack 会递归地找出 main.js 引入的所有模块(App.vue、router.js、axios 等)。关键点: 它把每一个文件都看作一个“模块”。
  4. 编译模块 (Loaders): 这是最关键的一步。Webpack 原生只懂 JS 和 JSON。遇到 .vue 文件?交给 vue-loader。遇到 .css 文件?交给 css-loader。遇到 ES6 语法?交给 babel-loader。
  5. 输出 (Output): 将所有处理好的模块合并、压缩,输出到 dist 目录。

Loader(模块转换器)

Webpack 本质上是一个 JS 编译器。它的核心逻辑非常死板:只认识 JS 和 JSON
当 Webpack 在构建依赖图时,遇到 .vue.scss.png 这种它看不懂的文件,就会报错。这时 Loader 就出场了。

  • 单一职责: 每个 Loader 只做一件事(比如把 Less 转成 CSS)。
  • 链式调用: Loader 支持链式传递。例如处理 .scss 文件: sass-loader (转成CSS) -> css-loader (转成JS模块) -> style-loader (注入HTML)。
  • 运行时机: 发生在 编译阶段 (Compile),在 Webpack 生成 AST(抽象语法树)并解析依赖之前。

核心示例

  • babel-loader:把 ES6+ 语法降级为浏览器兼容的 ES5。
  • vue-loader:把 .vue 单文件组件拆解成 template、script、style 三个部分。
  • url-loader / file-loader:处理图片资源。

Plugin:功能增强器(针对流程)

底层原理

Plugin 作用于整个 构建生命周期。Webpack 的底层架构是基于一个名为 Tapable 的插件系统。
Webpack 在运行过程中会广播出很多“事件钩子(Hooks)”,比如:

  • entryOption:开始读取配置了。
  • emit:文件打包好了,准备写磁盘了。
  • done:全部完工了。

Plugin 就像是一个监听器,它会“钩住”这些特定的时刻,在此时改变打包结果或者执行特定任务。

  • 更强大的能力: Plugin 可以访问 Webpack 的 compiler(编译器实例)和 compilation(当前的构建快照),它能改动最终生成的每一个文件。

核心示例

  • HtmlWebpackPlugin:在打包结束前,自动生成一个 index.html 并把所有 JS 路径注入进去。
  • CleanWebpackPlugin:在打包开始前,先去清空 dist 目录。
  • UglifyJsPlugin / TerserPlugin:在输出文件前,对 JS 进行极限混淆和压缩。

深入拆解 Webpack 打包与异步加载机制

阶段一:读取与解析

Webpack 启动后,首先加载你配置文件中的 entry: './src/main.js'

  1. 路径搜寻 (Resolution): Webpack 遇到 import Vue from 'vue'。内核会查询 alias(别名)和 node_modules 路径,找到 vue 在硬盘上的物理地址。
  2. 创建模块对象: Webpack 为 main.js 创建一个模块对象。此时它发现这是一个 .js 文件,默认规则可以直接读取。
  3. 递归探索: Webpack 像爬虫一样扫描 main.js 的内容:发现 import Cookies from 'js-cookie' -> 去找 js-cookie。发现 import App from './App' -> 去找 App.vue

阶段二:转换与“翻译”(Loaders 介入)

这是 main.js 中内容最复杂的一步。由于 Webpack 只懂原生 JS,遇到非标准代码时,它会暂停并根据配置调用 Loader

  1. CSS/SCSS 转换: 遇到 import 'normalize.css' 和 @/styles/index.scss。sass-loader:把 SCSS 翻译成标准 CSS。css-loader:把 CSS 转化成 Webpack 认识的 JS 模块字符串。
  2. Vue 组件处理: 遇到 import App from './App'(实际上是 App.vue)。vue-loader:极其关键!它把 .vue 拆开。<template> 转成 render 函数,<script> 交给 Babel 处理,<style> 交给 CSS Loaders。
  3. 语法降级 (Babel): 遇到 render: h => h(App)(箭头函数)和 ...filters。babel-loader:把这些 ES6+ 语法转成浏览器通用的 ES5,确保你的系统在旧浏览器不崩溃

阶段三:构建依赖图

在所有文件经过 Loader 翻译后,Webpack 得到了一份纯净的、JS 化的依赖关系。

  • AST 分析: Webpack 将翻译后的代码转为 AST(抽象语法树)。
  • 标记关系: 内核记录下:main.js 依赖 Vue, ElementUI, router, store 等。
  • 重复此流程: Webpack 继续去解析 router.js。如果在 router.js 里发现了 webpackChunkName(懒加载),它会给这个依赖打上一个特殊的“异步”标记

阶段四:封箱与分包

现在所有的代码都在内存里了,Webpack 开始按照你的 vue.config.js 配置进行物理分配:

  1. SplitChunks (分包策略):Webpack 看到你在 main.js 引入了 element-ui。根据你的配置,它把 ElementUI 的代码从 main.js 中“剥离”出来,放进 element-vend.js。。剩下的业务逻辑(那些 Vue.use 等)留在 index.js(也就是 app.js)。
  2. 代码压缩 (Minification):Plugin 登场。TerserPlugin 扫描所有分好的包,把变量名从 Cookies 改成 a,删掉注释和空格。

阶段五:输出与注入

这是最后一步,将内存中的结果写入硬盘的 dist 目录。

  1. 写入磁盘: 生成 js/index.[hash].js、js/vue-vend.[hash].js 等文件。
  2. HTML 注入 (HtmlWebpackPlugin):Plugin 读取你的 public/index.html 模板。自动生成 <script> 标签。注意顺序: 它会先插 vue-vend.js(如果有,因为它是基础),最后插 index.js(它是启动逻辑)


Vue 打包后,文件都去哪了?

当你运行 npm run build 后,生成的 dist 文件夹通常包含 index.html 和一个 static(或 js/css)目录。在 Vue CLI 的默认配置下,你会看到以下几类文件:

📁 JS 文件:代码的“大脑”

  • app.[hash].js (业务代码): 你写的 .vue 组件逻辑、router、store 都在这里。它是项目的核心业务逻辑。
  • chunk-vendors.[hash].js (第三方库): 为了提高缓存效率,Webpack 会把 node_modules 里的东西(比如 Vue、Vuex、Axios)抽离出来。因为你很少改动这些库,这样用户下次访问时,浏览器可以直接从缓存加载这个大文件。
  • chunk-xxx.[hash].js (异步组件): 如果你用了路由懒加载(import('./views/About.vue')),那么 About 页面的代码会被拆分成一个独立的文件。只有用户点击该页面时,浏览器才会下载它。

📁 CSS 文件:代码的“皮囊”

  • app.[hash].css: 所有在 .vue 文件中写的 <style>(且没被提取到异步块的)都会被合并到这里。
  • chunk-xxx.[hash].css: 对应异步 JS 组件的样式文件。

📁 静态资源:图片与字体

  • 这些通常在 img/ 或 fonts/ 文件夹下。如果图片很小(通常小于 10kb),Webpack 会把它转成 Base64 编码直接嵌在 JS 里,减少 HTTP 请求。


分包策略

分包是一门平衡的艺术。 并不是分的越细越好,过度分包(Over-splitting)反而会产生新的性能瓶颈

分的太细最直接的副作用

  • 痛点: 每一个独立的 chunk.js 或 chunk.css 文件都需要浏览器发起一个 HTTP 请求。
  • 虽然 HTTP/2 支持多路复用,但每个请求仍然有域名解析、TCP 连接握手(如果是新连接)以及浏览器解析报文头的开销。
  • 结果: 如果你把 1MB 的代码分成了 100 个 10KB 的小文件,浏览器的网络队列会瞬间塞满,加载速度反而比一个 1MB 的文件慢得多

太小的包“不拆”:

如果一个库只有 20KB-30KB(比如 axios 或 dayjs),没必要给它单开一个 cacheGroup。让它呆在 chunk-libs 里就好。

不常用的“懒加载”:

只有首页(LCP 关键路径)必须要用的库,才放进 initial 包。像 Echarts、Gantt 这种只在特定详情页用的库,绝对不要放进主包,必须配合路由懒加载。

引用频率低的组件“不抽”:

像配置 minChunks: 3 就比较科学。如果一个组件只被两个页面用了,抽出来反而增加一次 HTTP 请求;只有达到 3 次及以上,抽离才有“节省总体积”的收益。

webpack 如何知道引用次数?

webpack 从入口文件开始,递归分析所有 import / require 语句

webpack 遍历依赖图时,会维护一个引用计数器

// webpack 内部数据结构(简化版)
const moduleReferences = {
  'src/components/Header.vue': {
    refCount: 2,           // 被 main.js 和 About.vue 两个地方引用
    referencedBy: ['main.js', 'About.vue']
  },
  'src/utils/date.js': {
    refCount: 3,           // 被 main.js, Home.vue, Profile.vue 引用
    referencedBy: ['main.js', 'Home.vue', 'Profile.vue']
  },
  'src/utils/helper.js': {
    refCount: 1,           // 只被 utils/date.js 引用,不是直接被页面引用
    referencedBy: ['src/utils/date.js']
  }
}

splitChunks.minChunks 读取的就是这个 refCount

cacheGroups: {
  common: {
    test: /[\\/]src[\\/]/,
    name: 'common',
    // 根据模块路径和引用次数动态判断
    minChunks(module, count) {
      // 工具函数:被2个以上页面引用就提取
      if (module.resource && module.resource.includes('/utils/')) {
        return count >= 2
      }
      // UI组件:被4个以上页面引用才提取(太大)
      if (module.resource && module.resource.includes('/components/')) {
        return count >= 4
      }
      // 其他:默认3次
      return count >= 3
    },
    reuseExistingChunk: true
  }
}


Webpack如何进行的“摇树”

在 Webpack 处理 Vue 3 项目时,摇树优化发生在以下三个阶段:

第一步:标记 (Marking)

Webpack 在构建依赖图时,会分析每个 ESM 文件的 export。如果一个 export 出来的变量没有被任何地方 import,Webpack 会在生成的代码中给它打上一个注释标记:/* unused harmony export */

第二步:识别副作用 (Side Effects)

这是最关键的一步。在你的 package.json 中,你会看到 "sideEffects": false 或者在 Webpack 配置中进行设置。

  • 如果模块标记为“无副作用”,Webpack 就可以放心地把那些“被标记为未使用”的代码删掉。
  • Vue 3 的内部包都在 package.json 里正确配置了 sideEffects 标记。

第三步:压缩与删除 (Terser / Minimizer)

Webpack 本身只负责“标记”,真正的“砍树”动作是由压缩工具(如 Terseresbuild)完成的。压缩工具在扫描代码时,看到那些被标记为 unused 且确定没有副作用的代码块,会直接将其从 AST(抽象语法树)中剔除,不生成任何字节


npm run dev的运行原理

第一阶段:初始化(Initialization)

在代码运行之前,Webpack 需要先看清“家底”。

  1. 读取配置合并: Webpack 会将你的 vue.config.js、项目默认配置、以及各种插件配置合并成一个巨大的 Options 对象。
  2. 实例化 Compiler: Webpack 根据 Options 创建一个 Compiler 对象。你可以把它理解为整个打包过程的“总指挥”。它在整个生命周期中只存在一个。
  3. 加载插件(Plugins): 遍历配置中所有的 new Plugin(),执行它们的 apply 方法。此时,插件开始监听 Webpack 整个生命周期中的各个钩子(Hooks)。


第二阶段:构建依赖图(The Build Graph)

这是最耗 CPU 的阶段,也是 Webpack “黑盒”的核心。

  1. 确定入口(Entry): 通常是 src/main.js。
  2. 编译模块(Compile): * 从入口开始,调用 Loader 对文件进行转译。比如 .vue 文件会被 vue-loader 拆解,.js 会被 babel-loader 降低语法。
  3. 递归搜索: 当它遇到 import 或 require 时,会递归地去寻找依赖,直到所有的模块都被处理完毕。
  4. 生成 AST(抽象语法树): Webpack 内部会将代码解析成 AST,这样它就能精确地知道模块之间的引用关系,并为后续的模块 ID 分配做准备。


第三阶段:内存存储与服务启动(DevServer)

这是 npm run devbuild 的核心区别。

  1. 启动 webpack-dev-server: 这是一个基于 Express 的微型 Node 服务。
  2. 虚拟文件系统(Memory-fs): Webpack 不会将编译后的文件写到磁盘,而是利用 memfs 这样的库将结果写入 内存。

       底层逻辑: 当浏览器请求 localhost:9527/app.js 时,DevServer 会直接从内存中读取二进制流并返回。

3. 建立 WebSocket: 启动一个 WebSocket 服务器(通常通过 sockjs 或 ws),准备与浏览器进行“实时通讯”。


第四阶段:热更新(HMR - 核心底层实现)

当你修改一行代码并保存时,真正的“魔法”发生了:

  1. 文件监听: Webpack 内部的 watch 模块发现文件变动,触发 增量编译。
  2. 生成补丁(Manifest & Update): * Webpack 会计算出变动的模块,生成一个描述文件(.json)和一个补丁文件(.js)。
  3. 推送通知: WebSocket 向浏览器发送一个 hash 信号。
  4. 浏览器拉取: 浏览器端的 HMR Runtime 收到通知后,通过 Ajax 请求那个 .json 描述文件,确认哪些模块变了,再通过 JSONP 请求获取最新的 .js 补丁。
  5. 模块替换: Runtime 找到旧模块,将其从缓存中剔除,并执行新补丁代码,重新触发 Vue 组件的渲染,而不需要刷新整个页面。


assistant