CoffeeScript的全局變量污染與Node.js的模塊加載

最近發現Continuation.js的一個Bug:命令行使用-c開啓緩存模式的時候,有時候更新了代碼緩存不會更新,這個Bug時隱時現,難以捕捉。今天發現這個Bug升級了,不僅僅是在緩存模式的時候會有問題,即時沒有開啓-c一樣會發生這個問題。再到後來發現這個Bug只在CoffeeScript代碼中出現,於是就鎖定了目標開始調試。

Continuation.js和CoffeeScript一樣支持動態加載編譯,就是可以在Node.js中使用require的方法加載原始代碼,運行時編譯。這樣的好處不言而喻,給用戶提供了一致而透明的接口,無需事先編譯好再加載。具體的實現方法是,修改require.extensions下面的回調函數,把加載定向到自定義的函數來處理,最後再調用require.main.compile運行編譯後的代碼。

requiremodule是Node.js運行時的兩個重要變量,所有模塊的運行其實都是在一個這樣的函數中的:

function(module, require) {
  // Your code
}

所以requiremodule是模塊內的全局變量。require.extensions是一個對象,用於根據擴展名註冊require的回調函數,默認情況下,require.extensions是這樣的(Node.js 0.10.12):

{
  '.js': function (module, filename) {
    var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
    module._compile(stripBOM(content), filename);
  },
  '.json': function (module, filename) {
    var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
    try {
      module.exports = JSON.parse(stripBOM(content));
    } catch (err) {
      err.message = filename + ': ' + err.message;
      throw err;
    }
  },
  '.node': function () { [native code] }
}

在Node.js代碼中使用require(filename)時,實際上會根據filename的後綴擴展名依次來選擇加載的回調函數,例如我想增加一種自定義的自動加載類型.byv,只需設置require.extensions['.byv']即可。同理,也可以修改已有的後綴的加載函數,Continuation.js就是這麼做的(修改了.js文件的加載函數)。

爲了透明支持CoffeeScript,Continuation.js修改了.coffee的加載函數,在自定義的回調函數中調用CoffeeScript模塊,調用CoffeeScript編譯,然後再使用Continuation.js編譯。問題就在這裏,是加載CoffeeScript的時候require.extensions['.coffee']被修改了。閱讀CoffeeScript的代碼(版本1.6.3),發現在'coffee-script'模塊中,有這麼幾行代碼:

if require.extensions
  for ext in ['.coffee', '.litcoffee', '.coffee.md']
    require.extensions[ext] = loadFile

這段代碼不應該在加載CoffeeScript模塊中運行,而應該在通過命令行運行coffee命令的時候運行,可惜CoffeeScript沒有注意到這一點,應該算是一個Bug吧。給CoffeeScript提交了一個推送請求https://github.com/jashkenas/coffee-script/pull/3054 ,等待審覈中。

更新:這個issue被標註爲重複,已經在 https://github.com/jashkenas/coffee-script/issues/2323 合併了,CoffeeScript 2.0.0(未發佈)以後默認已經取消這個特性了。

Node.js中的child_process及進程通信

child_process是Node.js的一個十分重要的模塊,通過它可以實現創建多進程,以利用多核計算資源。

Node.js 0.8的child_process模塊提供了四個創建子進程的函數,分別是spawnexecexecFilefork。其中spawn是最原始的創建子進程的函數,其他三個都是對spawn不同程度的封裝。spawn只能運行指定的程序,參數需要在列表中給出,相當於execvp系統函數,而exec可以直接運行複雜的命令。

例如要運行ls -lh /usr,使用spawn需要寫成spawn('ls', ['-lh', '/usr']),而exec只需exec('ls -lh /usr')exec的實現原理是啓動了一個系統shell來解析參數,因此可以是非常複雜的命令,包括管道和重定向。此外,exec還可以直接接受一個回調函數作爲參數,回調函數有三個參數,分別是err, stdout, stderr,非常方便直接使用,例如:

child_process.exec('ls -lh /usr', function(err, stdout, stderr) {
  console.log(stdout);
});

如果使用spawn,則必須寫成:

child = child_process.spawn('ls', ['-lh', '/usr']);
child.stdout.setEncoding('utf8');
child.stdout.on('data', function(data) {
  console.log(data);
});

execFilespawn的參數相似,也需要分別指定執行的命令和參數,但可以接受一個回調函數,與exec的回調函數相同。它與exec的區別在於不啓動獨立的shell,因此相比更加輕量級。

fork函數用於直接運行Node.js模塊,例如fork('./child.js'),相當於spawn('node', ['./child.js'])。與默認的spawn不同的是,fork會在父進程與子進程直接建立一個IPC管道,用於父子進程之間的通信。例如:

var n = child_process.fork('./child.js');
n.on('message', function(m) {
  console.log('PARENT got message:', m);
});
n.send({ hello: 'world' });

child.js的內容:

process.on('message', function(m) {
  console.log('CHILD got message:', m);
});
process.send({ foo: 'bar' });

其中父進程調用fork函數獲取一個返回值,作爲子進程的句柄,通過send函數發送信息,on('message')監聽返回的信息,子進程通過內置的process對象相同的方法與父進程通信。

fork函數有一個問題,就是它只能運行JavaScript代碼,如果你喜歡用CoffeeScript(或者其他任何編譯到js的語言),是無法通過fork調用的。一個簡單的方法是把代碼編譯到JavaScript再運行,但是很不方便,有沒有什麼辦法呢?

答案是可以的,還是得回到spawn函數。spawn函數除了接受command, args外,還接受一個options參數。通過把options參數的stdio設爲['ipc'],即可在父子進程之間建立IPC管道。例如子進程使用CoffeeScript:

child_process = require('child_process')
options =
  stdio: ['ipc']
child = child_process.spawn 'coffee', ['./child.coffee'], options

其中只要把spawn的第一個參數設置爲運行對應腳本的解釋器,即可運行,例如使用Continuation.js,只需child = child_process.spawn('continuation', ['./child.coffee'], options)

JavaScript對象與原型

JavaScript中有兩個特殊的對象:Object與Function。Object.prototype是所有對象的原型,處於原型鏈的最底層。Function.prototype是所有函數對象的原型,包括構造函數。我把JavaScript中的對象分爲三類,一類是用戶創建的對象,一類是構造函數對象,一類是原型對象。這三類對象中每一類都有proto屬性,通過它可以遍歷原型鏈,追溯到原型鏈的最底層。構造函數對象還有prototype屬性,指向一個原型對象,通過該構造函數創建對象時,被創建的對象的proto屬性將會指向構造函數的prototype屬性。原型對象有constructor屬性,指向它對應的構造函數。

例如下面的代碼:

function Foo(){
}
var foo = new Foo();
var obj = new Object();

爲了說明這之間複雜的關係,我畫了一個圖:

JavaScript中通過原型實現繼承的本質就是一個對象可以訪問到它的原型鏈上任何一個原型對象的屬性,例如上圖foo對象,它擁有foo.proto以及foo.proto.proto所有屬性的淺拷貝(只拷貝基本數據類型,不拷貝對象)。所以可以直接訪問foo.constructor(來自foo.proto,即Foo.prototype),foo.toString(來自foo.proto.proto,即Object.prototype)。