
javascript 里模块化这件事绕到最后都绕不开两个关键字export和import。学了四十多期语法函数、对象、原型、异步都过了一遍真正让我在实际项目里反复翻文档、反复踩坑的反而是这两个看起来最简单的词。它属于 ES Module简称 ESM规范是语言层面的官方模块系统不是某个库造的轮子。它能干的事说起来朴素把一个文件里想给别人用的东西标出来再在另一个文件里把它取过来用。但真正解决的是全局污染、加载顺序、依赖关系隐式化这三件让人头疼的老问题。这篇东西适合刚把 JS 基础语法过完、准备写多文件项目的人也适合写了几年但一直靠打包工具自动处理、没认真想过 import 背后发生了什么的开发者。我下面写的是自己这些年踩过的坑和最后沉淀下来的用法习惯不是语法手册的复述。1. 模块化到底在解决什么问题1.1 从脚本拼接说到模块的诞生逻辑早年间写页面几个 script 标签依次排下来后一个文件用前一个文件的函数靠的是window上的全局变量。看起来能用但问题在于依赖关系是隐式的谁先谁后、谁依赖谁全靠在 HTML 里排顺序和脑子里记。文件一多删一个函数不知道会不会崩改一个变量名不知道会不会误伤这就是典型的命名空间污染 依赖不可见。后来有了立即执行函数包裹IIFE的写法把变量关进函数作用域只往全局暴露一个对象。这个思路解决了一部分污染问题但依赖声明仍然靠人为约定加载顺序该错还是错。再往后是 CommonJS用require同步加载、module.exports导出服务端跑得很舒服浏览器上却得先打包成一个大文件才能用。ESM 的设计和前面这些都不一样它走的是静态声明这条路。import和export必须写在模块顶层不能塞进if里不能放进函数里也不能用变量拼出模块名——因为语法层面就卡死了这种写法。这个限制乍看很别扭实际是整个体系最重要的一步因为模块名和导入的标识符在代码还没运行的时候就已经确定了工具才能在不执行代码的前提下把整张依赖图画出来。打包器能把没用的代码摇掉、编辑器能跳转到定义、构建时能做静态检查全部建立在这个前提上。所以理解 ESM 的第一个坎不是记语法而是接受一个思维转变模块的依赖关系是写死的不是运行时算出来的。想动态就得换import()这个函数形式那是另一条路径后面会展开。1.2 export 和 import 各自承担的角色一个模块文件在 ESM 眼里就是一个独立作用域。文件里声明的变量、函数、类默认都是私有的外面看不见。想让别人看见必须显式export。这个默认私有、显式公开的设计是它和早期全局脚本最本质的区别——你不是在往全局挂东西而是在声明这个模块对外提供什么。而import做的事是从别的模块里取绑定。这里有个关键点很多人一开始没意识到import拿到的不是值的拷贝是活绑定live binding。也就是说如果模块 A 导出了一个变量模块 B 导入它之后 A 里把这个变量改了B 那边看到的值也会变。这跟 CommonJS 的取一份当时的值完全不同。我第一次遇到这个行为时非常困惑明明 B 里没动过那个变量它自己就变了后来才明白这是设计使然。理解了这两点再去记export和import的写法就不是死背而是知道每种写法对应什么语义。下面我就按实际使用频率从导出写到导入把容易混的地方一个个拆开。2. export 的四种写法与选型判断2.1 命名导出一个模块可以提供多个命名导出是最常规的形式声明的时候直接加export前缀就行export const API_TIMEOUT 5000; export function formatDate(date) { /* ... */ } export class RequestPool { /* ... */ }也可以先声明再集中导出两种写法完全等价const API_TIMEOUT 5000; function formatDate(date) { /* ... */ } export { API_TIMEOUT, formatDate };我个人的习惯是声明处不加export统一在文件末尾集中一行导出。理由有两个。第一扫代码的时候末尾那一行就是模块的对外接口清单一眼能看全这个文件给外面提供了什么比在几百行代码里找export关键字高效得多。第二中途调整对外暴露的范围时只改末尾一行就行了不用回到声明处去删关键字改动集中、diff 干净。命名导出能被导入方按名字精确取用也天然对 Tree shaking 友好——打包器知道哪些名字被引用了、哪些没有。所以一个模块如果对外提供的是多个平等的能力命名导出是第一选择。2.2 默认导出一个模块只给一个主菜export default每个模块最多一个导出的是这个模块的代表。常见的写法有三种// 写法一匿名函数表达式 export default function () { /* ... */ } // 写法二具名函数名字只在模块内部有效 export default function initApp() { /* ... */ } // 写法三导出一个已有绑定 const config { mode: prod }; export default config;第三种写法有个很重要的差异export default config导出的是值本身对基本类型来说而不是一个叫default的活绑定。对象因为是引用改属性还能反映出来但如果是let count 0; export default count;之后模块内把count改成 1导入方看到的还是 0。这个坑我在一个计数器模块里踩过排查了半天才发现默认导出和命名导出的语义不同。还有个等价写法值得记住export { config as default }。某些需要同时提供默认导出和命名导出的场景用这个写法比混着写更整齐。什么时候用默认导出我现在的判断标准很窄这个模块整体就代表一个东西比如一个组件文件、一个类文件、一个配置对象文件。如果一个文件能自然地从嘴里说出它导出一个 X那默认导出合适。反之如果要说它导出 A、B、C 三样就别硬套默认导出老老实实用命名导出。顺带说个团队协作上的教训默认导出在重构时很不友好。导入方可以随便给它起名字同一个东西在不同文件里可能叫utils、helper、tools全项目搜不到统一关键词。命名导出强制大家用同一个名字可搜索性完全不是一个量级。所以后来我在带人时宁可一开始就要求全用命名导出也不图那点书写方便。2.3 导出时重命名给外部一个更合适的名字内部名字和外部名字未必一致as就是干这个的const _internalCache new Map(); export { _internalCache as cache };这个用法的价值不只是改名好看。更实际的场景是内部实现用下划线前缀表示别碰但对外的确要给一个正经名字或者内部有个同名变量冲突了导出时换个名避开。还有一种情况是同一份逻辑要在不同语境下暴露成不同名字虽然这种情况我建议直接拆模块别在一个文件里玩花活。需要注意重命名只是改了导出后的名字模块内部的引用不受影响而且绑定关系仍然是活的。2.4 聚合导出把多个模块收拢成一个入口一个目录下有十几个模块使用者总不能一个个去 import。这时候需要一个入口文件当中转站export { formatDate, parseDate } from ./date.js; export { default as HttpClient } from ./http/client.js; export * from ./constants.js; export * as validators from ./validators/index.js;这几种写法差别的细节必须说清楚不然很容易翻车export { x } from ./a.js是转手导出不会在当前模块内创建x这个变量当前作用域里用不了它。想用就得单独再import一次。export * from会把目标模块的所有命名导出都转出去但不会转出 default。这是规范明确规定的不支持讨论。同名冲突时export *的行为是静默忽略不报错。所以两个模块都有init全部export *出来最后哪个生效完全看运气。我一般要求入口文件里尽量写显式的export { x } from只在结构非常稳定、命名有前缀规范的常量模块上才用export *。export * as ns from是相对新的语法把一个模块整体打包成一个命名空间对象导出适合这个模块的 API 我要以一坨的形式暴露的场景。老一些的构建环境可能不认用之前确认一下目标环境的支持情况。聚合入口最大的风险是不小心把内部的实现细节也暴露成公共 API。写完入口文件后我通常会反过来问自己一句这个文件导出的东西我愿不愿意承诺未来一直维护它不愿意的就别往外放。3. import 的写法与那些绕不过去的细节3.1 静态导入的五种形态import的静态形式在语法上只有顶层合法写法上可以分成几类// 1. 默认导入 import HttpClient from ./client.js; // 2. 命名导入可重命名 import { formatDate, parseDate as parse } from ./date.js; // 3. 混合导入 import HttpClient, { TIMEOUT } from ./client.js; // 4. 命名空间导入 import * as dateUtils from ./date.js; // 5. 纯副作用导入 import ./polyfills.js;第五种很多人不太用但它很重要。有些模块的作用不是导出什么而是执行一下产生副作用比如注册全局样式、往原型上加方法、初始化某个单例。这时候直接import ./xxx.js不接收任何绑定。这种写法有个副作用它的执行时机跟其他导入一样会被提升到模块顶部也就是说不管写在文件的第几行都会在模块代码执行前先跑完。这一点在依赖初始化顺序的时候要特别小心。命名空间导入要提醒一句import * as ns拿到的对象是密封的你不能给它加属性也不能改已有属性的绑定虽然改对象内部的属性是可以的而且它对 Tree shaking 不友好——打包器没法确定你到底用了哪个属性通常把这个模块整包留下。所以能具名就具名别图省事。3.2 导入的绑定是只读的但只读的边界很微妙导入进来的标识符不能重新赋值import { count } from ./counter.js; count 10; // 报错Assignment to constant variable但注意不能重新赋值和不能改不是一回事import { config } from ./config.js; config.name changed; // 完全合法改的是对象的属性这条边界的坑在于它让只读看起来形同虚设。模块导出一个对象任何导入方都能改它的属性这在多人协作里是很危险的设计。我的做法是对外导出的配置对象一律Object.freeze处理或者干脆只导出获取函数不直接暴露可变引用。后者更彻底也更好排查——谁改的、什么时候改的加个日志就清楚了。还有一个容易忽略的点import的绑定是活绑定但只针对模块顶层作用域的let/var/function/class这类绑定。如果导出的是个解构出来的值或者基本类型的表达式那么它就不具备被后续修改的能力因为根本没有对应变量存在。理解这个的前提是记住export导出的是绑定不是值的快照默认导出部分除外。3.3 路径解析扩展名、目录、裸模块这块是新手最常翻车的地方因为浏览器、Node 环境、打包工具的解析规则各有各的规矩。浏览器里import ./utils.js是必须带扩展名的。写import ./utils会直接报错报错信息大概是Failed to resolve module specifier而且它不会像某些工具那样帮你补全。同理import ./utils/index.js里的目录索引也不会自动找必须写全。裸模块比如import _ from lodash在浏览器里也不能直接用浏览器不知道去哪找。要么用 import maps 声明映射关系要么交给打包工具处理。Node 环境这两个问题都在逐步放宽但具体行为取决于版本和配置文件。能确定的是package.json里的type字段决定了.js文件按哪种模块系统解析写type: module就按 ESM 走不写则默认 CommonJS。而.mjs永远按 ESM 处理.cjs永远按 CommonJS 处理跟type字段无关。这套扩展名约定我一开始觉得多余后来做混合项目的时候才发现它救了不少命——目录里同时有两种模块光看扩展名就一目了然。实操建议不管目标环境支不支持省略扩展名统一把扩展名写全。理由不是这样能跑而是搬代码的时候不会因为环境不同而突然崩。省那两个字符换来的是半年后排查一小时的代价不值。4. 动态 import 与按需加载4.1 import() 为什么是函数而不是语法静态import必须是顶层声明模块路径必须是字符串字面量。一旦需要根据运行时条件决定加载什么静态导入就无能为力了。这时候用import()const module await import(./feature-xxx.js); module.run();它有几个和静态导入完全不同的特征必须一次说清第一import()是函数调用返回 Promise所以可以写在任何地方——函数里、if里、事件回调里都行。第二路径可以用变量拼接动态生成。第三它不会被执行前的依赖分析算进初始依赖图里也就是说模块图会在运行时被扩展。第四正因为如此动态导入的目标模块不参与初始加载的 Tree shaking 判断它会单独打包成一个块需要的时候再去拿。这个单独打包成块的行为就是前端做代码分割的基础。路由懒加载、大型图表库按需加载、只在特定弹窗里才用到的富文本编辑器全靠它。我第一次用动态导入把首屏体积砍掉将近一半时的感觉比背十个语法特性都实在。4.2 实际用法与几个必须处理的问题一个典型的路由级懒加载写法大概是这样async function loadRoute(name) { const map { report: () import(./views/report.js), settings: () import(./views/settings.js) }; const loader map[name]; if (!loader) { throw new Error(未注册的路由${name}); } const mod await loader(); return mod.default || mod; }这里有几个我踩过的细节。一是要处理模块没有默认导出的情况。mod.default || mod这个兜底很常见但我建议别长期这么写它会掩盖模块契约不清晰的问题。正确的做法是约定好懒加载模块一律默认导出或者一律具名导出然后在调用处固定写法。二是错误处理不能省。动态导入是网络请求会失败——资源 404、部署后旧页面引用了新哈希文件名、网络抖动都会让 Promise reject。稳妥的做法是包一层 try/catch失败时降级到一个简单的错误组件或者重试一次。重试逻辑要注意别无限循环加次数上限。三是注意并发问题。同一个模块被并发import()多次实际只会发起一次请求历史上多次调用会拿到同一个模块实例。这是规范保证的不用自己做去重池。但反过来说如果你期望每次import()都拿到新的状态那是想多了模块只执行一次。还有一点动态导入在循环里写的时候要小心作用域for里await import变量路径是没问题的但如果用var加闭包就容易出岔子这种老问题换到 import 上照样成立。5. 模块图的构建与循环依赖5.1 一个模块的执行顺序是什么样的import语句会被提升到模块顶部这是很多人没意识到的。下面这段代码能正常跑console.log(square(4)); // 16能跑 import { square } from ./math.js;虽然 import 写在后面但它在模块代码执行前就已经处理完了。这个提升行为对函数声明有效但如果你在 import 之前访问的是一个const导出的绑定那就不行了——因为存在暂时性死区会抛ReferenceError: Cannot access before initialization。整个加载流程大致分阶段先解析入口模块找出它所有的静态 import递归解析每个依赖构建出完整的模块图然后深度优先地把模块实例化为每个导出创建绑定占位最后按依赖的逆序执行模块体。这个先全部实例化、再依次执行的顺序是理解循环依赖表现的关键。5.2 循环依赖不是不能用但要知道为什么能用模块 A 导入 BB 又导入 A这就是循环依赖。ESM 对它是有定义的不会死锁也不会直接报错但结果是否如你所愿取决于你访问的是什么。因为实例化阶段所有模块的绑定都已经建好了但执行阶段是深度优先的所以先执行的那个模块里很可能引用了还没初始化的东西。此时如果引用的是function声明没问题函数声明在执行前就已经提升并赋值了直接可用。如果引用的是const/let变量在对方模块还没执行到那一行时访问会抛暂时性死区错误。如果只是把它存起来、延迟到函数调用时才用那通常也没问题因为调用发生时通常都初始化完了。我在实际项目里遇到循环依赖基本都是架构问题而不是语法问题。常见的解法有这么几种第一种把公共部分抽出去。A 和 B 都依赖 C就把公共逻辑抽到 C 里让依赖变成单向的 A→C、B→C。这是最干净的解法成本也最低多数情况一抽就完事了。第二种延迟引用。把原来在模块顶层直接使用的导入改成在使用它的函数内部再引用。不过对于静态导入这招意义有限更常见的做法是改成import()动态引入等真正需要的时候再加载天然避开初始化顺序问题。第三种反转依赖。原本 A 直接调用 B改成 A 注册回调、B 在合适的时机触发。这在事件驱动的模块里很自然也能把依赖图理成单向。需要提醒的是不要依赖循环依赖能跑通这件事。它能跑是因为执行顺序恰好在那个版本、那个打包策略下是对的。换个构建配置、调整一下导出顺序可能就崩了。发现循环依赖就当成技术债处理掉别留。6. ESM 与 CommonJS 的互操作6.1 两套模块系统的核心差异对照老项目里require和module.exports到处都是新代码又想用 ESM 的静态分析能力两套系统必然要碰面。先把差异摆清楚维度ESMCommonJS语法import/exportrequire/module.exports加载时机静态声明执行前解析完整依赖图运行时同步调用走到哪加载到哪导出内容活绑定导出时的值对象引用顶层await支持不支持是否可条件写静态形式不行需用import()可以写在任意位置常见宿主浏览器、现代打包环境Node 传统生态Tree shaking天然支持效果很差甚至不支持这张表里最容易被低估的是静态声明这一行。它带来的是构建期的可分析性也是整个前端工程化能玩出代码分割、依赖预构建这些花样的前提。CommonJS 的运行时加载虽然灵活但工具在分析阶段根本不知道你会require什么只能保守处理。6.2 混用时的现实做法先说方向。CommonJS 里想用 ESM不能写静态import只能动态import()因为require是同步的而 ESM 的加载在规范上是异步的。所以老模块里想调新模块写法是async function loadNew() { const mod await import(./new-module.mjs); return mod.default; }ESM 里用 CommonJS 相对宽松一些import一个 CommonJS 模块把整体当成默认导出来拿import cjsModule from ./legacy.cjs; cjsModule.someMethod();这里要注意import { someMethod } from ./legacy.cjs这种写法在很多环境下不保证有效。工具会尽量做静态分析去推断module.exports上的属性名推断不出来的时候就只能拿到默认导出。所以稳妥写法是默认导入再取属性别赌具名导入能成。还有一个常见的坑在 ESM 环境里写require会报require is not defined in ES module scope。遇到这个报错先看package.json有没有type: module再看文件扩展名是不是.mjs。定位路径就这两条。至于__dirname和__filename在 ESM 里也不存在了替代方案是从import.meta.url推出来import { fileURLToPath } from node:url; import { dirname } from node:path; const __filename fileURLToPath(import.meta.url); const __dirname dirname(__filename);import.meta是 ESM 专属的元信息对象url是当前模块的完整地址。除了拿目录还可以用它来拼相对资源路径比写死路径可靠得多。7. 常见问题与排查技巧实录7.1 高频报错速查下面这些报错信息我几乎每次带新人都会遇到一轮。整理成表遇到时按图索骥比自己瞎猜快得多。报错信息关键词大概率原因处理方式Cannot use import statement outside a module浏览器没加typemodule或 Node 没声明type: module检查 script 标签属性或 package.json 字段Failed to resolve module specifier浏览器里用了裸模块名或路径没写扩展名补全相对路径与扩展名或用 import maps 映射does not provide an export named xxx导出/导入名不匹配或把默认导出当命名导出导了对照源文件确认导出形式默认导出要写mod.defaultDuplicate export of xxx同一模块内重复导出同名标识符合并或重命名其一Cannot access before initialization循环依赖下访问了未初始化的const/let抽公共模块或改用动态导入require is not defined in ES module scope在 ESM 文件里写了 require换成 import或调整该文件的模块类型Assignment to constant variable试图给导入的绑定赋值改副本或由源模块提供设置函数排查顺序我一般固定成三步先确认文件被当成什么模块解析看扩展名和type字段再确认路径能不能解析到真实文件最后才查导出名对不对。九成的报错在前两步就解决了直接跳到第三步去抠名字往往白费功夫。7.2 几个只有真做过项目才会遇到的问题第一个export *造成的隐蔽覆盖。一个入口文件里连续export *了三个模块其中两个恰好都有init。规范规定冲突名字会被排除结果就是init从公共 API 里消失了导入方报does not provide an export named init而你去三个源文件里查每个都有init怎么看都对。这种问题的排查成本极高唯一的办法是避免大面积export *或者写个脚本检查入口的导出集合。第二个Tree shaking 失效。明明只用了模块里的一个函数打包体积还是把整个库都带进来了。常见原因有三个库本身有副作用且没有在package.json里声明sideEffects导致工具不敢删你用的是命名空间导入import * as ns工具无法判断用了哪个或者模块顶层有立即执行的副作用代码比如直接往原型上挂东西工具出于正确性考虑必须保留。第三个原因最容易被忽略我自己就写过在模块顶层注册全局事件的代码结果整个模块怎么都摇不掉。第三个构建产物里模块重复。某个依赖被两个不同路径引用node_modules里存在多个版本最后打进去两份实现。表现出来的症状往往是单例不单例——配置读写不一致、事件总线两边收不到消息。排查方式是看产物里的模块路径通常一眼就能看出重复。处理办法是在包管理层面统一版本而不是在代码里打补丁。第四个动态导入的路径写成了变量拼接。import(./views/ name .js)这种写法不同工具的推断能力差别很大有的能把整个目录都打进去有的直接失败还有的只在特定写法下生效。稳妥做法是把所有可能的模块写成一个显式的映射表也就是第 4.2 节那种写法让工具能静态看到所有候选路径。最后分享一个我坚持了很多年的习惯每新建一个模块文件先在末尾把打算对外暴露的东西写出来再回头填实现。这个顺序逼着我在动手前想清楚这个模块的职责边界在哪比写完几百行再回头决定导出什么返工率低太多了。模块化的本质不是语法是边界感export那一行就是你画边界的地方。