Webpack 是如何打包的?(核心流程)
Webpack 的运行过程可以概括为:从一个“起点”出发,顺藤摸瓜,最后通过“翻译官”和“插件”输出成品。
- 初始化 (Initialization): 读取配置文件,加载各种插件(Plugins),确定构建环境。
- 寻找入口 (Entry): 找到你在配置中定义的 main.js(通常是 Vue 项目的起点)。
- 构建依赖图 (Dependency Graph): * Webpack 会递归地找出 main.js 引入的所有模块(App.vue、router.js、axios 等)。关键点: 它把每一个文件都看作一个“模块”。
- 编译模块 (Loaders): 这是最关键的一步。Webpack 原生只懂 JS 和 JSON。遇到 .vue 文件?交给 vue-loader。遇到 .css 文件?交给 css-loader。遇到 ES6 语法?交给 babel-loader。
- 输出 (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'。
- 路径搜寻 (Resolution): Webpack 遇到 import Vue from 'vue'。内核会查询 alias(别名)和 node_modules 路径,找到 vue 在硬盘上的物理地址。
- 创建模块对象: Webpack 为 main.js 创建一个模块对象。此时它发现这是一个 .js 文件,默认规则可以直接读取。
- 递归探索: Webpack 像爬虫一样扫描 main.js 的内容:发现 import Cookies from 'js-cookie' -> 去找 js-cookie。发现 import App from './App' -> 去找 App.vue
阶段二:转换与“翻译”(Loaders 介入)
这是 main.js 中内容最复杂的一步。由于 Webpack 只懂原生 JS,遇到非标准代码时,它会暂停并根据配置调用 Loader。
- CSS/SCSS 转换: 遇到 import 'normalize.css' 和 @/styles/index.scss。sass-loader:把 SCSS 翻译成标准 CSS。css-loader:把 CSS 转化成 Webpack 认识的 JS 模块字符串。
- Vue 组件处理: 遇到 import App from './App'(实际上是 App.vue)。vue-loader:极其关键!它把 .vue 拆开。<template> 转成 render 函数,<script> 交给 Babel 处理,<style> 交给 CSS Loaders。
- 语法降级 (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 配置进行物理分配:
- SplitChunks (分包策略):Webpack 看到你在 main.js 引入了 element-ui。根据你的配置,它把 ElementUI 的代码从 main.js 中“剥离”出来,放进 element-vend.js。。剩下的业务逻辑(那些 Vue.use 等)留在 index.js(也就是 app.js)。
- 代码压缩 (Minification):Plugin 登场。TerserPlugin 扫描所有分好的包,把变量名从 Cookies 改成 a,删掉注释和空格。
阶段五:输出与注入
这是最后一步,将内存中的结果写入硬盘的 dist 目录。
- 写入磁盘: 生成 js/index.[hash].js、js/vue-vend.[hash].js 等文件。
- 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 本身只负责“标记”,真正的“砍树”动作是由压缩工具(如 Terser 或 esbuild)完成的。压缩工具在扫描代码时,看到那些被标记为 unused 且确定没有副作用的代码块,会直接将其从 AST(抽象语法树)中剔除,不生成任何字节
npm run dev的运行原理
第一阶段:初始化(Initialization)
在代码运行之前,Webpack 需要先看清“家底”。
- 读取配置合并: Webpack 会将你的 vue.config.js、项目默认配置、以及各种插件配置合并成一个巨大的 Options 对象。
- 实例化 Compiler: Webpack 根据 Options 创建一个 Compiler 对象。你可以把它理解为整个打包过程的“总指挥”。它在整个生命周期中只存在一个。
- 加载插件(Plugins): 遍历配置中所有的 new Plugin(),执行它们的 apply 方法。此时,插件开始监听 Webpack 整个生命周期中的各个钩子(Hooks)。
第二阶段:构建依赖图(The Build Graph)
这是最耗 CPU 的阶段,也是 Webpack “黑盒”的核心。
- 确定入口(Entry): 通常是 src/main.js。
- 编译模块(Compile): * 从入口开始,调用 Loader 对文件进行转译。比如 .vue 文件会被 vue-loader 拆解,.js 会被 babel-loader 降低语法。
- 递归搜索: 当它遇到 import 或 require 时,会递归地去寻找依赖,直到所有的模块都被处理完毕。
- 生成 AST(抽象语法树): Webpack 内部会将代码解析成 AST,这样它就能精确地知道模块之间的引用关系,并为后续的模块 ID 分配做准备。
第三阶段:内存存储与服务启动(DevServer)
这是 npm run dev 与 build 的核心区别。
- 启动 webpack-dev-server: 这是一个基于 Express 的微型 Node 服务。
- 虚拟文件系统(Memory-fs): Webpack 不会将编译后的文件写到磁盘,而是利用 memfs 这样的库将结果写入 内存。
底层逻辑: 当浏览器请求 localhost:9527/app.js 时,DevServer 会直接从内存中读取二进制流并返回。
3. 建立 WebSocket: 启动一个 WebSocket 服务器(通常通过 sockjs 或 ws),准备与浏览器进行“实时通讯”。
第四阶段:热更新(HMR - 核心底层实现)
当你修改一行代码并保存时,真正的“魔法”发生了:
- 文件监听: Webpack 内部的 watch 模块发现文件变动,触发 增量编译。
- 生成补丁(Manifest & Update): * Webpack 会计算出变动的模块,生成一个描述文件(.json)和一个补丁文件(.js)。
- 推送通知: WebSocket 向浏览器发送一个 hash 信号。
- 浏览器拉取: 浏览器端的 HMR Runtime 收到通知后,通过 Ajax 请求那个 .json 描述文件,确认哪些模块变了,再通过 JSONP 请求获取最新的 .js 补丁。
- 模块替换: Runtime 找到旧模块,将其从缓存中剔除,并执行新补丁代码,重新触发 Vue 组件的渲染,而不需要刷新整个页面。