在Node.js中看JavaScript的引用
早期學習 Node.js 的時候 (2011-2012),有挺多是從 PHP 轉(zhuǎn)過來的,當時有部分人對于 Node.js 編輯完代碼需要重啟一下表示麻煩(PHP不需要這個過程),于是社區(qū)里的朋友就開始提倡使用 node-supervisor 這個模塊來啟動項目,可以編輯完代碼之后自動重啟。不過相對于 PHP 而言依舊不夠方便,因為 Node.js 在重啟以后,之前的上下文都丟失了。
雖然可以通過將 session 數(shù)據(jù)保存在數(shù)據(jù)庫或者緩存中來減少重啟過程中的數(shù)據(jù)丟失,不過如果是在生產(chǎn)的情況下,更新代碼的重啟間隙是沒法處理請求的(PHP可以,另外那個時候 Node.js 還沒有 cluster)。由于這方面的問題,加上本人是從 PHP 轉(zhuǎn)到 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。
再次執(zhí)行一次 require 并沒有刷新代碼。require 在執(zhí)行的過程中加載完代碼之后會把模塊導出的數(shù)據(jù)放在 require.cache 中。require.cache 是一個 { } 對象,以模塊的絕對路徑為 key,該模塊的詳細數(shù)據(jù)為 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 之上關(guān)于該模塊的 cache 清理掉后,用之前的方法再次測試。結(jié)果發(fā)現(xiàn),可以成功的刷新 b.js 的代碼,輸出新修改的值。
了解到這個點后,就想通過該原理實現(xiàn)一個無重啟熱更新版本的 node-supervisor。在封裝模塊的過程中,出于情懷的原因,考慮提供一個類似 PHP 中 include 的函數(shù)來代替 require 去引入一個模塊。實際內(nèi)部依舊是使用 require 去加載。以b.js為例,原本的寫法改為 var b = include(‘./b’),在文件 b.js 更新之后 include 內(nèi)部可以自動刷新,讓外面拿到***的代碼。
但是實際的開發(fā)過程中,這樣很快就碰到了問題。我們希望的代碼可能是這樣:
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的時候,我們發(fā)現(xiàn)了問題。無論我們在include.js內(nèi)部中如何實現(xiàn),都不能像開始那樣拿到新的 b.num。
對比開始的代碼,我們發(fā)現(xiàn)問題出在少了 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是怎么實現(xiàn)的,本文就不深入討論了,因為這個技巧使用度不高,寫起起來不是很優(yōu)雅[1],反而這其中有一個更重要的問題——JavaScript的引用。
JavaScript 的引用與傳統(tǒng)引用的區(qū)別
要討論這個問題,我們首先要了解 JavaScript 的引用于其他語言中的一個區(qū)別,在 C++ 中引用可以直接修改外部的值:
#include <iostream>
using namespace std;
void test(int &p) // 引用傳遞 {
p = 2048;
}
int main() {
int a = 1024;
int &p = a; // 設(shè)置引用p指向a
test(p); // 調(diào)用函數(shù)
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'; // 根據(jù)該對象修改其上的屬性
}
test2(obj);
console.log(obj); // { name: 'world' } // 修改成功②
我們發(fā)現(xiàn)與 C++ 不同,根據(jù)上面代碼 ① 可知 JavaScript 中并沒有傳遞一個引用,而是拷貝了一個新的變量,即值傳遞。根據(jù) ② 可知拷貝的這個變量是一個可以訪問到對象屬性的“引用”(與傳統(tǒng)的 C++ 的引用不同,下文中提到的 JavaScript 的引用都是這種特別的引用)。這里需要總結(jié)一個繞口的結(jié)論:Javascript 中均是值傳遞,對象在傳遞的過程中是拷貝了一份新的引用。
為了理解這個比較拗口的結(jié)論,讓我們來看一段代碼:
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。
打個比方最初設(shè)置 var data = obj.data 的時候,內(nèi)存中的情況大概是:
| Addr | 內(nèi)容 | |----------|-------- | obj.data | 內(nèi)存1 |
| data | 內(nèi)存1 |
所以通過 data.xx 可以修改 obj.data 的內(nèi)存1。
然后設(shè)置 data = xxx,由于 data 是拷貝的一個新的值,只是這個值是一個引用(指向內(nèi)存1)罷了。讓它等于另外一個對象就好比:
| Addr | 內(nèi)容 | |----------|-------- | obj.data | 內(nèi)存1 |
| data | 內(nèi)存2 |
讓 data 指向了新的一塊內(nèi)存2。
如果是傳統(tǒng)的引用(如上文中提到的 C++ 的引用),那么 obj.data 本身會變成新的內(nèi)存2,但 JavaScript 中均是值傳遞,對象在傳遞的過程中拷貝了一份新的引用。所以這個新拷貝的變量被改變并不影響原本的對象。
Node.js 中的 module.exports 與 exports
上述例子中的 obj.data 與 data 的關(guān)系,就是 Node.js 中的 module.exports 與 exports 之間的關(guān)系。讓我們來看看 Node.js 中 require 一個文件時的實際結(jié)構(gòu):
function require(...) {
var module = { exports: {} };
((module, exports) => { // Node.js 中文件外部其實被包了一層自執(zhí)行的函數(shù)
// 這中間是你模塊內(nèi)部的代碼.
function some_func() {};
exports = some_func;
// 這樣賦值,exports便不再指向module.exports
// 而module.exports依舊是{}
module.exports = some_func;
// 這樣設(shè)置才能修改到原本的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 才可以修改到其本身。表現(xiàn)上來說:
exports = 1; // 無效
module.exports = 1; // 有效
這是二者表現(xiàn)上的區(qū)別,其他方面用起來都沒有差別。所以你現(xiàn)在應(yīng)該知道寫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);
按照開始的結(jié)論我們可以一步步的來看這個問題:
var a = {n: 1}; // 引用a指向內(nèi)存1{n:1}
var b = a; // 引用b => a => { n:1 }
內(nèi)部結(jié)構(gòu):
| Addr | 內(nèi)容 | |---------|-------------|
| a | 內(nèi)存1 {n:1} | | b | 內(nèi)存1 |
繼續(xù)往下看:
a.x = a = {n: 2}; // (內(nèi)存1 而不是 a ).x = 引用 a = 內(nèi)存2 {n:2}
a 雖然是引用,但是 JavaScript 是值傳的這個引用,所以被修改不影響原本的地方。
| Addr | 內(nèi)容 | |-----------|-----------------------|
| 1) a | 內(nèi)存2({n:2}) | | 2) 內(nèi)存1.x | 內(nèi)存2({n:2}) |
| 3) b | 內(nèi)存1({n:1, x:內(nèi)存2}) |
所以***的結(jié)果
- a.x 即(內(nèi)存2).x ==> {n: 2}.x ==> undefined
- b.x 即(內(nèi)存1).x ==> 內(nèi)存2 ==> {n: 2}
總結(jié)
JavaScrip t中沒有引用傳遞,只有值傳遞。對象(引用類型)的傳遞只是拷貝一個新的引用,這個新的引用可以訪問原本對象上的屬性,但是這個新的引用本身是放在另外一個格子上的值,直接往這個格子賦新的值,并不會影響原本的對象。本文開頭所討論的 Node.js 熱更新時碰到的也是這個問題,區(qū)別是對象本身改變了,而原本拷貝出來的引用還指向舊的內(nèi)存,所以通過舊的引用調(diào)用不到新的方法。
Node.js 并沒有對 JavaScript 施加黑魔法,其中的引用問題依舊是 JavaScript 的內(nèi)容。如 module.exports 與 exports 這樣隱藏了一些細節(jié)容易使人誤會,本質(zhì)還是 JavaScript 的問題。另外推薦一個關(guān)于 Node.js 的進階教程 《Node.js 面試》。
注[1]:
- 老實說,模塊在函數(shù)內(nèi)聲明有點譚浩強的感覺。
- 把 b = include(xxx) 寫在調(diào)用內(nèi)部,還可以通過設(shè)置成中間件綁定在公共地方來寫。
- 除了寫在調(diào)用內(nèi)部,也可以導出一個工廠函數(shù),每次使用時 b().num 一下調(diào)用也可以。
- 還可以通過中間件的形式綁定在框架的公用對象上(如:ctx.b = include(xxx))。
- 要實現(xiàn)這樣的熱更新必須在架構(gòu)上就要嚴格避免舊代碼被引用的可能性,否則很容易寫出內(nèi)存泄漏的代碼。