你真的懂 export default 吗?

111 阅读5分钟

我们每天都在写 importexport,这是 ES6 模块的基础。但你有没有想过,下面这两种写法,到底有什么区别?

// 写法一
export default myVariable;

// 写法二
export { myVariable as default };

看上去,它们好像做的是同一件事:都是默认导出一个叫做 myVariable 的变量。在很多情况下,它们确实可以互换。但是,在某些特殊场景下,它们的行为完全不同,甚至可能导致难以发现的 bug。

今天,我们就来聊聊这个 JavaScript 模块中的“冷知识”。

一、值拷贝 vs 实时映射

要理解它们的区别,首先要明白 ES6 模块的一个核心特性:模块导出的是“实时映射”(live binding),而不是“值拷贝”(value copy)。

这是什么意思呢?我们来看一个例子。

假设我们有一个 module.js 文件:

// module.js
export let count = 1;

setTimeout(() => {
  count++;
  console.log('模块内部, count =', count); // 1秒后输出 2
}, 1000);

然后,在另一个文件 main.js 中导入它:

// main.js
import { count } from './module.js';

console.log('导入时, count =', count); // 输出 1

setTimeout(() => {
  console.log('1.5秒后, count =', count); // 1.5秒后输出 2
}, 1500);

你会发现,main.js 中导入的 count 变量,在1.5秒后自动变成了 2。它就像一个“传送门”,实时反映了 module.js 内部 count 变量的变化。

这就是“实时映射”。import 进来的变量,并不是把原始值复制了一份,而是创建了一个指向原始变量的只读引用。你可以把它想象成一个快捷方式,它自己没有实体,但总是指向源文件。

二、export default 的“陷阱”

理解了“实时映射”之后,我们再回来看 export default

你可能会觉得,既然模块导出的是实时映射,那么 export default 也应该一样吧?

我们来试一下。把 module.js 改成这样:

// module.js
let count = 1;

export default count;

setTimeout(() => {
  count++;
  console.log('模块内部, count =', count); // 1秒后输出 2
}, 1000);

然后 main.js 也相应修改:

// main.js
import count from './module.js';

console.log('导入时, count =', count); // 输出 1

setTimeout(() => {
  console.log('1.5秒后, count =', count); // 1.5秒后输出 1
}, 1500);

奇怪的事情发生了!main.js 里的 count 变量,在1.5秒后依然是 1,没有像我们预期的那样变成 2。

这就是 export default 的第一个关键点:export default 后面跟一个变量时,它导出的是这个变量的“值”,而不是“实时映射”。

你可以这么理解,当 JavaScript 引擎看到 export default count; 这行代码时,它做的事情类似于:

  1. 在模块内部创建一个隐藏的、匿名的变量,比如叫 *default*
  2. count 变量当前的值(也就是 1)赋给这个 *default* 变量。
  3. 将这个 *default* 变量作为默认导出。

所以,之后 module.js 内部的 count 变量就算变成了 2,也跟那个已经导出的 *default* 变量没关系了。main.js 导入的,自始至终都是那个值为 1 的 *default*

三、如何用 export default 实现实时映射?

那么,问题来了。如果我就是想用 export default,又想实现“实时映射”的效果,该怎么办呢?

答案就是我们开头提到的第二种写法:

// module.js
let count = 1;

export { count as default }; // 注意这里的写法

setTimeout(() => {
  count++;
  console.log('模块内部, count =', count); // 1秒后输出 2
}, 1000);

当你把 main.js 里的 import 语句改成 import count from './module.js'; 再运行一次,你会发现,1.5秒后,它正确地输出了 2。

为什么会这样?

因为 export { ... } 这种语法,遵循的是标准的“实时映射”规则。export { count as default } 的意思是,将 count 这个变量的“实时映射”作为默认导出。它没有创建中间变量和值拷贝的过程。

四、一个特殊的例外

你以为这就完了吗?JavaScript 的世界里,总有那么一些“但是”。

export default 有一个特殊的例外:当它后面直接跟着 functionclass 声明时,它导出的也是“实时映射”。

// module.js
export default function sayHello() {
  console.log('Hello');
}

// 这等同于
function sayHello() {
  console.log('Hello');
}
export { sayHello as default };

这种情况下,export default function ... 是一种语法糖,它会先在模块内部声明这个函数,然后再将这个函数的“实时映射”作为默认导出。class 也是同理。

这个设计主要是为了方便和保持一致性,因为函数和类声明本身就具有“提升”(hoisting)的特性。

五、结论

现在,我们可以总结一下了。

导出的是实时映射:

export { myVar } —— 这是标准的命名导出,导入的变量会随原始变量的改变而改变。

export { myVar as default } —— 与命名导出行为一致,实现了默认导出的实时映射。

export default function() {}export default class {} —— 这两个是特殊情况,行为等同于先声明再默认导出,也是实时映射。

导出的是值拷贝:

export default myVar —— 导入的变量是导出那一刻的快照,之后不再改变。

那么,我们应该用哪一种呢?

  • 如果你想导出一个不会改变的常量、或者一个纯函数,export default myVar 是最简单直观的选择。 大多数情况下,我们导出的就是这类东西。

  • 如果你需要导出一个可能会在模块内部被重新赋值的变量,并且希望外部能感知到这个变化,那么就必须使用 export { myVar as default } 这种情况比较少见,但一旦遇到,这个知识点就至关重要。

这个小小的区别,体现了 JavaScript 语言设计的复杂性和历史原因。虽然有点绕,但理解了它,可以帮助我们写出更健壮、更可预测的代码,也能在遇到奇怪的 bug 时,多一个排查问题的思路。