`export default A` 与 `export { A as default }` 是不同的

2,053 阅读8分钟

优秀博文翻译系列

原文翻译自: Jake Archibald Blog

Dominic Elm在推特上问我关于ES Module循环引用的问题,然而我并不知道答案。经过一番测试、探讨以及与V8团队的交流,我们解决了这个问题,在此过程中我学到了一些关于JavaScript的新知识。

我将把有关循环引用的内容放在文章结尾,因为它与文章标题不太相关。首先:

import 的内容是引用,而不是值

这是一个import:

import { thing } from './module.js';

在上面的例子里,thing 与 ./module.js里面的thing 是同一个。我知道这可能看起来是显然的,但是这样又会如何呢:

const module = await import('./module.js');
const { thing: destructuredThing } = await import('./module.js');

在这个例子里,module.thing 与 ./module.js中的 thing相同, 然而destructuredThing 是一个为./module.js中的thing声明的新标识符,它的表现有所不同。

让我们假设这是 ./module.js代码:

// module.js
export let thing = 'initial';

setTimeout(() => {
  thing = 'changed';
}, 500);

这是./main.js的内容:

// main.js
import { thing as importedThing } from './module.js';
const module = await import('./module.js');
let { thing } = await import('./module.js');

setTimeout(() => {
  console.log(importedThing); // "changed"
  console.log(module.thing); // "changed"
  console.log(thing); // "initial"
}, 1000);

import引入的内容是原内容的“动态绑定”或其他语言所讲的“引用”。这意味着,当一个不同的值被赋值给module.js中的thing时,这一改变也会反映在main.js中通过import引入的变量中。

通过import解构生成的变量,不会因原内容改变而改变,因为解构将import时刻thing当前值(而不是引用)赋值给了一个新变量。

解构的这一行为,不仅仅在import的时候发生:

const obj = { foo: 'bar' };

let { foo } = obj;

obj.foo = 'hello';
console.log(foo); // "bar"

上面的结果,在我看来是非常正常的。这里可能造成困惑的潜在问题是,具名静态引入语句(import { thing } …)可能看起来像是在解构赋值,但是实际上并不是。

好的,这是我们的结论:

// 这些import,会返回给你一个原变量的“动态引用”
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');

// 这种情况,会将原变量的当前值,赋值给一个新变量。(因为发生了解构赋值)
let { thing } = await import('./module.js');

但是, 'export default' 并不会这样工作

这是修改过的 ./module.js文件:

// module.js
let thing = 'initial';

export { thing };
export default thing;

setTimeout(() => {
  thing = 'changed';
}, 500);

以及 ./main.js:

// main.js
import { thing, default as defaultThing } from './module.js';
import anotherDefaultThing from './module.js';

setTimeout(() => {
  console.log(thing); // "changed"
  console.log(defaultThing); // "initial"
  console.log(anotherDefaultThing); // "initial"
}, 1000);

然而,我以为他们不会是initial!

这是为什么呢?

你可以直接 export default 一个值:

export default 'hello!';

... 但是你不能在具名exports中这样做:

// 这是不行的:
export { 'hello!' as thing };

为了让export default 'hello!' 可以正常工作,标准给予了 export default thing 与export thing不同的语义。

export default 后面的内容被当做一个表达式进行处理,所以它允许像 export default 'hello!' 和 export default 1 + 2.这样的写法。同样,export default thing中的thing变量也会被视为表达式,因而thing是被按值传递的。

这就好像它在被export前,被赋值给了一个隐藏的变量。并且正因如此,当原始的thingsetTimeout中重新赋值,这一改变并没有反映在实际被export的隐藏变量中。

因此:

// 这些会返回给你一个原export变量的动态引用:
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');

// 这会将原export变量的当前值,赋值给一个新变量:(因为发生了解构赋值)
let { thing } = await import('./module.js');

// 这些export会导出原变量的动态引用:
export { thing };
export { thing as otherName };

// 这些export导出的是变量的当前值:
export default thing;
export default 'hello!';

'export { thing as default }' 又不同了

你不能用 export {} 直接导出一个值,它总是导出原变量的引用,因此:

// module.js
let thing = 'initial';

export { thing, thing as default };

setTimeout(() => {
  thing = 'changed';
}, 500);

./main.js与之前的相同:

// main.js
import { thing, default as defaultThing } from './module.js';
import anotherDefaultThing from './module.js';

setTimeout(() => {
  console.log(thing); // "changed"
  console.log(defaultThing); // "changed"
  console.log(anotherDefaultThing); // "changed"
}, 1000);

不像export default thing一样, export { thing as default } 按引用导出了thing,因此:

// 这些会返回给你一个原export变量的动态引用:
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');

// 这会将原export变量的当前值,赋值给一个新变量:(因为发生了解构赋值)
let { thing } = await import('./module.js');

// 些export会导出原变量的动态引用:
export { thing };
export { thing as otherName };
export { thing as default };

// 这些export导出的是变量的当前值:
export default thing;
export default 'hello!';

很有趣,不是吗?我们还没结束。。

'export default function' 是另一种特殊情况

我说过export default之后的部分,会被当做表达式进行处理。但是这一规则存在例外:

看下面例子:

// module.js
export default function thing() {}

setTimeout(() => {
  thing = 'changed';
}, 500);

以及:

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

setTimeout(() => {
  console.log(thing); // "changed"
}, 1000);

它输出了 "changed",因为export default function被赋予了它自己的语义:函数在这个例子中被按引用传递

如果我们把module.js修改成:

// module.js
function thing() {}

export default thing;

setTimeout(() => {
  thing = 'changed';
}, 500);

...它不再是特例,所以最后会输出ƒ thing() {},因为thing函数此时被按值传递。

这是为什么呢?

并不仅仅是export default function, export default class也同样是特例。这与这些函数(类)声明在作为表达式处理时,行为的改变有关:

// 直接声明
function someFunction() {}
class SomeClass {}

console.log(typeof someFunction); // "function"
console.log(typeof SomeClass); // "function"

但是当我们让他们成为表达式:

// 表达式
(function someFunction() {});
(class SomeClass {});

console.log(typeof someFunction); // "undefined"
console.log(typeof SomeClass); // "undefined"

function 和 class 声明在作用域/代码块中创建了一个新的标识符,然而function 和 class表达式不会(虽然他们的变量名,可以在function/class各自内部被使用)。

因此,有如下现象:

export default function someFunction() {}
console.log(typeof someFunction); // "function"

如果 export default function不是特例,那么这个函数声明会被当做表达式,输出的就会是"undefined"

函数的这一特例也对循环引用提供了一定帮助,但是我稍后一些再进行说明。

总结一下:

// 这些会返回给你一个原export变量的动态引用:
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');

// 这会将原export变量的当前值,赋值给一个新变量:(因为发生了解构赋值)
let { thing } = await import('./module.js');

// 些export会导出原变量的动态引用:
export { thing };
export { thing as otherName };
export { thing as default };
export default function thing() {}

// 这些export导出的是变量的当前值:
export default thing;
export default 'hello!';

这让export default <变量名> 显得很奇怪。

我可以明确知道export default 'hello!'是按值传递的,但是因为存在特例 export default function是按引用传递的,这感觉就像export default <变量名>也是特例(按引用传递)一样。

我猜现在再去改变它,为时已晚。

我与Dave Herman 沟通了这一问题,他参与了JavaScript ES6模块的设计。他说默认导出export default xxx早期的设计是export default = thing这样的形式,会更明显地表明thing是被当做表达式对待的。我完全同意!

那么,循环引用是怎么一个情况呢?

真相很快就要大白。首先我们需要去探讨“提升”问题:

提升: Hoisting

你可能遇到过古早时期,JavaScript给函数设计的怪特性:

thisWorks();

function thisWorks() {
  console.log('yep, it does');
}

函数定义实际上被移到了文件的顶部。这只会发生在普通的函数声明中:

// 不能正常工作
assignedFunction();
// 同样不能正常工作
new SomeClass();

const assignedFunction = function () {
  console.log('nope');
};
class SomeClass {}

如果你试图在声明之前,去获取一个let/const/class声明的变量,它会造成报错。

var 则不同

...因为很显然:

var foo = 'bar';

function test() {
  console.log(foo);
  var foo = 'hello';
}

test();

上面会输出undefined,因为var foo声明在函数内部,被提升到了函数的开始位置。但是'hello'的赋值过程仍然在它书写的位置。这有点不好理解,这也就是为什么let/const/class被设计成这样做会报错。

循环引用的情况呢?

循环引用在JavaScript Module中是允许的,但是是混乱的,应该避免循环引用的情况发生。

例如:

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

foo();

export function hello() {
  console.log('hello');
}

然后:

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

hello();

export function foo() {
  console.log('foo');
}

可以正常工作!它先输出了 "hello" ,然后是"foo"

然而,这仅仅是因为“提升”才会正常工作,它让两个函数定义提升到他们的调用上方。如果我们将代码改为:

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

foo();

export const hello = () => console.log('hello');

And:

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

hello();

export const foo = () => console.log('foo');

...它无法工作。module.js 先执行,作为执行结果,它试图去在hello初始化前去获取hello,然后导致报错。

让我们使用'export default':

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

foo();

function hello() {
  console.log('hello');
}

export default hello;

以及:

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

hello();

function foo() {
  console.log('foo');
}

export default foo;

这就是Dominic给我的例子。它无法正常执行,因为 module.js中的hello指向main.js导出的隐藏变量,并且它被在初始化前访问。

如果 main.js变为使用export { hello as default },就会正常执行。因为它将函数按引用导出,然后被提升。如果 main.js被改为使用export default function hello(),也可以正常执行,但是这时是因为这触发了之前说过的export default function迷之特例。

我猜测这是export default function被特殊对待的另一原因 ———— 让函数提升如期正常工作。但是还是那句话,为了保持一致性, export default identifier 似乎也应该被如此特殊对待。

所以就是这样啦!我学到了一些新东西。但是,就像我之前的几篇博文一样,请不要把这些加入你们公司的面试题库,就默默别再使用循环依赖了就行😀。

感谢:  V8团队的Toon VerwaestMarja Hölttä, 和 Mathias Bynens让我在这篇文章中用正确的术语,Dave Herman and Daniel Ehrenberg 给我一些有关这一问题的历史信息,校对者SurmaAdam ArgyleAda Rose CannonRemy SharpLea Verou(嘿,我让好多人读了这篇文章,我想让它产生更大价值) ,当然还要感谢Dominic Elm 提出了这个问题。