本文为阅读深入浅出 Node.js(三):深入 Node.js 的模块机制>之后的总结。作为日常的知识储备,方便日后查看和消化。
起因 JavaScript 本来并没有模块化这一说,作为一种脚本语言却缺少模块化实现就很难在此之上有所拓展。
CommonJs 规范的出现改变了这一点,其最终目标是拓展 JavaScript 的生态范围。Node.js 实现了 CommonJs 的模块引入规范,NPM 实现了包规范。两者契合就有了现在的 Node.js 模块化。
模块载入策略 原生模块和文件模块都有模块缓存一说,在载入某模块之后,会缓存起来待之后使用。
文件模块 这种很常见,因为最基本的命令 node app.js
就是直接将 app.js 作为文件模块引入执行。加载文件模块是由原生模块Module
去完成的,该原生模块在启动时已经被加载。
// bootstrap main module.
Module.runMain = function () {
// Load the main module--the command line argument.
Module._load(process.argv[1], null, true);
};
Module._load
静态方法在分析文件名之后,会创建对应 Module
var module = new Module(id, parent);
这里举的例子是用 app.js
为例的,Node.js 支持 3 类文件的解析,分别是 .js
.json
.node
Node.js 会用一个闭包函数去包装引用文件的内容
(function (exports, require, module, __filename, __dirname) {
// Your file content
});
这也是为什么 Node.js 的模块内,会有类似全局变量的require
module
export
__dirname
的存在
模块查找优先级 在说载入 Node.js 原生模块之前,先说明一下 require 的模块查找优先级
require的模块查找策略
原生模块 Node.js 含有很多原生模块,例如 http, fs, path 等。 看这个图中会发现,如果目标模块在文件缓存区,原生模块的载入优先级会在文件缓存区之后。
也就是说如果你有一个 http.js,如果这个文件先被引入(即载入文件缓存区),在之后执行require('http')
会直接引入文件而非 Node.js 原生模块。
其实这个场景不会存在,在 Node.js 环境下,引入文件肯定会是用文件的 path,哪怕运行入口和 http.js 在同级,也就是说
const http = require("http");
这个肯定会载入原生模块,文件的引入是用
const httpFile = require("./http");
所以不用担心会有同名的文件模块和原生模块。
模块查找策略 如果使用 require 去引入一个模块,其查找优先级是
从当前文件目录开始查找 node_modules 目录 然后依次进入父目录,查找父目录下的 node_modules 目录 依次迭代,直到根目录下的 node_modules 目录 如下代码可以在 Node.js 的环境下使用,这个 module.path 就是查找的顺序
console.log(module.paths);
// 输出
[
"/Users/hao/Work/github/node_module_test/node_modules",
"/Users/hao/Work/github/node_modules",
"/Users/hao/Work/node_modules",
"/Users/hao/node_modules",
"/Users/node_modules",
"/node_modules",
];
结合之前的图示,完整的策略为:
模块查找策略
简而言之,如果 require 绝对路径的文件,查找时不会去遍历每一个 node_modules 目录,其速度最快。其余流程如下:
从 module path 数组中取出第一个目录作为查找基准。 直接从目录中查找该文件,如果存在,则结束查找。如果不存在,则进行下一条查找。 尝试添加.js、.json、.node 后缀后查找,如果存在文件,则结束查找。如果不存在,则进行下一条。 尝试将 require 的参数作为一个包来进行查找,读取目录下的 package.json 文件,取得 main 参数指定的文件。 尝试查找该文件,如果存在,则结束查找。如果不存在,则进行第 3 条查找。 如果继续失败,则取出 module path 数组中的下一个目录作为基准查找,循环第 1 至 5 个步骤。 如果继续失败,循环第 1 至 6 个步骤,直到 module path 中的最后一个值。 如果仍然失败,则抛出异常。 整个查找过程十分类似原型链的查找和作用域的查找。所幸 Node.js 对路径查找实现了缓存机制,否则由于每次判断路径都是同步阻塞式进行,会导致严重的性能消耗。
CommonJs 包规范 一个符合 CommonJS 规范的包应该是如下这种结构:
一个 package.json 文件应该存在于包顶级目录下 二进制文件应该包含在 bin 目录下。 JavaScript 代码应该包含在 lib 目录下。 文档应该在 doc 目录下。 单元测试应该在 test 目录下。 根据上文说的查找逻辑,其实很重要的一点就是 package.json 里的 main 字段。 这个定义了包的入口文件所在。
为何有些模块可以在 Node.js 运行也可以在浏览器端运行 浏览器端的 Js 代码都是通过 script 标签引入并执行。
Node.js 是会通过上文中提到的闭包函数去进行包装
(function (exports, require, module, __filename, __dirname) {
// Your file content
});
Node.js 由于是个闭包,所以不会污染全局变量。浏览器端则不然。 有些包可以在 Node.js 和浏览器端运行,也是利用闭包和变量检测去完成模块的解析和运行的。 例如著名类库 underscore 的定义方式
(function () {
// Establish the root object, `window` in the browser, or `global` on the server.
var root = this;
var _ = function (obj) {
return new wrapper(obj);
};
if (typeof exports !== "undefined") {
if (typeof module !== "undefined" && module.exports) {
exports = module.exports = _;
}
exports._ = _;
} else if (typeof define === "function" && define.amd) {
// Register as a named module with AMD.
define("underscore", function () {
return _;
});
} else {
root["_"] = _;
}
}.call(this));
以上代码优先检测了 exports 是否存在,也就是 CommonJs 规范,如果存在 exports 对象,直接将目标对象定义在 exports 上。
然后检测了 define 是否存在,这是 amd 的模块规范,以适配 amd 的模块规范。
最终如果检测不到 CommonJs 和 AMD 的运行环境,则认为是在一个沙盒内运行。
可以看到第一行的var root = this
,这个 this 在浏览器端即为 window。也就是说如果运行环境既不是 CommmonJs 也不是 AMD,则直接挂在 window 对象下。
现在很多的打包工具都会根据打包的 target(UMD, CommonJs 等)进行 build, 会自动的拼接这些判断,所以其实日常开发 js 插件/模块,是不需要担心这些环境检测的代码的。