我们每天都在写 import 和 export,这是 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; 这行代码时,它做的事情类似于:
- 在模块内部创建一个隐藏的、匿名的变量,比如叫
*default*。 - 把
count变量当前的值(也就是 1)赋给这个*default*变量。 - 将这个
*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 有一个特殊的例外:当它后面直接跟着 function 或 class 声明时,它导出的也是“实时映射”。
// 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 时,多一个排查问题的思路。