node-interview [II]:模块

require究竟做了什么

require的源代码位于lib/module.js下。关于require的分析,推荐阅读require()源码解读和CommonJS规范。
require的基本作用就是读取并执行一个javascript文件,并返回其module.exports对象。在Module.prototype.require函数中,会调用Module._load函数,Module._load完成了整个require的过程。整个require的过程在阮老师的博客里已经写的比较清晰了,我在其基础上补充几点(node源码为v9.3.0)。

_load函数的三个参数

request传入目标模块的路径。parent传入目标模块的父模块,即require代码出现的模块;对于main来说,不存在父模块,因此在Module.runMain函数里有这样一句话,针对main的特殊处理:

Module._load(process.argv[1], null, true);

可以看到,parent被置成null。第三个参数是isMain,用来区别是否是主模块。

ECMAScript Modules

if (isMain && experimentalModules): 这是node在8.5引入的一个新的对ECMAScript Modules的支持,通过设置--experimental-modules参数来启动。

路径解析

调用_resolveFilename函数来尝试解析目标模块的路径,尝试规则和优先级就不再重复了。解析成功的话会返回该模块的绝对路径。如果穷尽所有规则都无法匹配,会产生异常:

var err = new Error(`Cannot find module '${request}'`);
err.code = 'MODULE_NOT_FOUND';
throw err;

模块缓存

模块的缓存都是挂在Module._cache对象上的。node会首先尝试从缓存里去搜索是否有已存在的模块,如果有的话,除了直接将该缓存的exports属性返回之外,还做了一件事:updateChildren(parent, cachedModule, true);。这个函数将当前待载入的模块写入父模块的children属性里。也就是说,除了Module._cache对象持有模块的引用外,父模块的children属性里也将持有该模块的引用。如果尝试用删除缓存来进行热更新的话,那么这个东西可能会导致内存泄露。在写热更新的时候再详细说这个。

检查是否是原生模块

如果是原生模块的话就直接返回。

新建一个Module对象

调用Module函数来构造一个新的模块。构造函数里主要进行了一些模块属性的初始化,如id exports parent等。构造函数里同样调用了updateChildren函数来将新模块添加到父模块的children属性中。

针对主模块的处理

process.mainModule指向当前对象;将module.id置为.

添加该模块的缓存

载入模块

载入模块调用的是Module.prototype.load函数。代码里有这么一段:

if (threw) {
  delete Module._cache[filename];
}

如果载入失败,之前添加的缓存会被清除掉。
至此,一个模块的require工作就完成了。在require的最后一步载入模块时,还有很多的细节,放在下一节vm里写吧。

VM

vm是node的核心组件。引用一段node官方文档的话:
The vm module provides APIs for compiling and running code within V8 Virtual Machine contexts. JavaScript code can be compiled and run immediately or compiled, saved, and run later. A common use case is to run the code in a sandboxed environment. The sandboxed code uses a different V8 Context, meaning that it has a different global object than the rest of the code.
还是回到require的过程中看一下vm具体在require中做了什么。require的最后一步调用了Module.load这个函数。函数的主要操作如下:

  var extension = path.extname(filename) || '.js';
  if (!Module._extensions[extension]) extension = '.js';
  Module._extensions[extension](this, filename);
  this.loaded = true;

可以看到,如果原模块引用的时候没有填写扩展名的话,就默认补上.js,这也就是require的时候.js可以省略的原因。然后调用了挂在Module._extensions上对应的函数来载入模块。载入完成后,将模块的loaded属性置为true,整个过程结束。再调到Module._extensions['.js']看看js文件是怎么处理的吧:

var content = fs.readFileSync(filename, 'utf8');
module._compile(internalModule.stripBOM(content), filename);

再看Module.prototype._compile:

Module.wrap

在原始代码外面包上一层外壳:

(function (exports, require, module, __filename, __dirname) { 
})

vm.runInThisContext

终于到了调用vm的时候了。先上代码:

vm.runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true
  });

wrapper就是上一步加壳后的代码。第二个参数是选项,具体作用可以参考node官方文档。
runInThisContext这个函数确保了两件事情:

  • 定义在模块内的变量不会污染到全局作用域
  • 绑定当前的global对象到这个模块的上下文里。也就是说,global对象是共享的。因此,在模块里无意中定义的全局变量,将会污染到整个global对象上。

执行代码

compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);

compiledWrapper就是编译好的模块+壳,这里的几个参数对应Module.wrap步骤的那一层外壳函数的参数。值得一提的是,在这里可以发现exports实际上是module.exports的一份引用复制。所以:

  • 直接修改module.exports并不会对exports对象造成影响,反之亦然。
  • 可以通过exports.xxx =来间接修改module.exports
  • 最终导出的东西只看module.exports,跟exports没关系。

至此,require的核心步骤就描述完了。
顺带说一下vm的其他两个api吧。前面提到了,runInThisContext会共享global。另外两个api runInContextrunInNewContext则不会共享global对象,但是可以提供一个沙箱环境,使得代码可以以全局变量的形式访问到指定的变量。如下:

let vm = require("vm");
let sbox = {
    temp: 1,
    console: console
};
let temp = 5;
temp = 6;
vm.runInNewContext('temp = 10;console.log(typeof global);', sbox); // undefined
console.log(sbox.temp); //  changed to 10

a.js 和 b.js 两个文件互相 require 是否会死循环?

答案是不会。由于在实际执行代码前,Module._cache中已经建立了该模块的一个对象并且其exports属性是空的,因此如果去require一个尚未执行完毕的模块,那么会拿到一个空对象(module构造函数里赋予的初值)。
循环require应该是设计中需要尽量避免的行为。

如何在不重启 node 进程的情况下热更新一个 js/json 文件? 这个问题本身是否有问题?

热更新的话,清空掉require.cache里该模块路径的缓存即可。但如前文所说,除了Module._cache持有该模块的引用以外,父模块的children属性里也留存有该模块的引用,不释放会有内存泄露的风险。看如下代码:

//  b.js
var array = [];

for (var i = 0; i < 10000; i++) { 
                                                         array.push('memory_leak_test_' + i);
}
module.exports = array;
//  a.js
function cleanCache (module) {
    var path = require.resolve(module);
    require.cache[path] = null;
}

setInterval(() => {
    var code = require('./b.js');
    cleanCache('./b.js');
}, 10);

setInterval(() => {
    console.log(module.children.length);
}, 1000);

执行node a.js后,可以看到打印出来的数字越來越大,说明children里的对象都没有释放,大约几十秒后,内存耗尽。解决的办法是,删除缓存的同时,把父模块里children的引用也一并干掉。
不过话说回来,这只是一个最简单的情形,实际环境要比这个复杂的多。有热更新需求的地方,最好还是选择别的方案。

为什么 Node.js 不给每一个 .js 文件以独立的上下文来避免作用域被污染?

本身每个.js文件的作用域是独立的,共享的只有global对象。

node-reload源代码简析

npm & yarn

锁版本

你可能感兴趣的:(node-interview [II]:模块)