TA的每日心情 | 开心 2016-10-18 06:23 |
---|
签到天数: 72 天 连续签到: 1 天 [LV.6]常住居民II 扫一扫,手机访问本帖
|
原题
题目是这样的。 - <font color="#000000"><font color="#000000">var a = 2;function foo(){console.log(this.a);}foo();</font></font>
复制代码
上题由我们亲爱的小龙童鞋发现并在我们的 901 群里提问的。
经过
然后有下面的小对话。
小龙:你们猜这个输出什么?
弍纾:2
力叔:2 啊
死月·絲卡蕾特:2
力叔:有什么问题么?
小龙:输出 undefind。
死月·絲卡蕾特:你确定?
小龙:是不是我电脑坏了
力叔:你确定?
弍纾:你确定?
小龙:为什么我 node 文件名跑出来的是 undefined?
郑昱:-.- 一样阿。undefined
以上就是刚见到这个题目的时候群里的一个小讨论。
分析
后来我就觉得奇怪,既然小龙验证过了,说明他也不是随地大小便,无的放矢什么的。
于是我也验证了一下,不过由于偷懒,没有跟他们一样写在文件里面,而是直接 node 开了个 REPL 来输入上述代码。
结果是 2!
结果是 2!
结果是 2!
于是这就出现了一个很奇怪的问题。
尼玛为毛我是 2 他们俩是 undefined 啊!
不过马上我就反应过来了——我们几个的环境不同,他们是 $ node foo.js 而我是直接 node 开了个 REPL,所以有一定的区别。
而力叔本身就是前端大神,我估计是以 Chrome 的调试工具下为基础出的答案。
REPL vs 文件执行
其实上述的问题,需要解释的问题大概就是 a 到底挂在哪了。
因为细细一想,在 function 当中,this 指向的目标是 global 或者 window。
还无法理解上面这句话的童鞋需要先补一下基础。
那么最终需要解释的就是 a 到底有没有挂在全局变量上面。
这么一想就有点细思恐极的味道了——如果在 node 线上运行环境里面的源代码文件里面随便 var 一个变量就挂到了全局变量里面那是有多恐怖!
于是就有些释然了。
但究竟是上面原因导致 REPL 和文件执行方式不一样的呢?
全局对象的属性
首先是弍纾找出了阮老师 ES6 系列文章中的全局对象属性一节。
全局对象是最顶层的对象,在浏览器环境指的是 window 象,在 Node.js 指的是 global 对象。ES5 之中,全局对象的属性与全局变量是等价的。
- <font color="#000000"><font color="#000000">window.a = 1;a // 1a = 2;window.a // 2</font></font>
复制代码
上面代码中,全局对象的属性赋值与全局变量的赋值,是同一件事。(对于Node来说,这一条只对REPL环境适用,模块环境之中,全局变量必须显式声明成global对象的属性。)
有了阮老师的文章验证了这个猜想,我可以放心大胆继续看下去了。
repl.js
知道了上文的内容之后,感觉首要查看的就是 Node.js 源码中的 repl.js 了。
先是结合了一下自己以前用自定义 REPL 的情况,一般的步骤先是获取 REPL 的上下文,然后在上下文里面贴上各种自己需要的东西。
- <font color="#000000"><font color="#000000">var r = relp.start(" ➜ ");var c = r.context;// 在 c 里面贴上各种上下文c.foo = bar;// ...</font></font>
复制代码
关于自定义 REPL 的一些使用方式可以参考下老雷写的《Node.js 定制 REPL 的妙用》。
有了之前写 REPL 的经验,大致明白了 REPL 里面有个上下文的东西,那么在 repl.js 里面我们也找到了类似的代码。
- <font color="#000000"><font color="#000000">REPLServer.prototype.createContext = function() {var context;if (this.useGlobal) {context = global;} else {context = vm.createContext();for (var i in global) context[i] = global[i];context.console = new Console(this.outputStream);context.global = context;context.global.global = context;}context.module = module;
- context.require = require;this.lines = [];this.lines.level = [];// make built-in modules available directly// (loaded lazily)
- exports._builtinLibs.forEach(function(name) {Object.defineProperty(context, name, {get: function() {var lib = require(name);context._ = context[name] = lib;
- return lib;},// allow the creation of other globals with this nameset: function(val) {delete context[name];context[name] = val;},configurable: true});});
- return context;};</font></font>
复制代码
看到了关键字 vm。我们暂时先不管 vm,光从上面的代码可以看出,context 要么等于 global,要么就是把 global 上面的所有东西都粘过来。
然后顺带着把必须的两个不在 global 里的两个东西 require 和 module 给弄过来。
下面的东西就不需要那么关心了。
- <font color="#000000"><font color="#000000">VM</font></font>
复制代码
接下去我们来讲讲 vm。
VM 是 node 中的一个内置模块,可以在文档中看到说明和使用方法。
大致就是将代码运行在一个沙箱之内,并且事先赋予其一些 global 变量。
而真正起到上述 var 和 global 区别的就是这个 vm 了。
vm 之中在根作用域(也就是最外层作用域)中使用 var 应该是跟在浏览器中一样,会把变量粘到 global(浏览器中是 window)中去。
我们可以试试这样的代码:
- <font color="#000000"><font color="#000000">var vm = require('vm');
- var localVar = 'initial value';
- vm.runInThisContext('var localVar = "vm";');
- console.log('localVar: ', localVar);
- console.log('global.localVar: ', global.localVar);</font></font>
复制代码
其输出结果是:
- <font color="#000000"><font color="#000000">localVar: initial value
- global.localVar: vm</font></font>
复制代码
如文档中所说,vm 的一系列函数中跑脚本都无法对当前的局部变量进行访问。各函数能访问自己的 global,而 runInThisContext 的 global 与当前上下文的 global 是一样的,所以能访问当前的全局变量。
所以出现上述结果也是理所当然的了。
所以在 vm 中跑我们一开始抛出的问题,答案自然就是 2 了。
- <font color="#000000"><font color="#000000">var vm = require("vm");
- var sandbox = {console: console};
- vm.createContext(sandbox);
- vm.runInContext("var a = 2;function foo(){console.log(this.a);}foo();", sandbox);</font></font>
复制代码
Node REPL 启动的沙箱
最后我们再只需要验证一件事就能真相大白了。
平时我们自定义一个 repl.js 然后执行 $ node repl.js 的话是会启动一个 REPL,而这个 REPL 会去调 vm,所以会出现 2 的答案;或者我们自己在代码里面写一个 vm 然后跑之前的代码,也是理所当然出现 2。
那么我们就输入 $ node 来进入的 REPL 跟我们之前讲的 REPL 是不是同一个东西呢?
如果是的话,一切就释然了。
首先我们进入到 Node 的入口文件——C++ 的 int main()。
它在 Node.js 源码 src/node_main.cc 之中。
- <font color="#000000"><font color="#000000">int main(int argc, char *argv[]) {
- setvbuf(stderr, NULL, _IOLBF, 1024);
- return node::Start(argc, argv);}</font></font>
复制代码
就在主函数中执行了 node::Start。而这个 node::Start 又存在 src/node.cc 里面。
然后在 node::Start 里面又调用 StartNodeInstance,在这里面是 LoadEnvironment 函数。
最后在 LoadEnvironment 中看到了几句关键的语句:
- <font color="#000000"><font color="#000000">Local script_name = FIXED_ONE_BYTE_STRING(env->isolate(), "node.js");
- Local f_value = ExecuteString(env, MainSource(env), script_name);//...Local f = Local::Cast(f_value);//...Local</font></font>
复制代码
我们讲的是在 REPL 和 vm 中有什么事情,但是并没有解释为什么在文件模块的载入形式下,var 并不会挂载到全局变量去。
其实原因很简单,大家应该也都明白,在 Node.js 中,每个文件相当于是一个闭包,在 require 的时候被编译包了起来。
但是具体是怎么样的呢?虽然网上也有很多答案,我还是决定在这里按上一篇文章的尿性稍微解释一下。
分析
首先我们还是回到《Node REPL 启动的沙箱》一节,里面说了当启动 Node.js 的时候是以 src/node.js 为入口的。
如果以 REPL 为途径启动的话是直接启动一个 vm,而此时的所有根级变量都在最顶级的作用域下,所以一个 var 自然会绑定到 global 下面了。
而如果是以文件,即 $ node foo.js 形式启动的话,它就会执行 src/node.js 里面的另一坨条件分支了。- <font color="#000000"> // ...
- } else if (process.argv[1]) {
- // make process.argv[1] into a full path
- var path = NativeModule.require('path');
- process.argv[1] = path.resolve(process.argv[1]);
- var Module = NativeModule.require('module');
- // ...
- startup.preloadModules();
- if (global.v8debug &&
- process.execArgv.some(function(arg) {
- return arg.match(/^--debug-brk(=[0-9]*)?$/);
- })) {
- var debugTimeout = +process.env.NODE_DEBUG_TIMEOUT || 50;
- setTimeout(Module.runMain, debugTimeout);
- } else {
- // Main entry point into most programs:
- Module.runMain();
- }
- } else {
- // ...</font>
复制代码
从上面的代码看出,只要是以 $ node foo.js 形式启动的,都会经历 startup.preloadModules() 和 Module.runMain() 两个函数。
startup.preloadModules()
我们来看看这个函数。
- <font color="#000000">startup.preloadModules = function() {
- if (process._preload_modules) {
- NativeModule.require('module')._preloadModules(process._preload_modules);
- }
- };</font>
复制代码
实际上就是执行的 lib/module.js 里面的 _preloadModules 函数,并且把这个 process._preload_modules 给传进去。当然,前提是有这个 process._preload_modules。
process._preload_modules
这个 process._preload_modules 指的就是当你在使用 Node.js 的时候,命令行里面的 --require 参数。
- <font color="#000000">-r, --require module to preload (option can be repeated)</font>
复制代码
代码在src/node.cc 里面可考。
- <font color="#000000">// ...
- } else if (strcmp(arg, "--require") == 0 ||
- strcmp(arg, "-r") == 0) {
- const char* module = argv[index + 1];
- if (module == nullptr) {
- fprintf(stderr, "%s: %s requires an argument\n", argv[0], arg);
- exit(9);
- }
- args_consumed += 1;
- local_preload_modules[preload_module_count++] = module;
- } else if
- // ...</font>
复制代码
如果遇到了 --require 这个参数,则对静态变量 local_preload_modules 和 preload_module_count 做处理,把这个预加载模块路径加进去。
待到要生成 process 这个变量的时候,再把预加载模块的信息放到 process._preload_modules 里面去。
- void SetupProcessObject(Environment* env,
- int argc,
- const char* const* argv,
- int exec_argc,
- const char* const* exec_argv) {
- // ...
- if (preload_module_count) {
- CHECK(preload_modules);
- Local<Array> array = Array::New(env->isolate());
- for (unsigned int i = 0; i < preload_module_count; ++i) {
- Local<String> module = String::NewFromUtf8(env->isolate(),
- preload_modules[i]);
- array->Set(i, module);
- }
- READONLY_PROPERTY(process,
- "_preload_modules",
- array);
- delete[] preload_modules;
- preload_modules = nullptr;
- preload_module_count = 0;
- }
- // ...
- }
复制代码
最重要的就是这句
- READONLY_PROPERTY(process,
- "_preload_modules",
- array);
复制代码
require(‘module’)._preloadModules
上面我们讲了这个 process._preload_modules,然后现在我们说说是如何把 $ node --require bar.js foo.js 给预加载进去的。
接下去我们就要移步到 lib/module.js 文件里面去了。
在第 496 行左右的地方有这个函数。
- Module._preloadModules = function(requests) {
- if (!Array.isArray(requests))
- return;
- // Preloaded modules have a dummy parent module which is deemed to exist
- // in the current working directory. This seeds the search path for
- // preloaded modules.
- var parent = new Module('internal/preload', null);
- try {
- parent.paths = Module._nodeModulePaths(process.cwd());
- }
- catch (e) {
- if (e.code !== 'ENOENT') {
- throw e;
- }
- }
- requests.forEach(function(request) {
- parent.require(request);
- });
- };
复制代码
大概我们能看到,就是以 internal/preload 为 ID 的 Module 对象来载入这些预加载模块。
- var parent = new Module('internal/preload', null);
- requests.forEach(function(request) {
- parent.require(request);
- });
复制代码
根据这个函数的注释说明,这个 Module 对象是一个虚拟的 Module 对象,主要是跟非预加载的那些模块给隔离或者区别开来,并且提供一个模块搜索路径。
Module.runMain()
看完上面的说明,我们接下去看看 Module.runMain() 函数。
这个函数还是位于 lib/module.js 文件里面。
- Module.runMain = function() {
- // Load the main module--the command line argument.
- Module._load(process.argv[1], null, true);
- // Handle any nextTicks added in the first tick of the program
- process._tickCallback();
- };
复制代码
我们看到了就是在这句话中,Module 载入了 process.argv[1] 也就是文件名,自此一发不可收拾。
Module._load
这个函数相信很多人都知道它的用处了,无非就是载入文件,并加载到一个闭包里面。
这样一来在文件里面 var 出来的变量就不在根作用域下面了,所以不会粘到 global 里面去。它的 this 就是包起来的这个闭包了。
- Module._load = function(request, parent, isMain) {
- // ...
- var filename = Module._resolveFilename(request, parent);
- // ...
- var cachedModule = Module._cache[filename];
- if (cachedModule) {
- return cachedModule.exports;
- }
- if (NativeModule.nonInternalExists(filename)) {
- debug('load native module %s', request);
- return NativeModule.require(filename);
- }
- var module = new Module(filename, parent);
- if (isMain) {
- process.mainModule = module;
- module.id = '.';
- }
- Module._cache[filename] = module;
- module.load(filename);
- return module.exports;
- }
复制代码
上面的代码首先是根据传入的文件名找到真的文件地址,就是所谓的搜索路径了。比如 require("foo") 就会分别从 node_modules 路径等依次查找下来。
我经常 Hack 这个 _resolveFilename 函数来简化 require 函数,比如我希望我用 require("controller/foo") 就能直接拿到 ./src/controller/foo.js 文件。有兴趣讨论一下这个用法的童鞋可以转到我的 Gist 上查看 Hack 的一个 Demo。
第二步就是我们常说的缓存了。如果这个模块之前加载过,那么在 Module._cache 下面会有个缓存,直接去取就是了。
第三步就是看看是不是 NativeModule。
- if (NativeModule.nonInternalExists(filename)) {
- debug('load native module %s', request);
- return NativeModule.require(filename);
- }
复制代码
NativeModule
之前的代码里面其实也没少出现这个 NativeModule。那这个 NativeModule 到底是个 shenmegui 呢?
其实它还是在 Node.js 的入口 src/node.js 里面。
它主要用来加载 Node.js 的一些原生模块,比如说 NativeModule.require("child_process")等,也用于一些 internal 模块的载入,比如 NativeModule.require("internal/repl")。
之前代码的这个判断就是说如果判断要载入的文件是一个原生模块,那么就使用 NativeModule.require 来载入。
NativeModule.require
- NativeModule.require = function(id) {
- if (id == 'native_module') {
- return NativeModule;
- }
- var cached = NativeModule.getCached(id);
- if (cached) {
- return cached.exports;
- }
- if (!NativeModule.exists(id)) {
- throw new Error('No such native module ' + id);
- }
- process.moduleLoadList.push('NativeModule ' + id);
- var nativeModule = new NativeModule(id);
- nativeModule.cache();
- nativeModule.compile();
- return nativeModule.exports;
- };
复制代码
先看看是否是本身,再看看是否被缓存,然后看看是否合法。接下去就是填充 process.moduleLoadList,最后载入这个原生模块、缓存、编译并返回。
这个 compile 很重要。
NativeModule.prototype.compile
在 NativeModule 编译的过程中,大概的步骤是获取代码、包裹(Wrap)代码,把包裹的代码 runInContext 一遍得到包裹好的函数,然后执行一遍就算载入好了。
- NativeModule.prototype.compile = function() {
- var source = NativeModule.getSource(this.id);
- source = NativeModule.wrap(source);
- var fn = runInThisContext(source, { filename: this.filename });
- fn(this.exports, NativeModule.require, this, this.filename);
- this.loaded = true;
- };
复制代码
我们往这个 src/node.js 文件这个函数的上面几行看一下,就知道包裹代码是怎么回事了。
- NativeModule.wrap = function(script) {
- return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
- };
- NativeModule.wrapper = [
- '(function (exports, require, module, __filename, __dirname) {\n',
- '\n});'
- ];
复制代码
根据上面的代码,我们能知道的就是比如我们一个内置模块的代码是:
- var foo = require("foo");
- module.exports = 1;
复制代码
那么包裹好的代码将会是这样子的:
- (function (exports, require, module, __filename, __dirname) {
- var foo = require("foo");
- module.exports = 1;
- });
复制代码
这样一看就明白了这些 require、module、exports、__filename 和 __dirname 是怎么来了吧。
当我们通过 var fn = runInThisContext(source, { filename: this.filename }); 得到了这个包裹好的函数之后,我们就把相应的参数传进这个闭包函数去执行。
- fn(this.exports, NativeModule.require, this, this.filename);
复制代码
这个 this 就是对应的这个 module,自然这个 module 里面就有它的 exports;require 函数就是 NativeModule.require。
所以我们看到的在 lib/*.js 文件里面的那些 require 函数,实际上就是包裹好之后的代码的 NativeModule.require 了。
所以说实际上这些内置模块内部的根作用域下的 var 再怎么样高级也都是在包裹好的闭包里面 var,怎么的也跟 global 搭不着边。
内部原生模块
通过上面的追溯我们知道了,如果我们在代码里面使用 require 的话,会先看看这个模块是不是原生模块。
不过回过头看一下它的这个判断条件:
- if (NativeModule.nonInternalExists(filename)) {
- // ...
- }
复制代码
如果是原生模块并且不是原生内部模块的话。
那是怎么区分原生模块和内部原生模块呢?
我们再来看看这个 NativeModule.nonInternalExists(filename) 函数。
- NativeModule.nonInternalExists = function(id) {
- return NativeModule.exists(id) && !NativeModule.isInternal(id);
- };
- NativeModule.isInternal = function(id) {
- return id.startsWith('internal/');
- };
复制代码
上面的代码是去除各种杂七杂八的条件之后的一种情况,别的情况还请各位童鞋自行看 Node.js 源码。
也就是说我们在我们自己的代码里面是请求不到 Node.js 源码里面 lib/internal/*.js 这些文件的——因为它们被上面的这个条件分支给过滤了。(比如 require("internal/module")在自己的代码里面是无法运行的)
注意: 不过有一个例外,那就是 require("internal/repl")。详情可以参考这个 Issue 和这段代码。
Module.prototype.load
解释完了上面的 NativeModule 之后,我们要就上面 Module._load 里面的下一步 module.load 也就是 Module.prototype.load 做解析了。
- NativeModule.nonInternalExists = function(id) {
- return NativeModule.exists(id) && !NativeModule.isInternal(id);
- };
- NativeModule.isInternal = function(id) {
- return id.startsWith('internal/');
- };
复制代码
做了一系列操作之后得到了真·文件名,然后判断一下后缀。如果是 ".js" 的话执行 Module._extensions[".js"] 这个函数去编译代码,如果是 ".json" 则是 Module._extensions[".json"]。
这里我们略过 JSON 和 C++ Addon,直奔 Module._extensions[".js"]。
- Module._extensions['.js'] = function(module, filename) {
- var content = fs.readFileSync(filename, 'utf8');
- module._compile(internalModule.stripBOM(content), filename);
- }
复制代码
它也很简单,就是奔着 _compile 去的。
Module.prototype._compile
先上代码。
- Module.prototype._compile = function(content, filename) {
- var self = this;
- // remove shebang
- content = content.replace(shebangRe, '');
- function require(path) {
- return self.require(path);
- }
- require.resolve = function(request) {
- return Module._resolveFilename(request, self);
- };
- require.main = process.mainModule;
- // Enable support to add extra extension types
- require.extensions = Module._extensions;
- require.cache = Module._cache;
- var dirname = path.dirname(filename);
- // create wrapper function
- var wrapper = Module.wrap(content);
- var compiledWrapper = runInThisContext(wrapper,
- { filename: filename, lineOffset: -1 });
- // ...
- var args = [self.exports, require, self, filename, dirname];
- return compiledWrapper.apply(self.exports, args);
- };
复制代码
感觉流程上跟 NativeModule 的编译相似,不过这里是事先准备好要在载入的文件里面用的require 函数,以及一些 require 的周边。
接下去就是用 Module.wrap 来包裹代码了,包裹完之后把得到的函数用参数 self.exports, require, self, filename, dirname 去执行一遍,就算是文件载入完毕了。
最后回到之前载入代码的那一刻,把载入完毕得到的 module.exports 再 return 出去就好了。
Module.wrap
这个就不用说了。
在 lib/module.js 的最顶端附近有这么几行代码。
- Module.wrapper = NativeModule.wrapper;
- Module.wrap = NativeModule.wrap;
- Module._debug = util.debuglog('module');
复制代码
一切豁然开朗了吧。
连 NativeModule 的代码都逃不开被之前说的闭包所包裹,那么你自己写的 JS 文件当然也会被 NativeModule.wrap 所包裹。
那么你在代码根作用域申明的函数实际上在运行时里面已经被一个闭包给包住了。
以前可能很多同学只知道是被闭包包住了,但是包的方法、流程今天算是解析了一遍了。
- (function (exports, require, module, __filename, __dirname) {
- var a = 2;
- function foo(){
- console.log(this.a);
- }
- foo();
- });
复制代码
这个 var a 怎么也不可能绑到 global 去啊。
Module.prototype.require
虽然我们上面讲得差不多了,可能很多童鞋也厌烦了。
不过该讲完的还是得讲完。
我们在我们自己文件中用的 require 在上一节里面有提到过,传到我们闭包里面的 require 实际上是长这样的:
- function require(path) {
- return self.require(path);
- }
复制代码
所以实际上就是个 Module.prototype.require。
我们再看看这个函数。
- Module.prototype.require = function(path) {
- assert(path, 'missing path');
- assert(typeof path === 'string', 'path must be a string');
- return Module._load(path, this);
- };
复制代码
一下子又绕回到了我们一开始的 Module._load。
所以基本上就差不多到这过了。
REPL vs 文件启动
最后我们再点一下,或者说回顾一下吧。
REPL 启动的时候 Node.js 是开了个 vm 直接让你跑,并没有把代码包在一个闭包里面,所以再根作用域下的变量会 Biu 一下贴到 global 中去。
而文件启动的时候,会做本文中说的一系列事情,然后就会把各文件都包到一个闭包去,所以变量就无法通过这种方式来贴到 global 去了。
不过这种二义性会在 "use strict"; 中戛然而止。
珍爱生命,use strict。
小结
本文可能很多童鞋看完后悔觉得很坑——JS 为什么有那么多二义性那么坑呢。
其实不然,主要是可能很多人对 Node.js 执行的机制不是很了解。
|
|