ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
在Node中,每个文件模块都是一个对象,它的定义如下: ~~~ function Module(id, parent){ this.id = id; this.exports = {}; this.parent = parent; if(parent && parent.children){ parent.children.push(this); } this.filename = null; this.loaded = false; this.children = []; } ~~~ 编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,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] } ~~~ 如果想对自定义的扩展名进行特殊的加载,可以通过类似require.extensions['.ext']的方式实现。早起的CoffeeScript文件就是通过添加require.extensions['.coffee']扩展的方式来实现加载的。但是从v0.10.6版本开始,官方不鼓励通过这种方式来进行自定义扩展名的加载,而是期望先将其它语言或文件编译成JavaScript文件后再加载,这样做的好处在于不将繁琐的编译加载等过程引入Node的执行过程中。 在确定文件的扩展名之后,Node将调用具体的编译方式来将文件执行后返回给调用者。 ## 1.JavaScript模块的编译 回到CommonJS模块规范,我们知道每个模块文件中存在着require、exports、module这三个变量,但是它们在模块文件中并没有定义,那么从何而来呢?甚至在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原生模块的runInThisContex() 方法执行(类似eval,只是具有明确上下文,不污染全局),返回一个具体的function对象。最后,将当前模块对象的exports属性、require()方法、module(模块对象自身),以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个function()执行。 这就是这些变量并没有定义在每个模块文件中却存在的原因。在执行之后,模块的exports属性被返回给了调用方。exports属性上的任何方法和属性都可以被外部调用到,但是模块中的其余变量或属性则不可直接被调用。 至此,require、exports、module的流程已经完整,这就是Node对CommonJS规范的实现。 此外,许多初学者都曾经纠结过为何存在exports的情况下,还有module.exports。理想情况下,只要赋值给exports即可: ~~~ exports = function(){ //my class }; ~~~ 但是通常都会得到一个失败的结果。其原因在于,exports对象是通过形参的方式传入的,直接赋值形参会改变形参的引用,但并不能改变作用域外的值。测试代码如下: ~~~ var chage = (a)=>{ a=100; console.log(a); // => 100 }; var a = 10; chage(a); console.log(a); // => 10 ~~~ 如果要达到require引入一个类的效果,请赋值给module.exports对象。这个迂回的方案不改变形参的引用。 ## 2.C/C++模块的编译 Node调用process.dlopen()方法进行加载和执行。在Node的架构下,dlopen()方法在Windows和 *nix 平台下分别有不同的实现,通过 libuv兼容层进行了封装。 实际上,.node的模块文件并不需要编译,因为它是编写C/C++模块之后编译生成的,所以这里只有加载和执行的过程。在执行的过程中,模块的exports对象与.node模块产生联系,然后返回给调用者。 C/C++模块给Node使用者带来的优势主要是执行效率方面的,劣势则是C/C++模块的编写门槛比JavaScript高。 ## 3.JSON文件的编译 .json文件的编译是3种编译方式中最简单的。Node利用fs模块同步读取JSON文件的内容之后,调用JSON.parse()方法得到对象,然后将它赋给模块对象的exports,以供外部调用。 JSON文件在用作项目的配置文件时比较有用。如果你定义了一个JSON文件作为配置,那就不必调用fs模块去异步读取和解析,直接调用require()引入即可。此外,你还可以享受到模块缓存的便利,并且二次引入时也没有性能影响。 这里我们提到的模块编译都是指文件模块,即用户自己编写的模块。在下一节中,我们将展开介绍核心模块中的JavaScript模块和C/C++模块。