使用 ES Module 时,你可能每天都在犯这个错误

3,708 阅读6分钟

为了证明我没有标题党,首先花 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;

接下来的文章内容分别是:

  1. 向大家证明最初的写法是错误的
  2. 我们为什么会犯这种错误
  3. 剖析 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' "

截屏2021-05-30 下午1.42.51.png

错误是说:'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 的真正原理有一个更深的理解。不要在写文章最开始那样的代码啦 ~

谢谢阅读。