Node.js中的模块

  CommonJS模块
  CommonJS是一种规范,它定义了JavaScript 在服务端运行所必备的基础能力,比如:模块化、IO、进程管理等。其中,模块化方案影响深远,其对模块的定义如下:

    1,模块引用:使用require() 方法引用模块,它接受模块标识作为参数,将一个模块引入到当前运行环境中。

    2,模块定义:使用exports对象,导出当前模块的方法或者变量,并且它是唯一的导出出口。

    3,模块标识:就是模块的名字,传递给 require() 方法的参数。

  如果JS文件中存在 exports 或 require,该 JS文件就是一个模块,模块内的所有代码均为 隐藏代码,包括变量、函数,对其他文件不可见,也不会对全局变量造成污染。如果一个模块需要暴露一些API给外部使用,需要通过exports 导出,exports 是一个空对象,你可以为该对象添加任何需要导出的内容。如果一个模块需要导入其他模块,通过require 实现,require 是一个函数,传入模块的路径即可返回该模块导出的整个内容。

  Node.js 实现了CommonJS 模块,它主要做了三件事情,路径的解析,文件的查找,编译执行,就是当require一个模块标识时,怎么才能找到模块,并把exports对象获取到,引入当前运行环境中。模块标识是一个字符串,它主要有两种情况,以'/,'./' 或'../' 为主的路径和没有路径标识的字符串。如果是路径,就直接查找路径对应的模块。如果不是路径,Node.js先查找是不是核心模块,比如fs,http,如果不是,就在当前目录下的 node_modules 目录查找,如果没有,在父级目录的 node_modules 查找,如果没有,在父级目录的父级目录的 node_modules 中查找。沿着路径向上递归,直到根目录下的 node_modules 目录

  找到路径,它可以是一个文件,它还可以是一个文件夹。如果是一个文件,还会看有没有后缀,如果有,它就会直接加载文件。如果没有后比缀,它就会先查找.js,再查找.json,最后查找.node文件。 如果路径指的是一个文件夹,它先查找有没有package.json文件,如果有,就会找 package.json 下 main 属性指向的文件,如果没有 package.json ,在 node 环境下会以index.js ,index.json ,index.node。也就是说,在Node.js中,模块可以是一个文件,也可以是一个文件夹,还可以是一个包。包就是一个文件夹中包含package.json。只要使用require方法引入的,都称为模块。

  找到了要加载的文件,为了隐藏模块中的代码,同时提供export 和require方法,nodejs 执行模块时,会将模块中JS代包括到一个函数中。

(function (exports, require, module, __filename, __dirname) {
    // 模块中的js代码
})

   当然,为了高效的执行,Node.js在CommonJS模块上做了一些改进, 

  1,运行时加载:Node.js 执行到require函数时才会加载模块并执行,然后将模块的exports对象返回。加载模块是同步的,只有当模块加载完成后,才能执行后面的操作。加载,执行,返回exports对象, require就像一个普通的函数调用,把返回值exports对象赋值给一个变量。比如number.js

let num = 1
function add() {
    num++
}
exports.num = num
exports.add = add

  在index.js中引入

const number = require('./number.js')

console.log(number.num)

  当require('./number.js')时,Node.js执行number.js,exports.num = 1; exports.add = add, 执行完毕,然后把exports对象返回,相当于把{num:1, add: add} 对象赋值给index.js中的number,require函数执行完华,number变量和模块就没有关系了。这时即使调用number.add() 也不会必改变number 对象中num的值,因为add函数引用的是它自己作用域中的num,而不是index.js中number对象的属性。相反,你可以把修改number变量中num属性的值。

number.add()
console.log(number.num) //1

number.num = 3
console.log(number.num) //3

  2,缓存:当require一个模块时,会先将模块缓存,然后执行代码,以后再加载该模块时,就直接从缓存中读取该模块。比如a.js

console.log('a 模块加载')
exports.a = 'a'

  b.js

console.log('b 模块加载')
const moduleA = require('./a.js');

exports.b = 'b'

  index.js 

const a = require('./a')
const b = require('./b')

  执行index.js,可以发现a模块只加载了一次。当b.js中再require a.js时,a.js已经缓存了,所以就没有加载,执行了。模块的缓存也有助于解决循环依赖。a.js改为

const { getMes } = require('./b')

console.log('我是 a 文件')

exports.say = function () {
    const message = getMes()
    console.log(message)
}

  b.js 

const say = require('./a');

console.log('我是 b 文件') 
exports.getMes = function(){return "Hello"}

   执行index.js,先加载a.js 放入缓存中,然后执行a.js,它又加载 b.js,b.js放入缓存,并执行,它又引入a.js,因为a模块已经在缓存中,所以直接读取缓存中的a, 实现上缓存中的a模块,只是一个空对象,加载完之后,b.js继续执行,控制台输出"我是b文件"。b.js执行完以后,再执行a.js,输出 “我是b文件”

  当然,也可以删除缓存,缓存是按照路径的形式将模块进行缓存,放到 require.cache对象上。通过delete require.cache[modulePath]将缓存的模块删除。需要注意的是modulePath是绝对路径。delete require.cache[path.resolve('./myclass')];

  3,exports 和 module.exports。CommonJS模块化只规定exports对象向外导出。想要导出什么,只给exports对象,添加属性,但只想导出方法,对象就有点麻烦,所以Node.js 可以直接使用module.exports 进行导出。

(function(module){
   module.exports = {};
   var exports = module.exports
   // a.js 写入的代码
   exports.a = 'a';
 
   return module.exports;
})()

  到了commonjs2,module.exports也可以导出。exports只是module.exports的引用,相当于exports = modules.exports ={},整个模块只暴露modules.exports指向的一个对象出去,exports只能用来添加属性,所以exports 不能再被赋值给任何对象,即使赋值了,它就不能指向module.exports了,打破了引用,也就不能export出去(module.exports 是真正暴露出的对象),要想export一个对象出去,只能给module.exports赋值。exports.myFun 就是module.exports.myFunc.

  ES模块

  ES模块是ECMAScript官方推出的模块化解决方案,它吸取CommonJS的优点,一个JS文件就是一个独立的模块,模块内部的变量都是私有化的,其他模块无法访问;要想让其它模块进行访问,就要暴露出去,其它模块需要引入才能使用。但语法更简洁

  1,使用export 导出模块,不仅可以export对象,还可以export 变量,函数等,其实,在ES模块下,可以导出任意的标识符

export const a = 'a';
export function sayHello() { console.log('hello , world') }

  export导出的是标识符,也就是内存地址,而不是值,所下面两种是错误的写法:

// 报错,是个值=
export 1;

var m = 1;
export m;  

  2,使用 import并配合 from关键词进行导入。注意,from后面的文件名要加后缀。

import { a, sayHello } from'./a.js' //引入的文件要加后缀名

  import 导入的就是变量名, 相当于内存地址,因此,导入的是只读引用,不能修改a 和sayHello 的值。也正因为是import的是变量名,导出模块内部的变量一旦发生变化,对应导入模块的变量也会跟随变化。

  3,以上的import 和export 称为有名字的import和 export。ES模块还定义default export和import。就是导出的时候,使用 export default,

export default class Logger {
    log (message) {
        console.log(`${message}`)
    }
}

  导出是default, 而不是Logger,导出的内容被注册到default上,所以后面的logger 被忽略了, 正是由于导出的是default,所以export default 后面跟的是值,而不是变量, default在某种意义上来说,可以称为变量声明了,所以需要提供值。

export default 1; // 正确
export default const a = 1; // 错误

  导出default 还有一个影响, 就是,一个模块中只能有一个默认的导出。默认导出的import 是

import MyLogger from './logger.js'

  导入的时候,不加{}, 并且可以随意命名变量。实际上在内部,模块导出的就是default

import * as loggerModule from './logger.js'
console.log(loggerModule) // [Module] { default: [Function: Logger] }

  但你不能是用 import {default} from './logger.js',语法错误,default是关键字。  

  4,模块标识符(要import的模块的位置):

    相对路径标识符,就是 ‘./a.js’, '../a.js'

    绝对路径标识符: file:// 本地url, 比如"file:///opt/nodejs/config.js" , ES 模块绝对路径标识符,只有这一种格式,直接使用/ 或// 作为绝对路径不起作用

    无路径标识符,就是node 核心模块和node_modules中的第三方包,比如 fs, http 和fastify

    深度import 标识符,比如fastify/lib/logger.js。node_modules中fastify下的lib下的logger.js

  5,ES模块的加载方式是静态化的,只有在编译时加载,不会在执行时加载,也就是说引入模块的语句必须在模块的最顶层,不能在任何控制语句中。并且引入的模块名称也只能是常量字符串,不能是需要运行期动态求值的表达式,因为编译不会运算求值。静态化加载,也有好处,比如也可以是异步的。如果想要动态加载模块,只能调用import()函数,它接受模块标识符作为参数,返回一个promise, promise  resolve之后,就是模块对象。

  Node 14中实现了ES 模块,来看一下它是怎么解析和执行ES模块的? 解析入口文件,生成模块记录(Module Record),知道import模块,寻找模块,再解析成Module Record,深度优先遍历,构建整个项目的模块依赖图(dependency graph),根据module Record 构建Module instance,建立各个模块之间的依赖关系。 这个过程又分为三个阶段

  1,构建或解析阶段:根据路径,加载模块,解析成Module Record。加载入口文件,生成Module Record,识别它的依赖,根据依赖路径,加载依赖模块,再生成Module Record,再加载依赖,层层递进,深度优先,直到依赖没有依赖为止。

  加载依赖的过程中,会把加载完成的依赖(Module Record)缓存起来,放到module map中, 如果以后再加载相同的依赖,就不执行加载操作,所以每一个模块只会加载一次,

 

  最终形成完整的module record的依赖树。

  2, 实例化阶段:从依赖树的最底端module record 开始,JS引擎会为每一个module record 创建模块环境记录(module environment record) 管理里面的变量,同时,为export出去的变量的内存中找一块空间,沿着依赖树向上,module record中 有import 也有export, 先为export变量在内存中找空间,然后再为import 的依赖建立联系。由于import 的模块在依赖树的底层,我们是从是树底层向上走的,所以import的依赖都已经export 出去了,只要为import 找到export 就可以了,import和export都指向内存的同一位置。这一个过程是一个树的后序遍历的过程。

  3,执行阶段:执行代码,也是按照后序遍历的顺序,从下向上,依次执行每一个module instance中的代码,得到的值放到export 指向的内存中的位置,每一个模块的代码只执行一次。此时,可以从入口文件开始执行代码,整个项目开始执行。

  这三个阶段相互分离,在构建完整个依赖图之前,没有代码会执行,因此模块的导入或导出都是静态的。

  理解了模块的加载,看一下ES模块是怎么处理循环依赖的?

// a.js
import * as bModule from './b.js'
export let loaded = false
export const b = bModule
loaded = true

// b.js
import * as aModule from './a.js'
export let loaded = false
export const a = aModule
loaded = true

// main.js
import * as a from './a.js'
import * as b from './b.js'
console.log('a ->', a)
console.log('b ->', b)

  解析阶段:node main.js,main.js就是入口文件。main.js 加载a.js, a.js加载b.js,b.js再加载a.js,因为,a.js已经加载过了,就不加载了,它也没有其它import,直接返回到a.js,a.js也没有其它import,就返回到main.js,main.js再加载b.js,由于b.js已经加载过了,也就不加载了,注意,这里只执行inport 加载,不执行代码。

  2, 实例化阶段,根据第一阶段得到的依赖树,从下到上遍历,对于每一个模块,解释器找到所有export出来的属性,然后,再建立build out a map of the exported names
in memory:

 

 从b.js开始,它export出了loaded 和a, 再到a.js,它也export出了loaded 和b,最后到main.js, 它没有export什么。注意,图中exports 射只track export出来的名字,值没有初始化。再link the exported names to the modules importing them

 

   所有的值仍然是未初始化的。

  执行阶段,每一个文件中的所有代码最终执行。执行顺序,也是沿着依赖图从下到上执行。b.js先执行,loaded设为false,a指向代表a.js模块的aModule, loaded再调为true. 至此b.js执行完了,再执行a.js, loaded设为false,b指向模块b.js,loaded重置为御true, a.js也执行完了,再执行main.js, 所有export出来的属性都执行完了,引入的模块a, b 都是引用,直接找到a, b 进行输出。在ES 模块中,每一个模块都能引用到其它模块实时更新的或最新的状态。

 

  模块使用

  Node.js 中同时存在两种模块机制,要怎么使用呢?.js文件默认是CommonJS模块(语法),不能使用ESM语法,否则报错。要想使用ESM语法,可以把文件命名为.mjs,或者文件名是.js, 但在项目的package.json中加个type字段, 值为"module", "type": "module",为了后者进行对应,CommonJS解析也进行了相应的变化,1,文件以.cjs结尾,2,如果有package.json, package.json中没有type 字段或type 字段设为comonjs, 以 .js结尾的文件以CommonJS 解析。因此,Node.js 团队强烈建议包的作者在package.json文件中注明type 字段

{
  "type": "module"
}

  当使用CommonJS时,Node向模块中注入了__dirname, __firename 等全局对象。但ES模块是通过 import/export关键词来实现,没有对应的函数包裹,所以在 ES模块中,没法使用这些CommonJS对象。但可能通过import.meta来获取到当前文件的URL的引用。import.meta是 ECMA 实现的一个包含模块元数据的特定对象,主要用于存放模块的 url,而 node 中只支持加载本地模块,所以 url 都是使用 file:协议。import.meta.url is a reference to the current module
file in a format similar to file:///path/to/current_module.js. This value can be
used to reconstruct __filename and __dirname in the form of absolute paths:

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

const __firname = fileURLToPath(import.meta.url);
const __dirname = dirname(__firname);

  在ES模块文件中,可以引用CommonJS模块的内容,使用default import可以import进来CommonJS模块exports出来的整个对象, 使用name import 可以单独import 进来CommonJS模块exports出来的某个属性。假设cmj.js中 exports.a = 3; 在m.mjs 中,

import aa from './cmj.js' // 整个对象 {a: 3}
import {a} from './cmj.js' // 单个属性 a, 3

  在CommonJS模块文件中,也可以引用ES 模块中的内容, 不过,不能使用require, 而是使用import()函数,动态加载。

import('./m.mjs').then(data => {
    console.log(data) // [Module] { a: 3 }
})

  有些包还包含子包,除了直接引用整个包外,Node.js还允许我们引用包里的某个模块。这时require 或import接受的文件标识符参数,就变成了包名/引用的模块。以mine包为例,你可以 require('mine') 引用整个包,也可以引用require('mine/lite'). import 就是import 'mine' 或import 'mine/lite'。如果遇到这样的模块标识符, Node.js先在node_moudules中找到主包,在这里是mine。然后再根据模块标识符找主包下面的文件。模块标识符也标识出了路径,mine目录下面的lite(主包mine也是一个目录),lite可以是lite.js, 也可以是lite目录,它里面包含index.js。

  像这种深入引用的模块标识符,包的作者也可以在package.json中定义,而不用使用上面的路径对应的方式。

{
    "exports": {
        "./cjsmodule": "./src/cjs-module.js",
        "./es6module": "./src/es6-module.mjs"
    }
}
    

  require('module-name/cjsmodule') or  import 'module-name/es6module' , 就可以加载相应的子模块或子包。

posted @ 2022-11-05 08:59  SamWeb  阅读(299)  评论(0编辑  收藏  举报