曾几何时, Javascript 还没那么牛逼, 几乎所有人都觉得它是用来做网页特效的脚本而已. 彼时仓促创建出来的 javascript 的自身缺点被各种吐槽. 随着 web 的发展, Javascript 如今是媳妇熬成婆, 应用越来越广泛.
虽然 Javascript 自身很努力, 但是还是缺乏一项重要功能, 那就是模块. 毕竟 Python 有 require,PHP 有 include 和 require.js 通过 <script> 标签引入的方式虽说也没问题, 但是缺乏组织和约束, 也很难达到安全和易用. 所以 CommonJS 规范的提出简直就是革命性的.
现在我们就来说说在 Node 中的 CommonJS 模块的规范和实现.
CommonJS 的模块规范
CommonJS 对模块定义分为三部分: 模块定义, 模块引用和模块标识.
模块定义:
首先创建一个 a.js 的文件, 在里面写上:
module.exports = 'hello world';
在 Node 中, 一个文件就是一个模块, module.exports 对象可以导出当前模块的方法或者变量. 以上的代码就是将字符串 hello world 导出, 上下文就提供了 require() 方法来引入外部模块.
模块引用:
创建一个 b.js 的文件, 在里面写上:
- let b = require('./b.js'); //.js 可以不写, 这我们后面会讲到
- console.log(b); // hello world
3) 模块标识: 模块标识其实就是 require() 方法里的参数, 它可以以 . 和 .. 开头的相对路径, 也可以是绝对路径. 可以没有文件名后缀. js, 后面会讲到为何可以没有后缀.
Node 的模块实现
上面的代码就是最简单的模块的使用. 那么在我们这几行简单的代码背后, 在实现过程中究竟是什么样的过程呢? 我们一点点来分析.
Node 在引入模块经历 3 个步骤: 路径分析, 文件定位和编译执行
在 Node 中模块有两类: 一类是 Node 提供的核心模块, 另一类是用户自己编写的文件模块.
部分的核心模块直接加载在内存中, 所以引入这部分模块时, 文件定义和编译执行都可以省略, 在路径分析中优先判断, 加载速度也是最快的.
另外需要知晓的是, Node 引入的模块都会进行缓存, 以减少二次引用时的开销. 它缓存的是编译和执行后的对象, 而不是和浏览器一样, 缓存的是文件.
路径分析 上面已经说了, require() 方法里参数叫模块标识, 路径分析其实就是基于标识符来查找的. 模块标识符在 Node 中分为以下几类:
核心模块, 比如 http,fs,path 等.
. 或 .. 开始的相对路径文件模块.
以 / 开始的绝对路径文件模块.
非路径形式的文件模块, 如自定义的 connect 模块.
核心模块的优先级仅次于缓存加载, 由于在 Node 源码编译中已经被编译为二进制代码, 所以加载过程是最快的.
相对路径文件模块在分析路径时, require() 方法会将路径转为真实路径, 并以绝对路径最为索引, 将编译执行后的结果放入缓存, 以使二次引用时加载更快.
自定义模块是特殊的文件模块, 可能是文件或者包的形式, 也是查找最慢的一种模块.
Node 在定位文件模块的具体文件时制定的查找策略可以表现为一个路径数组.
在 js 文件中 console.log(module.paths);
放到任意目录中执行;
就会得到类似以下的数组:
- [ '/Users/lq/Desktop/node_modules', // 当前文件目录下的 node_modules 目录
- '/Users/lq/node_modules',// 父目录下的 node_modules 目录
- '/Users/node_modules',// 父目录的父目录下的 node_modules 目录
- '/node_modules' ]// 沿路径向上逐级递归, 直到找到根目录下的 node_modules 目录
当前文件的路径越深, 模块查找就越耗时, 这是自定义模块加载慢的原因.
文件定位
require() 在分析标识符的时候, 可能会出现没有传递文件拓展名的情况. CommonJS 模块规范是允许这种情况出现的, 不过 Node 会按照. js,.json,.node 的顺序补足拓展名. 依次调用 fs 模块同步阻塞式地判断文件是否存在.
在这个过程中, Node 对 CommonJS 模块规范进行了一定的支持. 首先, Node 会在当前目录下查找包描述文件 package.json, 通过 JSON.parse() 解析出包描述对象, 取出 main 属性指定的文件名进行定位, 如果缺少拓展名, 就进入上面说的拓展名分析步骤, 按顺序补足拓展名再查找. 如果 main 属性指定的文件名错误或者没有包描述文件 package.json,Node 会将 index 当作默认文件名, 依次查找 index.js,index.json,index.node. 如果所有的路径数组都遍历了, 还是没有找到, 就会抛出错误.
模块编译 在定位到具体的文件后, Node 会新建一个模块对象, 然后根据路径进行编译. 根据不同的拓展名操作不同的方法.
如果是. js 文件, 通过 fs 模块同步读取文件后编译执行
如果是. json 文件, 通过 fs 模块同步读取文件后, 用 JSON.parse() 解析返回结果
如果是. node 文件, 通过 dlopen() 方法加载最后编译生成的文件. 这是 C/C++ 编写的拓展文件, 本人在后面实现原理的过程中予以忽略.
说了这么多, 下面直接进入实现环节
先创建一个 a.js 文件, 写入:
module.exports = 'hello world';
再创建一个 b.js, 写入:
- let b = require('./a.js');
- console.log(b); //hello world
打印的结果是 hello wrold. 这是 Node 自带的 require 方法. 现在我们来实现下我们自己的 require 方法.
我们直接在 b.js 里修改下:
- // 引入 Node 的核心模块
- let fs = require('fs');
- let path = require('path');
- let vm = require('vm');
- function Module(p) {
- this.id = p; // 当前模块的标识, 也就是绝对路径
- this.exports = {}; // 每个模块都有 exports 属性, 添加一个
- this.loaded = false; // 是否已经加载完
- }
- // 对文件内容进行头尾包装
- Module.wrapper = ['(function(exports,require,module){', '})']
- // 所有的加载策略
- Module._extensions = {
- '.js': function (module) { // 读取 js 文件, 增加一个闭包
- let script = fs.readFileSync(module.id, 'utf8');
- let fn = Module.wrapper[0] + script + Module.wrapper[1];// 包装在一个闭包里
- vm.runInThisContext(fn).call(module.exports, module.exports, myRequire, module);// 通过 runInThisContext() 方法执行不污染全局
- return module.exports;
- },
- '.json': function (module) {
- return JSON.parse(fs.readFileSync(module.id, 'utf8')); // 读取文件
- }
- }
- Module._cacheModule = {} // 存放缓存
- Module._resolveFileName = function (moduleId) { // 根据传入的路径参数返回一个绝对路径的方法
- let p = path.resolve(moduleId);
- if (!path.extname(moduleId)) { // 如果没有传文件后缀
- let arr = Object.keys(Module._extensions); // 将对象的 key 转成数组
- for (let i = 0; i < arr.length; i++) { // 循坏数组添加后缀
- let file = p + arr[i];
- try {
- fs.accessSync(file); // 查看文件是否存在, 存在的就返回
- return file;
- } catch (e) {
- console.log(e); // 不存在报错
- }
- }
- } else {
- return p; // 如果已经传递了文件后缀, 直接返回绝对路径
- }
- }
- Module.prototype.load = function (filepath) { // 模块加载的方法
- let ext = path.extname(filepath);
- let content = Module._extensions[ext](this);
- return content;
- }
- function myRequire(moduleId) { // 自定义的 myRequire 方法
- let p = Module._resolveFileName(moduleId); // 将传递进来的模块标示转成绝对路径
- if (Module._cacheModule[p]) { // 如果模块已经存在
- return Module._cacheModule[p].exports; // 直接返回编译和执行之后的对象
- }
- let module = new Module(p); // 模块不存在, 先创建一个新的模块对象
- let content = module.load(p); // 模块加载后的内容
- Module._cacheModule[p] = module;
- module.exports = content;
- return module.exports;
- }
- let b = myRequire('./a.js');
- console.log(b);
这样就可以通过自己的 myRequire() 方法拿到 a.js 里的字符串 hello world 了. 当然, module 的源码不止这么多, 有兴趣的可以自己查看. 本文只是说明下 module 加载的原理. 有写的不够严谨的地方, 望谅解. 如有错漏, 可指出, 定及时修改.
参考
部分内容根据深入浅出 Node.js一书整理
来源: https://juejin.im/post/5b0266216fb9a07ac23b099a