模块中默认导出和命名导出的区别

最近碰到一个循环依赖的问题,看了下编译后的webpack模块,发现webpack对不同默认导出方式的处理是不同的,跟之前认为的有出入,于是记录一下。

之前觉得在模块中,导出的内容是不可变的,比如导出了一个变量,那么这个变量就是导出时候的内容;如果导出的是一个函数,那么这个函数也是导出函数的引用。

但是实际上,这个并不是绝对的,这个内容可变性取决于导出的方式。

导出的是引用,不是值

在一个模块中,通过命名导出了一个变量 thing。并在 500ms 后修改了这个变量的值。

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

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

那么我们在引用这个变量的时候,会发现这个变量的值是会跟随变化的。

// 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);

导入的时候,相当于 引用 了原有模块的变量,这个特性类似于单向绑定。 但是解构赋值是不同的,因为解构的时候已经将变量的值取出来赋予了新的变量,所以不会跟随变化。

默认导出是不同的

类似的有一个默认导出的模块,并在 500ms 后修改了默认导出的值。

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

export { thing };
export default thing;

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

通过模块的 default 入口获取对应的值

// 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);

可以发现,不管是具名引用了default还是直接导入了default,获取到的值都是不会变的。

这是因为 export default 类似于一个表达式,它在导出的时候就已经确定了值,而不是一个引用。后续不管怎么变化,都只会是执行语句时候的值。

但是需要注意的是,如果是通过 export { thing as default } 这种命名导出方式导出了默认出口,那么这个值是会跟随变化的。

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

export { thing, thing as default };

setTimeout(() => {
  thing = 'changed';
}, 500);
// 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 是一个表达式,那么如果这个表达式是一个函数呢?

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

setTimeout(() => {
  thing = 'changed';
}, 500);
// main.js
import thing from './module.js';

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

可以发现,这个值居然会发生变化。这说明默认导出的如果是一个函数,导出的时候就变成了一个引用而不是值。这跟之前的说明又发生了些出入。

这是因为,如果导出的是函数表达式的值的话,在导出的时候无法确定这个值是什么,所以只能特殊处理一下导出一个引用。

// module1.js
// 函数定义
function someFunction() {}
class SomeClass {}

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

// module2.js
// 函数表达式
(function someFunction() {});
(class SomeClass {});

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

不得不说,JavaScript 真是一门神奇的语言。

模块循环依赖

最后,再说一下最开始提到的循环依赖的问题。在讲循环依赖之前,简单复习一下变量提升。

通过 function 直接定义,或者 var 定义的变量,都会被提升到当前作用域的顶部。

thisWorks();

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

var foo = 'bar';

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

test();

let/const/class 定义的变量,不会被提升,所以在使用之前必须先定义。

// Doesn't work
assignedFunction();
// Doesn't work either
new SomeClass();

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

回到模块循环依赖的问题,假设有两个模块,moduleAmoduleB,它们互相依赖。

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

foo();

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

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

hello();

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

这样是没有问题的,因为变量提升的原因,moduleA 中的 foo 函数在 moduleB 中是可以使用的。

但是如果不满足变量提升的条件,模块就会报错。

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

foo();

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

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

hello();

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

这样就会报错,在引用另一个文件的时候,因为不满足变量提升的条件,引用了未初始化的变量而报错。

参考内容