Node.js 啟動方式:一道關於全域性變數的題目引發的思考·續

發表於2015-11-27

本文是上文《Node.js 啟動方式:一道關於全域性變數的題目引發的思考》的續章。

原題回顧

我們還是先回顧下原題吧。

上題由我們親愛的小龍童鞋發現並在我們的 901 群裡提問的。

不過在上面一篇文章中,我們講的是在 REPL 和 vm 中有什麼事情,但是並沒有解釋為什麼在檔案模組的載入形式下,var 並不會掛載到全域性變數去。

其實原因很簡單,大家應該也都明白,在 Node.js 中,每個檔案相當於是一個閉包,在 require 的時候被編譯包了起來。

但是具體是怎麼樣的呢?雖然網上也有很多答案,我還是決定在這裡按上一篇文章的尿性稍微解釋一下。

分析

首先我們還是回到上一篇文章的《Node REPL 啟動的沙箱》一節,裡面說了當啟動 Node.js 的時候是以 src/node.js 為入口的。

如果以 REPL 為途徑啟動的話是直接啟動一個 vm,而此時的所有根級變數都在最頂級的作用域下,所以一個 var 自然會繫結到 global 下面了。

而如果是以檔案,即 $ node foo.js 形式啟動的話,它就會執行 src/node.js 裡面的另一坨條件分支了。

從上面的程式碼看出,只要是以 $ node foo.js 形式啟動的,都會經歷 startup.preloadModules() 和 Module.runMain() 兩個函式。

startup.preloadModules()

我們來看看這個函式

實際上就是執行的 lib/module.js 裡面的 _preloadModules 函式,並且把這個 process._preload_modules 給傳進去。當然,前提是有這個 process._preload_modules

process._preload_modules

這個 process._preload_modules 指的就是當你在使用 Node.js 的時候,命令列裡面的 --require 引數。

程式碼在 src/node.cc 裡面可考。

如果遇到了 --require 這個引數,則對靜態變數 local_preload_modules 和 preload_module_count 做處理,把這個預載入模組路徑加進去。

待到要生成 process 這個變數的時候,再把預載入模組的資訊放到 process._preload_modules 裡面去。

最重要的就是這句

require(‘module’)._preloadModules

上面我們講了這個 process._preload_modules,然後現在我們說說是如何把 $ node --require bar.js foo.js 給預載入進去的。

接下去我們就要移步到 lib/module.js 檔案裡面去了。

第 496 行左右的地方有這個函式。

大概我們能看到,就是以 internal/preload 為 ID 的 Module 物件來載入這些預載入模組。

根據這個函式的註釋說明,這個 Module 物件是一個虛擬的 Module 物件,主要是跟非預載入的那些模組給隔離或者區別開來,並且提供一個模組搜尋路徑。

Module.runMain()

看完上面的說明,我們接下去看看 Module.runMain() 函式。

這個函式還是位於 lib/module.js 檔案裡面。

我們看到了就是在這句話中,Module 載入了 process.argv[1] 也就是檔名,自此一發不可收拾。

Module._load

這個函式相信很多人都知道它的用處了,無非就是載入檔案,並載入到一個閉包裡面。

這樣一來在檔案裡面 var 出來的變數就不在根作用域下面了,所以不會粘到 global 裡面去。它的 this 就是包起來的這個閉包了。

上面的程式碼首先是根據傳入的檔名找到真的檔案地址,就是所謂的搜尋路徑了。比如 require("foo") 就會分別從 node_modules 路徑等依次查詢下來。

我經常 Hack 這個 _resolveFilename 函式來簡化 require 函式,比如我希望我用 require("controller/foo") 就能直接拿到 ./src/controller/foo.js 檔案。有興趣討論一下這個用法的童鞋可以轉到我的 Gist 上檢視 Hack 的一個 Demo。

第二步就是我們常說的快取了。如果這個模組之前載入過,那麼在 Module._cache 下面會有個快取,直接去取就是了。

第三步就是看看是不是 NativeModule

NativeModule

之前的程式碼裡面其實也沒少出現這個 NativeModule。那這個 NativeModule 到底是個 shenmegui 呢?

其實它還是在 Node.js 的入口 src/node.js 裡面。

它主要用來載入 Node.js 的一些原生模組,比如說 NativeModule.require("child_process")等,也用於一些 internal 模組的載入,比如 NativeModule.require("internal/repl")

之前程式碼的這個判斷就是說如果判斷要載入的檔案是一個原生模組,那麼就使用 NativeModule.require 來載入。

NativeModule.require

先看看是否是本身,再看看是否被快取,然後看看是否合法。接下去就是填充 process.moduleLoadList,最後載入這個原生模組、快取、編譯並返回。

有興趣的同學可以在 Node.js 中輸出 process.moduleLoadList 看看。

這個 compile 很重要。

NativeModule.prototype.compile

在 NativeModule 編譯的過程中,大概的步驟是獲取程式碼、包裹(Wrap)程式碼,把包裹的程式碼 runInContext 一遍得到包裹好的函式,然後執行一遍就算載入好了。

我們往這個 src/node.js 檔案這個函式的上面幾行看一下,就知道包裹程式碼是怎麼回事了。

根據上面的程式碼,我們能知道的就是比如我們一個內建模組的程式碼是:

那麼包裹好的程式碼將會是這樣子的:

這樣一看就明白了這些 requiremoduleexports__filename 和 __dirname 是怎麼來了吧。

當我們通過 var fn = runInThisContext(source, { filename: this.filename }); 得到了這個包裹好的函式之後,我們就把相應的引數傳進這個閉包函式去執行。

這個 this 就是對應的這個 module,自然這個 module 裡面就有它的 exportsrequire 函式就是 NativeModule.require

所以我們看到的在 lib/*.js 檔案裡面的那些 require 函式,實際上就是包裹好之後的程式碼的 NativeModule.require 了。

所以說實際上這些內建模組內部的根作用域下的 var 再怎麼樣高階也都是在包裹好的閉包裡面 var,怎麼的也跟 global 搭不著邊。

內部原生模組

通過上面的追溯我們知道了,如果我們在程式碼裡面使用 require 的話,會先看看這個模組是不是原生模組。

不過回過頭看一下它的這個判斷條件:

如果是原生模組並且不是原生內部模組的話。

那是怎麼區分原生模組和內部原生模組呢?

我們再來看看這個 NativeModule.nonInternalExists(filename) 函式。

上面的程式碼是去除各種雜七雜八的條件之後的一種情況,別的情況還請各位童鞋自行看 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 做解析了。

做了一系列操作之後得到了真·檔名,然後判斷一下字尾。如果是 ".js" 的話執行 Module._extensions[".js"] 這個函式去編譯程式碼,如果是 ".json" 則是 Module._extensions[".json"]

這裡我們略過 JSON 和 C++ Addon,直奔 Module._extensions[".js"]

它也很簡單,就是奔著 _compile 去的。

Module.prototype._compile

先上程式碼

感覺流程上跟 NativeModule 的編譯相似,不過這裡是事先準備好要在載入的檔案裡面用的require 函式,以及一些 require 的周邊。

接下去就是用 Module.wrap 來包裹程式碼了,包裹完之後把得到的函式用引數 self.exports, require, self, filename, dirname 去執行一遍,就算是檔案載入完畢了。

最後回到之前載入程式碼的那一刻,把載入完畢得到的 module.exports 再 return 出去就好了。

Module.wrap

這個就不用說了。

在 lib/module.js 的最頂端附近有這麼幾行程式碼。

一切豁然開朗了吧。

連 NativeModule 的程式碼都逃不開被之前說的閉包所包裹,那麼你自己寫的 JS 檔案當然也會被 NativeModule.wrap 所包裹。

那麼你在程式碼根作用域申明的函式實際上在執行時裡面已經被一個閉包給包住了。

以前可能很多同學只知道是被閉包包住了,但是包的方法、流程今天算是解析了一遍了。

這個 var a 怎麼也不可能綁到 global 去啊。

Module.prototype.require

雖然我們上面講得差不多了,可能很多童鞋也厭煩了。

不過該講完的還是得講完。

我們在我們自己檔案中用的 require 在上一節裡面有提到過,傳到我們閉包裡面的 require 實際上是長這樣的:

所以實際上就是個 Module.prototype.require

我們再看看這個函式

一下子又繞回到了我們一開始的 Module._load

所以基本上就差不多到這過了。

REPL vs 檔案啟動

最後我們再點一下,或者說回顧一下吧。

REPL 啟動的時候 Node.js 是開了個 vm 直接讓你跑,並沒有把程式碼包在一個閉包裡面,所以再根作用域下的變數會 Biu 一下貼到 global 中去。

而檔案啟動的時候,會做本文中說的一系列事情,然後就會把各檔案都包到一個閉包去,所以變數就無法通過這種方式來貼到 global 去了。

不過這種二義性會在 "use strict"; 中戛然而止。

珍愛生命,use strict

小結

本文可能很多童鞋看完後悔覺得很坑——JS 為什麼有那麼多二義性那麼坑呢。

其實不然,主要是可能很多人對 Node.js 執行的機制不是很瞭解。

本文從小龍丟擲的一個簡單問題進入,然後淺入淺出 Node.js 的一些執行機制什麼的,希望對大家還是有點幫助,更何況我在意的不是問題本身,而是分析的這個過程。

相關文章