我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

CommonJS与ES Module:加载机制、循环依赖与工程化配置避坑

CommonJS与ES Module:加载机制、循环依赖与工程化配置避坑 CommonJS 和 ES Module 这两套模块体系我前后踩了差不多两年的坑才真正摸清楚它们的脾气。刚入行那会儿觉得模块化无非就是require和import两种写法谁顺手用谁直到项目里出现循环依赖导致某个配置对象莫名其妙变成空对象、某个枚举值在打包后跑成了undefined才发现这两套体系从设计哲学到执行时机根本就是两条路。这篇文章不讲教科书式的概念罗列而是把两套模块体系的加载机制、值传递方式、循环依赖表现、互操作限制和工程化落地配置掰开揉碎讲一遍顺带把我这些年积攒的排查经验和避坑手法一并交底。不管你是刚接触前端工程化、正在被ERR_REQUIRE_ESM折磨的新手还是已经在负责构建配置、需要决定整个团队模块规范的老手这篇内容应该都能让你少走几段弯路。1. 为什么这两套模块体系总被拿来对比1.1 从一次真实的线上故障说起几年前我接手一个后台管理项目的重构代码从旧的打包方案迁到新的构建流程迁移过程很顺利本地跑起来一切正常上线之后有个页面白屏。排查了半天发现问题出在一个常量模块上这个模块用 CommonJS 导出module.exports { STATUS_MAP }同时在文件顶部还有一行exports.STATUS_MAP STATUS_MAP。看起来是重复赋值其实不然——这两行代码在两个地方被引用了不同的对象。一处拿到了完整的对象另一处拿到了一个只有STATUS_MAP属性的对象而后续代码又往这个对象上挂了一个方法两边看到的东西就彻底不一样了。这个问题的根源是 CommonJS 的exports和module.exports在模块包装函数执行初期的引用关系以及导出之后的值传递特性。后来我把这段代码改成 ES Module 的export const STATUS_MAP问题立刻消失。但换成 ESM 之后又冒出新问题某个只在特定条件下才需要加载的重型依赖以前用require写在if分支里完全没问题改成 ESM 之后构建工具直接报错说import必须在顶层。这两次经历让我意识到绝大多数所谓「用哪个都行」的说法都是建立在「没遇到过复杂场景」的前提上的。一旦项目规模上来、依赖图变复杂、出现循环引用、涉及跨端复用两套体系的行为差异就会像裂缝一样暴露出来。1.2 两套体系的出身决定了它们的能力边界CommonJS 诞生在服务端场景那个年代浏览器里根本没有模块化的原生方案服务端的脚本需要一种方式来组织代码、复用逻辑、控制依赖。它的设计目标非常朴素让每个文件成为一个独立的模块作用域通过同步读取文件内容并执行来完成加载。同步这个特性在服务端完全不是问题因为文件在本地磁盘上读取耗时是毫秒级甚至微秒级,阻塞一下用户根本感知不到。ES Module 则是语言层面的规范,从设计之初就瞄准了跨环境的目标——既要能在浏览器里用 HTTP 拉取,也要能预编译、能被静态分析。浏览器环境里每一次网络请求都是不确定的,如果模块加载是同步阻塞的,整个页面就直接卡死了。所以 ESM 从一开始就采用了静态解析 异步加载的组合构建阶段把依赖关系摸清楚,运行阶段按图索骥地加载执行。这两种不同的出身,直接导致了后面所有差异为什么 ESM 的import必须写在顶层、为什么 ESM 的导出是活绑定、为什么 ESM 能做 tree shaking 而 CommonJS 很难。理解了这层因果,后面所有细节都是自然而然推导出来的结果,不需要死记。1.3 选型时真正该看的三个维度很多文章讨论这个话题时喜欢列出「ESM 是新标准应该优先用」这类结论但实际工程里选型要看的东西比这复杂得多。我一般从三个维度判断第一个维度是运行环境。如果代码只在浏览器里跑、并且有构建工具介入那其实用什么都行,构建工具会帮你抹平差异如果代码要在服务端直接运行、不经过打包那就必须严格考虑 ESM 的解析规则扩展名、目录导入、package.json的exports字段。第二个维度是依赖链的复杂度。项目如果只有几个内部模块互相引用风险很低但如果依赖了大量第三方包而这些包有的只发 CJS、有的只发 ESM、有的双格式发布但状态不共享那模块体系的边界就会成为故障高发区。第三个维度是是否需要静态分析能力。Tree shaking、按需加载、构建时依赖图分析、类型推导这些东西都依赖 ESM 的静态结构。一个纯 CommonJS 的项目打包器只能保守地把整个模块都打进去因为require的参数可以是任何动态表达式。判断维度倾向 CommonJS 的场景倾向 ES Module 的场景运行环境服务端脚本直接运行工具链老旧浏览器、服务端新版本运行时、跨端复用依赖情况依赖的第三方包以 CJS 为主依赖现代双格式发布的包构建需求不需要代码分割和体积优化需要 tree shaking、按需加载团队现状存量代码多迁移成本高新项目、可统一规范表格只是参考真实情况往往是混合的。我自己的项目里业务代码统一 ESM配置文件、构建脚本这类需要同步执行的场景保留 CJS两边通过明确的边界隔开不让它们随意互调。2. 核心机制拆解加载时机决定一切2.1 CommonJS 的运行时同步加载与模块包装CommonJS 的实现原理说穿了非常简单每个文件在被加载时都会被包进一个函数里执行。这个函数的参数固定形式大致如下(function (exports, require, module, __filename, __dirname) { // 你写的模块代码 });这个包装函数是理解 CommonJS 一切行为的钥匙。exports是module.exports的一份初始引用require是这个模块专属的加载器__filename和__dirname是当前文件的路径信息。因为是函数作用域所以模块内部的变量天然不会泄漏到全局——这就是为什么可以在两个文件里都声明const config {}而不会冲突。exports和module.exports的关系是新手最容易翻车的地方。在函数执行开始时exports指向module.exports指向的那个空对象。如果你写exports.foo bar实际上是在修改那个对象如果你写module.exports {}你把module.exports指向了一个新对象但exports还指着旧的后续的exports.xxx赋值全部失效。所以社区里的共识是要么统一用exports.xxx逐个挂载要么统一用module.exports xxx整体替换绝不要混用。模块加载过程中的缓存机制也值得说清楚。require第一次加载某个模块时会执行模块代码把module.exports的值存入require.cache键是模块的绝对路径。后续任何地方再require同一个模块直接返回缓存里的值不重复执行。这就意味着模块内部的状态是单例的——你在模块里定义一个计数器多个地方引用拿到的是同一个计数器。这也是为什么有些库会利用这个特性做全局状态管理比如连接池、配置中心。但这个单例特性在某些场景下会成为陷阱。比如同一个包以不同路径被解析软链接、包管理器的工作区机制、大小写不一致的路径就会产生两份缓存得到两个互不相干的实例。我见过一个项目因为工作区软链接导致状态管理库被加载了两次两个 store 各自更新UI 就是不同步排查了一整天才定位到缓存键不一样。2.2 ES Module 的编译期静态解析与三阶段执行ES Module 的规范把模块求值拆成了三个阶段这个划分是理解一切 ESM 行为的基础第一阶段是构造Construction。从入口文件开始解析找出所有的import语句递归地构建出一张完整的模块图。这个阶段只做静态分析不做执行也不会去访问被导入模块内部的值。正因为是静态分析import的路径不能是动态表达式的结果也不能写在if分支里。同时这个阶段会完成模块实例化也就是为每个模块创建环境记录把导入导出关系对应起来。第二阶段是实例化Linking。为所有模块的导出创建绑定把导入方的标识符和导出方的绑定连接起来。注意这里连接的是绑定而不是值——这是 ESM 和 CommonJS 最本质的区别之一。导入方拿到的不是一份拷贝而是一个指向导出方内部变量的引用。第三阶段是求值Evaluation。按深度优先后序执行模块代码子模块先执行完再执行父模块。因为有完整的模块图执行顺序是确定性的、可预测的。这套机制带来几个直接影响。第一个是import会被提升即使写在文件中间执行时也相当于在顶部所以不能在import语句之前使用被导入的变量——当然这也没什么意义。第二个是循环依赖的表现不同这个后面单独讲。第三个是 ESM 默认运行在严格模式下this在顶层是undefined而不是module.exports而且不能使用arguments。还有一个容易被忽略的点ESM 的求值顺序是严格按依赖图来的同一个模块只会求值一次多个地方导入共享同一份执行结果。这点和 CommonJS 的缓存效果类似但实现机制完全不同。2.3 值拷贝与值绑定一个例子看清本质差别理论说多了容易糊涂直接看代码。先看 CommonJS// counter.cjs let count 0; function increment() { count 1; return count; } module.exports { count, increment };// main.cjs const counter require(./counter.cjs); console.log(counter.count); // 0 counter.increment(); console.log(counter.count); // 还是 0结果还是0。原因很直白module.exports { count, increment }这里创建了一个新对象count属性的值是当时count变量的值也就是 0。后续increment函数内部改的是模块作用域里的那个count变量和导出对象上的count属性毫无关系。同样的代码用 ESM 写// counter.mjs export let count 0; export function increment() { count 1; return count; }// main.mjs import { count, increment } from ./counter.mjs; console.log(count); // 0 increment(); console.log(count); // 1这次输出1。因为 ESM 的导出是活绑定count这个标识符始终指向模块内部的那个变量模块内部改变量可变导入方看到的值也跟着变。这个差异在实际开发中影响巨大。如果你写了一个 ESM 模块导出let变量并且提供修改函数外部能实时感知到变化而 CommonJS 想做到这一点必须导出 getter 或者一个返回值的函数。反过来说如果你依赖 CommonJS 的值快照特性做了一些逻辑比如导出配置对象后在模块内部修改配置但不希望外部感知迁移到 ESM 时就会出问题。3. 实操两套模块的写法与互操作3.1 CommonJS 的写法规范与易错点CommonJS 的写法看起来简单坑其实不少。先看几种导出风格// 逐个挂载 exports.name demo; exports.version 1.0.0; // 整体替换 module.exports function handler() { /* ... */ }; module.exports.helper () { /* ... */ }; // 混合导出不推荐但常见 module.exports { name: demo }; exports.extra extra; // 无效exports 已经指向旧对象第三种写法是典型的反面教材。module.exports { name: demo }执行完之后exports仍然指向最初那个空对象后面exports.extra extra只是给那个已经被遗弃的对象加属性最终导出的结果里根本没有extra。所以定一条团队规范很有必要统一用module.exports整体导出禁止单独使用exports能省掉一整类低级错误。require的行为也有细节。require的路径解析规则是以./和../开头按相对路径找否则到node_modules找加载文件时会依次尝试精确文件名、.js、.json、.node后缀如果路径指向目录则查找该目录下的package.json的main字段没有就找index.js。这套规则非常宽松代价是解析过程存在不确定性不同环境可能有细微差别。还有一点require是同步的这意味着可以在任何函数体内、if分支里、try-catch里调用它。这也是 CommonJS 的一个优势——条件加载非常自然let sharp; try { sharp require(sharp); } catch { sharp null; }这段代码在 ESM 里就得换成await import(sharp)写法完全不同。3.2 ES Module 的语法细节与静态限制ESM 的导出主要有两种形式// 命名导出 export const a 1; export function fn() {} export class Foo {} // 默认导出 export default function () {}导入也有对应形式另外还有几种容易搞混的写法import defaultExport from ./mod.mjs; // 默认导入 import { a, fn } from ./mod.mjs; // 命名导入 import * as ns from ./mod.mjs; // 命名空间导入 import defaultExport, { a } from ./mod.mjs; // 混合 import ./mod.mjs; // 仅执行无导入有一个语法陷阱值得单独提命名空间导入得到的对象是不可扩展的并且属性是只读的尝试给ns.a赋值会直接抛错严格模式下。这和 CommonJS 的require返回一个可随意修改的对象完全不同。ESM 的静态约束体现在几个地方。第一import声明必须在模块顶层不能嵌套在块作用域里。第二模块说明符必须是字符串字面量不能是变量。第三在原生运行环境浏览器和 Node 的严格模式下路径解析非常严格必须写完整扩展名不支持目录导入node_modules里的包必须通过包名导入。这几条加起来导致从 CommonJS 迁移到 ESM 时经常出现一堆ERR_MODULE_NOT_FOUND。如果确实需要动态加载用import()函数const modulePath ./features/ featureName .mjs; const { default: feature } await import(modulePath);import()返回 Promise可以出现在任何地方这实际上给了 ESM 一个逃生舱——动态性可以保留只是要用显式的方式表达出来。3.3 互操作的四组合与实际表现CJS 和 ESM 混用是绕不开的现实四种组合的表现各不相同导入方被导入方写法行为说明CJSCJSrequire()标准行为返回module.exportsESMESMimport标准行为活绑定ESMCJSimport拿到的是module.exports作为默认导出CJSESMrequire()或import()老版本只能用import()新版本支持require第三种组合最容易出问题。ESM 导入 CommonJS 模块时Node 会做一层包装module.exports整体变成默认导出。至于具名导出Node 使用静态分析工具去猜测module.exports上挂了哪些属性能猜对大部分常见写法但遇到动态赋值就猜不到// 这种能被正确识别 exports.foo 1; module.exports.bar 2; // 这种识别不出来只能拿默认导出 const obj {}; obj[someVar] 1; module.exports obj;所以如果你在项目里写 ESM 去导入一个老包发现具名导入报「找不到导出」别急着怀疑自己先试试改成默认导入再解构。第四种组合在 Node 22 之后有了官方支持CJS 里可以直接requireESM 模块前提是被导入的 ESM 模块没有顶层await。这个能力解决了一大批实际问题但要注意它返回的是一个模块命名空间对象需要加.default才能拿到默认导出。3.4 双格式发布的配置要点如果你维护一个对外发布的库同时支持两套体系package.json的配置就得小心{ name: my-lib, version: 1.0.0, type: module, main: ./dist/index.cjs, module: ./dist/index.mjs, types: ./dist/index.d.ts, exports: { .: { types: ./dist/index.d.ts, import: ./dist/index.mjs, require: ./dist/index.cjs }, ./package.json: ./package.json } }这里有几个必须记住的点。exports字段一旦设置包的子路径就默认全部封闭外部只能访问显式声明过的路径这解决了一类历史问题。条件字段的顺序是有意义的运行时按对象键的顺序匹配types必须放最前面import和require的相对顺序可以先import后require。./package.json这一条几乎是标配很多工具会读它不暴露的话容易被误判。还有个概念叫「双包危害」同一个包同时提供 CJS 和 ESM 两份文件如果项目里两个格式都被加载到了就会产生两个独立实例各自的状态、单例、缓存都不共享。典型的翻车场景是一个状态管理库一部分代码用require引入另一部分用import引入结果两边的 store 互不感知。规避思路有两条要么内部做成无状态的纯函数库要么用统一的入口强制只走一个格式。4. 循环依赖最容易出事的场景4.1 CommonJS 循环依赖的实际表现循环依赖在两套体系里都会出问题但表现形式完全不同。先看 CommonJS// a.cjs console.log(a 开始执行); exports.done false; const b require(./b.cjs); console.log(在 a 中b.done , b.done); exports.done true; console.log(a 执行完毕);// b.cjs console.log(b 开始执行); const a require(./a.cjs); console.log(在 b 中a.done , a.done); exports.done true; console.log(b 执行完毕);执行node a.cjs输出是a 开始执行 b 开始执行 在 b 中a.done false b 执行完毕 在 a 中b.done true a 执行完毕关键看第三行b里拿到a时a还没执行完module.exports里只有done false这一个属性。因为 CommonJS 的缓存机制是在模块开始执行时就放入一个不完整的exports对象其他模块require到的是这个进行中的半成品。这就是为什么循环依赖在 CommonJS 里常常表现为「某些属性是undefined」。如果你在b里直接const { something } require(./a.cjs)然后立刻调用something()会直接报TypeError。规避办法有几种把require挪到函数内部延迟到调用时执行、用事件机制解耦、或者干脆重构消除循环。4.2 ES Module 循环依赖的表现差异同样的逻辑用 ESM 写结果会有变化// a.mjs console.log(a 开始执行); export const aDone false; import { bDone } from ./b.mjs; console.log(在 a 中bDone , bDone); console.log(a 执行完毕);// b.mjs console.log(b 开始执行); import { aDone } from ./a.mjs; export const bDone true; console.log(b 执行完毕);这里会直接抛错ReferenceError: Cannot access bDone before initialization。原因是 ESM 先完成所有模块的实例化然后按后序求值。b.mjs先执行执行到访问aDone时a.mjs还没求值aDone处于暂时性死区一碰就炸。这个差异的意义在于ESM 的循环依赖错误更早暴露、更明显而 CommonJS 的错误往往是隐性的、延迟的、到了某个分支才炸。从排查难度看前者反而友好一些。那 ESM 里怎么让循环依赖工作只要不在模块顶层立即访问导入的值就行// b.mjs import { aDone } from ./a.mjs; export const bDone true; export function check() { return aDone; // 调用时才访问此时两个模块都求值完成 }只要访问发生在求值完成之后活绑定的机制就能正常拿到值。这也是 ESM 活绑定带来的一个实际好处。4.3 从架构层面消除循环依赖工具层面能做的事有限循环依赖本质上是模块划分的问题。我在项目里推过几条规则效果还不错按职责分层下层模块绝不导入上层模块用目录结构强制约束公共类型、常量、工具函数抽到独立的底层模块各方都向下依赖需要双向通信的场景用事件总线或依赖注入解耦而不是互相导入构建流程里加一条循环依赖检测超过阈值就报警。检测手段的话不少打包工具在构建时会输出循环依赖警告也可以在 CI 里跑一个基于静态分析的检查脚本。我倾向的做法是把警告当错误对待只要出现就修别等到它变成线上故障再回头找。5. 工程化落地运行时与构建工具怎么配5.1 Node 运行时下的判断逻辑Node 判断一个文件按哪种方式解析规则是按顺序来的.mjs一律当 ESM.cjs一律当 CommonJS.js看最近的package.json里的type字段module当 ESM其他值包括没有该字段当 CommonJS。这个规则简单清晰但实际项目里经常因为多层目录的package.json导致意外。一个常见现象是项目根目录type: module但某个子目录里有自己的package.json且没声明type导致该目录下的.js又被当成了 CommonJS报Cannot use import statement outside a module。排查时用node --input-type或者直接看报错堆栈的路径就能定位。ESM 在 Node 下的路径解析规则也需要适应。必须写扩展名不支持目录导入不支持省略index.js。这些限制乍看麻烦但换来的是行为确定性不会因为文件系统差异出现解析歧义。__dirname和__filename在 ESM 里不可用替代方案是import { fileURLToPath } from node:url; import { dirname } from node:path; const __filename fileURLToPath(import.meta.url); const __dirname dirname(__filename);新一些的 Node 版本直接提供了import.meta.dirname和import.meta.filename用起来更省事。如果非要在 ESM 里用require可以用createRequire构造一个import { createRequire } from node:module; const require createRequire(import.meta.url); const pkg require(./package.json);这个手法在读package.json版本号、加载原生插件这类场景里特别实用。5.2 构建工具如何处理模块边界打包器对模块体系的处理比原生运行时宽松得多。比如某些打包器允许在 ESM 里写require也允许省略扩展名因为它在构建阶段就完成了模块解析和依赖收集。这种宽松是把双刃剑本地开发很爽但一旦某段代码被挪到不经过打包的环境比如服务端脚本、脚手架工具立刻就会崩。我踩过最典型的一次一个工具函数库在项目里用得挺好后来想把它抽成独立的命令行工具结果发现里面到处是省略扩展名的import和无视exports字段的深层路径引入直接跑不起来。重写解析逻辑花的时间比写功能本身还多。打包器的另一个特性是 tree shaking而这个能力完全依赖 ESM 的静态结构。CommonJS 的require是运行时行为打包器只能保守处理。所以如果你的项目里混着大量 CommonJS 代码构建产物体积往往是偏大的。想要按需加载生效被引用的模块必须是 ESM而且导出方式要避免副作用// 推荐能被摇掉 export function used() {} export function unused() {} // 不推荐整体对象导出摇不掉 export default { used() {}, unused() {} };第二种写法的问题在于打包器无法判断unused是否被使用因为它是对象的一部分只能整个保留。所以库作者在写 ESM 入口时尽量用命名导出。5.3 TypeScript 项目的模块配置取舍TypeScript 项目里模块配置的坑集中在两个字段module和moduleResolution。这两者必须搭配好否则会出现编译能过、运行时炸掉的情况。如果代码最终要在 Node 里直接运行用这套{ compilerOptions: { module: nodenext, moduleResolution: nodenext, target: es2022 } }这套配置会严格遵循 Node 的解析规则包括要写扩展名、支持exports条件解析。代价是写 import 时必须带.js后缀即使源文件是.ts很多人第一次见到会觉得别扭但这就是运行时的真实要求。如果代码要经过打包器处理用这套更顺手{ compilerOptions: { module: esnext, moduleResolution: bundler, target: es2022 } }bundler模式放宽了扩展名要求更贴近打包器的实际解析行为。但要注意用了这套配置之后就默认项目必须经过打包不能直接扔给 Node 跑。我的建议是按最终产物选配置而不是按开发时的舒适度选。库作者尤其要注意源码用什么配置无所谓产物的格式和声明文件必须和package.json的exports对齐否则使用者会遇到类型找不到、格式不匹配等一系列问题。6. 常见问题速查与排查技巧6.1 报错信息与成因对照整理了一份高频报错清单都是这些年真实遇到过的报错信息常见成因处理方向require is not defined代码在 ESM 环境或浏览器里执行改用import或用createRequireCannot use import statement outside a module.js文件被按 CJS 解析但写了import加type: module或改后缀为.mjsERR_REQUIRE_ESMCJS 里require了纯 ESM 包改用await import()或升级运行时ERR_MODULE_NOT_FOUND漏写扩展名、目录导入、子路径未导出补全后缀检查exports字段__dirname is not definedESM 环境没有该变量用import.meta.url转换exports is not defined在 ESM 或浏览器里用了 CJS 语法改用exportCannot access X before initialization循环依赖 暂时性死区延迟访问或重构依赖ERR_UNSUPPORTED_DIR_IMPORTESM 里导入了一个目录指向具体文件或加index.jsUnexpected token export构建产物里残留 ESM 语法但被当 CJS 跑检查目标环境和产物格式ERR_PACKAGE_PATH_NOT_EXPORTED引用了包里未在exports声明的位置用包公开的入口别钻深层路径6.2 现场排查的三步定位法遇到模块相关问题我一般按三步走。第一步是确认执行环境。这段代码到底是在 Node 里跑、在浏览器里跑、还是被打包器处理过很多人排查半天其实是对环境判断错了。快速确认的办法是在出问题的文件里打印typeof process、typeof window、typeof require、typeof import一眼就能看出当前上下文。第二步是确认解析结果。同样一个导入路径不同环境解析到的文件可能完全不同。在 Node 里可以用node --experimental-print-required或者直接在导入后打印import.meta.url看实际解析到的路径在打包器里则是看构建产物里的模块路径。第三步是确认值与绑定的关系。如果报错是「某属性为undefined」或者「值不符合预期」先判断这是不是循环依赖或者值拷贝导致的。最快的验证方式是在该模块的导出语句前后各打一条日志看执行顺序。这套流程看着笨但覆盖面广能解决八成以上的模块问题。6.3 几条不写在文档里的经验第一条新项目直接上 ESM不要留后路。我见过太多项目为了兼容某个老依赖把整个项目配成 CommonJS结果后面想用某个只发 ESM 的现代库时整个构建链都要改。正确的做法是主体用 ESM个别需要的旧依赖用动态导入或者createRequire局部兼容边界清晰成本可控。第二条用软链接或工作区时留意模块实例是否唯一。状态管理、单例服务、事件总线这类东西一旦出现两份实例症状会非常诡异——数据写入成功了但读取不到事件发出去了但没人收到。验证办法是在模块顶层打印一个随机数看看日志里出现几次。出现两次就说明模块被加载了两遍得去查路径解析。第三条exports字段的严格性会打破很多隐式约定。老包没有exports字段时外部可以随意引入包内任意文件一旦加上这个字段没显式声明的路径全部不可访问。所以给已有包补exports字段属于破坏性变更至少得升一个小版本号并在变更说明里写清楚。第四条动态导入不是万能的解药。它能解决条件加载和 CJS 引用 ESM 的问题但会带来时序上的复杂性。曾经有个项目把首屏关键依赖改成了动态导入结果首屏渲染时机变得不可控用户看到了一段白屏。判断标准很简单影响首屏渲染的东西老老实实静态导入非关键路径、按交互触发的东西才考虑动态。第五条调试模块问题时先用最小复现。模块问题的现场往往被构建工具、转译插件、路径别名层层包裹眼花缭乱。我常用的手法是建一个只有两个文件的最小目录把两套模块体系的导入导出原样还原裸跑一遍 Node。真实环境里的行为如果和最小复现不一致问题基本就锁定在中间那层工具上了。这五条每一条背后都有一次实实在在的加班写出来给后来的人省点时间。模块体系这东西文档里写的是「应该怎样」实际项目里跑的是「碰巧怎样」两者之间的缝隙只能靠一次次踩坑去填。
返回列表