一文带你了解node中的的模块系统
本篇文章带大家进行node学习,深入浅出的了解node中的的模块系统,希望对大家有所帮助!
两年前写过一篇文章介绍模块系统:理解前端模块概念:CommonJs与ES6Module。这篇文章的知识面都是针对刚入门的,比较浅显。在这也纠正文章的几个错误:
- 【模块】和【模块系统】 是两码事。模块是软件中的一个单元,而模块系统则是一套语法或者工具,模块系统能让开发者在项目中定义和使用模块。
- ECMAScript Module缩写是ESM,或者ESModule,而不是ES6Module。
关于模块系统的基础知识都在上一篇文章说的差不多了,所以这篇文章会重点关注模块系统的内部原理以及更加完整的介绍不同模块系统之间的区别,上一篇文章出现的内容在这就不再重复了。【相关教程推荐:nodejs视频教程、编程教学】
模块系统
并不是所有编程语言都有内置的模块系统,JavaScript诞生之后的很长一段时间里都没有模块系统。
在浏览器环境中只能使用<script>
标签来引入不用的代码文件,这种方法共享一个全局作用域,可谓是问题多多;加上前端日新月异的发展,这种方法已经不满足当下的需求了。在没官方的模块系统出现前,前端社区自己创建第三方模块系统,用的较多的有:异步模块定义AMD、通用模块定义UMD等,当然最著名还得是CommonJS。
由于Node.js它是一个JavaScript的运行环境,可以直接访问底层的文件系统。所以开发者通过它,并按照CommonJS规范实现了一套模块系统。
最开始,CommonJS只能用于Node.js平台,随着Browserify和Webpack之类的模块打包工具的出现,CommonJS也终于能在浏览器端运行了。
到2015年发布了ECMAScript6规范,才有了模块系统的正式标准,按照该标准打造出来的模块系统称为ECMAScript module简称【ESM】,由此ESM就开始统一了Node.js环境与浏览器环境。当然ECMAScript6只是提供了语法和语义,至于实现部分得由各浏览器服务厂商和Node开发者去努力。所以才有了令其他编程语言羡慕不已的babel神器,实现模块系统并不是一件容易的事,Node.js也是到了13.2版本才算是比较稳定的支持ESM。
但不管怎么样,ESM才是JavaScript的“亲儿子”,学习它一定不会有错!
模块系统的基本思路
在刀耕火种的年代中使用JavaScript开发应用,脚本文件只能通过script标签引入。其中遇到比较严重的问题就是缺乏命名空间机制,这意味着每个脚本都共享同一作用域。这个问题在社区中有一个比较好的解决方法:Revevaling module
const myModule = (() => { const _privateFn = () => {} const _privateAttr = 1 return { publicFn: () => {}, publicAttr: 2 } })() console.log(myModule) console.log(myModule.publicFn, myModule._privateFn)登录后复制
运行结果如下:
这个模式很简单,利用IIFE创建一个私有的作用域,同时使用return需要暴露的变量。而属于内部的变量(比如_privateFn、_privateAttr)是不能从外面的作用域访问的。
【revealing module】正是利用了这些特性,来隐藏私有的信息,同时把应该公布给外界的API导出。后面的模块系统也正是基于这样的思路开发的。
CommonJS
基于上面思路,来开发一个模块加载器。
首先编写一个加载模块内容的函数,并把这个函数包裹在私有作用域里面,然后通过eval()求值,以运行该函数:
function loadModule (filename, module, require) { const wrappedSrc = `(function (module, exports, require) { ${fs.readFileSync(filename, 'utf8)} }(module, module.exports, require)` eval(wrappedSrc) }登录后复制
和【revealing module】一样,把模块的源代码包裹在函数里面,区别在于,还把一系列变量(module, module.exports, require)传给该函数。
值得注意的是,通过【readFileSync】读取模块内容。一般来说,在调用涉及文件系统的API时,不应该使用同步版本。但此时不同,因为通过CommonJs系统来加载模块,本身就应该实现成同步操作,以确保多个模块能够按照正确的依赖顺序得到引入。
接着模拟require()函数,主要功能是加载模块。
function require(moduleName) { const id = require.resolve(moduleName) if (require.cache[id]) { return require.cache[id].exports } // 模块的元数据 const module = { exports: {}, id } // 更新缓存 require.cache[id] = module // 载入模块 loadModule(id, module, require) // 返回导出的变量 return module.exports } require.cache = {} require.resolve = (moduleName) => { // 根据moduleName解析出完整的模块id }登录后复制
(1)函数接收到moduleName后,首先解析出模块的完整路径,赋值给id。
(2)如果cache[id]
为true,说明该模块已经被加载过了,直接返回缓存结果
(3)否则,就配置一套环境,用于首次加载。具体来说,创建module对象,包含exports(也就是导出内容),id(作用如上)
(4)将首次加载的module缓存起来
(5)通过loadModule从模块的源文件中读取源代码
(6)最后return module.exports
返回想要导出的内容。
require是同步的
在模拟require函数的时候,有一个很重要的细节:require函数必须是同步的。它的作用仅仅是直接将模块内容返回而已,并没有用到回调机制。Node.js中的require也是如此。所以针对module.exports的赋值操作,也必须是同步的,如果用异步就会出问题:
// 出问题 setTimeout(() => { module.exports = function () {} }, 1000)登录后复制
require是同步函数这一点对定义模块的方式有着非常重要的影响,因为它迫使我们在定义模块时只能使用同步的代码,以至于Node.js都为此,提供了大多数异步API的同步版本。
早期的Node.js有异步版本的require函数,但很快就移除了,因为这会让函数的功能变得十分复杂。
ESM
ESM是ECMAScript2015规范的一部分,该规范给JavaScript语言指定了一套官方的模块系统,以适应各种执行环境。
在Node.js中使用ESM
Node.js默认会把.js后缀的文件,都当成是采用CommonJS语法所写的。如果直接在.js文件中采用ESM语法,解释器会报错。
有三种方法可以在让Node.js解释器转为ESM语法:
1、把文件后缀名改为.mjs;
2、给最近的package.json文件添加type字段,值为“module”;
3、字符串作为参数传入--eval
,或通过STDIN管道传输到node,带有标志--input-type=module
比如:
node --input-type=module --eval "import { sep } from 'node:path'; console.log(sep);"登录后复制
不同类型模块引用
ESM可以被解析并缓存为URL(这也意味着特殊字符必须是百分比编码)。支持file:
、node:
和data:
等的URL协议
file:URL
如果用于解析模块的import说明符具有不同的查询或片段,则会多次加载模块
// 被认为是两个不同的模块 import './foo.mjs?query=1'; import './foo.mjs?query=2';登录后复制
data:URL
支持使用MIME类型导入:
text/javascript
用于ES模块application/json
用于JSONapplication/wasm
用于Wasm
import 'data:text/javascript,console.log("hello!");'; import _ from 'data:application/json,"world!"' assert { type: 'json' };登录后复制
data:URL
仅解析内置模块的裸说明符和绝对说明符。解析相对说明符不起作用,因为data:
不是特殊协议,没有相对解析的概念。
导入断言
这个属性为模块导入语句添加了内联语法,以便在模块说明符旁边传入更多信息。
import fooData from './foo.json' assert { type: 'json' }; const { default: barData } = await import('./bar.json', { assert: { type: 'json' } });登录后复制
目前只支持JSON模块,而且assert { type: 'json' }
语法是具有强制性的。
导入Wash模块
在--experimental-wasm-modules
标志下支持导入WebAssembly模块,允许将任何.wasm文件作为普通模块导入,同时也支持它们的模块导入。
// index.mjs import * as M from './module.wasm'; console.log(M)登录后复制
使用如下命令执行:
node --experimental-wasm-modules index.mjs登录后复制
顶层await
await关键字可以用在ESM中的顶层。
// a.mjs export const five = await Promise.resolve(5) // b.mjs import { five } from './a.mjs' console.log(five) // 5登录后复制
异步引用
前面说过,import语句对模块依赖的解决是静态的,因此有两项著名的限制:
- 模块标识符不能等到运行的时候再去构造;
- 模块引入语句,必须写在文件的顶端,而且不能套在控制流语句里;
然而,对于某些情况来说,这两项限制无疑是过于严格。就比如说有一个还算是比较常见的需求:延迟加载:
在遇到一个体积很大的模块时,只想在真正需要用到模块里的某个功能时,再去加载这个庞大的模块。
为此,ESM提供了异步引入机制。这种引入操作,可以在程序运行的时候,通过import()
运算符实现。从语法上看,相当于一个函数,接收模块标识符作为参数,并返回一个Promise,待Promise resolve后就能得到解析后的模块对象。
ESM的加载过程
用一个循环依赖的例子来说明ESM的加载过程:
// index.js import * as foo from './foo.js'; import * as bar from './bar.js'; console.log(foo); console.log(bar); // foo.js import * as Bar from './bar.js' export let loaded = false; export const bar = Bar; loaded = true; // bar.js import * as Foo from './foo.js'; export let loaded = false; export const foo = Foo; loaded = true登录后复制
先看看运行结果:
通过loaded可以观察到,foo和bar这两个模块都能log出加载完整的模块信息。而CommonJS却不一样,一定会有一个模块无法打印出完整加载后的样子。
我们深入加载过程,看看为什么会出现这样的结果。
加载过程可以分为三个阶段:
- 第一个阶段:解析
- 第二个阶段:声明
- 第三个阶段:执行
解析阶段:
解释器从入口文件出发(也就是index.js),解析模块之间的依赖关系,以图的形式展示出来,这张图也被称为依赖关系图。
在这个阶段只关注与import语句,并把这些语句想要引入的模块所对应的源码,给加载进来。并以深度解析的方式得到最后的依赖关系图。以上面例子说明:
1、从index.js开始,发现import * as foo from './foo.js'
语句,从而去到foo.js文件中。
2、从foo.js文件继续解析,发现import * as Bar from './bar.js'
语句,从而去到bar.js中。
3、从bar.js继续解析,发现import * as Foo from './foo.js'
语句,形式循环依赖,但由于解释器已经在处理foo.js模块了,所以不会再进入其中,然后继续解析bar模块。
4、解析完bar模块后,发现没有import语句了,所以返回foo.js,并继续往下解析。一路都没有再次发现import语句,返回index.js。
5、在index.js中发现import * as bar from './bar.js'
,但由于bar.js已经解析过了,所以略过,继续往下执行。
最后通过深度优先的方式把依赖图完整的展示出来:
声明阶段:
解释器从得到的依赖图出发,从底到上的顺序对每个模块进行声明。具体来说,每到达一个模块,就寻找该模块所要导出的全部属性,并在内存中声明导出值的标识符。请注意,该阶段只作声明,不会进行赋值操作。
1、解释器从bar.js模块出发,声明loaded和foo的标识符。
2、向上回溯,到了foo.js模块,声明loaded和bar标识符。
3、到了index.js模块,但这个模块没有导出语句,所以没有声明任何标识符。
声明完所有导出标识符后,再走一遍依赖图,把import引入和export导出的关系连接起来。
可以看到,由import引进来的模块与export所导出值之间,建立了一种类似于const的绑定关系,引入方这一端是只能读而不能写。而且在index.js读取的bar模块,与在foo.js读取的bar模块实质是同一个实例。
所以这就是为什么在这个例子的结果中都能输出完整的解析结果的原因。
这跟CommonJS系统所用的方法有根本的区别。如果有某个模块要引入CommonJS模块,那么系统会对后者的整个exports对象做拷贝,从而将其中的内容复制到当前模块里面,这样的话,如果受引入的那个模块修改了自身的那一份变量,那么用户这边是看不到新值的。
执行阶段:
在这个阶段中,引擎才会去执行模块的代码。依然采用从底向上的顺序访问依赖图,并逐个执行访问到的文件。从bar.js文件开始执行,到foo.js,最后才是index.js。在这个过程中,逐步完善export表中标识符的值。
这套流程与CommonJS看似没有太大区别,但实际有着重大差异。由于CommonJS是动态的,因此它一边解析依赖图,一边执行相关的文件。所以只要看到一条require语句,就可以肯定的说,当程序来到这条语句时,已经把前面的代码都执行完了。因此,require语句不一定非要出现在文件的开头,而是可以出现在任意地方,而且,模块标识符也可以通过变量来构造。
但ESM不同,在ESM里,上述这三个阶段是彼此分离的,它必须先把依赖图完整地构造出来,然后才能执行代码,因此,引入模块与导出模块的操作,都必须是静态的,而不能等到执行代码的时候再去做。
ESM与CommonJS区别
除了前面提到的几个区别之外,还有一些区别是值得注意的:
强制的文件扩展名
在ESM中使用import关键字解析相对或绝对的说明符时,必须提供文件扩展名,还必须完全指定目录索引('./path/index.js')。而CommonJS的require函数则允许省略这个扩展名。
严格模式
ESM是默认运行于严格模式之下,而且该严格模式是不能禁用。所以不能使用未声明的变量,也不能使用那些仅仅在非严格模式下才能使用的特性(例如with)。
ESM不支持CommonJS提供的某些引用
CommonJS中提供了一些全局变量,这些变量不能在ESM下使用,如果试图使用这些变量会导致ReferenceError错误。包括
require
exports
module.exports
__filename
__dirname
其中__filename
指的是当前这个模块文件的绝对路径,__dirname
则是该文件所在文件夹的绝对路径。这连个变量在构建当前文件的相对路径时很有帮助,所以ESM提供了一些方法去实现两个变量的功能。
在ESM中,可以使用import.meta
对象来获取一个引用,这个引用指的是当前文件的URL。具体来说,就是通过import.meta.url
来获取当前模块的文件路径,这个路径的格式类似file:///path/to/current_module.js
。根据这条路径,构造出__filename
和__dirname
所表达的绝对路径:
import { fileURLToPath } from 'url' import { dirname } from 'path' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename)登录后复制
而且还能模拟CommonJS中require()函数
import { createRequire } from 'module' const require = createRequire(import.meta.url)登录后复制
this指向
在ESM的全局作用域中,this是未定义(undefined),但是在CommonJS模块系统中,它是一个指向exports的引用:
// ESM console.log(this) // undefined // CommonJS console.log(this === exports) // true登录后复制
ESM加载CommonJS
上面提到过在ESM中可以模拟CommonJS的require()函数,以此来加载CommonJS的模块。除此之外,还可以使用标准的import语法引入CommonJS模块,不过这种引入方式只能把默认导出的东西给引进来:
import packageMain from 'commonjs-package' // 完全可以 import { method } from 'commonjs-package' // 出错登录后复制
而CommonJS模块的require总是将它引用的文件视为CommonJS。不支持使用require加载ES模块,因为ES模块具有异步执行。但可以使用import()
从CommonJS模块中加载ES模块。
导出双重模块
虽然ESM已经推出了7年,node.js也已经稳定支持了,我们开发组件库的时候可以只支持ESM。但为了兼容旧项目,对CommonJS的支持也是必不可少的。有两种广泛使用的方法可以使得组件库同时支持两个模块系统的导出。
使用ES模块封装器
在CommonJS中编写包或将ES模块源代码转换为CommonJS,并创建定义命名导出的ES模块封装文件。使用条件导出,import使用ES模块封装器,require使用CommonJS入口点。举个例子,example模块中
// package.json { "type": "module", "exports": { "import": "./wrapper.mjs", "require": "./index.cjs" } }登录后复制
使用显示扩展名.cjs
和.mjs
,因为只用.js
的话,要么是被默认为CommonJS,要么"type": "module"
会导致这些文件都被视为ES模块。
// ./index.cjs export.name = 'name'; // ./wrapper.mjs import cjsModule from './index.cjs' export const name = cjsModule.name;登录后复制
在这个例子中:
// 使用ESM引入 import { name } from 'example' // 使用CommonJS引入 const { name } = require('example')登录后复制
这两种方式引入的name都是相同的单例。
隔离状态
package.json文件可以直接定义单独的CommonJS和ES模块入口点:
// package.json { "type": "module", "exports": { "import": "./index.mjs", "require": "./index.cjs" } }登录后复制
如果包的CommonJS和ESM版本是等效的,则可以做到这一点,例如因为一个是另一个的转译输出;并且包的状态管理被仔细隔离(或包是无状态的)
状态是一个问题的原因是因为包的CommonJS和ESM版本都可能在应用程序中使用;例如,用户的引用程序代码可以importESM版本,而依赖项require CommonJS版本。如果发生这种情况,包的两个副本将被加载到内存中,因此将出现两个不同的状态。这可能会导致难以解决的错误。
除了编写无状态包(例如,如果JavaScript的Math是一个包,它将是无状态的,因为它的所有方法都是静态的),还有一些方法可以隔离状态,以便在可能加载的CommonJS和ESM之间共享它包的实例:
- 如果可能,在实例化对象中包含所有状态。比如JavaScript的Date,需要实例化包含状态;如果是包,会这样使用:
import Date from 'date'; const someDate = new Date(); // someDate 包含状态;Date 不包含登录后复制
new关键字不是必需的;包的函数可以返回新的对象,或修改传入的对象,以保持包外部的状态。
- 在包的CommonJS和ESM版本之间共享的一个或过个CommonJS文件中隔离状态。比如CommonJS和ESM入口点分别是index.cjs和index.mjs:
// index.cjs const state = require('./state.cjs') module.exports.state = state; // index.mjs import state from './state.cjs' export { state }登录后复制
即使example在应用程序中通过require和import使用example的每个引用都包含相同的状态;并且任一模块系统修改状态将适用二者皆是。
最后
如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我持续进行创作的动力。
本文引用以下资料:
- node.js官方文档
- Node.js Design Patterns
更多node相关知识,请访问:nodejs 教程!