优秀博文翻译系列
原文翻译自: 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前,被赋值给了一个隐藏的变量。并且正因如此,当原始的thing被setTimeout中重新赋值,这一改变并没有反映在实际被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 Verwaest, Marja Hölttä, 和 Mathias Bynens让我在这篇文章中用正确的术语,Dave Herman and Daniel Ehrenberg 给我一些有关这一问题的历史信息,校对者Surma, Adam Argyle, Ada Rose Cannon, Remy Sharp, Lea Verou(嘿,我让好多人读了这篇文章,我想让它产生更大价值) ,当然还要感谢Dominic Elm 提出了这个问题。