为了证明我没有标题党,首先花 10 秒钟来测试一下,你有没有中招。
这个错误就是使用 ESM (ECMAScript Module) 的默认导出方式(Default Exports)去导出一个对象
// a.js
export default {
foo: () => {},
bar: () => {}
}
再使用具名导入 (Named imports) 的方式去引入:
// b.js
import {foo, bar} from './a.js'
如果有,恭喜我,没有标题党;也「恭喜你」,中招啦,也是大多数中的一个 :)
尽管上述写法可能可以用,但是事实上,这种写法并不正确的。
正确的写法是不能混用两种导入导出,下面是正确示范中的一个:
import A from './a.js'
const {foo, bar} = A;
接下来的文章内容分别是:
- 向大家证明最初的写法是错误的
- 我们为什么会犯这种错误
- 剖析 ESM 的原理
如果看完了对你真的有帮助,跪求大佬赏一个赞,废话不多说,开始 ~
证明这种写法是错误的
我们不借助任何打包工具,直接在浏览器上面实验一下,代码很少,你可以跟着我做一遍。
首先新建一个 a.js 和 b.js ,他们的内容分别如下:
// a.js
export {
a: 1
}
// b.js
import { a } from './a.js'
console.log(a)
我们新建一个 index.html 文件,里面填入以下内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<script type="module" src='./a.js'></script>
<script type="module" src='./b.js'></script>
</body>
</html>
运行 index.html ,我们会发现控制台会成功打印出 a
的值。
到这里是没有任何问题的,但是当我们修改 a.js ,改成下面这样:
// 变成了 Default exports
export default {
a: 1
}
我们再去运行 index.html ,就发现控制台报错了:
"Uncaught SyntaxError: The requested module './a.js' does not provide an export named 'a' "
错误是说:'a.js' 里面没有提供一个名叫 a
的导出值。
这就说明了: 在规范里,我们不能使用形如 import { a } from './a.js'
这样的代码去引入默认导出方式 ( Default exports ) 导出的对象。而我们平常可以这么用的原因,是因为在第三方打包工具中做了特殊的兼容。
我们为什么会犯这种错误
但是对于很多同学来说,文章最开始的那个例子,在直觉上来说是正确的。为此,我曾在专门同事间做过调查,在没有事先告知的情况下,没有一个人发现问题。
那是什么让我们产生了「这么写是正确的」这种直觉呢?
我猜测有两个原因:
可能是 Named imports 的方式和解构语法实在太像了,有些同学想当然的认为 import
语句里的 {
也是解构语法的一种了,事实上,完全不是。
const obj = { a: 1, b: 2 }
const {a, b} = obj;
又可能是 Node.js 使用的 CommonJS 规范误导了我们,在 CommonJS 中,导出一个包的方式如下:
module.export.a = 100
module.export.foo = () => {}
在引入的时候,我们比较随意,可能是下面两种方法的任意一种:
const A = require('./a.js');
const {a, foo} = require('./a.js')
这种写多了,就让我们产生两种方法可以混用的错觉。但是我们忽略了一点,CommonJS 没有默认导出呀,而 ESM 里有,除此之外,二者差别大了去了 ~ 不过本文就不展开讨论了。
剖析 ESM 的原理
接下来,我们终于要分析深层原因了。
搞懂这个问题为什么是错误,还是要和 CommonJS 对比一下。
其实 CommonJS 和 ESM 不仅仅是引入包的语法不同,二者在实现逻辑上也可谓是天差地别。以至于为了在 Node.js 中使用 EMS ,不得不采取 mjs 的方案。
在 node 中,require
其实就是一个函数。
当我们写如下代码的时候:
module.exports.a = 100
它实际是把导出的变量 a
挂载到 module.exports
这个对象上:
function (exports, require, module, __filename, __dirname) {
const a = 100;
module.exports.a = a;
}
当我们使用 require
引入一个包的时候,就是去拿到这个文件里的 module.exports
这个对象的结果,这个结果是在代码运行到那一行的时候知道的,既然是这样,那我们使用解构语法也好,一个对象接受也好,都没什么关系了。
而 ESM 的原理却完全不一样。它确定导出的东西有哪些的过程是在代码编译阶段确定的。为了让大家直观的感受这一点,我们设计一下代码,还是拿本篇文章最开始的例子。
在刚才报错的 b.js 最开始,加一句 console.log(1)
。
console.log(1);
import {a} from './a.js'
console.log(a)
如果控制台先打印出了数字 1 ,再报错,就说明 import 的结果是运行期间决定的;如果没有打印出数字 1 直接就报错了,那就说明 import 的结果是编译期决定的(为了便于理解,你可以想象成:它和检查 JS 语法错误在一个阶段)。
实验表明,我们没有看到我们加的那个 log,直接就报错了。
所以我们得出结论:确定导入导出项的这个过程是在编译期决定的。
既然这样,如果我们我们支持 Named imports 的方式去导出 Default exports 的东西,岂不是矛盾了吗?如果我们使用 Default exports 的方式导出一个对象,我们肯定无法确定这个对象在运行期有没有被修改过。因为 JS 的对象是可以在运行阶段动态添加的呀。
所以在这种情况下,我们肯定也无法在编译期就知道 Default exports 会导出什么东西!那用 import { ... } from '...'
这种语法肯定就不行了。
const obj = {
a: 1
}
setTimout(() => {
obj.b = 2;
});
export default obj;
相信推导到这里,大家都已经相信本文开头的那种写法是有问题的了。
所以说,当我们写下下面这句话的时候:
import {x} from './a.js'
整体会是这样一个过程:在编译阶段, JS 引擎将要从文件 a.js 里引入 x 这个值,它说,请确定有这个导出项,没有我就报错啦,如果有的话就进入的代码运行阶段,在这个阶段,这个值就计算出来了,我们的引入方也就拿到了导出的结果。
事实上,在代码的编译阶段,JS 的内部会初始化一个叫做 Module Record
的结构,里面存储了导出项的静态列表,这些信息已经能够确定是否可以从某个文件导出某个东西了,只不过,这些导出项的具体值还没有被确定,要到代码运行阶段才能被真正的确定。
希望通过上面的解释,既让大家明白我们最开始的代码为什么是错的,也让大家对 ESM 的真正原理有一个更深的理解。不要在写文章最开始那样的代码啦 ~
谢谢阅读。