1.3、ESM
当下前端主流用法是基于ESM方式进行编写,Node.js v20已经非常好的支持ESM了,推荐大家使用这种方式进行编写。
为了能够让大家理解ESM前世今生,本节我们也花了一点时间了解模块规范演进历史和必须了解的CommonJS,最后给出了现在Node.js里最常用的引用写法。
下面我们从模块规范演进历史开始进行一一讲解。
模块规范演进历史
JavaScript模块规范的发展历史可以追溯到早期的CommonJS规范,然后发展到ESM(ECMAScript Modules)规范。下面是一个简要的描述:
- CommonJS规范:在Node.js出现之前,JavaScript缺乏一种官方的模块化规范。为了解决这个问题,CommonJS规范在2009年提出,它定义了一种模块加载和导出机制,使得开发者可以将代码组织成独立的模块,并在需要时进行加载和使用。CommonJS规范主要用于服务器端的JavaScript开发,Node.js采用了这个规范。
- CommonJS规范定义了
require
和module.exports
等关键字,用于加载和导出模块。 - 在Node.js中,采用了CommonJS规范,通过
require
函数加载模块,通过module.exports
导出模块。
- CommonJS规范定义了
- AMD规范:在浏览器端,由于网络请求的异步性质,CommonJS规范在加载模块时存在一些问题。为了解决这个问题,AMD(Asynchronous Module Definition)规范在2011年提出,它引入了
define
和require
函数,使得模块的加载可以异步进行。RequireJS是一个遵循AMD规范的模块加载器。 - UMD规范:由于CommonJS和AMD规范在语法和用法上存在差异,为了兼容两者,UMD(Universal Module Definition)规范在2013年提出。UMD规范允许开发者编写兼容CommonJS和AMD规范的模块,使得模块可以在不同的环境中使用。
- ES6模块规范:随着ES6(ECMAScript 2015)的发布,JavaScript语言本身引入了官方的模块化规范,即ESM规范。ESM规范在语法和用法上与CommonJS和AMD规范有所不同,它使用
export
和import
语句来导出和导入模块。ESM规范提供了更强大和灵活的模块化功能,并逐渐成为JavaScript的主流模块规范。
总结来说,JavaScript模块规范经历了CommonJS、AMD、UMD等发展阶段,最终在ES6中引入了官方的ESM规范。这些规范的出现和发展,为JavaScript开发者提供了不同的模块化方案,使得代码的组织和复用更加方便和灵活。
作为Node.js开发者来说,我们只需要了解Commonjs(上一代)和ESM(当前Web规范)二种规范即可,甚至可以只会ESM,短期内也是够用的。
CommonJS
CommonJS是一种用于JavaScript模块化的规范,它定义了一种模块加载和导出机制,使得开发者可以将代码组织成独立的模块,并在需要时进行加载和使用。
在CommonJS规范中,每个模块都是一个单独的文件,文件中的代码通过module.exports
导出,其他模块可以通过require
函数来加载和使用这些导出的模块。
以下是一个使用CommonJS规范的简单示例:
假设有两个文件,分别为math.js
和app.js
。
math.js文件中定义了一个简单的加法函数:
// math.js
function add(a, b) {
return a + b;
}
module.exports = add;
app.js文件中使用了math.js中导出的加法函数:
// app.js
const add = require('./math.js');
console.log(add(2, 3)); // 输出:5
在app.js中,我们使用了require
函数来加载math.js文件,并将导出的add函数赋值给变量add
。然后,我们可以直接调用add
函数进行加法运算。
这是一个简单的CommonJS规范的示例,通过模块的导出和加载,我们可以将代码组织成独立的模块,提高代码的可维护性和复用性。
市面上很多教程里的代码都是下面这样采用CommonJS规范实现的,这是因为当下市面上能够见到的书籍基本都是基于Node.js 8版本之前编写的。
const fs = require('fs');
fs.writeFile('example.txt', 'Hello, world!', 'utf8', (error) => {
if (error) {
console.error(error);
} else {
console.log('File written successfully.');
}
});
在Node.js v20里,它也是可以运行的,只是不推荐而已。有更先进且符合Web标准的ESM规范,了解一下就够,不必深学(以前为了动态加载模块,很多时候还会处理require.cache,比如jest里的测试Node.js有时候会有一些莫名奇妙的bug,就是这个原因)。
ESM
ESM(ECMAScript Modules)是JavaScript官方的模块化规范,它最早于2015年在ES6(ECMAScript 2015)中引入。ESM规范在语法和用法上与CommonJS和AMD规范有所不同,它使用export
和import
语句来导出和导入模块。
在ES6中,ESM规范仅仅是一个提案,需要通过Babel等工具进行转换才能在浏览器和Node.js中使用。随着时间的推移和ES6的广泛应用,ESM规范逐渐成为JavaScript的主流模块规范。
自从2021年 sindresorhus 发表 esm only 的宣言 一年多以来,许多项目开始转向了 esm only,即仅支持 esm 而不支持 cjs,以此来迫使整个生态更快的迁移到 esm only。
一些流行的项目已经这样做了
- sindresorhus 维护的上千个 npm 包
- node-fetch
- remark 系列
- 更多开源模块。。。
我是非常支持sindresorhus的做法,规范是不断演进的,背着包袱前行,不过是权宜之计。
export和import
在Node.js中,ESM规范最早于2017年在v8.5.0版本中引入(狼书卷一中讲过esnext,甚至还有systemjs这样的esm加载器,如今浏览器内置,用武之地极小),但是需要在文件中添加"type": "module"
的声明才能启用ESM模块。随着Node.js的版本更新,ESM规范的支持逐渐完善,可以通过import
和export
语句来导入和导出模块,也可以使用动态导入等高级特性。
在ESM规范中,每个模块都是一个单独的文件,文件中的代码通过export
和import
语句来导出和导入模块。
1、export
export 用于对外输出模块,可导出常量、函数、文件等,相当于定义了对外的接口,两种导出方式:
- export: 使用 export 方式导出的,导入时要加上 {} 需预先知道要加载的变量名,在一个文件中可以使用多次。
- export default: 为模块指定默认输出,这样加载时就不需要知道所加载的模块变量名,一个文件中仅可使用一次。
// src/caculator.mjs
export function add (a, b) {
return a + b;
};
export function subtract (a, b) {
return a - b;
}
const caculator = {
add,
subtract,
}
export default caculator;
2、import
import 语句用于导入另一个模块导出的绑定,三种导入方式:
- 导入默认值:导入在 export default 定义的默认接口。
- as 别名导入:在导入时可以重命名在 export 中定义的接口。
- 单个或多个导入:根据需要导入 export 定一个的一个或多个接口。
// src/app.mjs
import caculator from "./src/caculator.mjs";
import * as caculatorAs from "./src/caculator.mjs";
const result = caculator.add(4, 2);
console.dir(result);
// caculator.subtract(4, 2);
const result2 = caculatorAs.subtract(4, 2);
console.dir(result2);
我们使用了import
语句来加载math.js文件中导出的add
函数,并将其赋值给变量add
。然后,我们可以直接调用add
函数进行加法运算。这是一个简单的ESM规范的示例,通过模块的导出和加载,我们可以将代码组织成独立的模块,提高代码的可维护性和复用性。
ESM执行的2种方式
需要注意的是ESM在Node.js世界里有2种执行方式。
1、在Node.js中,ESM规范需要使用.mjs
文件扩展名,如果在package.json文件中没有指定"type": "module"
,就必须使用.mjs
文件扩展名。
$ node src/app.mjs
2、使用.js
文件扩展名,需要在package.json文件中指定"type": "module"
,
$ node app.js
这种用法在Node.js v17以上版本都可以直接使用。
在命令行中如果Node.js版本大于8或小于17,你也可以使用--experimental-modules
参数来启用ESM模块,在Node.js v17版本以上已经是默认开启ESM模块了。
$ node --experimental-modules app.js
说明。
- 第一种方式,最为原始,最开始实现loader的时候,为了区别CommonJS和ESM而做区分使用的文件扩展
- 第二种方式,通过确定当前模块是ESM模块,你就可以不用使用文件后缀名来区分它是什么格式,很明显这是更简单的。
node: 引用
在Node.js v12.20之后就已经开始支持URLs引用了。通过node:可以引用Node.js内置的模块,简言之,Node.js 内置的SDK方法都可以通过这种方式来引用。
举个例子,参见src/buildin-modules.mjs
import { builtinModules as builtin } from "node:module";
console.dir(builtin);
执行结果如下。
这些其实就是Node.js SDK里所有模块,需要说明的是以”_“开头的是内部私有模块,不要直接使用,比如”_http_agent“是在”http“模块里应用的,对应的功能有对外导出。
Async/await
理解了ESM规范中的export和import,以及import xx from ‘node:xx’方式,你已经可以开始写Node.js v20的项目了。但Node.js最核心的点其实是异步流程控制,如果处理不好异步流程控制,在Node.js世界里,你就只能写写Cli工具,能做的相对比较有限。
本节,我们之所以以Async/await命名,是因为它是你在Node.js异步世界里必须掌握的内容,它和ESM一样重要。
Node.js SDK Api演进过程
为了让大家更好的理解为什么Async/await如此重要,我们需要先讲一下Node.js SDK API风格演示过程。
作为Node.js开发者,以下是一些常见的Node.js SDK写法风格:
- 回调函数(Callback)风格:
- 在早期的Node.js版本中,常见的异步操作是通过回调函数来处理的。
- 回调函数通常有两个参数,第一个参数用于传递错误信息,第二个参数用于传递结果或数据。
- 开发者需要在回调函数中处理错误和结果,以确保代码的正确执行。
- Promise风格:
- 随着ES6的普及,Promise成为了处理异步操作的一种新方式。
- Promise是一种表示异步操作的对象,可以通过
.then
和.catch
方法进行链式调用。 - 开发者可以使用
new Promise
来创建Promise对象,并在异步操作完成后调用resolve
或reject
来处理结果和错误。
- async/await风格:
- ES8引入了async/await语法糖,使得异步代码的书写更加简洁和直观。
- 使用
async
关键字定义一个异步函数,其中可以使用await
关键字等待一个Promise的完成。 - 开发者可以像编写同步代码一样编写异步代码,提高了代码的可读性和可维护性。
总结来说,Node.js SDK的写法风格在异步流程调用方面经历了从回调函数到Promise再到async/await的发展历程。开发者可以根据自己的喜好和项目需求选择合适的写法风格,以提高代码的可读性和可维护性。
异步流程控制概览
关于异步流程调用的发展历史,在《狼书1》第七章异步流程控制一章中,总结的已经非常全面了,如下图。
说明如下。
- callback hell:在esm出现之前error-first callback是默认API风格,所以会出现很多回调函数里嵌套回调的情况。在esm+promisify api之后,基本很少有人用error-first callback写法的api了,虽然Node.js会一直提供。
- Thunk已经退出历史舞台了,今天知道且用的人已经很少了。
- Generator在遍历和操作数据集合时候偶尔会用,更有Async Generator,我目前只在 https://github.com/typicode/xv 里见过。
在Node.js v20项目里,你需要掌握的是只有2个异步流程控制知识点:Promise和Async函数,就足够开发Node.js项目了。
同步还是回调?
Node.js本身以异步著名,绝大部分任务都是需要在EventLoop里运行,但也有意外,比如writeFile
和writeFileSync
就是特例,它们都是用于写入文件的两个不同的函数,具体用法行的差异如下。
1、writeFile
是一个异步函数,它接受文件路径、要写入的数据和可选的编码参数,并在写入完成后调用回调函数。这意味着在写入文件时,程序可以继续执行其他操作,而不必等待写入完成。示例代码如下:
const fs = require('fs');
fs.writeFile('example.txt', 'Hello, world!', 'utf8', (error) => {
if (error) {
console.error(error);
} else {
console.log('File written successfully.');
}
});
2、writeFileSync
是一个同步函数,它接受文件路径、要写入的数据和可选的编码参数,并在写入完成后返回。这意味着在写入文件时,程序会阻塞并等待写入完成,然后继续执行后续操作。示例代码如下:
const fs = require('fs');
try {
fs.writeFileSync('example.txt', 'Hello, world!', 'utf8');
console.log('File written successfully.');
} catch (error) {
console.error(error);
}
总的来说,writeFile
适用于异步编程场景,可以在写入文件的同时执行其他操作,而writeFileSync
适用于同步编程场景,需要等待写入完成后再执行后续操作。选择使用哪个函数取决于具体的应用场景和需求。
讲这个的目的是为了说明原理。
但Sync方法是由场景限制的,不可以乱用。
反思
Promise
Promise是JS异步编程中的重要概念,异步抽象处理对象,是目前比较流行Javascript异步编程解决方案之一。在Node.js世界里,本身是Error-first Callback写法,和Promise结合是最简单的用法。所以在Node.js 10之前,基本上都会使用bluebird这样的Promise库来实现,后面ES6内置Promise,所以Node.js(基于v8内核,v8是Chrome的js渲染引擎)也开始支持Promise对象,并且对fs等模块进行了Promise化,甚至还出现了util.promisify
这样的工具函数。
ES6 Promise
ES6引入了Promise对象,它是一种用于处理异步操作的对象。Promise可以将异步操作转化为类似同步操作的链式调用方式,使得代码更易读、更易维护。
Promise对象具有以下特点:
- Promise对象是一个构造函数,通过
new Promise()
来创建一个Promise实例。 - Promise对象的构造函数接受一个函数作为参数,该函数包含两个参数:resolve和reject。resolve用于将Promise状态从pending转为fulfilled,reject用于将Promise状态从pending转为rejected。
- Promise对象的实例具有
then
方法,用于指定Promise状态变为fulfilled时的回调函数,并返回一个新的Promise对象。then
方法可以被链式调用,即可以在一个then
方法的回调函数中再调用另一个then
方法。
下面是一个简单的示例代码,演示了Promise的基本用法:
// 创建一个Promise对象
const promise = new Promise((resolve, reject) => {
// 模拟异步操作
setTimeout(() => {
const randomNumber = Math.random();
if (randomNumber > 0.5) {
// 异步操作成功,将Promise状态从pending转为fulfilled,并返回结果
resolve(randomNumber);
} else {
// 异步操作失败,将Promise状态从pending转为rejected,并返回错误信息
reject(new Error('Random number is less than 0.5'));
}
}, 1000);
});
// 使用then方法指定Promise状态变为fulfilled时的回调函数
promise.then(result => {
console.log('Promise fulfilled:', result);
}).catch(error => {
console.error('Promise rejected:', error);
});
在上面的示例中,我们首先创建了一个Promise对象,通过new Promise()
来定义一个异步操作。在异步操作中,通过setTimeout
模拟了一个耗时1秒的操作,并根据随机数的大小决定操作成功还是失败。如果随机数大于0.5,操作成功,调用resolve
函数将Promise状态从pending转为fulfilled,并返回随机数;如果随机数小于等于0.5,操作失败,调用reject
函数将Promise状态从pending转为rejected,并返回一个错误对象。
然后,我们使用then
方法指定了Promise状态变为fulfilled时的回调函数,通过链式调用的方式,可以在then
方法的回调函数中继续调用另一个then
方法。
最后,我们通过catch
方法指定了Promise状态变为rejected时的回调函数,用于处理异步操作失败的情况。
综上所述,ES6 Promise是一种用于处理异步操作的对象,通过将异步操作转化为类似同步操作的链式调用方式,使得代码更易读、更易维护。Promise具有三种状态:pending、fulfilled和rejected,可以通过resolve
和reject
函数将Promise状态从pending转为fulfilled或rejected,并返回相应的结果或错误信息。then
方法用于指定Promise状态变为fulfilled时的回调函数,catch
方法用于指定Promise状态变为rejected时的回调函数。
Promisify
如果不用Sync方法,也不想使用回调函数写法,使用Promise是一个更好的选择。从 Node.js v10开始,他们在使用 Promise 的fs
模块中创建了一个promises
对象,而主fs
模块继续公开使用回调的函数。在此程序中,你可以导入模块的 promise 版本。
- 以前导入模块后,创建一个异步函数来读取文件。异步函数学习bluebird方式,以
async
关键字开头。现在和fs保持一致了,更方便。
使用异步函数,您可以使用await
关键字解析promise
,而不是将promise
与.then()
方法链接起来。
fs.promises
模块提供了一组以Promise风格的方式封装的文件系统函数,可以方便地处理文件操作。以下是一个使用fs.promises
模块的示例:
const fs = require('fs').promises;
// 使用Promise风格的函数读取文件内容
fs.readFile('example.txt', 'utf8')
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
});
// 使用Promise风格的函数写入文件内容
fs.writeFile('example.txt', 'Hello, world!', 'utf8')
.then(() => {
console.log('File written successfully.');
})
.catch(error => {
console.error(error);
});
在上面的示例中,我们首先引入了fs.promises
模块,然后使用readFile
函数读取文件内容,并使用writeFile
函数写入文件内容。这些函数都返回Promise对象,因此我们可以使用.then
和.catch
方法处理异步操作的结果和错误。
需要注意的是,fs.promises
模块仅在Node.js v10及以上版本中可用。如果你使用的是Node.js v8及以下版本,可以使用util.promisify
方法将其他异步函数转换为Promise风格的函数,然后使用该函数进行文件操作。
综上所述,通过使用fs.promises
模块,我们可以方便地处理文件操作,并使用Promise风格的函数处理异步操作。
util.promisify
在Node.js中,可以使用util.promisify
方法将遵循错误优先回调的函数转换为返回Promise的函数,从而简化异步操作的处理。在文件系统模块(fs)中,可以使用promisify
方法将异步的文件操作函数转换为Promise风格的函数。
以下是一个使用fs.promises
模块(Node.js v10及以上版本)和util.promisify
方法的示例:
const fs = require('fs');
const { promisify } = require('util');
// 使用promisify方法将fs.readFile函数转换为Promise风格的函数
const readFileAsync = promisify(fs.readFile);
// 使用Promise风格的函数读取文件内容
readFileAsync('example.txt', 'utf8')
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
});
在上面的示例中,我们首先引入了fs
模块和util
模块的promisify
方法。然后,我们使用promisify
方法将fs.readFile
函数转换为返回Promise的函数readFileAsync
。最后,我们使用readFileAsync
函数来读取文件内容,并通过.then
和.catch
方法处理异步操作的结果和错误。
需要注意的是,上述示例使用了Node.js v10及以上版本中引入的fs.promises
模块,该模块提供了一组以Promise风格的方式封装的文件系统函数。如果你使用的是Node.js v8及以下版本,可以使用util.promisify
方法将其他异步函数转换为Promise风格的函数,然后使用该函数进行文件操作。
综上所述,通过使用util.promisify
方法,我们可以将Node.js中的异步函数转换为Promise风格的函数,从而更方便地处理异步操作。
Async函数
ES6中的async函数是一种异步编程的解决方案,它使得异步操作更加简洁明了,并且可以避免回调地狱的问题。async函数本质上是一个返回Promise对象的函数,可以使用await关键字来等待Promise对象的状态变化。
async函数具有以下特点:
- async函数声明时需要在函数前面添加async关键字,表示该函数是一个异步函数。
- async函数内部可以使用await关键字来等待Promise对象的状态变化,await关键字后面跟上一个Promise对象,表示等待该Promise对象的状态变为fulfilled或rejected。
- async函数内部可以包含多个await关键字,这些异步操作将按照顺序依次执行。
- async函数返回一个Promise对象,该对象的状态由async函数内部的异步操作决定,如果异步操作成功,则Promise状态为fulfilled,并返回异步操作的结果;如果异步操作失败,则Promise状态为rejected,并返回错误信息。
下面是一个简单的示例代码,演示了async函数的基本用法:
// 定义一个异步函数
async function main() {
console.dir("hi async function");
try {
// 调用Promise函数
await fn();
} catch (error) {
console.log(error);
}
}
// 调用Promise函数
function fn() {
return new Promise(function (resolve, reject) {
console.dir("hi promise function");
resolve();
});
}
// 调用异步函数
main();
在上面的示例中,我们首先定义了一个异步函数main
,该函数使用了async关键字来声明,表示该函数是一个异步函数。在函数内部,我们使用了await关键字来等待异步操作完成,首先使用await fn函数来执行fn函数中的Promise方法。最后,我们在最下面调用main方法来获得异步操作的结果。
综上所述,ES6中的async函数是一种异步编程的解决方案,它使得异步操作更加简洁明了,并且可以避免回调地狱的问题。async函数使用async关键字来声明,内部可以使用await关键字来等待异步操作完成,返回一个Promise对象,该对象的状态由异步操作的结果决定。