在 JavaScript 模块化里,export default和具名 export 这组概念,我前前后后花了很长时间才真正搞透。早期写代码只靠“import 后面有没有花括号”来区分,一旦碰上模块解析报错、打包产物变大、或者 Node 和浏览器行为不一致,整个人就懵了。这篇笔记不打算只抄一遍 MDN 文档,而是从一次真实的问题排查出发,把 default 和具名导出背后的绑定机制、工程化差异、设计取舍和那些文档里没写透的坑全部过一遍。如果你刚接触 ES Modules,或者已经写了一阵子但还是会对着SyntaxError发呆,这篇应该能帮你省下不少时间。
1. 先别研究写法:导出的其实是“活引用”,不是“值快照”
1.1 一段颠覆认知的小代码
很多教程上来就讲“export default 是默认导出,具名 export 是命名导出”,这话没错,但容易让人误解成“导出的是某个值”。ES Modules 的导出和 CommonJS 的module.exports有一个本质区别:它导出的是一个绑定,而不是值的拷贝。哪怕导出的是一个基本类型,模块内部后续修改,导入方也能感知到。
// counter.js export let count = 0; export function increment() { count += 1; } // main.js import { count, increment } from './counter.js'; console.log(count); // 0 increment(); console.log(count); // 1如果这套代码放在 CommonJS 里,main.js拿到的count很可能是旧值,因为 CJS 是在require瞬间把module.exports上的属性读取出来,后面模块里再怎么改,导入方已经拿到的是拷贝。而 ESM 的导入导出之间是一条活的引用通道,只有当读取count变量的那个瞬间,才会去模块环境记录里取当前值。
理解这一点之后,很多怪现象就有了解释。比如循环依赖里,明明导出了函数却拿到undefined,往往不是“导出写错了”,而是“取值时绑定还没初始化完成”。后面第 6 章会展开讲。
1.2export default到底是什么:一个名叫 default 的具名导出
export default看起来和具名导出完全是两套语法,但实际上,ES Module 的模块记录(Module Record)只认“导出名”。default并不是什么特殊机制,它只是一个固定的导出名,只不过语法上给了它特殊地位。
// versionA.js export default function greet() { return 'hello'; } // versionB.js function greet() { return 'hello'; } export { greet as default };上面的 A 和 B 对模块外部来说完全等价。区别只在于export default function是语法糖,函数声明可以直接声明并立即被提升;而export { greet as default }是更“显式”的写法,要等到greet初始化完成时才会建立默认导出绑定。
要验证这一点,最直接的办法是把模块的命名空间整个打印出来:
// helper.js export const named = 1; export default function main() {} // inspect.js import * as helper from './helper.js'; console.log(Object.keys(helper)); // ["named", "default"] console.log(typeof helper.default); // "function"命名空间对象里确实有一个名为default的键。所以以后看到import something from './module.js',可以理解为import { default as something } from './module.js'的简写。把这个心智模型建立起来,后面所有关于“为什么这种导入能成功、那种导入报错”的疑问,都能统一到“导出名是否匹配”这一个问题上。
2. 导入端的行为差:花括号背后是一条语法边界
2.1 三种基础写法,名称约束都不一样
先看最常用的三种导入方式和它们的约束条件。我一直建议团队新人把这张表贴在 IDE 旁边:
| 导入写法 | 对应导出 | 导入变量名约束 |
|---|---|---|
import x from './m.js' | export default ... | 变量名可以任意取 |
import { a } from './m.js' | export const a | 变量名必须和导出名一致 |
import { a as b } from './m.js' | export const a | 右侧 b 是导入后的本地名,可任意取 |
import * as ns from './m.js' | 任意导出 | 通过ns.a/ns.default访问 |
花括号后面的名字不是自由决定的,它是在模块环境记录里去精确匹配导出名。很多报错“does not provide an export named 'xxx'”,根源就是名字对不上。而export default导出的名字固定是default,所以import x里的x无所谓,因为导入器只是把default这个名字重命名成了本地变量x。
2.2 别再把 import 的花括号当成“解构”
这可能是新手最容易搞混的地方。JS 解构语法里也有花括号,比如const { a } = obj,看起来和import { a } from './m.js'很像,但解析逻辑完全不同。解构是对一个对象执行属性读取,花括号里的名字是“要取出的属性名”;而import是静态模块语法,花括号里的名字是“要从模块命名空间里绑定的导出名”,并且这个绑定关系是在模块加载阶段就确定的,不参与运行时求值。
// 这样写是合法的 import { default as foo } from './module.js'; // 这样写会报错,因为 default 是关键字,不能作为本地绑定名 // import { default } from './module.js';很多人以为import { default } from './module.js'能拿到默认导出,结果收到一个语法错误,转头怀疑是不是模块写错了。实际上就是命名冲突:default在 JS 里不能作为变量名,所以必须借助as重命名。这也能反过来证明一件事:default在导出端确实只是一个“导出名”,到了导入端它还能被塞进花括号里处理,只是不能直接作为绑定名。
混合导入时,规则也很容易记:import React, { useState } from './framework'本质上是默认导入加上具名导入的合写。如果你愿意,完全可以写成:
import { default as React, useState } from './framework';这两种写法对运行时没有任何区别。建议只在可读性层面选择:默认导入在前、具名导入在后是社区最常见的风格。
2.3 从命名空间对象看全部导出
排查模块问题时,我几乎不会先去翻源码,而是直接在控制台打印命名空间对象。这个习惯救过我无数次。
import * as mod from './dist/module.js'; console.log(mod);输出的对象会列出所有导出名,包括default。看到命名空间对象里没有某个具名导出,再去源码里找,通常会发展成两种结果:第一种是模块只写了export default,却想在导入端用import { createApp };第二种是模块把变量声明和 export 语句分开了,漏写了其中一个名字。无论哪种,console.log(mod)都是最快的定位方式。
3. 工程化带来的“甜与苦”:转译、tree-shaking 和互操作
3.1 转译后 default 会被“包一层”
浏览器原生支持 ESM 之后,很多场景仍然需要转译。Babel 或 TypeScript 把 ESM 转成 CommonJS 时,default导出通常会被处理成exports.default = ...。
// 源码 export default function run() {} export const version = '1.0.0'; // 简化后的转译结果 exports.default = function run() {}; exports.version = '1.0.0';这就解释了为什么在 Node 的老版本环境里,用const mod = require('./module.js')之后,mod.default才是真正的默认导出,而mod.run可能是 undefined。再往深一层看,如果一个库同时提供了 ESM 产物和 CJS 产物,打包器在不同的解析条件下可能会选择不同产物,于是出现“在 Node 里 require 正常,在浏览器里 import 报错;或者反过来”的诡异现象。这不是哪份代码写错了,而是导入导出形态和运行环境不匹配。
反过来还有一个常见互操作:Node 里import一个 CommonJS 模块时,module.exports整体会被当作默认导出暴露给 ESM 侧。
// cjs-module.cjs module.exports = function greet() {}; // esm-importer.mjs import greet from './cjs-module.cjs'; greet(); // 运行正常Node 的cjs-module-lexer还能静态分析出一部分具名导出,让 ESM 直接import { greet }。但这套分析有边界,遇到动态赋值、module.exports被整体覆盖等情况就不一定可靠了。所以我的经验是:在混合 CJS/ESM 的项目里,尽量用import pkg from 'cjs-lib'配合pkg.method(),少依赖具名导入的自动分析结果。
3.2 Tree-shaking 视角:具名导出为什么更容易被摇树
打包器做 tree-shaking 依赖静态分析。它需要知道“哪个导出名没被用到”,然后把这个导出关联的代码删掉。具名导出天然适合这种分析:每个导出名对应一段清晰的绑定关系,打包器能精准地把未使用的具名导出标记为 dead code。
而export default导出的“默认对象”比较特殊,比如:
// utils.js export function add(a, b) { return a + b; } export function remove(a, b) { return a - b; } export default { add, remove };假设用户只需要add,并且写成import utils from './utils.js',打包器只能看到整个default对象被引用。要访问utils.add,由于对象整体被引用,它很难判断remove是否真的没被使用,很多情况下就会把整个对象保留下来,导致remove的代码也被打包进产物。
不过这不是说default就一定影响 tree-shaking。如果export default function App() {},默认导出就是一个函数,且没有额外对象把其他函数“包”进去,那打包器能直接把函数整体切除或保留,大可放心。需要警惕的其实是“默认导出聚合对象”这种写法——它等于告诉打包器“这个对象是入口,内部所有属性我都可能用到”。
我曾在某个工具库里做过对比:同样 5 个纯函数,A 版本用具名导出逐个暴露,B 版本在文件底部export default { a, b, c, d, e }。在只使用其中两个函数的业务代码里,A 版本的产物明显更小。这不是偏向性结论,而是打包器面对对象整体引用时的无奈。
3.3 TypeScript 的 esModuleInterop 到底开了什么
TypeScript 里有一段很容易被忽略的配置历史。如果你在tsconfig.json里开启过esModuleInterop,它并不是“允许 default 导入”这么简单。
先说allowSyntheticDefaultImports:它只是让 TypeScript 编译器在类型检查阶段认为“可以默认导入”,不会生成任何运行时辅助代码。如果代码最终由打包器处理,打包器有自己的互操作逻辑,也许没问题;但如果由 Node 直接执行编译后的 CommonJS 产物,运行时可能根本没有__esModule标记,于是require('./module').default拿到的是 undefined。
再看esModuleInterop:它会开启编译器的一个运行时辅助逻辑,典型代码如下:
// 辅助函数简化版 function __importDefault(mod) { return mod && mod.__esModule ? mod : { default: mod }; }也就是说,转译时会判断被导入的模块是不是一个真正的 ESM 模块。若模块已经有__esModule标记,则直接返回模块自身;否则把整个模块塞进{ default: mod }。这样import express from 'express'编译成const express = __importDefault(require('express')).default之后,无论底层的express是 CJS 还是 ESM,都能稳定拿到默认导出。不理解这段历史,很容易出现“在本地构建通过,部署后运行报xxx.default is not a function”的问题。
4. 设计视角的选择:什么时候用 default,什么时候用具名
4.1 按模块类型建立默认约定
写代码最怕没有约定。我在实际项目里会按模块的“物理角色”来决定用哪种导出风格,而不是只凭个人喜好。
| 模块类型 | 推荐方向 | 例子 |
|---|---|---|
| 业务页面入口 / 组件文件 | 默认导出为主 | export default function Page() {} |
| 工具函数集合 | 具名导出为主 | export function formatDate() {} |
| 同时有主功能和附属能力的模块 | 默认导出主功能 + 具名导出附属能力 | export default createStore; export const version = ... |
| 需要被路由懒加载的模块 | 默认导出组件或页面对象 | 懒加载 API 通常要求 default 导出 |
这样约定之后,团队协作时看到import Page from './Page.vue'或import { formatDate } from './utils',不需要再翻源码就能猜到模块的大致形态了。
4.2 心智模型:default 是“入口”,具名是“货架”
我更愿意用另一种方式理解 default 与具名导出:把模块想象成一间店铺。export default是摆在门口的“主打商品”,只放一个;具名导出是店里的货架,每个商品都有自己的名字,客户(导入方)按名字精确取货。
用这种心智模型做设计时,问自己三个问题就足够了:
- 这个文件是不是只有一个“主角”?如果是,主角用 default,配角用具名。
- 这个文件是不是一组工具集?是的话,全部具名导出,让调用方明确按名取用。
- 这个文件后续会不会被别人重构或二次封装?具名导出更容易被检索、重命名和静态分析,default 的“名字”在导入方那边千变万化,重构成本更高。
我见过很多组件库,每个组件文件里只写export default,但对外统一入口文件里又用export { default as Button } from './Button'把具名导出重新暴露出来。这就是“内部 default + 对外具名”的经典组合,兼顾了单文件主入口语义和库使用方的按名导入体验。
4.3 一个代价高昂的踩坑实录
去年参与某个内部模拟项目,一开始我把所有页面组件都写成了具名导出:
export function HomePage() {} export function AboutPage() {}到了接入路由懒加载阶段,某前端框架的懒加载函数只认 default 导出的组件,于是所有页面文件都得改一遍,把export function HomePage改成export default function HomePage。反过来,另一个工具模块当初图省事,全部用了export default,结果被一处业务代码当成具名模块导入,import { uniqueId }直接报错,又得返工。
这两次教训之后,我给自己定了一条硬规则:组件和页面入口,一律 default 导出;工具函数和跨模块复用的纯逻辑,一律具名导出。模块里既有主入口又有附属能力时,只允许“一个 default + 若干具名”。这套规则不复杂,但确实把导入导出的心智成本降了下来。
5. 一次线上模块解析报错的完整排查链路:default 的“回马枪”
5.1 事故现场:导入和导出对不上
某个项目在联调阶段突然报了一个很典型的错误:
SyntaxError: The requested module './render.js' does not provide an export named 'render'当时render.js源码末尾写的是:
export default function render() {}而调用方写的是:
import { render } from './render.js';从代码语义上看,render.js确实存在一个render标识符。很多人的第一反应就是“我明明导出了 render,为什么找不到?”其实问题就在于:export default function render() {}并不等于export function render() {}。前者只是把一个匿名函数赋值给名为default的导出绑定,函数名render只是函数本身的内部名字,没有作为模块导出名暴露出去。
5.2 排查过程:先看命名空间,再决定改哪边
第一件事不是改代码,而是确认模块到底暴露了什么。我把render.js的导入改成:
import * as renderModule from './render.js'; console.log(Object.keys(renderModule)); console.log(renderModule.default);控制台输出里只有default一个键,并没有render。到这里就确认了:不是构建缓存的问题,不是路径写错,而是导出名和导入名不匹配。
接下来面临二选一的修复方案:
- 修改
render.js:改成export function render() {},适合这个模块未来还会导出其他函数的情况; - 修改调用方:改成
import render from './render.js',适合这个模块本来就是单入口组件或页面。
我们最后选择了第二种方案,因为render.js本身就是“一个文件一个入口”的设计,default 导出更合理。改完后线上报错消失。
5.3 另一个高发坑:default 被误认为同名具名导出
同系列问题里还有一个高发坑,尤其容易出现在类组件文件里:
export default class Toast { // ... }有人会下意识地在调用方写:
import { Toast } from './toast.js';结果是熟悉的报错。原因和上面函数例子完全一致:default是一个固定的导出名,和class Toast自身的名字没有关系。如果既想保留 default 导出,又要让Toast这个名字可以按具名导入,正确写法是:
export default class Toast {} export { Toast };注意,这里不能写export class Toast {}并且同时写export default Toast,那样会导致重复导出。上面的写法算是比较干净的“一个 default + 一个具名”并存方案。
5.4 把这类报错整理成一张速查表
为了以后少踩坑,我把常见的 default 相关报错整理成了一张表,团队内部一直沿用:
| 导入写法 | 模块写法 | 结果 | 修复方向 |
|---|---|---|---|
import { render } from './m.js' | export default function render(){} | 报错,没有导出名 render | 改成export function render()或导入端用 default |
import render from './m.js' | export function render(){} | 报错,需要 default 导出 | 模块增加export default render或导入端用{ render } |
import * as ns from './m.js'; ns.default() | export default function(){} | 正常 | 无需修改 |
import { default as X } from './m.js' | export default function(){} | 正常 | 无需修改 |
import { default } from './m.js' | export default function(){} | 语法错误 | 必须用default as X |
这张表不是我凭空列出来的,每一条都是实际排查或者评审时遇到过的真实组合。遇到“明明导出了却找不到”的问题,先套表,再看命名空间,基本能在五分钟内定位。
6. 文档没强调但迟早会踩的细节:动态导入、循环依赖与 HMR
6.1 动态导入返回的命名空间对象,default 也得走对象访问
静态 import 可以自由地在代码顶层import x from './m.js',但动态import()返回的是一个 Promise,resolve 后拿到的永远是模块的 namespace 对象。这意味着:
const mod = await import('./page.js'); mod.default(); // 如果模块是 default 导出如果模块同时有具名导出,还可以这样:
const { default: init, helper } = await import('./page.js'); init(); helper();动态导入场景下,default 的“特殊地位”其实更弱了——它只是 namespace 对象里一个名为 default 的属性,必须显式取。某些前端框架的懒加载组件函数只接受 default 导出组件,也是基于这种约定。如果你写的是具名导出组件,要么调整导出方式,要么包一层:
const load = () => import('./Comp.js').then((m) => m.Comp);这种包一层的方式不改变模块本身,适合不想动原有导出约定的场景。
6.2 循环依赖:default 和具名导出在 TDZ 面前一样脆弱
ES Modules 的活绑定(live binding)确实能解决部分循环依赖问题,但它不是银弹。看下面这个典型例子:
// a.js import { b } from './b.js'; export const a = () => b(); // b.js import { a } from './a.js'; export const b = () => a();这是一个环。a.js先开始初始化,这时b.js里的b绑定还没有完成初始化。如果a.js顶层就执行了b(),就会触发暂时性死区异常。但如果调用发生在模块都初始化完成之后,比如通过某个事件函数间接调用,那绑定会正常工作。
default 导出在循环依赖里的表现也类似。模块的 namespace 对象可能一开始就存在,但default指向的实际值可能还是 undefined:
// x.js import y from './y.js'; export default function x() { return y; } // y.js import x from './x.js'; console.log(x()); // 如果 y.js 在 x.js 完成前执行这里,会报 x is not a function这类问题最稳的解法不是去研究 ESM 的初始化顺序,而是不要让业务模块之间出现环。我通常在代码评审里见到 import 环都会要求重构,哪怕运行时没报错,也说明模块边界已经开始模糊了。
6.3 HMR 对 default 导出有特殊照顾
前端开发时热更新(HMR)对 default 导出的组件或页面入口往往有额外处理。很多框架的热更新机制会默认把模块的 default 导出当作“可替换主组件”,只更新这一部分,而对具名导出的修改可能需要更完整地重新加载模块。这不意味着具名导出不能热更新,而是在设计模块时,如果你希望某个模块在调试时能被高效热替换,让主角走 default 导出通常能得到更完整的框架支持。
另外,热更新过程中如果模块的导出名发生改变,旧的引用关系可能失效。比如某个组件之前是export default function App,改成export function App,那么沿用旧导入代码的地方不会触发完整 reload,调试期看到页面卡死也别急着怀疑框架,先检查是不是导出形态被改了。
借用命名空间打印来排查 HMR 失效也很有效:哪个模块热更新后行为不对,就在那个模块里console.log(import.meta)或者打印import * as module的当前键列表,观察它和上一次更新时差了什么。大部分“热更新灵异事件”最后都会落到“导出名变了但导入方还是旧名字”这一点上。
最后说一点个人习惯
写了这些年代码,我现在的默认策略已经固定下来:页面组件和单文件入口组件统一用export default,因为“这个文件的主要角色是谁”一目了然;工具函数和需要跨模块复用的逻辑全部具名导出,方便 tree-shaking,也方便别人按名字快速检索。遇到不确定该用哪种导出的情况,我会先看调用方怎么用:如果调用方是import { a, b },那就尊重具名风格;如果调用方是import Main from ...,那默认导出更顺手。
排查导出类报错时,我的第一步永远是import * as mod from './xxx.js'然后打印Object.keys(mod)。这个动作看起来简单,但能直接告诉我们模块对外暴露了哪些导出名,省去翻源码、猜命名、怀疑缓存的漫长过程。最后再分享一个小技巧:如果工具库想同时照顾两种用户,可以在文件底部写一次具名export,再单独写一个export default,比如export { formatDate }; export default formatDate;,这样两种导入方式都兼容,代价只是文件里多一行代码,但换取的是 API 层面的双向友好。