什么是模块化?
模块化
本文主要包含以下知识点:
- 使用模块的好处
- 模块加载规则
- 模块缓存
- CommonJS 规范
- 模块原理
使用模块化的好处
模块化是一种设计思想,利用模块化可以把一个非常复杂的系统结构细化到具体的功能点,每个功能看
作一个模块,然后通过某种规则把这些⼩的模块组合到一起,构成模块化系统。
在计算机程序的开发过程中,随着程序代码越写越多,在一个文件里代码就会越来越长,越来越不容易
维护。
为了编写可维护的代码,我们把很多函数分组,分别放到不同的文件里,这样,每个文件包含的代码就
相对较少,很多编程语言都采用这种组织代码的方式。在 Node.js 环境中,一个 JavaScript 文件就称之
为一个模块。
从生产⻆度来看,模块化开发有如下 2 个特点:
1. 生产效率高
- 灵活架构,焦点分离
- 多人协作,互不干扰
- 方便模块间组合,分解
2. 维护成本低
- 可分的单元测试
- 方便单个模块功能的调试和升级
模块加载规则
在 Node.js 中,使用 require() 来进行模块的加载。但是加载模块有一定的加载规则,在介绍具体的
加载规则之前,我们先来看一下模块的分类。主要可以分为 3 大类:文件模块,核心模块以及第三方模
块。
文件模块
使用 require() 函数加载文件模块时,需要使用两种模块标识:
- 以 / 开头的模块标识,指向当前文件所属盘符的跟路径。
- 以 ./ 或者 ../ 开头的相对路径模块标识。
加载文件模块的语法如下:
require("路径.扩展名");
例如,加载不同路径下的.js文件,其语法如下:
require("/example.js"); // 如果当前文件在 C 盘,将加载 C:\example.js require("./example.js"); // 当前目录下的 example.js require("../example.js"); // 上一级目录下的 example.js
在上面的代码中,可以省略文件的扩展名 .js ,写作 require("/example") ,Node.js 会尝试为文件
名添加 .js , .json , .node 来进行查找。
核心模块
核心模块可以看作是 Node.js 的心脏,它由一些精简而高效的库组成,为 Node.js 提供了基本的 API。
常用的核心模块有:
- 全局对象
- 常用工具
- 事件机制
- 文件系统访问
- HTTP 服务器与客户端
由于 Node.js 的模块机制,这些 Node.js 中内置的核心模块被编译成二进制文件,保存在 Node.js 源码
的 lib 文件夹下,在本质上也是文件模块,但是在加载方式上与文件模块有所区别。
核心模块是唯一的,所以在加载核心模块的时候,不需要书写 ./ , ../ 或者 / 这些开头,直接书写模
块名即可,如下:
require("模块名");
例如,Node.js 模块中提供了一个 OS 核心模块,在该模块中提供了一些与操作系统相关的 API,如
下:
// 核心模块就是一个固定标识 // 如果写错就无法加载 const os = require('os'); // 输出 CPU 信息 console.log(os.cpus());
效果:
NPM_test node index [ { model: 'Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz', speed: 2700, times: { user: 7304340, nice: 0, sys: 6596550, idle: 85524090, irq: 0 } }, { model: 'Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz', speed: 2700, times: { user: 3011400, nice: 0, sys: 1714130, idle: 94697450, irq: 0 } }, { model: 'Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz', speed: 2700, times: { user: 7244960, nice: 0, sys: 4380050, idle: 87797960, irq: 0 } }, { model: 'Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz', speed: 2700, times: { user: 2767370, nice: 0, sys: 1446820, idle: 95208790, irq: 0 } } ]
第三方模块
社区或第三方开发的功能模块,这种模块在 Node.js 里面本身没有,需要通过 NPM 的方式下载之后再
引入。比如要操作 mysql 数据库,则需要引入 mysql 这个模块。
例如:
const express = require('mysql');
加载规则
在了解了模块的分类以后,我们就可以来具体地看一下模块的加载规则了。使用 require() 进⾏模块
加载时,需要经历 3 个步骤:
- 路径分析
- 文件定位
- 编译执行
先来看路径分析。
当require() 当中的参数字符串以 ./ 或 ../ 开头,表示按照相对路径,从当前文件所在的文件夹开始
寻找要载入的模块文件。当 require() 当中的参数字符串以 / 开头,则表示从系统根目录开始寻找该
模块文件。不能直接写文件名。若在参数字符串当中直接写文件名,则代表载入的是一个模块包,模块
包必须放在一个特定名字的文件夹当中,即 node_modules。
使用 require() 来加载文件时可以省略扩展名。比如 require('./module') ,此时会作出如下的匹
配操作:
- 按 js 文件来执行(先找对应路径当中的 module.js 文件来加载)
- 按 json 文件来解析(若上面的 js 文件找不到时,则找对应路径当中的 module.json 文件来加载)
- 按照预编译好的c++模块来执行(寻找对应路径当中的module.node文件来加载)
- 若参数字符串为一个目录(文件夹)的路径,则自动先查找该文件夹下的 package.json 文件,然后再加载该文件当中 main 字段所指定的入口文件。
注:若 package.json 文件当中没有 main 字段,或者根本没有 package.json 文件,则再默认查
找该文件夹下的 index.js 文件作为模块来载入。
上面所介绍的是加载文件的路径分析。如果参数字符串不以 ./ , ../ 或 / 开头,说明要加载的不是一个文件,而是一个默认提供的核心模
块。此时则先在 Node.js 平台所提供的核心模块当中找,然后再寻找 NPM 模块(即第三方模块包,或自己写的模块包)。在寻找 NPM 模块包时,
会从当前目录出发,向上搜索各级当中的 node_modules 文件夹当中的文件,但若有两个同名文件,则遵循就近原则。
路径分析完毕后,就会根据路径去定位文件,然后进行编译执行。但是不同类型的模块也存在一个优先
级的问题。其中 Node.js 的系统模块的优先级最高,一旦有第三方模块包与系统模块重名,则以系统模
块为准。总的来讲,其优先顺序从上往下依次为:
- 核心模块,如 http、fs、path
- 以 . 或 .. 开始的相对路径文件模块
- 以 / 开始的绝对路径文件模块
- 非路径形式的文件模块
模块缓存
在模块加载的过程中,对于多次加载同一个模块的情况,Node.js 只会加载一次。这是由于第一次加载
某模块时,Node.js 会缓存该模块,再次加载时将从缓存中获取。所有缓存的模块保存
在 require.cache 中,可以自动删除模块缓存。下面我们来演示模块的缓存,如下:
首先在项目根目录下创建 2.js ,代码如下:
console.log("模块被加载了");
接下来在 index.js 中使用 require() 方法来引入5次 2.js 文件:
require("./2.js"); require("./2.js"); require("./2.js"); require("./2.js"); require("./2.js");
效果:
模块被加载了
可以看到,在上述代码中,虽然加载了 5 次 2.js 模块,但是只打印一次"模块被加载了",这就说
明 2.js 模块只被加载了一次。
我们可以在 REPL 模式下输入 require 来查看当前的模块缓存情况,如下:
NPM_test node > require { [Function: require] resolve: { [Function: resolve] paths: [Function: paths] }, main: undefined, extensions: { '.js': [Function], '.json': [Function], '.node': [Function] }, cache: {} }
此时会返回一个对象,该对象的具体信息如下
require(): 加载外部模块
require.resolve(): 将模块名解析到一个绝对路径
require.main: 指向主模块
require.cache: 指向所有缓存的模块
require.extensions: 根据文件的后缀名,调用不同的执行函数
在实际开发中,有些时候开发者并不希望加载的模块被缓存,这个时候可以选择删除缓存操作,在被加
载的模块下面添加如下的代码:
//删除指定模块的缓存 delete require.cache[module.filename]; // or // 删除所有模块的缓存 Object.keys(require.cache).forEach(function(key) { delete require.cache[key]; })
这⾥我们来示例一个就好了。例如这里的 2.js 是被加载的模块,所以在该模块的下面添加如下的代
码:
console.log("foo模块被加载了"); delete require.cache[module.filename];
之后我们再次访问 index.js ,结果如下:
模块被加载了
模块被加载了
模块被加载了
模块被加载了
模块被加载了
可以看到,加载了 2.js 模块后,模块并没有被缓存,所以输出了 5 次"模块被加载了",这说明缓存成
功被清除了。
CommonJS 规范
上面我们所介绍的模块加载机制属于 CommonJS 规范。这⾥简单介绍一下什么是 CommonJS 规范。
Node.js 并不是第一个尝试使 JavaScript 运行在浏览器之外的项目。追根溯源,在 JavaScript 诞生之
初,网景公司就实现了服务端的 JavaScript,但由于需要支付一大笔授权费用才能使用,服务端
JavaScript 在当年并没有像客户端 JavaScript 一样流行开来。真正使大多数人见识到 JavaScript 在服务
器开发威力的,是微软的 ASP。
2000年左右,也就是 ASP 蒸蒸日上的年代,很多开发者开始学习 JScript。然而 JScript 在当时并不是很
受欢迎,一方面是早期的 JScript 和 JavaScript 兼容较差,另一方面微软大力推广的是 VBScript,而不
是 JScript。随着后来 LAMP 的兴起,以及 Web 2.0 时代的到来,Ajax 等一系列概念的提出,JavaScript
成了前端开发的代名词,同时服务端 JavaScript 也逐渐被人遗忘。
直至几年前,JavaScript 的种种优势才被重新提起,JavaScript 又具备了在服务端流行的条件,Node.js
应运而生。与此同时,RingoJS 也基于 Rhino 实现了类似的服务端 JavaScript 平台,还有像CouchDB、
MongoDB 等新型非关系型数据库也开始用 JavaScript 和 JSON 作为其数据操纵语言,基于 JavaScript
的服务端实现开始遍地开花。
CommonJS 规范与实现
正如当年为了统一 JavaScript 语言标准,人们制定了 ECMAScript 规范一样,如今为了统一JavaScript
在浏览器之外的实现,CommonJS 诞生了。CommonJS 试图定义一套普通应用程序使用的 API,从而
填补 JavaScript 标准库过于简单的不足。
CommonJS 的终极目标是制定一个像 C++ 标准库一样的规范,使得基于 CommonJS API 的应用程序可
以在不同的环境下运行,就像用 C++ 编写的应用程序可以使用不同的编译器和运行时函数库一样。为了
保持中立,CommonJS 不参与标准库实现,其实现交给像 Node.js 之类的项目来完成。
CommonJS 规范包括了模块(modules)、包(packages)、系统(system)、二进制(binary)、
控制台(console)、编码(encodings)、⽂件系统(filesystems)、套接字(sockets)、单元测试
(unit testing)等部分。
目前大部分标准都在拟定和讨论之中,已经发布的标准有 Modules/1.0、Modules/1.1、
Modules/1.1.1、Packages/1.0、System/1.0。
Node.js 是目前 CommonJS 规范最热门的一个实现,它基于 CommonJS 的 Modules/1.0 规范实现了
Node.js 的模块,同时随着 CommonJS 规范的更新,Node.js 也在不断跟进。由于目前 CommonJS 大
部分规范还在起草阶段,Node.js 已经率先实现了一些功能,并将其反馈给 CommonJS 规范制定组
织,但 Node.js 并不完全遵循 CommonJS 规范。这是所有规范制定者都会遇到的尴尬局面,因为规范
的制定总是滞后于技术的发展。
模块原理
Node.js 应⽤是由模块组成的,遵循了 CommonJS 的模块规范,来隔离每个模块的作用域,使每个模
块在它自身的命名空间中执行。
CommonJS 规范的主要内容:
模块必须通过 module.exports 导出对外的变量或接口,通过 require() 来导入其他模块的输出到当
前模块作用域中。
CommonJS 模块的特点:
- 所有代码运行在当前模块作用域中,不会污染全局作用域
- 模块同步加载,根据代码中出现的顺序依次加载
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,
- 就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
一个简单的例子:
我们在 2.js 中导出一些属性和方法,如下
module.exports.name = 'Aphasia'; module.exports.sayHello = function(){ console.log('Hello World'); };
接下来我们就可以在 index.js 中引用该模块,如下:
const test = require('./2.js'); console.log(test.name); // Aphasia test.sayHello(); // Hello World
module 对象
根据 CommonJS 规范,每一个文件就是一个模块,在每个模块中,都会有一个 module 对象,这个对
象就指向当前的模块。 module 对象具有以下属性:
- id:当前模块的id
- exports:表示当前模块暴露给外部的值
- parent: 是一个对象,表示调用当前模块的模块
- children:是一个对象,表示当前模块调用的模块
- filename:模块的绝对路径
- paths:从当前文件目录开始查找node_modules目录;然后依次进入父目录,查找父目录下的
- node_modules目录;依次迭代,直到根目录下的node_modules目录
- loaded:一个布尔值,表示当前模块是否已经被完全加载
下面我们可以在导出的模块和引入的模块中分别打印这个 module 对象,如下:
2.js 作为导出的模块
module.exports.name = 'Aphasia'; module.exports.sayHello = function(){ console.log('Hello World'); }; console.log(module); // 打印结果如下 NPM_test node 2 Module { id: '.', exports: { name: 'Aphasia', sayHello: [Function] }, parent: null, filename: '/Users/Desktop/NPM_test/2.js', loaded: false, children: [], paths: [ '/Users/Desktop/NPM_test/node_modules', '/Users/Desktop/node_modules', '/Users/node_modules','/node_modules' ] }
index.js 引入了 2.js 这个模块,当然自己本身也会存在 module 对象:
const test = require('./2.js'); console.log(module); // 打印结果如下 Module { id: '.', exports: {}, parent: null, filename: '/Users/Desktop/NPM_test/index.js', loaded: false, children: [ Module { id: '/Users/Desktop/NPM_test/2.js', exports: [Object], parent: [Circular], filename: '/Users/Desktop/NPM_test/2.js', loaded: true, children: [], paths: [Array] } ], paths: [ '/Users/Desktop/NPM_test/node_modules', '/Users/Desktop/node_modules', '/User/node_modules','/node_modules' ] }
从上面的例子我们也能看到, module 对象具有一个 exports 属性,该属性就是用来对外暴露变量、方
法或整个模块的。当其他的文件 require 进来该模块的时候,实际上就是读取了该模块 module 对象
的 exports 属性。
exports 对象
为了让开发者使用起来更方便,Node.js 还提供了一个 exports 对象。它是一个指向的module.exports 的引用, module.exports 的初始值为一个空对象 {} ,所以 exports 的初始值也是
一个 {} 。
我们在 2.js 中打印这个 exports :
// 通过 module.exports 的方式导出属性 module.exports.name = 'Aphasia'; // 直接通过 exports 的方式导出方法 exports.sayHello = function(){ console.log('Hello World'); }; console.log('module:',module); console.log('exports:',exports); // 打印结果如下: module: Module { id: '.', exports: { name: 'Aphasia', sayHello: [Function] }, parent: null, filename: '/Users/Jie/Desktop/NPM_test/2.js', loaded: false, children: [], paths: [ '/Users/Desktop/NPM_test/node_modules', '/Users/Desktop/node_modules', '/Users/node_modules','/node_modules' ] } exports: { name: 'Aphasia', sayHello: [Function] }
可以看到该对象就是指向了 module 对象的 exports 属性。
虽然这 2 种方式都可以对外暴露变量、方法或整个模块的。但是其实两者还是有细微的区别。
module.exports 可以单独定义,返回数据类型,而exports 只能是返回一个 object 对象。
例如,我们在 2.js 中将 module.exports 单独定义成一个数组:
// 现在导出的整个模块不再是一个对象,而是一个数组 module.exports = ['zhangsan','lisi','wangwu'];
然后在 index.js 中引入该模块时,也就变成了数组,如下:
const test = require('./2.js'); console.log(test); // ['zhangsan','lisi','wangwu']
但是 exports 就不能单独定义,因为它只能返回一个 object 对象,例如我现在在 2.js 中将 exports
也单独定义成一个数组:
// exports 的指向已经被改变,已经切断了 exports 与 module.exports 的联系 exports = ['zhangsan','lisi','wangwu'];
然后在 index.js 中引入该模块时,因为始终引入的是 module.exports ,所以仍然为空,如下:
const test = require('./2.js'); console.log(test); // {}