从代码可维护性角度出发,命名导出比默认导出更好,因为它减少了因引用产生重命名情况的发生。
但命名导出与默认导出的区别不止如此,在逻辑上也有很大差异,为了减少开发时在这方面栽跟头,有必要提前了解它们的区别。
本周找来了这方面很好的的文章:export-default-thing-vs-thing-as-default,先描述梗概,再谈谈我的理解。
一般我们认为,import 导入的是引用而不是值,也就是说,当导入对象在模块内值发生变化后,import 导入的对象值也应当同步变化。
// module.js
export let thing = 'initial';
setTimeout(() => {
thing = 'changed';
}, 500);
上面的例子,500ms 后修改导出对象的值。
// 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);
1s 后输出发现,前两种输出结果变了,第三种没有变。也就是对命名导出来说,前两种是引用,第三种是值。
但默认导出又不一样:
// module.js
let thing = 'initial';
export { thing };
export default thing;
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); // "initial"
console.log(anotherDefaultThing); // "initial"
}, 1000);
为什么对默认导出的导入结果是值而不是引用?
原因是默认导出可以看作一种对 “default 赋值” 的特例,就像 export default = thing
这种旧语法表达的一样,本质上是一种赋值,所以拿到的是值而不是引用。
那么默认导出的另一种写法 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);
为什么 export default function
是引用呢?原因是 export default function
是一种特例,这种写法就会导致导出的是引用而不是值。如果我们用正常方式导出 Function,那依然遵循前面的规则:
// module.js
function thing() {}
export default thing;
setTimeout(() => {
thing = 'changed';
}, 500);
只要没有写成 export default function
语法,哪怕导出的对象是个 Function,引用也不会变化。所以取决效果的是写法,而与导出对象类型无关。
对于循环引用也有时而生效,时而不生效的问题,其实也取决于写法。下面的循环引用是可以正常工作的:
// 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');
}
为什么呢?因为 export function
是一种特例,JS 引擎对其做了全局引用提升,所以两个模块都能各自访问到。下面方式就不行了,原因是不会做全局提升:
// main.js
import { foo } from './module.js';
foo();
export const hello = () => console.log('hello');
// module.js
import { hello } from './main.js';
hello();
export const foo = () => console.log('foo');
所以是否生效取决于是否提升,而是否提升取决于写法。当然下面的写法也会循环引用失败,因为这种写法会被解析为导出值:
// main.js
import foo from './module.js';
foo();
function hello() {
console.log('hello');
}
export default hello;
作者的探索到这里就结束了,我们来整理一下思路,尝试理解其中的规律。
可以这么理解:
- 导出与导入均为引用时,最终才是引用。
- 导入时,除
{} = await import()
外均为引用。 - 导出时,除
export default thing
与export default 123
外均为引用。
对导入来说,{} = await import()
相当于重新赋值,所以具体对象的引用会丢失,也就是说异步的导入会重新赋值,而 const module = await import()
引用不变的原因是 module
本身是一个对象,module.thing
的引用还是不变的,即便 module
是被重新赋值的。
对导出来说,默认导出可以理解为 export default = thing
的语法糖,所以 default
本身就是一个新的变量被赋值,所以基础类型的引用无法被导出也很合理。甚至 export default '123'
是合法的,而 export { '123' as thing }
是非法的也证明了这一点,因为命名导出本质是赋值到 default
变量,你可以用已有变量赋值,也可以直接用一个值,但命名导出不存在赋值,所以你不能用一个字面量作命名导出。
而导出存在一个特例,export default function
,这个我们尽量少写就行了,写了也无所谓,因为函数保持引用不变一般不会引发什么问题。
为了保证导入的总是引用,一方面尽量用命名导入,另一方面要注意命名导出。如果这两点都做不到,可以尽量把需要维持引用的变量使用 Object
封装,而不要使用简单变量。
最后对循环依赖而言,只有 export default function
存在声明提升的 Magic,可以保证循环依赖正常 Work,但其他情况都不支持。要避免这种问题,最好的办法是不要写出循环依赖,遇到循环依赖时使用第三个模块作中间人。
一般我们都希望 import 到的是引用而不是瞬时值,但因为语义与特殊语法糖的原因,导致并不是所有写法效果都是一致的。
我也认为不需要背下来这些导入导出细枝末节的差异,只要写模块时都用规范的命名导入导出,少用默认导出,就可以在语义与实际表现上规避掉这些问题啦。
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)