本篇文章带大家进行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:
不是特殊协议,没有相对解析的概念。
导入断言
这个属性为模块导入语句添加了内联语法,以便在模块说明符旁边传入