在 Node.js 中看 JavaScript 的引用

lellansin發表於2017-04-10

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

雖然可以通過將 session 資料儲存在資料庫或者快取中來減少重啟過程中的資料丟失,不過如果是在生產的情況下,更新程式碼的重啟間隙是沒法處理請求的(PHP可以,另外那個時候 Node.js 還沒有 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.data
var data = obj.data;

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

data.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 的時候,記憶體中的情況大概是:

|   Addr   |  內容  |
|----------|--------
| obj.data |  記憶體1 |
|   data   |  記憶體1 |

所以通過 data.xx 可以修改 obj.data 的記憶體1。

然後設定 data = xxx,由於 data 是拷貝的一個新的值,只是這個值是一個引用(指向記憶體1)罷了。讓它等於另外一個物件就好比:

|   Addr   |  內容  |
|----------|--------
| 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 }

內部結構:

|   Addr  |     內容     |
|---------|-------------|
|    a    |  記憶體1 {n:1} |
|    b    |  記憶體1       |

繼續往下看:

a.x = a = {n: 2};  //  (記憶體1 而不是 a ).x = 引用 a = 記憶體2 {n:2}

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

|    Addr   |          內容         |
|-----------|-----------------------|
| 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}

總結

JavaScrip t中沒有引用傳遞,只有值傳遞。物件(引用型別)的傳遞只是拷貝一個新的引用,這個新的引用可以訪問原本物件上的屬性,但是這個新的引用本身是放在另外一個格子上的值,直接往這個格子賦新的值,並不會影響原本的物件。本文開頭所討論的 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))。
  5. 要實現這樣的熱更新必須在架構上就要嚴格避免舊程式碼被引用的可能性,否則很容易寫出記憶體洩漏的程式碼。

相關文章