
前端开发者每天都会和构建工具打交道Webpack、Vite、Rollup、esbuild、Babel……装依赖、跑脚本、等编译、看报错然后继续写业务代码。但很多人从来没有认真想过一个问题构建工具到底在做什么它凭什么知道我改了哪个文件又是怎么把几百个互相关联的模块变成几个静态文件的我之前也处于这种“会用但不懂”的状态直到有一次用Webpack打包遇到一个诡异的循环依赖报错被逼着去看构建产物才真正开始理解构建工具的内部机制。后来在PyInstaller、Electron、Docker这些不同领域里也反复看到类似的概念越来越发现一件事所有构建工具的核心工作其实就是三件事——把代码转译成目标环境能懂的形式、把散落的模块打包成可发布的产物、用递归的方式遍历出一张完整的依赖关系图。这篇文章我不讲某个具体工具的配置优化而是把这三大核心机制拆开讲清楚构建工具在“构建”时到底走了哪些步骤每一步为什么要这么做以及各个工具之间看似完全不同的功能背后为什么能统一到这套逻辑里。无论你用的是Webpack还是Vite是打包前端还是打包Python脚本理解这套底层逻辑都能让你在遇到问题时更快定位在看构建日志时不再一头雾水。1. 转译让代码变成目标环境真正认识的样子转译是构建流程的第一道工序也是很多开发者最先接触到的概念。简单说转译就是把一种代码变成另一种语义等价的代码。注意这里不是“翻译”也不是“编译”而是“转译”——输入和输出的抽象层级差不多只是语法、特性、格式不同。1.1 转译的本质是“特性降级”不是“语言翻译”很多人一听到Babel就以为它是在把ES6翻译成ES5。这个说法不算错但不够准确。ES6和ES5是同一种语言的不同版本Babel做的事情是把你代码里用到的“目标环境还不支持的新特性”转换成“目标环境已经支持的老特性”。所以更准确的说法是转译是对语言特性的降级处理。举一个最典型的例子箭头函数// 源码 const add (a, b) a b; // Babel转译后 var add function(a, b) { return a b; };前者是ES6语法后者是ES5语法两者都是JavaScript。Babel做的事情就是扫描你的代码找到目标环境不支持的语法节点把节点替换成语义等价的旧语法节点。但如果你以为转译只是改改箭头函数、模板字符串这些语法糖那就错了。真正复杂的转译场景往往发生在TypeScript、JSX、Vue单文件组件这些“不是标准JavaScript”的语法上。拿JSX来说它既不是HTML也不是JavaScript浏览器根本不认识。构建工具必须先把JSX转译成JavaScript的函数调用。// 源码 const element h1 classNametitleHello/h1; // 转译后 const element React.createElement(h1, { className: title }, Hello);这行代码很能说明问题转译不是把JSX“翻译”成HTML字符串而是把JSX语法结构展开成普通的JavaScript函数调用由React运行时去创建真实的DOM节点。这个设计的关键在于JSX只是一个语法层面的约定真正干活的是React.createElement这个运行时函数。1.2 Babel的插件机制一段代码的三次变身说到Babel需要理解它的工作流程这样才能看懂它为什么由那么多零散的包组成以及为什么在babel.config.js里要配置preset和plugin。Babel的处理过程分为三步解析、转换、生成。解析阶段把源码变成AST抽象语法树转换阶段对AST做修改生成阶段把修改后的AST变回代码。三个步骤里真正起到“转译”作用的是第二步而第二步的行为完全由插件决定。{ presets: [ [babel/preset-env, { targets: 0.25%, not dead }] ], plugins: [babel/plugin-transform-runtime] }这里有个非常容易混淆的点preset和plugin的关系。plugin是最小粒度的转换规则比如babel/plugin-transform-arrow-functions只管箭头函数babel/plugin-transform-template-literals只管模板字符串。preset是一组plugin的集合babel/preset-env就是一个动态组合的插件包它会根据你配置的targets目标浏览器版本来决定启用哪些plugin。为什么要分这么多层因为转译对象是语法节点每种语法特性对应一种转换逻辑拆成独立插件才能按需加载。如果一个老旧的浏览器只需要降级箭头函数就没必要把所有ES6新特性都转换一遍这样能减少构建产物体积也能加快构建速度。值得一提的是preset-env默认只转译语法不转译新API。比如Promise、Array.from、Object.assign这些ES6新增的全局对象和方法在旧环境里根本不存在转译语法解决不了这个问题需要引入polyfill——也就是用老语法重新实现一遍新API。// 数组的includes方法老环境没有 if (!Array.prototype.includes) { Array.prototype.includes function(searchElement, fromIndex) { // 用indexOf做等价实现 }; }这就是为什么构建工具打出来的包有时候看起来很长因为你可能在三行业务代码里用了一个includes构建工具为了让它能在旧浏览器里运行会把整个includes的polyfill实现都打进去。这也是性能优化里经常提到的“按需引入polyfill”的原始动机。1.3 转译器的性能分化从Babel到esbuild、SWC前面说的Babel工作流程——解析成AST、修改AST、生成代码——属于典型的“三段式”转译器设计。这种设计的优点是灵活插件生态极其丰富缺点是慢因为JS本身是解释型语言处理大规模AST时性能有限。于是出现了用Go或者Rust写的转译器。esbuild是用Go写的SWC是用Rust写的。它们做的事情和Babel一模一样——解析源码、遍历AST、转换语法、生成代码——但因为底层语言是编译型语言而且充分利用了并行计算能力转译速度比Babel快几十倍甚至上百倍。Vite在开发环境下用esbuild做依赖预构建在构建生产版本时用Rollup做打包这个被很多人熟知的组合本质上就是“一个超快的转译器 一个成熟的打包器”的组合。转译管语法转换打包管模块组织各司其职。我在实际项目中体会很深的一点是转译不是一次性完成的构建工具会在多个阶段调用转译器。比如Vite在扫描依赖时会调用esbuild做预构建在加载每个模块时又可能调用Babel做JSX转换到了生成最终产物时可能还要再做一次压缩转译。如果你对“构建工具到底做了什么事”没有一个全局认知性能分析就会无从下手因为你会把所有耗时都笼统归纳为“编译慢”。2. 打包把零散的模块组装成可以运行的产物如果说转译解决的是“代码能不能被目标环境认识”那打包解决的就是“代码能不能被目标环境按正确顺序加载并运行”。浏览器原生支持ESModule现代Node.js也支持那为什么还要打包这是很多新手的疑惑。2.1 为什么不能直接拿ESModule源码上线浏览器确实支持script typemodule的加载方式理论上你可以直接让浏览器去加载原始源码的各个模块文件。但实际生产环境中直接跑源码会遇到几个非常现实的问题。第一是请求数量。一个中型前端项目轻松有几百个模块如果浏览器按照模块的引用关系逐个发起HTTP请求那首屏加载会发起数百个网络请求。每个请求都有连接建立、TLS握手、传输、解析的开销在网络环境不理想的情况下这种加载方式基本不可用。打包的本质目的之一就是把这几百个小文件合并成少数几个大文件用更少的请求换取更快的加载。第二是代码体积。源码中包含了大量注释、未压缩的变量名、格式化空白符这些在运行阶段都不需要但会显著增加传输体积。打包器在生成产物时可以顺便做压缩去掉注释和空白把长变量名替换成短变量名甚至做更激进的代码优化。第三是兼容性。不是所有代码都是标准ESModule。CommonJS模块require/module.exports、AMD模块、全局变量脚本、CSS、图片资源……这些都不是浏览器原生ESModule能直接理解的。打包器需要把这些不同格式的内容统一转换成可管理的模块再按正确的顺序注入到最终产物里。2.2 打包器如何组织模块以Webpack为例以Webpack为例打包的核心是入口entry加模块图module graph。Webpack从入口文件出发解析它的依赖然后递归解析每个依赖的依赖最终建立起一张完整的模块关系图。关键的部分是打包器把所有模块的代码包装成一个个函数放进同一个运行时环境里。// 这是Webpack产物的极简形态 (function(modules) { var installedModules {}; function __webpack_require__(moduleId) { // 检查模块是否加载过 // 如果没加载过调用模块函数存储导出结果 // 返回模块的导出 } return __webpack_require__(0); // 加载入口模块 })({ 0: function(module, exports, require) { // 入口模块的代码 }, 1: function(module, exports, require) { // 另一个模块的代码 } });这段伪代码虽然简陋但Webpack产物的灵魂已经在这里了。每个模块都被包进一个函数模块之间不能直接访问对方的变量只能通过require函数按模块ID获取模块的导出这就在实现模块隔离的同时保持模块间的引用关系。为什么模块需要被包成函数而不是直接摊平写在一起因为模块作用域。如果直接把所有模块代码拼接成一个文件那每个模块里var a 1这样的声明全会泄漏到全局作用域互相覆盖。包成函数之后函数天然形成了作用域边界模块里的变量只在函数内部可见。这个设计还有一个额外的好处支持懒加载。如果你把某些模块单独打包成一个chunk后续会聊到那运行时的__webpack_require__可以从远程加载这个chunk文件再继续执行模块。因为没有把所有东西都一股脑塞进初始文件首屏才能做到真正“按需加载”。2.3 产物体积优化Tree Shaking、代码分割与压缩打包器做体积优化本质上是两件事砍掉不用的代码推迟加载需要的代码。这两件事分别对应Tree Shaking和代码分割。Tree Shaking摇树优化这个词听起来很玄乎但原理很简单通过静态分析ESModule的导入导出关系找出哪些导出从来没被引用过在构建产物中把这些代码整段删除。// utils.js export function used() { return used; } export function unused() { return unused; } // main.js import { used } from ./utils; console.log(used());如果构建工具启用了Tree Shakingunused函数不会被包含到最终产物中。为什么ESModule才能做Tree ShakingCommonJS不行因为ESModule的导入导出是静态的——你在代码里写import { foo } from ./module这个关系在语法解析阶段就确定了不依赖运行时逻辑。而CommonJS的require是运行时函数调用你可以if (condition) { const x require(./x) }也可以require(动态字符串)打包器在静态分析阶段很难确定哪些代码一定不会被用到保险起见只能全部保留。代码分割则是从加载策略的角度优化。设想一个场景你的项目有三个页面首页、详情页、个人中心它们共享一个很大的工具库。如果不做代码分割那你首屏加载时就不得不把详情页和个人中心的代码也全下载下来。做了代码分割之后打包器会把共享逻辑抽到独立的chunk里把每个页面自己的代码单独打包浏览器加载首页时只下载公共chunk加首页chunk切到详情页时再按需加载对应的chunk。// 打包后的产物示意 dist/ main.8f3k2j.js // 入口文件 vendor.3k9d2m.js // 公共依赖库 detail.a1b2c3.js // 详情页 profile.x9y8z7.js // 个人中心Webpack里配置代码分割最常用的是动态导入// 不这么做所有页面代码都会打到一个包里 import DetailPage from ./pages/DetailPage; // 这么做DetailPage会被单独拆成chunk访问到时才加载 const DetailPage () import(./pages/DetailPage);import()语法在构建阶段就会告诉打包器这里是一个分割点把动态导入的模块单独打包。打包器看到这个语法会把它转换成运行时加载chunk的代码逻辑。压缩则更直接了去掉代码中所有不需要的字符同时在不改变语义的前提下改写表达式。// 压缩前 function calculateTotal(price, quantity) { const total price * quantity; return total; } // 压缩后 function calculateTotal(price, quantity){return price*quantity}高级压缩器还会做作用域提升、常量折叠、死代码删除等优化。比如const isDebug false; if (isDebug) { ... }这段代码压缩器能推断出isDebug永远是false直接把整个if分支删掉。3. 递归依赖图构建工具的“大脑”与“血管网络”很多教程在讲构建工具时会提到“依赖图”这个概念但通常一句话带过。实际上依赖图是构建工具最核心的数据结构转译和打包都建立在依赖图之上。非要说一个比喻的话依赖图就是构建操作的施工蓝图——没有它打包器不知道有哪些模块需要处理也不可能知道模块之间的加载顺序。3.1 依赖图是怎么生成的从入口文件开始的深度遍历要了解依赖图最直观的方式是看一个极简的构建器。下面这段代码用不到五十行就能实现一个“玩具级”依赖图采集器理解它的运行逻辑就理解了Webpack内部很多核心机制。const fs require(fs); const path require(path); const parser require(babel/parser); const traverse require(babel/traverse).default; function collectDependencies(entry) { const queue [entry]; const graph []; while (queue.length 0) { const current queue.shift(); const content fs.readFileSync(current, utf-8); const ast parser.parse(content, { sourceType: module }); const dependencies []; traverse(ast, { ImportDeclaration({ node }) { const importPath node.source.value; const absolutePath path.resolve(path.dirname(current), importPath); dependencies.push(absolutePath); } }); graph.push({ file: current, dependencies }); for (const dep of dependencies) { if (!graph.find((item) item.file dep)) { queue.push(dep); } } } return graph; }这个采集器做的事情很简单维护一个待处理队列从入口模块开始解析它的依赖把依赖加入队列然后继续出队、解析、入队直到队列清空。这个算法其实就是广度优先遍历。每处理一个模块都要做三件事读取源码、解析成AST、找出所有import语句指向的文件路径。注意代码中有两个关键的路径处理import语句写的是相对路径比如./utils但实际文件在磁盘上的位置是相对于当前模块的目录。所以必须用path.resolve(path.dirname(current), importPath)把相对路径换算成绝对路径否则无法正确读取文件。Webpack内部做的事情和这个玩具版在逻辑上是完全一致的只是复杂了很多倍需要处理非JS模块、需要处理CSS里的import和url()、需要处理动态导入、需要考虑模块别名、需要处理node_modules下的包解析等。3.2 依赖图的节点与边模块ID是怎么分配的如果你翻过Webpack的构建产物大概率会看到类似./src/index.js: (module, exports, require) { ... }这样的结构。每一行对应一个模块冒号前面是这个模块的ID。模块ID就是依赖图中每个节点的唯一标识。模块ID怎么生成最简单的形式是把模块相对于项目根目录的路径作为ID但Webpack默认并不总是可读的路径形式。在生产构建时为了让代码更紧凑Webpack常常会把模块ID哈希成短数字。Development环境下则保留可读的路径形式方便调试定位。依赖图中的“边”则是模块与模块之间的引用关系。一个模块import了另一个模块就形成了一条从引用方指向被引用方的边。打包器在生成最终代码时需要通过这些边确定模块的执行顺序——被引用的模块必须先于引用它的模块加载。考虑一个简单场景src/ index.js入口—— import { helper } from ./helper helper.js —— import { format } from ./utils utils.js —— export function format()入口模块的处理顺序是加载index.js发现依赖helper.js加载helper.js发现依赖utils.js加载utils.js发现无依赖。所以图结构是utils → helper → index构建产物运行时也必须按照这个顺序初始化模块否则可能在index模块执行时发现helper还没有初始化。这里有一个隐藏很深但极其重要的点构建工具生成的模块初始化代码里模块运行时引用是被“推迟执行”的。// 产物中模块的执行逻辑 1: function(module, exports, require) { const helper require(2); // 此时2号模块必须先执行完 const result helper(hello); }如果依赖顺序错了比如1号模块引用了2号模块但2号模块的初始化代码在1号之后执行就会出现“使用未定义变量”的运行时错误。构建工具通过依赖图的边来保证初始化顺序的正确性这正是判断一张依赖图是否正确的核心标准。3.3 循环依赖依赖图最经典的坑依赖图一旦涉及递归就绕不开一个经典问题——循环依赖。所谓循环依赖就是A引用了BB又直接或间接地引用了A。在依赖图里这表现为一个环。// a.js import { b } from ./b; export const a a; // b.js import { a } from ./a; export const b b;在运行阶段当入口模块加载了a.jsa.js执行到import { b }时需要先加载并初始化b.jsb.js执行到import { a }时需要先加载a.js但a.js还在初始化过程中尚未完整导出。这时候就陷入了一个鸡生蛋、蛋生鸡的问题。不同模块系统对循环依赖的处理策略不一样这也是理解构建工具时容易踩坑的地方。CommonJS的应对策略是“部分导出”当模块A被加载时Node会先为module.exports创建一个空对象。如果A执行到一半引用了B而B又反过来引用了AB拿到的A的exports就是那个尚未填充完整的空对象。如果B只用了A中在循环触发前已经导出的内容程序能正常运行如果用到了还没导出的内容就是undefined。ESModule对循环依赖的处理则更严格模块在初始化时会先扫描所有导出建立“实时绑定”。如果循环引用中访问了尚未初始化的导出会直接抛出ReferenceError。在打包产物里构建工具会尽量把循环依赖的模块放到同一个包里并且在运行时用“模块是否已初始化”的标记来跳过重复初始化。但前提是你得在业务代码里避免真正的错误访问。我个人的建议是尽量避免循环依赖通过提取公共模块、改变模块划分来破环。加了import循环不要慌重点看是否为顶层赋值因为顶层代码的执行顺序最容易踩到未初始化导出的问题。如果循环引用只是发生函数内部调用时机在模块全部初始化完成之后往往不会出事。用工具检查madge可以扫描项目的循环依赖在CI里加上检查能防患于未然。# 使用madge检查循环依赖 npx madge --circular src/3.4 依赖图在构建阶段之外的复用价值依赖图的用途不局限于“构建时确定模块顺序”。很多构建工具还利用依赖图做另外一个重要的事情监听文件变更在开发服务器里做模块热更新。以Webpack的dev server为例本地开发时它不会每次都全量构建而是维护一张增量更新的模块图。当某个文件变化后webpack会沿着依赖图找到所有“受影响的模块”只重新转译这些模块通过websocket通知浏览器触发对应模块的替换。这个能力让开发体验大幅提升——保存代码后几乎秒级看到界面反馈而代价就是开发环境始终运行着一个复杂的增量构建系统。这套逻辑本质上还是那张递归依赖图以文件路径作为节点定位用边传播变化事件最终决定哪些模块需要重新执行。从工程化角度看依赖图还是一个“代码健康度分析”的入口。项目的层次结构是否合理、哪些模块被打包器判定为公共依赖、一个很小的改动会触发多少个模块重新编译——这些都是依赖图可以回答的问题。理解了依赖图的构建方式和数据结构调试构建问题就像拿到了一张地图不再只能靠猜。4. 从单点工具到多元生态一套逻辑各自实现聊到这里再回头看今天各个热门构建工具会有一个非常有趣的发现它们的功能差异看起来五花八门但核心还是那三件事——转译、打包、依赖图。只是各自在不同环节做了不同的取舍和侧重。4.1 前端生态Webpack、Vite、Rollup、esbuild的差异化路线先看前端领域。Webpack是全能型选手插件生态最丰富转译、打包、代码分割、热更新、资源处理全覆盖但代价是配置复杂、构建速度偏慢。Rollup更专注于模块打包和Tree Shaking在库开发领域占据主导地位因为库作者往往希望产出最干净的ESModule产物。esbuild把转译和打包的性能拉到了一个极端但不做复杂代码分割插件能力相对简单。Vite在开发阶段用esbuild做依赖预构建用原生ESModule按需加载源码文件在生产阶段又用Rollup做最终打包属于“组合式路径”。这些工具之间看起来差异巨大但可以从“它们分别优化了三大机制的哪个环节”来理解工具转译打包依赖图核心优势Webpack适配各种loader成熟且功能全面完整支持复杂关系全能和生态Rollup适配插件Tree Shaking强简单直接产物干净esbuild极快基础简单极致的速度Viteesbuild预构建开发用原生ESM生产用Rollup开发时按需开发体验好这种视角对选型很有帮助。如果你的需求是极致的首屏性能、复杂的多页面应用Webpack的代码分割能力是最成熟的如果是要做个库发布到npmRollup产出的ESModule格式最干净如果只想本地开发快一点Vite的开发模式可以说香得不行。4.2 其他语言的构建场景PyInstaller、Docker、Electron的对应逻辑构建工具的三大机制并不局限于JavaScript生态。拿Python的PyInstaller来说它也需要分析代码里所有import语句递归构建出完整的依赖图。PyInstaller内部用modulegraph库来做这个分析找到从入口脚本出发所有可达的Python模块然后把它们连同Python解释器一起打包成单一可执行文件。这就是一种“依赖图 打包”的实现。再来看Docker的构建过程。docker build执行的每一步指令都会生成一个镜像层而COPY、RUN这些指令的执行顺序和上下文文件路径之间也隐含着一种依赖关系后面的指令依赖前面的指令产生的文件状态。镜像分层缓存机制本质上就是“以每一步执行的输入输出作为依赖判断依据决定是否复用缓存”。Electron打包Linux应用时electron-builder或electron-packager需要把JavaScript源码、原生Node模块、各种资源文件、不同平台的可执行二进制整理到一起然后按平台打包成.AppImage或.deb。原生模块的编译尤为麻烦因为Electron的Node版本和系统Node版本可能不同必须用electron-rebuild重新编译原生模块。这里的依赖关系就跨了三种语言的边界npm包的依赖、Node原生模块的平台依赖、Electron对不同操作系统的构建产物差异。把这些完全不同领域的工具放在一起对比会发现一个普遍的规律只要是“构建”就必然涉及“搞清楚哪些输入文件参与了最终产物”也就是依赖分析只要是“多文件工程”就必然涉及“按什么顺序和格式组织产物”也就是打包只要是“现代语言”就必然涉及“代码经过什么转换才能运行在目标环境”也就是转译。4.3 热词背后的真实场景与排查思路最近各个社群里涌入大量构建相关的问题比如“Python Flet打包APK”“uniapp离线打包UTS插件怎么用”“Electron打包Linux报错”“IntelliJMaven项目打包报错”“Webpack打包优化配置”“Vue打包后布局异常”。这些问题的技术栈天差地别但排错思路高度相似。先说“打包报错”这一类。遇到构建报错第一步永远不是改配置而是完整读一遍构建日志把第一个真正报错的位置找出来。构建工具报错时经常会出现一长串trace真正的错误原因往往在开头几行后面的堆栈只是“传播路径”。比如Webpack报“Module not found: Cant resolve xxx”问题十有八九是模块路径写错了或者依赖没安装。Maven打包报错如果出现在package阶段的依赖解析位置优先检查私服配置和本地仓库状态。再说“打包后运行异常”这一类。这类问题往往是构建环境与运行环境不一致导致的。Vue打包后布局异常一个常见原因是样式被抽成了独立的CSS文件但加载顺序发生了变化另一个常见原因是有些运行时判断process.env.NODE_ENV的逻辑被替换成了生产模式导致开发分支代码没有执行。遇到这类问题建议先用sourcemap定位到具体代码再对照生产构建的差异项逐项排查。最后是“特定工具的构建配置”问题。像pyinstaller打包后exe体积过大、electron打包Linux之后在部分发行版上打不开、docker构建时中文文件名乱码等基本都能从文档中找到答案。但与其每次遇到再搜不如在动手之前先把“这个工具在哪个环节做转译、在哪个环节做依赖分析、在哪个环节做产物打包”搞清楚因为这类问题无一例外都源于某个环节的配置没对上。5. 实操心得我自己踩过的构建工具“坑”说了这么多理论最后分享几个我在实际项目中踩过的和构建工具相关的坑。这些坑说大不大但每次排查都花了不少时间写出来供大家参考。第一个坑是循环依赖的隐藏形式。我当时在一个图表项目里两个模块互相引用了对方的工具函数语法检查、ESLint都没报错构建也顺利通过但运行时偶尔会出现“xxx is not a function”的报错时好时坏。后来用madge扫了一下发现两个模块确实构成了循环依赖。因为其中一个模块是在函数内部、运行阶段才调用对方的导出所以只要调用时机在所有模块初始化完成之后就不会有异常。但因为有另一个模块在顶层初始化时调用了这个函数就偶发性地触发了未初始化问题。这个案例说明循环依赖检测不能只依赖构建器报错工具扫描很有必要。第二个坑是CSS里的url()资源路径没有被打包器处理。我在一个React项目里用Webpack在CSS文件里写background: url(./images/bg.jpg)。开发环境一切正常打包到生产环境之后图片找不到。排查发现Webpack默认的CSS处理逻辑里url()路径解析依赖css-loader的配置。如果url()指向的不是能被打包器识别的静态资源而是一个运行时变量拼出来的路径打包器就不会把它当作依赖自然也不会把文件复制到输出目录。生产环境找不到图片就是这个原因。这类资源引用问题一定要在依赖分析阶段就把所有资源路径以静态字符串的形式写清楚不能动态拼接。第三个坑是Tree Shaking失效。项目升级了依赖库版本之后构建产物体积突然变大了。排查后发现是因为新版的库从ESModule改成了CommonJS格式导致Tree Shaking无法生效。Tree Shaking的前提是模块的导入导出关系能被静态分析CommonJS的动态特性让打包器没法确定哪些导出没被使用。所以体积异常膨胀时先确认依赖库的模块格式有没有变化。现代npm包通常会在package.json里同时提供module字段ESModule格式和main字段CommonJS格式打包器优先使用module字段时才能保证Tree Shaking正常。说了这么多其实构建工具并不神秘。它做的事情用一句话概括就是沿着依赖图把代码转译成目标环境的形式再按照依赖关系把它们组织成最优的加载单元。这个理解建立起来之后再去看任何一款构建工具的文档感受会完全不一样。你不会再被各种抽象概念绕得晕头转向而是能直接对应到“这配置影响的是转译、打包还是依赖图”上。遇到报错也不再是瞎猜而是能顺着构建流程一步一步定位问题。这就是我想要分享的核心价值。