本文是上文《Node.js 啟動方式:一道關於全域性變數的題目引發的思考》的續章。
原題回顧
我們還是先回顧下原題吧。
1 2 3 4 5 6 |
var a = 2; function foo(){ console.log(this.a); } foo(); |
上題由我們親愛的小龍童鞋發現並在我們的 901 群裡提問的。
不過在上面一篇文章中,我們講的是在 REPL 和 vm
中有什麼事情,但是並沒有解釋為什麼在檔案模組的載入形式下,var
並不會掛載到全域性變數去。
其實原因很簡單,大家應該也都明白,在 Node.js 中,每個檔案相當於是一個閉包,在 require
的時候被編譯包了起來。
但是具體是怎麼樣的呢?雖然網上也有很多答案,我還是決定在這裡按上一篇文章的尿性稍微解釋一下。
分析
首先我們還是回到上一篇文章的《Node REPL 啟動的沙箱》一節,裡面說了當啟動 Node.js 的時候是以 src/node.js 為入口的。
如果以 REPL 為途徑啟動的話是直接啟動一個 vm
,而此時的所有根級變數都在最頂級的作用域下,所以一個 var
自然會繫結到 global
下面了。
而如果是以檔案,即 $ node foo.js
形式啟動的話,它就會執行 src/node.js 裡面的另一坨條件分支了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// ... } 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 { // ... |
從上面的程式碼看出,只要是以 $ node foo.js
形式啟動的,都會經歷 startup.preloadModules()
和 Module.runMain()
兩個函式。
startup.preloadModules()
我們來看看這個函式。
1 2 3 4 5 |
startup.preloadModules = function() { if (process._preload_modules) { NativeModule.require('module')._preloadModules(process._preload_modules); } }; |
實際上就是執行的 lib/module.js 裡面的 _preloadModules
函式,並且把這個 process._preload_modules
給傳進去。當然,前提是有這個 process._preload_modules
。
process._preload_modules
這個 process._preload_modules
指的就是當你在使用 Node.js 的時候,命令列裡面的 --require
引數。
1 |
-r, --require module to preload (option can be repeated) |
程式碼在 src/node.cc 裡面可考。
1 2 3 4 5 6 7 8 9 10 11 12 |
// ... } 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 // ... |
如果遇到了 --require
這個引數,則對靜態變數 local_preload_modules
和 preload_module_count
做處理,把這個預載入模組路徑加進去。
待到要生成 process
這個變數的時候,再把預載入模組的資訊放到 process._preload_modules
裡面去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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; } // ... } |
最重要的就是這句
1 2 3 |
READONLY_PROPERTY(process, "_preload_modules", array); |
require(‘module’)._preloadModules
上面我們講了這個 process._preload_modules
,然後現在我們說說是如何把 $ node --require bar.js foo.js
給預載入進去的。
接下去我們就要移步到 lib/module.js 檔案裡面去了。
在第 496 行左右的地方有這個函式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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 物件來載入這些預載入模組。
1 2 3 4 |
var parent = new Module('internal/preload', null); requests.forEach(function(request) { parent.require(request); }); |
根據這個函式的註釋說明,這個 Module 物件是一個虛擬的 Module 物件,主要是跟非預載入的那些模組給隔離或者區別開來,並且提供一個模組搜尋路徑。
Module.runMain()
看完上面的說明,我們接下去看看 Module.runMain()
函式。
這個函式還是位於 lib/module.js 檔案裡面。
1 2 3 4 5 6 |
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
就是包起來的這個閉包了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
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
。
1 2 3 4 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
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
,最後載入這個原生模組、快取、編譯並返回。
有興趣的同學可以在 Node.js 中輸出
process.moduleLoadList
看看。
這個 compile
很重要。
NativeModule.prototype.compile
在 NativeModule
編譯的過程中,大概的步驟是獲取程式碼、包裹(Wrap)程式碼,把包裹的程式碼 runInContext
一遍得到包裹好的函式,然後執行一遍就算載入好了。
1 2 3 4 5 6 7 8 9 |
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 檔案這個函式的上面幾行看一下,就知道包裹程式碼是怎麼回事了。
1 2 3 4 5 6 7 8 |
NativeModule.wrap = function(script) { return NativeModule.wrapper[0] + script + NativeModule.wrapper[1]; }; NativeModule.wrapper = [ '(function (exports, require, module, __filename, __dirname) {\n', '\n});' ]; |
根據上面的程式碼,我們能知道的就是比如我們一個內建模組的程式碼是:
1 2 |
var foo = require("foo"); module.exports = 1; |
那麼包裹好的程式碼將會是這樣子的:
1 2 3 4 |
(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 });
得到了這個包裹好的函式之後,我們就把相應的引數傳進這個閉包函式去執行。
1 |
fn(this.exports, NativeModule.require, this, this.filename); |
這個 this
就是對應的這個 module
,自然這個 module
裡面就有它的 exports
;require
函式就是 NativeModule.require
。
所以我們看到的在 lib/*.js
檔案裡面的那些 require
函式,實際上就是包裹好之後的程式碼的 NativeModule.require
了。
所以說實際上這些內建模組內部的根作用域下的 var
再怎麼樣高階也都是在包裹好的閉包裡面 var
,怎麼的也跟 global
搭不著邊。
內部原生模組
通過上面的追溯我們知道了,如果我們在程式碼裡面使用 require
的話,會先看看這個模組是不是原生模組。
不過回過頭看一下它的這個判斷條件:
1 2 3 |
if (NativeModule.nonInternalExists(filename)) { // ... } |
如果是原生模組並且不是原生內部模組的話。
那是怎麼區分原生模組和內部原生模組呢?
我們再來看看這個 NativeModule.nonInternalExists(filename)
函式。
1 2 3 4 5 6 7 |
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
做解析了。
1 2 3 4 5 6 7 |
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"]
。
1 2 3 4 |
Module._extensions['.js'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); module._compile(internalModule.stripBOM(content), filename); }; |
它也很簡單,就是奔著 _compile
去的。
Module.prototype._compile
先上程式碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
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 的最頂端附近有這麼幾行程式碼。
1 2 3 |
Module.wrapper = NativeModule.wrapper; Module.wrap = NativeModule.wrap; Module._debug = util.debuglog('module'); |
一切豁然開朗了吧。
連 NativeModule
的程式碼都逃不開被之前說的閉包所包裹,那麼你自己寫的 JS 檔案當然也會被 NativeModule.wrap
所包裹。
那麼你在程式碼根作用域申明的函式實際上在執行時裡面已經被一個閉包給包住了。
以前可能很多同學只知道是被閉包包住了,但是包的方法、流程今天算是解析了一遍了。
1 2 3 4 5 6 7 8 |
(function (exports, require, module, __filename, __dirname) { var a = 2; function foo(){ console.log(this.a); } foo(); }); |
這個 var a
怎麼也不可能綁到 global
去啊。
Module.prototype.require
雖然我們上面講得差不多了,可能很多童鞋也厭煩了。
不過該講完的還是得講完。
我們在我們自己檔案中用的 require
在上一節裡面有提到過,傳到我們閉包裡面的 require
實際上是長這樣的:
1 2 3 |
function require(path) { return self.require(path); } |
所以實際上就是個 Module.prototype.require
。
我們再看看這個函式。
1 2 3 4 5 |
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 執行的機制不是很瞭解。
本文從小龍丟擲的一個簡單問題進入,然後淺入淺出 Node.js 的一些執行機制什麼的,希望對大家還是有點幫助,更何況我在意的不是問題本身,而是分析的這個過程。