浅谈 ECMAScript 的模块化

2020 年 07 月 20 日

阅读量:0

正文共 7272 字,预计阅读时间 36 分钟

概述

模块式是目前前端开发最重要的范式之一。

随着前端项目的日渐复杂,不得不花费大量时间去管理。

模块化就是最主流的代码组织方式。

将复杂的代码按照功能不同划分为不同的模块,通过单独维护的方式,提高开发效率,降低维护成本。

「模块化」只是思想,不包含具体实现。

演变过程

早期的技术标准并没有预料到如今前端项目的规模,所以很多设计上的遗留问题导致我们现在去实现模块化有很多问题。

虽然这些问题都被现在的模块化标准和工具所解决了,但它的演变过程值得去思考。

总体来看,JavaScript 模块化大致有 4 个阶段。

1. 文件划分的形式

这种形式就是以每个 js 文件为一个模块,在 html 文件中导入它们进行使用。

这么做有 3 个问题:

  1. 污染全局作用域,这样一个模块内的任何成员都可以被访问和修改。

  2. 命名冲突,模块多了过后,很容易产生命名冲突。

  3. 无法管理模块依赖关系

这种方式完全依靠约定,项目一旦体量过大,就很难保证所有模块完全按照约定来使用。

var name = "module-a";

function method1() {
  console.log(name + "#method1");
}

function method2() {
  console.log(name + "#method2");
}

2. 命名空间方式

基于文件划分进行优化,每个模块只暴露一个对象。模块的成员都只暴露在这个对象下面。

这种方式可以减少命名冲突的可能。

但这种方式仍然没有私有空间,内部成员仍然可以被访问和修改。模块间的依赖关系也没有得到解决。

var moduleA = {
  name: "module-a",

  method1: function () {
    console.log(name + "#method1");
  },

  method2: function () {
    console.log(name + "#method2");
  },
};

3. IIFE

IIFE 就是立即执行函数,将需要暴漏出来的成员挂载到 window 对象上,通过这种方式可以保证内部成员无法被访问和修改。

(function () {
  var name = "module-a";

  function method1() {
    console.log(name + "#method1");
  }

  function method2() {
    console.log(name + "#method2");
  }

  window.moduleA = {
    method1,
    method2,
  };
})();

4. IIFE 通过参数声明依赖

在 IIFE 基础上接收一个参数,这样模块的依赖关系也更加明确。

(function ($) {
  var name = "module-b";

  function method1() {
    console.log(name + "#method1");
    $("body").animate({ margin: "200px" });
  }

  function method2() {
    console.log(name + "#method2");
  }

  window.moduleB = {
    method1,
    method2,
  };
})(jQuery);

这些就是早期在没有工具和规范的情况下对模块化的落地方式。

规范的出现

上面介绍的几种方式,在不同项目和不同开发者的实际使用中,会存在细微的差异。为了统一差异,就需要一个标准来规范模块加载模块。

手动在 html 中引入 js 文件会有很多问题。当新增加模块或者修改模块名字时,需要手动修改。模块的依赖关系发生改变时,需要手动修改。模块多余,不需要时,需要手动移除。总之就是需要人工维护模块的加载。

所以我们需要模块化标准和模块加载器,通过代码的方式来帮我们自动加载模块。

CommonJS 规范

CommonJS 是 Nodejs 提出的一套模块化规范,具有以下几条约定。

  • 一个文件就是一个模块
  • 每个模块都有单独的作用域
  • 通过 module.exports 导出成员
  • 通过 require 函数载入模块

但是这套规范在浏览器中使用会有问题。

CommonJS 是以同步的方式加载模块。nodejs 的机制是在启动时加载所有模块,运行时不会再去加载,只会去使用模块。

这种模式运行在浏览器中,会导致应用效率低下,每打开一个页面都会导致大量的同步请求出现。

所以早起浏览器中并没有使用 CommonJS 规范,而是结合浏览器的特点,重新设计了一套浏览器规范,AMD。

AMD(Asynchronous Module Definition)

AMD 是异步模块定义规范。

Require.js 是一个实现了 AMD 规范的库。

具体用法如下:

define("moduleName", ["jQuery", "./module2"], function ($, module2) {
  return {
    start: function () {
      $("body").animate({ margin: "200px" });
      module2();
    },
  };
});

define 是定义模块的函数。它接收两个或三个参数。

第一个参数是模块名,第二个参数是依赖数组,第三个是模块函数。

模块函数提供了独立的作用域,并可以按照依赖参数数组的顺序接收参数列表。返回的对象就是暴露的成员。

第二个参数是可选的,如果没有依赖的模块,可以省略。

除了 define,AMD 还有一个 require 函数,用法如下:

require(["./module1"], function (module1) {
  module1.start();
});

require 用法和 define 类似,不同的是 require 只是会导入模块和执行代码,而不会去定义模块。导入模块的方式是创建一个 script 标签,然后去请求模块代码。

目前绝大多数第三方库都支持 AMD 规范。

所以 AMD 规范生态是很好的,但是使用相对复杂,而且如果模块划分过细的话,js 文件请求会很频繁,导致页面效率低下。

Sea.js + CMD

同时期的淘宝推出了 sea.js,和 require.js 类似。CMD 规范类似于 AMD,目的是想简化 AMD 的写法,尽量和 CMD 保持一致,从而减少开发者学习成本,但是后来被 AMD 兼容了。算是一个重复的轮子。

define(function (require, exports, module) {
  var $ = require("jquery");
  module.exports = function () {
    console.log("module 2~");
    $("body").append("<p>module2</p>");
  };
});

标准规范

上面的几种规范虽然都解决了模块化,但或多或少存在一些问题。

现在的前端模块化已经非常成熟了,而且大家对目前的前端模块化方式已经基本统一。

在浏览器中,使用 ES Modules 规范;在 nodejs 中,使用 CommonJS 规范。

由于 CommonJS 是 nodejs 内置支持的模块化规范,所以不存在兼容性问题。

ES Modules 是 ECMAScript2015 才被定义的标准,会存在各种环境下的兼容性问题。不过随着 Webpack 等打包工具的流行,这个问题也逐渐被解决。

目前来说,ES Modules 是最流行的模块化规范。相较于社区提出的 AMD 规范,ES Modules 在语言层面实现了模块化,更加完善。

因为 ES Modules 是官方提出的规范,所以迟早所有浏览器都会原生实现这个特性。未来有着非常好的发展。

而且,短期内应该也不会再有新的模块化标准轮子出现。

ES Modules

基本特性

在浏览器中直接使用模块的方式是给 script 标签设置 type=module。

首先来创建一个 index.html 文件体验一下。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script type="module">
      var hi = "hello, world!";
      console.log(hi);
    </script>
  </body>
</html>

通过 serve 工具启动它,会发现这个模块的内容和普通脚本一样正常执行,没有什么区别。

接下来看一下模块和脚本的几个具体区别。

区别 1 模块自动采用严格模式

在普通的脚本文件中,默认采用宽松模式,如果需要启用严格模式,需要使用 "use strict"声明。

拿 this 举例。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      console.log(`脚本:${this}`);
    </script>
    <script type="module">
      console.log(`模块:${this}`);
    </script>
  </body>
</html>

这里会有两句日志输出。第一句会输出 window,第二句会输出 undefined。

区别 2 每个模块都会拥有私有作用域

看下面的例子。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      var a = 1;
    </script>
    <script type="module">
      var b = 2;
      console.log("模块1: ", a, b);
    </script>
    <script type="module">
      console.log("模块2: ", a, b);
    </script>
  </body>
</html>

它也会打印 2 句日志。

模块1:  1 2
ReferenceError: b is not defined

可以看到,脚本中用 var 创建的变量被挂载到 window 对象上,所以所有脚本和模块都可以访问到变量 a。

但是模块中使用 var 创建的变量并不会被挂载到 window 对象上,所以接下来的模块或者脚本访问 b 时都会得到 b is not defined 的错误。

这样就可以放心的在模块中创建变量,而不需要担心全局作用域命名空间污染问题。

区别 3 模块是通过 CORS 的方式请求外部模块的

如果通过 src 请求模块文件,同源的情况下没有问题。非同源的话比如服务端开启 CORS 响应头信息才可以。

查看下面百度 jquery 的例子,这个 js 文件是不支持 CORS 的。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="https://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
    <script>
      console.log("get success");
    </script>
    <script
      type="module"
      src="https://libs.baidu.com/jquery/2.0.0/jquery.min.js"
    ></script>
  </body>
</html>

可以看到控制台先打印 get success,由于 script 默认是同步执行的,所以意味着通过脚本的模式加载文件成功了。接下来会得到一个跨域的错误,意味着以模块的方式加载文件失败了。

而且模块只支持通过 http 协议加载,不支持本地 file 协议加载。

区别 4 模块会延迟执行脚本

脚本默认会立即执行,脚本的执行过程中会中断页面渲染。

而模块会延迟执行,相当于给 script 标签添加了 defer 属性。

看下面的例子。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./alert.js"></script>
    <div>world</div>
  </body>
</html>

要在 index.html 同级目录下创建一个 alert.js 文件。

alert("hello");

alert 的执行会阻塞页面渲染,所以在 alert 存在的时候,是看不到 world 的。

如果加上 type=module,就可以实现脚本的延迟执行。

导入/导出

模块本身具有私有作用域,所以内部成员都无法被外部所访问到。如果要把模块内的成员暴露出去,需要使用 export 关键词去修饰要暴露的变量,而如果需要导入其他模块的成员,则需要 import 关键词。

下面演示一下导入导出。

创建 module.js,其中暴露一个变量。

export var msg = "hello world";

创建 app.js 文件,导入这个变量并在控制台打印。

import { msg } from "./module.js";

console.log(msg);

创建 index.html,导入 app.js,用来测试。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script type="module" src="./app.js"></script>
  </body>
</html>

在浏览器中打开 index.html,就可以看到正常的输出结果。

这就是最简单的导入导出用法。

export 除了可以导出变量以外,还可以导出 function 和 class。

export var msg = "hello world";

export function log(...args) {
  console.log(args);
}

export class Clazz {}

除了可以直接在变量声明前面使用 export,也可以在模块底部统一导出需要导出的成员。

var msg = "hello world";

function log(...args) {
  console.log(args);
}

class Clazz {}

export { msg, log, Clazz };

这种写法更为常用,因为可以很直观的知道这个模块导出了哪些成员,而且通过这种方式导出的成员,可以被重命名。

export { msg as myMsg, log, Clazz };

如果导出的成员被重命名了,导入的地方就需要使用重命名后的名字。

import { myMsg } from "./module.js";

如果导出成员的名字为 default,那么 default 的值就会作为这个模块的默认导出。由于 default 是关键字,所以导入的地方必须重命名。

export { msg as default, log, Clazz };
import { default as msg } from "./module.js";

针对这种情况,还有一种更简单的写法。通过 export 和 default 关键字的组合来导出默认的模块。

export { log, Clazz };
export default msg;

这样在导入模块的默认导出时,就可以随便起一个名字。

import iMsg from "./module.js";

导入/导出注意事项

语法上和字面量对象的区别

export 的语法和对象字面量语法完全一致,这就会产生迷惑行为。

修改 module.js 内容。

var name = "jack";
var age = 18;

const obj = { name, age };
export { name, age };

这就导致很多人认为 export 导出的就是一个对象,而导入就是对这个对象的解构。实际上这是错误的。

虽然写法一样,但意义却没有任何关系。

export 单独使用时,就必须用花括号包裹要导出的成员。

当 export 和 default 同时使用时,后面的花括号就表示一个对象。

这一点要区分开。

var name = "jack";
var age = 18;

const obj = { name, age };
// export { name, age };// 这是导出语法
export default { name, age }; // 这是默认导出一个对象

这时通过 import 来获取 module.js 的默认导出。

修改 app.js 的内容。

import { name, age } from "./module";

console.log(name, age);

会得到一个错误。

Uncaught SyntaxError: The requested module './module.js' does not provide an export named 'age'

这说明 import 和 export 一样,导出的不是一个对象的解构。花括号同样是固定写法。

成员的引用

模块中导出的成员都不是值传递,而是引用传递。即使是基础类型也是这样。

可以通过下面的实例来观察。

修改 module.js。

var name = "jack";
var age = 18;

const obj = { name, age };
export { name, age };

setTimeout(function () {
  age = 20;
}, 1000);

修改 app.js。

import { name, age } from "./module.js";

console.log(name, age);

setTimeout(function () {
  console.log(age);
}, 1500);

这样在 1 秒后,age 的值被修改为 20,1.5 秒后打印 age 的值,如果是 20,就意味着是引用传递。

1.5 秒后打印的结果是 20。

这和 nodejs 的 CommonJS 规范是完全不同的。

暴露出来的成员都是只读的

我们没有办法在模块外部修改模块内部暴露的成员。

比如尝试在 app.js 中修改 module.js 中暴露的成员 name。

import { name, age } from "./module.js";

name = "tom";

会得到一个错误。

Uncaught TypeError: Assignment to constant variable.

这和 const 有点像。

导入

引用路径

import 导入模块时,from 后面的字符串实际上是模块的路径。这个路径必须是完整的文件名,不可以省略扩展名。

但是在一些打包工具中,比如 Webpack,可以省略文件扩展名,或者只填写目录名,省略 index.js。

import utils from "./utils/index.js"; // 标准语法
import utils from "./utils/index"; // 打包工具的语法,省略扩展名
import utils from "./utils"; // 打包工具的语法,省略 index.js

这一点和 CommonJS 是不同的。

在网页开发中,引用网络资源时,如果使用的是相对路径,可以省略掉./,但是 import 不可以省略。

<img src="cat.png" />
<!-- 等价于 -->
<!-- <img src='./cat.png'/> -->

但是 import 不可以省略。

import utils from "./utils/index.js"; // 正确语法
import utils from "utils/index.js"; // 错误语法

如果省略了/ ./ 或者 ../,那就会以字母开头,会被认为是加载第三方模块,这点和 CommonJS 是相同的。

除了相对路径,也可以通过项目的绝对路径或者 http 的 URL 来导入模块。

只去执行某个模块代码

比如模块 A 只是输出一串文字,并没有导出任何成员。

// moduleA.js
console.log("hello, world");

在模块 B 中想要执行模块 A 的内容,只需要导入这个模块,不需要导入成员,就可以执行里面的脚本。

// moduleB.js
import {} from "./moduleA.js";

除此之外,还有一种简单写法。就是省略成员列表和 from 关键词。

import "./moduleA.js";
导出成员较多

如果一个模块导出的成员非常多,而且其中很多成员都会被使用到,那么可以使用星号(*)的方式导入。

这种导入需要使用 as 关键词给导出的所有成员重命名,然后导出的成员都会被挂载到这个对象身上。

比如在 module.js 中导出了两个成员。

var name = "jack";
var age = 18;

export { name, age };

那么在 app.js 中用星号的方式导入。

import * as mod from "./module.js";

console.log(mod);

就可以在控制台看到这个对象。

{
  "age": 18,
  "name": "jack"
}
import 不可以导入变量

当一个模块路径是在代码运行阶段得到的,那么无法使用 import 导入。

import 关键词只可以导入在代码运行前已知的模块路径。

所以 import 关键词只可以出现在模块文件的最顶部。

错误示例。

var modulePath = "./module.js"
import * as mod from modulePath
console.log(mod)

当我们遇到需要动态导入的机制时,就需要使用 import 函数。

因为这里的 import 是一个函数,而不是关键词,所以可以在代码的任何位置去执行。

import 函数返回一个 Promise 对象。模块暴露的对象会通过 Promise 中 then 参数函数的参数拿到。

var modulePath = "./module.js";

import(modulePath).then((module) => {
  console.log(module);
});
同时导出默认成员和命名成员

如果 module.js 同时暴露了默认成员和命名成员,而在 app.js 想同时导入它们,可以用下面这种写法。

module.js

var name = "jack";
var age = 18;

export { name, age };
export default "hi";

app.js

import { name, age, default as title } from "./module.js";

console.log(name, age, title);

除了重命名这种写法,还可以直接在花括号外前面直接导出默认成员,更加简洁。

这种写法必须让 default 成员在命名成员的前面,保证花括号和 from 关键词是在一起的。

import title, { name, age } from "./module.js";

console.log(name, age, title);

直接导出导入成员

import 可以和 export 组合使用,便于我们直接导出。

export { name, age, default as title } from "./module.js";

// console.log(name, age, title);

但是这种导出需要注意两个点。

第一是 default 要写到花括号中。

第二是直接 export 的话,模块内部无法访问到这些成员。

这种语法通常在一个模块文件夹中的 index.js 上使用。

比如有一个 components 文件夹,里面存放了很多组件。

每个组件都以一个模块的形式维护。这样会有 button.js、table.js、avatar.js 等等。

使用的时候就需要挨个导入,非常麻烦。

import { Button } from "./components/button.js";
import { Table } from "./components/table.js";
import { Avatar } from "./components/avatar.js";

优化这种导入导出体验的办法是在 components 文件夹下创建一个 index.js,由它负责导出所有的组件。

index.js

export { Button } from "./button.js";
export { Table } from "./table.js";
export { Avatar } from "./avatar.js";

这样在导入时会非常简单。

import { Button, Table, Avatar } from "./components/index.js";

ES Modules in Browser

由于 ES Module 是 2014 年才提出的,到现在还是有很多浏览器原生不支持这种用法。

所以使用 ES Module 时,还需要考虑兼容性的问题。

Polyfill 兼容方案

一般来说,我们使用 ES Module 编写的源代码都会经过编译工具的编译转换为 ES5,再拿去浏览器中工作。

但是也有办法让浏览器直接支持 ES Module。

社区提供了一个叫做 browser-es-module-loader 的模块,其实就是两个 js 文件。在 html 中先导入这两个模块,就可以让 ES Module 工作了。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
    <script src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>
    <script type="module" src="./app.js"></script>
  </body>
</html>

上面用到的两个包,第一个是 babel 运行在浏览器的版本,第二个是用来加载 es module 的。

原理比较简单,首先 browser-es-module-loader 会通过 ajax 方式或者直接提取标签中代码的方式加载 module 文件,然后交给 babel 转换,得到浏览器可以直接运行的代码。

需要注意,这只是解决了模块导入的问题,如果代码中存在 ES6 的语法,还需要额外的 polyfill。

但是这种做法会有一个小问题。

在直接支持 es module 的浏览器中,模块的代码会被执行两次。原因很简单,浏览器本身就会执行一次模块中的代码,polyfill 也会执行一次模块中的代码。

解决这个问题的方法就是给 script 添加 nomodule 属性。

被添加 nomodule 属性的脚本,只会在不支持 es module 的浏览器中去执行,而支持 es module 的浏览器则不执行。

<script
  nomodule
  src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"
></script>
<script
  nomodule
  src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"
></script>

polyfill 方案仅适合开发测试阶段使用,而不适合生产阶段去使用。因为所有的模块代码都是实时编译的,性能非常差。

ES Modules in Node.js

ES Modules 作为 JavaScript 语言层面的标准,它会逐渐取代 JavaScript 领域内所有模块化的需求。

Node.js 作为 JavaScript 一个重要运行环境,它已经开始逐渐支持这种特性了。

支持情况

在 node.js8.5 版本后,内部就开始以实现特性的方式支持 ES Module 了。所以现在可以直接在 node.js 中编写 ES Module 的代码。

在 node.js 中使用 ES Module,首先需要将文件的扩展名设置为 mjs。

创建一个 module.mjs 文件。

export foo = 'foo'
export bar = 'bar'

再创建一个 index.mjs 文件。

import { foo, bar } from "./module.mjs";

console.log(foo, bar);

在命令行运行 index.mjs 时需要加上 --experimental-modules 参数,表示启用 ES Modules 实验特性。

node --experimental-modules index.mjs

虽然这两个成员会被正常打印,但同时也会有一句警告。

(node:7586) ExperimentalWarning: The ESM module loader is experimental.

意思就是告诉我们 ESM module 属于实验特性,尽量不要在生产环境去使用。

上面就是在 node.js 直接使用 ES Module 需要做的两件事情。

导入内置模块和第三方模块

node.js 内置模块可以正常导入,没有任何问题。

import fs from "fs";

fs.writeFileSync("./foo.txt", "es module working");

第三方模块也可以这样导入,比如 lodash。

首先安装一下 lodash。

npm i lodash

编写测试代码。

import _ from "lodash";

console.log(_.upperCase("es module"));

运行代码,发现它是可以正常工作的。

再尝试一下导出 upperCase。

import { upperCase } from "lodash";

console.log(upperCase("es module"));

会得到一个错误。

SyntaxError: The requested module 'lodash' does not provide an export named 'upperCase'

原因也很简单。

lodash 只提供了一个 default 的默认导出,而没有将所有的成员全部单独导出。

所以我们也只能通过 import default 的方式导入默认成员。

很多第三方模块都是这样,因为它们都只提供了 CommonJS 标准的模块。

但是 node.js 内置模块对导出做了兼容,可以使用命名成员的方式单独导出。

import { writeFileSync } from "fs";

writeFileSync("./foo.txt", "es module working");

上面的代码是可以正常工作的。

ES Module 与 CommonJS 模块交互

因为很多第三方模块提供的都是 CommonJS 标准的模块,所以就会面临 ES Module 和 CommonJS 交互的情况。

ES Module 导入 CommonJS

在 ES Module 中导入 CommonJS 和导入 ES Module 没有太大区别。

编写 commonjs.js。

module.exports = {
  foo: "hello world",
};

编写 esm.js,导入 commons.js 中导出的模块。

import mod from "./commonjs.js";

console.log(mod);

运行 esm.js,可以正常拿到 commonjs.js 中导出的对象。

在 commons.js 中,是无法实现 ES Module 命名导出的。

exports.foo = "hello world";

上面这种导出方式仍然是一个 default 导出。

在 ES Module 中仍然无法命名导入。

import { foo } from "./commonjs.js";

这会得到一个错误信息,提示我们没有导出一个名为 foo 的成员。

CommonJS 导入 ES Module

在 node.js 环境中,不允许 CommonJS 直接导入 ES Module。

修改 esm.js。

export const foo = "hello world";

修改 common.js

const mod = require("./esm.mjs");

console.log(mod);

运行 common.js,会得到一个错误。

Must use import to load ES Module

如果要在 node.js 中使用 CommonJS 导入 ES Module,需要使用 webpack 之类的打包工具。

ES Module 与 CommonJS 的差异

CommonJS 提供了几个模块内置的全局成员,类似于全局变量。

创建 cjs.js 文件,并输出这几个全局成员。

// 加载模块函数
console.log(require);

// 模块对象
console.log(module);

// 导出对象别名
console.log(exports);

// 当前文件绝对路径
console.log(__filename);

// 当前文件所在目录
console.log(__dirname);

运行之后,没有任何问题。

再创建一个 esm.mjs 文件,把 cjs.js 中的内容复制过去,通过 node --experimental-modules esm.mjs 来运行。

会得到一个错误。

ReferenceError: require is not defined

使用了 ES Module 载入模块后,这些全局成员全部都无法获取了。

require、module 和 exports 在 ES Module 中没有意义,但 __filename 和 __dirname 还是需要的。

通过 import.meta.url 拿到当前的文件绝对路径,再通过 nodejs 内置的 fileURLToPath 和 dirname 函数,就可以获取到 __filename 和 __dirname 了。

import { fileURLToPath } from "url";
import { dirname } from "path";

const __filename = fileURLToPath(import.meta.url);
console.log(__filename);
const __dirname = dirname(__filename);
console.log(__dirname);
新版本的进一步支持

在 nodejs 最新的版本(v12+)中,对 ES Module 支持度更高了。

可以通过在 package.json 中添加 type:module 属性来提高对 ES Module 的支持。

{
  "type": "module"
}

这样项目下所有的 js 文件都会按照 ES Module 的方式执行,但是 --experimental-modules 参数不可以省略。

如果开启了这个特性,那么以 Commonjs 编写的模块也会以 ES Modules 的模式来运行,这样并不会识别 require 等函数,所以会报错。

const fs = require("fs");

fs.writeFileSync("./foo.txt", "es module working");

这种情况下,就需要将 CommonJS 标准规范的模块后缀名改为.cjs。

Babel 兼容方案

在早期的 nodejs 版本中,并不支持 ES Module。这时就需要借助 babel 来让 ES Module 转换为 CommonJS 代码。

可以安装一个低版本的 node,或者使用类似 nvm 或 n 的工具来设置一个低版本的 node。

在项目中安装 babel 和 babel 相关的插件。

npm i -D @babel/node @babel/core @babel/preset-env

只是安装了 babel 是不够的,因为 babel 并不会自动帮助我们转换代码,所以还需要自己编写 babel 的配置,在项目根目录下创建.babelrc 文件。

{
  "presets": ["@babel/preset-env"]
}

再去运行 ES Module 的代码,就可以正常工作了。

npx babel-node index.js

@babel/preset-env 是一个 ES6 转 ES5 的插件集合,我们也可以通过单个的插件去转换。

比如使用@babel/plugin-transform-modules-commonjs。

安装。

npm i -D @babel/plugin-transform-modules-commonjs

修改配置。

{
  "presets": ["@babel/plugin-transform-modules-commonjs"]
}

这样的话,编译速度可能会更快一些。


你好,我是 卢振千,一名软件工程师。欢迎你来到我的网站。