详谈CommonJS模块化

3,045 阅读12分钟

一、前言

模块化其实很早就在很多高级语言中如JavaRubyPython出现了,甚至在C语言都有类似的模块化,比如include语句引入头文件,各种库等等。而在前端中,JavaScript作为主要语言,它设计之初并没有实现模块化。随着Web的发展,JavaScript地位越来越高,同时也出现了以下主要问题:

  • 代码难以维护
  • 作用域污染
  • 无法唯一标识变量
  • ...

这可谓是一大痛点呐!但是广大的软件工程师们也不是吃素的,于是解决方案如AMDCMDES ModuleCommonJS便雨后春笋般涌现出来了。

现如今AMDCMD已经慢慢淡出我们的视野了,我们接触最多就是两种模块规范:ES ModuleCommonJS,前者应用在ECMAScript中而后者在Node中。

CommonJS规范是一个超级大的概念,和ECMAScript规范一样,它是整个语言层面的规范,模块化只是偌大的规范中的一种,我相信很多人容易搞混淆,在此还是说明一下。

如果还是不理解,我举个例子吧:在CommonJS规范中实现了以下规范:

  • ECMAScript(不同的版本支持有差异)
  • 模块
  • 二进制
  • Buffer
  • I/O流
  • ...

我相信你应该可以理解了,下面我会介绍一下我所学习的CommonJS模块规范。

二、规范内容

主要分为三部分:模块引用模块定义模块表示

2.1 模块引用

Node模块类型分为两种:核心模块文件模块,并通过require方法来引入模块。前者是Node中内置的模块,而后者一般是用户自己定义的模块。后面提到的自定义模块也属于文件模块,只是为了区分说明。

代码如下:

// 引入`http`内置模块
const http = require('http')

// 引入文件模块
const sum = require('./sum')

// 引入第三方包`koa`,这是一个自定义模块
const koa = require('koa')

require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。

2.2 模块定义

在CommonJS模块规范中,一个文件就是一个模块,并通过module.exportsexports两种方式来导出模块中的变量或函数。

代码如下:

// 通过exports导出一个`sum`函数
exports.sum = (x, y) => x + y;

// 通过module.exports导出一个`sum` 函数
module.exports = (a, b) => a - b;

为了方便,Node为每个模块提供一个exports变量,指向module.exports。等价于:

var exports = module.exports;

如果exports导出的变量类型是引用类型如函数,则会断开与module.exports的地址指向,导致变量导出失败。因为最终还是要靠module.exports来导出变量的。

exports = function() {...};

用图来表示大概就是这个样子:

在这里插入图片描述

同理,如果你要使用module.exports直接导出一个对象或者函数也会重新指向新地址,而你还使用exports导出原来地址中的变量或函数是没有用的。

// 在原来的空对象中存储一个a变量
exports.a = function() {}

// 通过module.exports 直接导出一个引用类型变量
// 前面导出的变量失效了

module.exports = {...}

2.3 模块标识

模块标识是require方法中的参数,该参数就是引入的模块文件的路径,可以没有后缀,但是必须符合小驼峰命名规范。

在上面的模块引用中,http./sumkoa就是模块标识。具体有以下几类:

  • 核心模块
  • ...开始的相对路径模块
  • /开始的绝对路径模块
  • 自定义模块,常见的如第三方包

三、模块加载过程

从Node中引入模块,主要经历了四个过程:

  • 缓存加载
  • 路径分析
  • 文件定位
  • 编译执行

下面来具体看看它们的过程。

3.1 缓存加载

不管是内置模块还是文件模块,在第一次加载模块后,会把模块编译执行并放在缓存中。从而以后再次加载模块的时候,会直接去缓存中找相应的模块。

内置模块跟文件模块不同的是,它在Node源代码编译过程中直接编译成了二进制可执行文件,在启动Node进程的同时就从内存中加载了核心模块,并缓存起来。所以内置模块的加载跳过了文件定位编译执行的步骤,并且优先于文件模块加载。

缓存一般放在了require.cache,如果想删除模块的缓存,可以像下面这样写。

// 删除指定模块的缓存
delete require.cache[moduleName];

// 删除所有模块的缓存
Object.keys(require.cache).forEach(function(key) {
  delete require.cache[key];
})

注意,缓存是根据绝对路径识别模块的,如果同样的模块名,但是保存在不同的路径,require命令还是会重新加载该模块。

3.2 路径分析

路径分析主要是对模块标识符分析,根据不同类型的模块标识符使用不同规则分析路径。

下面是模块加载速度比较:

核心模块 > 文件模块 > 自定义模块

核心模块在Node启动的时候就已经编译成了二进制文件了,所以加载速度最快。 文件模块因为带有.../路径标识,具体标识了文件的位置,所以模块加载速度仅次于核心模块。自定义模块是三者最慢的了,具体原因我们在下面会有说明。

值得注意的是,如果自定义模块和核心模块重名了,则不会加载自定义模块,因为核心模块优先于自定义模块

Node是如何去寻找文件模块和自定义模块路径并加载的呢?下面我要先介绍一个很特殊的对象module

3.2.1 module 对象介绍

Node内部提供一个Module构建函数。所有模块都是Module的实例。每个模块内部,都有一个module对象,代表当前模块。

Module

function Module(id, parent) {
  this.id = id;
  this.exports = {};
  this.parent = parent;
  // ...

为了测试,我建立了一个项目结构:

在这里插入图片描述
sum.js

module.exports = (x, y) => x + y;

main.js

const sum = require('./sum')
const result = sum(1, 2);

module.exports = result;
console.log(module);

我们在main.js中打印module,它存放了当前main.js模块的所有信息:

Module {
  id: '.',
  path: 'e:\\web\\font-end-code\\Node\\01-Commonjs',
  exports: 3,
  parent: null,
  filename: 'e:\\web\\font-end-code\\Node\\01-Commonjs\\main.js',
  loaded: false,
  children: [
    Module {
      id: 'e:\\web\\font-end-code\\Node\\01-Commonjs\\sum.js',
      path: 'e:\\web\\font-end-code\\Node\\01-Commonjs',
      exports: [Function],
      parent: [Circular],
      filename: 'e:\\web\\font-end-code\\Node\\01-Commonjs\\sum.js',
      loaded: true,
      children: [],
      paths: [Array]
    }
  ],
  paths: [
    'e:\\web\\font-end-code\\Node\\01-Commonjs\\node_modules',
    'e:\\web\\font-end-code\\Node\\node_modules',
    'e:\\web\\font-end-code\\node_modules',
    'e:\\web\\node_modules',
    'e:\\node_modules'
  ]
}

简单介绍一下它其中的每个属性。

  • id:模块的识别符,通常是带有绝对路径的模块文件名。

  • path:当前模块的绝对路径。

  • export:表示模块对外输出的值。我这里导出了一个3

  • parent:返回一个对象,表示调用该模块的模块。没有就返回null

  • filename:模块的文件名,带有绝对路径。

  • loaded:返回一个布尔值,表示模块是否已经完成加载。

  • children:返回一个数组,表示该模块要用到的其他模块。

  • paths:当前模块查找的绝对路径数组。它遵循一定的模块路径查询规则。

我们可以利用parent属性来判断当前文件是不是一个入口文件:

if(!module.parent) {
	// do something
} else {
	// export something
}

我们了解了module对象后,对接下来分析模块路径查询规则很有帮助了。

3.2.2 模块路径查询规则

上面我们已经看到了在module对象中有个很重要的属性paths,里面存放了一个路径数组。现在换成自定义模块的写法来引入sum模块:

main.js

const sum = require('sum');
const result = sum(1, 2);

module.exports = result;

console.log('---------------main.js-----------')
console.log(module.paths);

然后在main.js同级目录下创建一个node_modules目录,创建一个sum.js模块:

node_modules/sum.js

module.exports = (x, y) => x + y;

console.log('---------------node_module/sum.js-----------')
console.log(module.paths);

在这里插入图片描述

执行main.js

在这里插入图片描述

我们发现,通过自定义模块方式引入的sum.js和文件模块中的paths是一样的结果。所以我们可以得出一个规则:

  • 查询当前文件目录下的node_modules路径
  • 查询父级目录下的node_modules路径
  • 查询父级的父级目录下的node_modules路径
  • 一直递归,直到查询到根目录下的node_modules路径

奇怪的是,在main.js中我们发现node_modules目录也被打印了出来,可是,我们看到的是main.js并不在该目录下面啊,这是怎么回事呢? 这里留个思考题。

3.3 文件定位

路径分析好了后,下面要具体定位文件的位置了,主要分为两个步骤:文件拓展名分析目录分析

3.3.1 文件拓展名分析

我们使用require引入模块的时候,可以不加文件的后缀名。比如:

const sum = require('./sum')

这个时候Node就会进行文件拓展名分析,会依次分析下面三个拓展名:

  • .js
  • .node
  • .json

在分析的过程中,Node会同步阻塞式调用fs模块来判断文件是否存在。如果查找不到这个文件,而得到了一个目录,那么将会进行目录(包)分析。

3.3.2 目录(包)分析

为了测试,我们来整理下目录结构:

在这里插入图片描述

有同学肯定还是要问,为啥要放在node_modules下面呢,这个还真不好回答,请翻到上面再看一下路径分析,希望对你有帮助哦。

package.json

{
  "main": "sum.js"
}

其余代码不变。执行main.js后发现能够正常打印结果就能说明模块加载成功了。上面其实就是一个目录分析的过程了。

  • 找到sum这个目录(或者叫包)
  • 判断有没有package.json文件,如果有,使用JSON.parse解析JSON对象,找到main属性名对应的文件名,我这里的属性名就是sum.js,如果文件名没有拓展名就先进行拓展名分析,然后定位到这个模块就可以了。
  • 如果没有package.json文件,就会在当前目录下依次寻找index.jsindex.nodeindex.json
  • 如果都不符合条件就抛出异常

如果你理解了这个过程,你也就理解了npm install 的时候为什么会自动生成node_modules文件夹,并且文件夹下有好多包。然后我们引入的方式就是自定义模块引入的方式。

3.4 模块编译

上面的步骤完成后,也就是说现在已经找到了模块了,我们需要对模块进行编译,并执行模块里面的代码,把需要暴露出来的变量都暴露出来。不同的文件拓展名,载入的编译方法是不一样的。

  • .js:通过fs模块同步载入后编译执行。
  • .node:这是c/c++编写的拓展文件,需要调用dlopen()方法来编译。
  • .json:通过fs模块同步载入后使用JSON.parse解析结果。
  • 其余拓展名都被当做js文件来处理。

每一个编译成功的模块都会将文件的绝对路径当作索引缓存在Module._cache对象上,来提升二次引入的性能。

3.4.1 JSON文件编译

这一块的编译主要是通过Node同步调用fs模块读取JSON文件内容后,使用JSON.parse方法解析,然后将解析后的结果放到exports对象暴露出去。它一般都是作为一个配置文件说明,而且一般都是node自己加载pacakage.json。处理代码如下:

Module._extensions['.json'] = function(module, filename) {
	var content = NativeModule.require('fs).readFileSync(filename, 'utf-8');
	try {
		module.exports = JSON.parse(stripBOM(content));
	} catch(err) {
		err.message = filename + ':' + err.message;
		thow err;
	}
}

3.4.2 node文件编译

node文件主要是C/C++ 的拓展,其实C/C++ 已经编译好了封装在了libuv层,只需要在node曾调用procee.dlopen方法就可以加载执行。这一块难度较大,就不多说了。

3.4.3 JavaScript文件编译

编译JavaScript文件过程中,给当前模块包上一层函数,采用闭包来解决全局变量污染问题。下面是一个简单的实现。

(function(exports, require, module, __dirname, __filename){
	var load = function (exports, module) {
	    // 读取的main.js代码:
		const sum = require('./sum');
		const result = sum(1,2);
		module.exports.result = result;
	    // main.js代码结束
	    return module.exports;
	};
	var exported = load(module.exports, module);
	// 保存module:
	save(module, exported);
})

包装完后,将需要导出的变量通过module.exports导出去了。其他模块就只能访问导出来的变量,其余变量是访问不到的

四、和ES Module的区别

CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。 我们将main.js代码改变一下:

let sum = require('./sum');
sum = { a: 1 };
console.log(require('./sum'))
console.log(sum)

可以看到,两个模块是不会影响的。

在这里插入图片描述

但是在ES Module中是不一样的,它是静态加载。也就是在代码静态解析阶段就已经确认好模块依赖关系了。一句话总结就是:

CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

所以在ES Module模块与模块是互相影响的。

五、总结

之前对CommonJS模块化的理解模模糊糊的,这篇文章算是我的一个笔记吧,毕竟大部分内容是从前辈们的文章或书籍里面借鉴的,跟着他们的脚步来走的。即便如此,我发现还是收获不少,再次感谢它们的文章和书籍。虽然还有很多不完善的地方,但我对自己还是有信心的,争取以后有更多自己的理解。

六、参考

【1】《深入浅出Node.js》朴灵编著

【2】阮一峰 CommonJS规范

【3】廖雪峰 模块