Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Javascript模块化编程(二)— Node的模块实现 #74

Open
LightXJ opened this issue Aug 10, 2020 · 0 comments
Open

Javascript模块化编程(二)— Node的模块实现 #74

LightXJ opened this issue Aug 10, 2020 · 0 comments

Comments

@LightXJ
Copy link
Owner

LightXJ commented Aug 10, 2020

Node模块简述

Node在实现中并非完全按照CommonJs规范实现,而是对模块规范进行了一定的取舍,同时增加了少许自身的特性。

在Node中引入模块,需要经历如下三个步骤

  • 路径分析
  • 文件定位
  • 编译执行

Node的模块分为两类

  • Node提供的模块:核心模块
    核心模块部分,在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,这部分核心模块引入时,文件定位和编译执行两个步骤可以省略掉,并在路径分析中优先判断,所以它的加载速度是最快的。
  • 用户编写的模块:文件模块
    文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

模块加载过程

优先从缓存加载

展开介绍路径分析和文件定位之前,我们需要知道的一点是,与浏览器会缓存静态文件以提高性能一样,Node对引入过的模块都会进行缓存,以减少二次引入时的开销。不同的地方在于,浏览器仅仅缓存文件,而Node缓存的是编译和执行之后的对象。
不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存的方式,这是第一优先级的。不同之处在于核心模块的缓存检查先于文件模块的缓存检查。

路径分析和文件定位

1、模块标识符分析
require()方法接受一个标识符作为参数。在Node实现中,正式基于这样一个标识符进行模块查找的。标识符在Node中主要分为以下几类。

  • 核心模块,如http、fs、path等
  • .或..开始的相对路径文件模块
  • 以/开始的绝对路径文件模块
  • 非路径形式的文件模块,如自定义的connect模块
    核心模块
    核心模块的优先级仅次于缓存加载,它在node源代码编译过程中已经编译为二进制代码,其加载过程更快。
    路径形式的文件模块
    以.或..和/开始的标识符,都会被当做文件模块来处理。在分析路径模块时,require()方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放在缓存中,以使二次加载时更快。
    由于文件模块给Node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块。
    自定义模块
    自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式。这类模块的查找是最费时的,也是所有方式中最慢的一种。
    模块路径:模块路径是Node在定位文件模块的具体文件时指定的查找策略,具体表现为一个路径组成的数组。
    我们自己动手尝试一番。
    1、创建moudule_path.js文件,其内容为console.log(moudle.paths)
    2、将其放到任意一个目录中然后执行node module_path.js
    在linux下,肯能得到这样一个数组输出
    [ '/home/jackson/research/node_modules',
    '/home/jackson/node_modules',
    '/home/node_modules',
    '/node_modules’ ]
    而在windows下,也许是这样:
    [ 'c:\nodejs\node_modules', 'c:\node_modules’ ]
    可以看出,模块路径的生成规则如下
  • 当前文件目录下的node_modules目录。
  • 父目录下的node_modules目录。
  • 父目录的父目录下的node_modules目录。
  • 沿路径向上逐级递归,直到根目录下的node_modules目录。
    它的生成方式与JavaScript的原型链或作用域链的查找方式十分类似。在加载的过程中,Node会逐个尝试模块路径中的路径,直到找到目标文件位置。可以看出,当前文件的路径越深,模块查找耗时会越多,这是自定义模块的加载速度是最慢的原因。
    webpack可以配置模块的查找路径
resolve: {
    modules: [path.resolve(__dirname, 'node_modules’)]
}

文件定义
文件定位的过程中,有些细节需要注意,主要包括文件扩展名的分析、目录和包的处理。
文件扩展名分析
require()在分析标识符的过程中个,会出现标识符中不含文件扩展名的情况,这种情况下,Node会按.js、.json、.node的次序依次补充扩展名,依次尝试。在尝试过程中,需要调用fs模块同步阻塞式判断文件是否存在。因为Node是单线程的,所以这里是一个会引起性能问题的地方。小诀窍是:如果是.node和.json文件,在传递给require()的标识符中带上扩展名,会加快一点速度。另一个诀窍是:同步配合缓存,大幅度缓解Node单线程中阻塞式调用的缺陷。
目录和包
在分析过程中,require()通过分析文件扩展名之后,可能没有查找到对应文件,却得到一个目录,这在引入自定义模块和逐个模块路径进行查找时经常会出现,此时Node会将目录当做一个包来处理。
在这个过程中,Node对CommonJS包规范进行了一定程度的支持。首先,Node在当前目录下查找package.json目录,通过JSON.parse()解析除包描述对象,从中取出main属性指定的文件名进行定位,如果文件名缺少扩展名,将会进入扩展名分析的步骤。 如果main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当做默认文件名,然后依次查找index.js,index.json, index.node。如果都没有定位成功,则进入下一个模块路径进行查找,如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。

模块编译
module对象
Node内部提供了一个Module构造函数。所有的模块都是Module的实例。

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

每个模块内部,都有一个module对象,代表当前模块。它有以下属性

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

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

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

  • module.parent 返回一个对象,表示调用该模块的模块。

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

  • module.exports 表示模块对外输出的值。

module.exports属性
module.exports属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取module.exports变量。

exports变量
为了方便,Node为每个模块提供了一个exports变量,指向module.exports。这等同于在每个模块头部,有一行这样的命令。

var exports = module.exports;

造成的结果是,在对外输出模块接口时,可以向exports对象添加方法。

exports.area = function (r) {
  return Math.PI * r * r;
};
exports.circumference = function (r) {
  return 2 * Math.PI * r;
};

_注意,不能直接将exports变量指向一个值,因为这样等于切断了exports与module.exports的联系。

exports = function(x) {console.log(x)};

上面这样的写法是无效的,因为exports不再指向module.exports了。

exports.hello = function() {
  return 'hello';
};

module.exports = 'Hello world';

上面代码中,hello函数是无法对外输出的,因为module.exports被重新赋值了。这意味着,如果一个模块的对外接口,就是一个单一的值,不能使用exports输出,只能使用module.exports输出。
如果你觉得,exports与module.exports之间的区别很难分清,一个简单的处理方法,就是放弃使用exports,只使用module.exports。

编译和执行过程细节
编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,Node会新建一个模块对象,然后根据路径载入并编译。对于不同的文件扩展名,其载入方法也有所不同,具体如下所示。

  • .js文件。通过fs模块同步读取文件后编译执行。
  • .node文件。这是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件。
  • .json文件。通过fs模块同步读取文件后,用JSON.parse()解析返回结果。
  • 其余扩展名文件。它们都被当做.js文件载入。
    每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上,以提高二 次引入的性能。
    根据不同的文件扩展名,Node会调用不同的读取方式,如.json文件的调用如下:
// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
    var content = NativeModule.require('fs').readFileSync(filename, 'utf8'); 
    try {
        module.exports = JSON.parse(stripBOM(content));
    } catch (err) {
        err.message = filename + ': ' + err.message; throw err;
    } 
};

其中,Module._extensions会被赋值给require()的extensions属性,所以通过在代码中访问 require.extensions可以知道系统中已有的扩展加载方式。编写如下代码测试一下:

console.log(require.extensions);

得到的执行结果如下:

{ '.js': [Function], '.json': [Function], '.node': [Function] }

在确定文件的扩展名之后,Node将调用具体的编译方式来将文件执行后返回给调用者。

JavaScript模块的编译
回到CommonJS模块规范,我们知道每个模块文件中存在着require、exports、module这3个变量,但是它们在模块文件中并没有定义,那么从何而来呢?甚至在Node的API文档中,我们知道每个模块中还有__filename、__dirname这两个变量的存在,它们又是从何而来的呢?如果我们把直接定义模块的过程放诸在浏览器端,会存在污染全局变量的情况。
事实上,在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加 了(function (exports, require, module, __filename, __dirname) {\n,在尾部添加了\n});。 一个正常的JavaScript文件会被包装成如下的样子:

(function (exports, require, module, __filename, __dirname) { 
    var math = require('math');
    exports.area = function (radius) {
        return Math.PI * radius * radius; 
    };
});

这样每个模块文件之间都进行了作用域隔离。包装之后的代码会通过vm原生模块的runInThisContext()方法执行(类似eval,只是具有明确上下文,不污染全局),返回一个具体的 function对象。

最后,将当前模块对象的exports属性、require()方法、module(模块对象自身),以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个function()执行。这就是这些变量并没有定义在每个模块文件中却存在的原因。在执行之后,模块的exports属性被返回给了调用方。exports属性上的任何方法和属性都可以被外部调用到,但是模块中的其余变量或属性则不可直接被调用。

至此,require、exports、module的流程已经完整,这就是Node对CommonJS模块规范的实现。

简单模块加载器的实现

//node原生的模块,用来读写文件(fileSystem)
let fs = require('fs')
//node原生的模块,用来解析文件路径
let path = require('path')
//提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码。
let vm = require('vm')
//Module类,就相当于我们的模块(因为node环境不支持es6的class,这里用function)
function Module(p){
  //当前模块的标识
  this.id = p
  //每个模块都有一个exports属性
  this.exports = {}
  //这个模块默认没有加载完
  this.loaded = false
  //模块加载方法
  this.load = function(filepath){
    //判断文件是json还是 node还是js
    let ext = path.extname(filepath)
    return Module._extensions[ext](this)
  }
}
//js文件加载的包装类
Module._wrapper = ['(function(exports,require,module,__dirname,__filename){','\n})']
//所有的加载策略
Module._extensions = {
  '.js': function(module){
    let fn = Module._wrapper[0] + fs.readFileSync(module.id,'utf8') + Module._wrapper[1]
    //执行包装后的方法 把js文件中的导出引入module的exports中
    //模块中的this === module.exports === {}  exports也只是module.exports的别名
    vm.runInThisContext(fn).call(module.exports,module.exports,req,module)
    return module.exports
  },
  '.json': function(module){
    return JSON.parse(fs.readFileSync(module.id,'utf8'))
  },
  '.node': 'xxx',
}
//以绝对路径为key存储一个module
Module._catcheModule = {}
// 解析绝对路径的方法,返回一个绝对路径
Module._resolveFileName = function(moduleId){
  let p = path.resolve(moduleId)
  try{
    fs.accessSync(p)      
    return p
  }catch(e){
    console.log(e)
  }
  //对象中所有的key做成一个数组[]
  let arr = Object.keys(Module._extensions)
  for(let i=0;i<arr.length;i++){
    let file = p+arr[i]
    //因为整个模块读取是个同步过程,所以得用sync,这里判断有没有这个文件存在
    try{
      fs.accessSync(file)      
      return file
    }catch(e){
      console.log(e)
    }
  }
}
//require方法
function req(moduleId){
  let p = Module._resolveFileName(moduleId)
  if(Module._catcheModule[p]){
    //模块已存在
    return Module._catcheModule[p].exports
  }
  //没有缓存就生成一个
  let module = new Module(p)
  Module._catcheModule[p] = module
  //加载模块
  module.exports = module.load(p)
  return module.exports
}

流程图:
image

扩展

浏览器不兼容CommonJS的根本原因,在于缺少四个Node.js环境的变量

  • module
  • exports
  • require
  • global

只要能够提供这四个变量,浏览器就能加载CommonJS模块
Browserify的实现
知道了原理,就能做出工具了。Browserify是目前最常用的CommonJS格式转换工具。
请看一个例子,main.js模块加载foo.js模块。

// foo.js
module.exports = function(x) {
  console.log(x);
};

// main.js
var foo = require("./foo");
foo("Hi");

使用下面的命令,就能将main.js转为浏览器可用的格式。

browserify main.js > compiled.js

Browserify到底做了什么?安装一下browser-unpack,就能看清楚了。

$ npm install browser-unpack -g

然后,将前面生成的compile.js解包。

$ browser-unpack < compiled.js

[
  {
    "id":1,
    "source":"module.exports = function(x) {\n  console.log(x);\n};",
    "deps":{}
  },
  {
    "id":2,
    "source":"var foo = require(\"./foo\");\nfoo(\"Hi\");",
    "deps":{"./foo":1},
    "entry":true
  }
]

可以看到,browerify将所有模块放到一个数组,id属性是模块的编号,source属性是模块的源码,deps属性是模块的依赖。
因为main.js里面加载了foo.js,所以deps属性就指定./foo对应1号模块。执行的时候,浏览器遇到require(‘./foo’)语句,就自动执行1号模块的source属性,并将执行后的module.exports属性值输出。

参考

1、你真的懂模块化吗?教你CommonJS实现:https://juejin.im/post/5b67c342e51d45172832123d
2、深入浅出Nodejs
3、浏览器加载CommonJS模块的原理与实现(阮一峰):http://www.ruanyifeng.com/blog/2015/05/commonjs-in-browser.html

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant