在Node.js中看JavaScript的引用

早期學習Node.js的時候,有挺多是從PHP轉過來的,當時有部分人對於Node.js編輯完代碼需要重啟一下表示麻煩(PHP不需要這個過程),於是社區裡的朋友就開始提倡使用node-supervisor這個模塊來啟動項目,可以編輯完代碼之後自動重啟。不過相對於PHP而言依舊不夠方便,因為Node.js在重啟以後,之前的上下文都丟失了。

雖然可以通過將session數據保存在數據庫或者緩存中來減少重啟過程中的數據丟失,不過如果是在生產的情況下,更新代碼的重啟間隙是沒法處理請求的(PHP可以,另外那個時候還沒有cluster)。由於這方面的問題,加上本人是從PHP轉到Node.js的,於是從那時開始思考,有沒有辦法可以在不重啟的情況下熱更新Node.js的代碼。

最開始把目光瞄向了require這個模塊。想法很簡單,因為Node.js中引入一個模塊都是通過require這個方法加載的。於是就開始思考require能不能在更新代碼之後再次require一下。嘗試如下:

a.js

var express = require('express');var b = require('./b.js');var app = express; app.get('/', function (req, res) { b = require('./b.js'); res.send(b.num); }); app.listen(3000);

b.js

exports.num = 1024;

兩個JS文件寫好之後,從a.js啟動,刷新頁面會輸出b.js中的1024,然後修改b.js文件中導出的值,例如修改為2048。再次刷新頁面依舊是原本的1024。

再次執行一次require並沒有刷新代碼。require在執行的過程中加載完代碼之後會把模塊導出的數據放在require.cache中。require.cache是一個{}對象,以模塊的絕對路徑為key,該模塊的詳細數據為value。於是便開始做如下嘗試:

a.js

var path = require('path');var express = require('express');var b = require('./b.js');var app = express; app.get('/', function (req, res) { if (true) { // 檢查文件是否修改 flush; } res.send(b.num); });function flush { delete require.cache[path.join(__dirname, './b.js')]; b = require('./b.js'); } app.listen(3000);

再次require之前,將require之上關於該模塊的cache清理掉後,用之前的方法再次測試。結果發現,可以成功的刷新b.js的代碼,輸出新修改的值。

瞭解到這個點後,就想通過該原理實現一個無重啟熱更新版本的node-supervisor。在封裝模塊的過程中,出於情懷的原因,考慮提供一個類似PHP中include的函數來代替require去引入一個模塊。實際內部依舊是使用require去加載。以b.js為例,原本的寫法改為var b = include(‘./b’),在文件b.js更新之後include內部可以自動刷新,讓外面拿到最新的代碼。

但是實際的開發過程中,這樣很快就碰到了問題。我們希望的代碼可能是這樣:

web.js

var include = require('./include');var express = require('express');var b = include('./b.js');var app = express; app.get('/', function (req, res) { res.send(b.num); }); app.listen(3000);

但按照這個目標封裝include的時候,我們發現了問題。無論我們在include.js內部中如何實現,都不能像開始那樣拿到新的b.num。

對比開始的代碼,我們發現問題出在少了b = xx。也就是說這樣寫才可以:

web.js

var include = require('./include');var express = require('express');var app = express; app.get('/', function (req, res) { var b = include('./b.js'); res.send(b.num); }); app.listen(3000);

修改成這樣,就可以保證每次能可以正確的刷新到最新的代碼,並且不用重啟實例了。讀者有興趣的可以研究這個include是怎麼實現的,本文就不深入討論了,因為這個技巧使用度不高,寫起起來不是很優雅[1],反而這其中有一個更重要的問題————JavaScript的引用。

JavaScript的引用與傳統引用的區別

要討論這個問題,我們首先要了解JavaScript的引用於其他語言中的一個區別,在C++中引用可以直接修改外部的值:

#include <iostream>using namespace std;void test(int &p) // 引用傳遞{ p = 2048; }int main { int a = 1024; int &p = a; // 設置引用p指向a test(p); // 調用函數 cout << "p: " << p << endl; // 2048 cout << "a: " << a << endl; // 2048 return 0; }

而在JavaScript中:

var obj = { name: 'Alan' };function test1(obj) { obj = { hello: 'world' }; // 試圖修改外部obj} test1(obj); console.log(obj); // { name: 'Alan' } // 並沒有修改①function test2(obj) { obj.name = 'world'; // 根據該對象修改其上的屬性} test2(obj); console.log(obj); // { name: 'world' } // 修改成功②

我們發現與C++不同,根據上面代碼①可知JavaScript中並沒有傳遞一個引用,而是拷貝了一個新的變量,即值傳遞。根據②可知拷貝的這個變量是一個可以訪問到對象屬性的“引用”(與傳統的C++的引用不同,下文中提到的JavaScript的引用都是這種特別的引用)。這裡需要總結一個繞口的結論:Javascript中均是值傳遞,對象在傳遞的過程中是拷貝了一份新的引用

為了理解這個比較拗口的結論,讓我們來看一段代碼:

var obj = { data: {}}; // data 指向 obj.datavar data = obj.data;console.log(data === obj.data); // true-->data所操作的就是obj.datadata.name = 'Alan';data.test = function  { console.log('hi') };// 通過data可以直接修改到data的值console.log(obj) // { data: { name: 'Alan', test: [Function] } }data = { name: 'Bob', add: function (a, b) { return a + b; }}; // data是一個引用,直接賦值給它,只是讓這個變量等於另外一個引用,並不會修改到obj本身console.log(data); // { name: 'Bob', add: [Function] }console.log(obj); // { data: { name: 'Alan', test: [Function] } }obj.data = { name: 'Bob', add: function (a, b) { return a + b; }}; // 而通過obj.data才能真正修改到data本身console.log(obj); // { data: { name: 'Bob', add: [Function] } }

通過這個例子我們可以看到,data雖然像一個引用一樣指向了obj.data,並且通過data可以訪問到obj.data上的屬性。但是由於JavaScript值傳遞的特性直接修改data = xxx並不會使得obj.data = xxx。

打個比方最初設置var data = obj.data的時候,內存中的情況大概是:

格子內容
obj.data內存1
data內存1

所以通過data.xx可以修改obj.data的內存1。

然後設置data = xxx,由於data是拷貝的一個新的值,只是這個值是一個引用(指向內存1)罷了。讓它等於另外一個對象就好比:

格子內容
obj.data內存1
data內存2

讓data指向了新的一塊內存2。

如果是傳統的引用(如上文中提到的C++的引用),那麼obj.data本身會變成新的內存2,但JavaScript中均是值傳遞,對象在傳遞的過程中拷貝了一份新的引用。所以這個新拷貝的變量被改變並不影響原本的對象。

Node.js中的module.exports與exports

上述例子中的obj.data與data的關係,就是Node.js中的module.exports與exports之間的關係。讓我們來看看Node.js中require一個文件時的實際結構:

function require(...) { var module = { exports: {} }; ((module, exports) => { // Node.js 中文件外部其實被包了一層自執行的函數 // 這中間是你模塊內部的代碼. function some_func {}; exports = some_func; // 這樣賦值,exports便不再指向module.exports // 而module.exports依舊是{} module.exports = some_func; // 這樣設置才能修改到原本的exports })(module, module.exports); return module.exports; }

所以很自然的:

console.log(module.exports === exports); // true --> exports所操作的就是module.exports

Node.js中的exports就是拷貝的一份module.exports的引用。通過exports可以修改Node.js當前文件導出的屬性,但是不能修改當前模塊本身。通過module.exports才可以修改到其本身。表現上來說:

exports = 1; // 無效module.exports = 1; // 有效

這是二者表現上的區別,其他方面用起來都沒有差別。所以你現在應該知道寫module.exports.xx = xxx;的人其實是多寫了一個module.。

更復雜的例子

為了再練習一下,我們在來看一個比較複雜的例子:

var a = {n: 1}; var b = a; a.x = a = {n: 2}; console.log(a.x);console.log(b.x);

按照開始的結論我們可以一步步的來看這個問題:

var a = {n: 1}; // 引用a指向內存1{n:1}var b = a; // 引用b => a => { n:1 }
格子內容
a內存1({n:1})
b內存1

a 雖然是引用,但是JavaScript是值傳的這個引用,所以被修改不影響原本的地方。

格子內容
1) a內存2({n:2})
2) 內存1.x內存2({n:2})
3) b內存1({n:1, x:內存2})

所以最後的結果

  • a.x 即(內存2).x ==> {n: 2}.x ==> undefined

  • b.x 即(內存1).x ==> 內存2 ==> {n: 2}

總結

JavaScript中沒有引用傳遞,只有值傳遞。對象(引用類型)的傳遞只是拷貝一個新的引用,這個新的引用可以訪問原本對象上的屬性,但是這個新的引用本身是放在另外一個格子上的值,直接往這個格子賦新的值,並不會影響原本的對象。本文開頭所討論的Node.js熱更新時碰到的也是這個問題,區別是對象本身改變了,而原本拷貝出來的引用還指向舊的內存。

Node.js並沒有對JavaScript施加黑魔法,其中的引用問題依舊是JavaScript的內容。如module.exports與exports這樣隱藏了一些細節容易使人誤會,本質還是JavaScript的問題。另外推薦一個關於 Node.js 的進階教程 《Node.js 面試》。

注[1]:

  1. 老實說,模塊在函數內聲明有點譚浩強的感覺。

  2. 把b = include(xxx)寫在調用內部,還可以通過設置成中間件綁定在公共地方來寫。

  3. 除了寫在調用內部,也可以導出一個工廠函數,每次使用時b.num一下調用也可以。

  4. 還可以通過中間件的形式綁定在框架的公用對象上(如:ctx.b = include(xxx))。

相關推薦

推薦中...