摘要:回調(diào)函數(shù)是在異步操作完成后傳播其操作結(jié)果的函數(shù),總是用來替代同步操作的返回指令。下面的圖片顯示了中事件循環(huán)過程當異步操作完成時,執(zhí)行權就會交給這個異步操作開始的地方,即回調(diào)函數(shù)。
本系列文章為《Node.js Design Patterns Second Edition》的原文翻譯和讀書筆記,在GitHub連載更新,同步翻譯版鏈接。
歡迎關注我的專欄,之后的博文將在專欄同步:
Encounter的掘金專欄
知乎專欄 Encounter的編程思考
segmentfault專欄 前端小站
Node.js Essential Patterns對于Node.js而言,異步特性是其最顯著的特征,但對于別的一些語言,例如PHP,就不常處理異步代碼。
在同步的編程中,我們習慣于把代碼的執(zhí)行想象為自上而下連續(xù)的執(zhí)行計算步驟。每個操作都是阻塞的,這意味著只有在一個操作執(zhí)行完成后才能執(zhí)行下一個操作,這種方式利于我們理解和調(diào)試。
然而,在異步的編程中,我們可以在后臺執(zhí)行諸如讀取文件或執(zhí)行網(wǎng)絡請求的一些操作。當我們在調(diào)用異步操作方法時,即使當前或之前的操作尚未完成,下面的后續(xù)操作也會繼續(xù)執(zhí)行,在后臺執(zhí)行的操作會在任意時刻執(zhí)行完畢,并且應用程序會在異步調(diào)用完成時以正確的方式做出反應。
雖然這種非阻塞方法相比于阻塞方法性能更好,但它實在是讓程序員難以理解,并且,在處理較為復雜的異步控制流的高級應用程序時,異步順序可能會變得難以操作。
Node.js提供了一系列工具和設計模式,以便我們最佳地處理異步代碼。了解如何使用它們編寫性能和易于理解和調(diào)試的應用程序非常重要。
在本章中,我們將看到兩個最重要的異步模式:回調(diào)和事件發(fā)布器。
回調(diào)模式在上一章中介紹過,回調(diào)是reactor模式的handler的實例,回調(diào)本來就是Node.js獨特的編程風格之一。回調(diào)函數(shù)是在異步操作完成后傳播其操作結(jié)果的函數(shù),總是用來替代同步操作的返回指令。而JavaScript恰好就是表示回調(diào)的最好的語言。在JavaScript中,函數(shù)是一等公民,我們可以把函數(shù)變量作為參數(shù)傳遞,并在另一個函數(shù)中調(diào)用它,把調(diào)用的結(jié)果存儲到某一數(shù)據(jù)結(jié)構中。實現(xiàn)回調(diào)的另一個理想結(jié)構是閉包。使用閉包,我們能夠保留函數(shù)創(chuàng)建時所在的上下文環(huán)境,這樣,無論何時調(diào)用回調(diào),都保持了請求異步操作的上下文。
在本節(jié)中,我們分析基于回調(diào)的編程思想和模式,而不是同步操作的返回指令的模式。
CPS在JavaScript中,回調(diào)函數(shù)作為參數(shù)傳遞給另一個函數(shù),并在操作完成時調(diào)用。在函數(shù)式編程中,這種傳遞結(jié)果的方法被稱為CPS。這是一個一般概念,而且不只是對于異步操作而言。實際上,它只是通過將結(jié)果作為參數(shù)傳遞給另一個函數(shù)(回調(diào)函數(shù))來傳遞結(jié)果,然后在主體邏輯中調(diào)用回調(diào)函數(shù)拿到操作結(jié)果,而不是直接將其返回給調(diào)用者。
同步CPS為了更清晰地理解CPS,讓我們來看看這個簡單的同步函數(shù):
function add(a, b) { return a + b; }
上面的例子成為直接編程風格,其實沒什么特別的,就是使用return語句把結(jié)果直接傳遞給調(diào)用者。它代表的是同步編程中返回結(jié)果的最常見方法。上述功能的CPS寫法如下:
function add(a, b, callback) { callback(a + b); }
add()函數(shù)是一個同步的CPS函數(shù),CPS函數(shù)只會在它調(diào)用的時候才會拿到add()函數(shù)的執(zhí)行結(jié)果,下列代碼就是其調(diào)用方式:
console.log("before"); add(1, 2, result => console.log("Result: " + result)); console.log("after");
既然add()是同步的,那么上述代碼會打印以下結(jié)果:
before Result: 3 after異步CPS
那我們思考下面的這個例子,這里的add()函數(shù)是異步的:
function additionAsync(a, b, callback) { setTimeout(() => callback(a + b), 100); }
在上邊的代碼中,我們使用setTimeout()模擬異步回調(diào)函數(shù)的調(diào)用。現(xiàn)在,我們調(diào)用additionalAsync,并查看具體的輸出結(jié)果。
console.log("before"); additionAsync(1, 2, result => console.log("Result: " + result)); console.log("after");
上述代碼會有以下的輸出結(jié)果:
before after Result: 3
因為setTimeout()是一個異步操作,所以它不會等待執(zhí)行回調(diào),而是立即返回,將控制權交給addAsync(),然后返回給其調(diào)用者。Node.js中的此屬性至關重要,因為只要有異步請求產(chǎn)生,控制權就會交給事件循環(huán),從而允許處理來自隊列的新事件。
下面的圖片顯示了Node.js中事件循環(huán)過程:
當異步操作完成時,執(zhí)行權就會交給這個異步操作開始的地方,即回調(diào)函數(shù)。執(zhí)行將從事件循環(huán)開始,所以它將有一個新的堆棧。對于JavaScript而言,這是它的優(yōu)勢所在。正是由于閉包保存了其上下文環(huán)境,即使在不同的時間點和不同的位置調(diào)用回調(diào),也能夠正常地執(zhí)行。
同步函數(shù)在其完成操作之前是阻塞的。而異步函數(shù)立即返回,結(jié)果將在事件循環(huán)的稍后循環(huán)中傳遞給處理程序(在我們的例子中是一個回調(diào))。
非CPS風格的回調(diào)模式某些情況下情況下,我們可能會認為回調(diào)CPS式的寫法像是異步的,然而并不是。比如以下代碼,Array對象的map()方法:
const result = [1, 5, 7].map(element => element - 1); console.log(result); // [0, 4, 6]
在上述例子中,回調(diào)僅用于迭代數(shù)組的元素,而不是傳遞操作的結(jié)果。實際上,這個例子中是使用回調(diào)的方式同步返回,而非傳遞結(jié)果。是否是傳遞操作結(jié)果的回調(diào)通常在API文檔有明確說明。
同步還是異步?我們已經(jīng)看到代碼的執(zhí)行順序會因同步或異步的執(zhí)行方式產(chǎn)生根本性的改變。這對整個應用程序的流程,正確性和效率都產(chǎn)生了重大影響。以下是對這兩種模式及其缺陷的分析。一般來說,必須避免的是由于其執(zhí)行順序不一致導致的難以檢測和拓展的混亂。下面是一個有陷阱的異步實例:
一個有問題的函數(shù)最危險的情況之一是在特定條件下同步執(zhí)行本應異步執(zhí)行的API。以下列代碼為例:
const fs = require("fs"); const cache = {}; function inconsistentRead(filename, callback) { if (cache[filename]) { // 如果緩存命中,則同步執(zhí)行回調(diào) callback(cache[filename]); } else { // 未命中,則執(zhí)行異步非阻塞的I/O操作 fs.readFile(filename, "utf8", (err, data) => { cache[filename] = data; callback(data); }); } }
上述功能使用緩存來存儲不同文件讀取操作的結(jié)果。不過記得,這只是一個例子,它缺少錯誤處理,并且其緩存邏輯本身不是最佳的(比如沒有緩存淘汰策略)。除此之外,上述函數(shù)是非常危險的,因為如果沒有設置高速緩存,它的行為是異步的,直到fs.readFile()函數(shù)返回結(jié)果為止,它都不會同步執(zhí)行,這時緩存并不會觸發(fā),而會去走異步回調(diào)調(diào)用。
解放zalgo關于zalgo,其實就是指同步或異步行為的不確定性,幾乎總是導致非常難追蹤的bug。
現(xiàn)在,我們來看看如何使用一個不可預測其順序的函數(shù),它甚至可以輕松地中斷一個應用程序。看以下代碼:
function createFileReader(filename) { const listeners = []; inconsistentRead(filename, value => { listeners.forEach(listener => listener(value)); }); return { onDataReady: listener => listeners.push(listener) }; }
當上述函數(shù)被調(diào)用時,它創(chuàng)建一個充當事件發(fā)布器的新對象,允許我們?yōu)槲募x取操作設置多個事件監(jiān)聽器。當讀取操作完成并且數(shù)據(jù)可用時,所有的監(jiān)聽器將被立即被調(diào)用。前面的函數(shù)使用之前定義的inconsistentRead()函數(shù)來實現(xiàn)這個功能。我們現(xiàn)在嘗試調(diào)用createFileReader()函數(shù):
const reader1 = createFileReader("data.txt"); reader1.onDataReady(data => { console.log("First call data: " + data); // 之后再次通過fs讀取同一個文件 const reader2 = createFileReader("data.txt"); reader2.onDataReady(data => { console.log("Second call data: " + data); }); });
之后的輸出是這樣的:
First call data: some data
下面來分析為何第二次的回調(diào)沒有被調(diào)用:
在創(chuàng)建reader1的時候,inconsistentRead()函數(shù)是異步執(zhí)行的,這時沒有可用的緩存結(jié)果,因此我們有時間注冊事件監(jiān)聽器。在讀操作完成后,它將在下一次事件循環(huán)中被調(diào)用。
然后,在事件循環(huán)的循環(huán)中創(chuàng)建reader2,其中所請求文件的緩存已經(jīng)存在。在這種情況下,內(nèi)部調(diào)用inconsistentRead()將是同步的。所以,它的回調(diào)將被立即調(diào)用,這意味著reader2的所有監(jiān)聽器也將被同步調(diào)用。然而,在創(chuàng)建reader2之后,我們才開始注冊監(jiān)聽器,所以它們將永遠不被調(diào)用。
inconsistentRead()回調(diào)函數(shù)的行為是不可預測的,因為它取決于許多因素,例如調(diào)用的頻率,作為參數(shù)傳遞的文件名,以及加載文件所花費的時間等。
在實際應用中,例如我們剛剛看到的錯誤可能會非常復雜,難以在真實應用程序中識別和復制。想象一下,在Web服務器中使用類似的功能,可以有多個并發(fā)請求;想象一下這些請求掛起,沒有任何明顯的理由,沒有任何日志被記錄。這絕對屬于煩人的bug。
npm的創(chuàng)始人和以前的Node.js項目負責人Isaac Z. Schlueter在他的一篇博客文章中比較了使用這種不可預測的功能來釋放Zalgo。如果您不熟悉Zalgo。可以看看Isaac Z. Schlueter的原始帖子。
使用同步API從上述關于zalgo的示例中,我們知道,API必須清楚地定義其性質(zhì):是同步的還是異步的?
我們合適fix上述的inconsistentRead()函數(shù)產(chǎn)生的bug的方式是使它完全同步阻塞執(zhí)行。并且這是完全可能的,因為Node.js為大多數(shù)基本I/O操作提供了一組同步方式的API。例如,我們可以使用fs.readFileSync()函數(shù)來代替它的異步對等體。代碼現(xiàn)在如下:
const fs = require("fs"); const cache = {}; function consistentReadSync(filename) { if (cache[filename]) { return cache[filename]; } else { cache[filename] = fs.readFileSync(filename, "utf8"); return cache[filename]; } }
我們可以看到整個函數(shù)被轉(zhuǎn)化為同步阻塞調(diào)用的模式。如果一個函數(shù)是同步的,那么它不會是CPS的風格。事實上,我們可以說,使用CPS來實現(xiàn)一個同步的API一直是最佳實踐,這將消除其性質(zhì)上的任何混亂,并且從性能角度來看也將更加有效。
請記住,將API從CPS更改為直接調(diào)用返回的風格,或者說從異步到同步的風格。例如,在我們的例子中,我們必須完全改變我們的createFileReader()為同步,并使其適應于始終工作。
另外,使用同步API而不是異步API,要特別注意以下注意事項:
同步API并不適用于所有應用場景。
同步API將阻塞事件循環(huán)并將并發(fā)請求置于阻塞狀態(tài)。它會破壞JavaScript的并發(fā)模型,甚至使得整個應用程序的性能下降。我們將在本書后面看到這對我們的應用程序的影響。
在我們的inconsistentRead()函數(shù)中,因為每個文件名僅調(diào)用一次,所以同步阻塞調(diào)用而對應用程序造成的影響并不大,并且緩存值將用于所有后續(xù)的調(diào)用。如果我們的靜態(tài)文件的數(shù)量是有限的,那么使用consistentReadSync()將不會對我們的事件循環(huán)產(chǎn)生很大的影響。如果我們文件數(shù)量很大并且都需要被讀取一次,而且對性能要求較高的情況下,我們不建議在Node.js中使用同步I/O。然而,在某些情況下,同步I/O可能是最簡單和最有效的解決方案。所以我們必須正確評估具體的應用場景,以選擇最為合適的方案。上述實例其實說明:在實際應用程序中使用同步阻塞API加載配置文件是非常有意義的。
因此,記得只有不影響應用程序并發(fā)能力時才考慮使用同步阻塞I/O。
延時處理另一種fix上述的inconsistentRead()函數(shù)產(chǎn)生的bug的方式是讓它僅僅是異步的。這里的解決辦法是下一次事件循環(huán)時同步調(diào)用,而不是在相同的事件循環(huán)周期中立即運行,使得其實際上是異步的。在Node.js中,可以使用process.nextTick(),它延遲函數(shù)的執(zhí)行,直到下一次傳遞事件循環(huán)。它的功能非常簡單,它將回調(diào)作為參數(shù),并將其推送到事件隊列的頂部,在任何未處理的I/O事件前,并立即返回。一旦事件循環(huán)再次運行,就會立刻調(diào)用回調(diào)。
所以看下列代碼,我們可以較好的利用這項技術處理inconsistentRead()的異步順序:
const fs = require("fs"); const cache = {}; function consistentReadAsync(filename, callback) { if (cache[filename]) { // 下一次事件循環(huán)立即調(diào)用 process.nextTick(() => callback(cache[filename])); } else { // 異步I/O操作 fs.readFile(filename, "utf8", (err, data) => { cache[filename] = data; callback(data); }); } }
現(xiàn)在,上述函數(shù)保證在任何情況下異步地調(diào)用其回調(diào)函數(shù),解決了上述bug。
另一個用于延遲執(zhí)行代碼的API是setImmediate()。雖然它們的作用看起來非常相似,但實際含義卻截然不同。process.nextTick()的回調(diào)函數(shù)會在任何其他I/O操作之前調(diào)用,而對于setImmediate()則會在其它I/O操作之后調(diào)用。由于process.nextTick()在其它的I/O之前調(diào)用,因此在某些情況下可能會導致I/O進入無限期等待,例如遞歸調(diào)用process.nextTick()但是對于setImmediate()則不會發(fā)生這種情況。當我們在本書后面分析使用延遲調(diào)用來運行同步CPU綁定任務時,我們將深入了解這兩種API之間的區(qū)別。
我們保證通過使用process.nextTick()異步調(diào)用其回調(diào)函數(shù)。
Node.js回調(diào)風格對于Node.js而言,CPS風格的API和回調(diào)函數(shù)遵循一組特殊的約定。這些約定不只是適用于Node.js核心API,對于它們之后也是絕大多數(shù)用戶級模塊和應用程序也很有意義。因此,我們了解這些風格,并確保我們在需要設計異步API時遵守規(guī)定顯得至關重要。
回調(diào)總是最后一個參數(shù)在所有核心Node.js方法中,標準約定是當函數(shù)在輸入中接受回調(diào)時,必須作為最后一個參數(shù)傳遞。我們以下面的Node.js核心API為例:
fs.readFile(filename, [options], callback);
從前面的例子可以看出,即使是在可選參數(shù)存在的情況下,回調(diào)也始終置于最后的位置。其原因是在回調(diào)定義的情況下,函數(shù)調(diào)用更可讀。
錯誤處理總在最前在CPS中,錯誤以不同于正確結(jié)果的形式在回調(diào)函數(shù)中傳遞。在Node.js中,CPS風格的回調(diào)函數(shù)產(chǎn)生的任何錯誤總是作為回調(diào)的第一個參數(shù)傳遞,并且任何實際的結(jié)果從第二個參數(shù)開始傳遞。如果操作成功,沒有錯誤,第一個參數(shù)將為null或undefined。看下列代碼:
fs.readFile("foo.txt", "utf8", (err, data) => { if (err) handleError(err); else processData(data); });
上面的例子是最好的檢測錯誤的方法,如果不檢測錯誤,我們可能難以發(fā)現(xiàn)和調(diào)試代碼中的bug,但另外一個要考慮的問題是錯誤總是為Error類型,這意味著簡單的字符串或數(shù)字不應該作為錯誤對象傳遞(難以被try catch代碼塊捕獲)。
錯誤傳播對于同步阻塞的寫法而言,我們的錯誤都是通過throw語句拋出,即使錯誤在錯誤棧中跳轉(zhuǎn),我們也能很好地捕獲到錯誤上下文。
但是對于CPS風格的異步調(diào)用而言,通過把錯誤傳遞到錯誤棧中的下一個回調(diào)來完成,下面是一個典型的例子:
const fs = require("fs"); function readJSON(filename, callback) { fs.readFile(filename, "utf8", (err, data) => { let parsed; if (err) // 如果有錯誤產(chǎn)生則退出當前調(diào)用 return callback(err); try { // 解析文件中的數(shù)據(jù) parsed = JSON.parse(data); } catch (err) { // 捕獲解析中的錯誤,如果有錯誤產(chǎn)生,則進行錯誤處理 return callback(err); } // 沒有錯誤,調(diào)用回調(diào) callback(null, parsed); }); };
從上面的例子中我們注意到的細節(jié)是當我們想要正確地進行異常處理時,我們?nèi)绾蜗?b>callback傳遞參數(shù)。此外,當有錯誤產(chǎn)生時,我們使用了return語句,立即退出當前函數(shù)調(diào)用,避免進行下面的相關執(zhí)行。
不可捕獲的異常從上述readJSON()函數(shù),為了避免將任何異常拋到fs.readFile()的回調(diào)函數(shù)中捕獲,我們對JSON.parse()周圍放置一個try catch代碼塊。在異步回調(diào)中一旦出錯,將拋出異常,并跳轉(zhuǎn)到事件循環(huán),不把錯誤傳播到下一個回調(diào)函數(shù)去。
在Node.js中,這是一個不可恢復的狀態(tài),應用程序會關閉,并將錯誤打印到標準輸出中。為了證明這一點,我們嘗試從之前定義的readJSON()函數(shù)中刪除try catch代碼塊:
const fs = require("fs"); function readJSONThrows(filename, callback) { fs.readFile(filename, "utf8", (err, data) => { if (err) { return callback(err); } // 假設parse的執(zhí)行沒有錯誤 callback(null, JSON.parse(data)); }); };
在上面的代碼中,我們沒有辦法捕獲到JSON.parse產(chǎn)生的異常,如果我們嘗試傳遞一個非標準JSON格式的文件,將會拋出以下錯誤:
SyntaxError: Unexpected token d at Object.parse (native) at [...] at fs.js:266:14 at Object.oncomplete (fs.js:107:15)
現(xiàn)在,如果我們看看前面的錯誤棧跟蹤,我們將看到它從fs模塊的某處開始,恰好從本地API完成文件讀取返回到fs.readFile()函數(shù),通過事件循環(huán)。這些信息都很清楚地顯示給我們,異常從我們的回調(diào)傳入堆棧,然后直接進入事件循環(huán),最終被捕獲并拋出到控制臺中。
這也意味著使用try catch代碼塊包裝對readJSONThrows()的調(diào)用將不起作用,因為塊所在的堆棧與調(diào)用回調(diào)的堆棧不同。以下代碼顯示了我們剛才描述的相反的情況:
try { readJSONThrows("nonJSON.txt", function(err, result) { // ... }); } catch (err) { console.log("This will not catch the JSON parsing exception"); }
前面的catch語句將永遠不會收到JSON解析異常,因為它將返回到拋出異常的堆棧。我們剛剛看到堆棧在事件循環(huán)中結(jié)束,而不是觸發(fā)異步操作的功能。
如前所述,應用程序在異常到達事件循環(huán)的那一刻中止,然而,我們?nèi)匀挥袡C會在應用程序終止之前執(zhí)行一些清理或日志記錄。事實上,當這種情況發(fā)生時,Node.js會在退出進程之前發(fā)出一個名為uncaughtException的特殊事件。以下代碼顯示了一個示例用例:
process.on("uncaughtException", (err) => { console.error("This will catch at last the " + "JSON parsing exception: " + err.message); // Terminates the application with 1 (error) as exit code: // without the following line, the application would continue process.exit(1); });
重要的是,未被捕獲的異常會使應用程序處于不能保證一致的狀態(tài),這可能導致不可預見的問題。例如,可能還有不完整的I/O請求運行或關閉可能會變得不一致。這就是為什么總是建議,特別是在生產(chǎn)環(huán)境中,在接收到未被捕獲的異常之后寫上述代碼進行錯誤日志記錄。
模塊系統(tǒng)及相關模式模塊不僅是構建大型應用的基礎,其主要機制是封裝內(nèi)部實現(xiàn)、方法與變量,通過接口。在本節(jié)中,我們將介紹Node.js的模塊系統(tǒng)及其最常見的使用模式。
關于模塊JavaScript的主要問題之一是沒有命名空間。在全局范圍內(nèi)運行的程序會污染全局命名空間,造成相關變量、數(shù)據(jù)、方法名的沖突。解決這個問題的技術稱為模塊模式,看下列代碼:
const module = (() => { const privateFoo = () => { // ... }; const privateBar = []; const exported = { publicFoo: () => { // ... }, publicBar: () => { // ... } }; return exported; })(); console.log(module);
此模式利用自執(zhí)行匿名函數(shù)實現(xiàn)模塊,僅導出旨希望被公開調(diào)用的部分。在上面的代碼中,模塊變量只包含導出的API,而其余的模塊內(nèi)容實際上從外部訪問不到。我們將在稍后看到,這種模式背后的想法被用作Node.js模塊系統(tǒng)的基礎。
Node.js模塊相關解釋CommonJS是一個旨在規(guī)范JavaScript生態(tài)系統(tǒng)的組織,他們提出了CommonJS模塊規(guī)范。 Node.js在此規(guī)范之上構建了其模塊系統(tǒng),并添加了一些自定義的擴展。為了描述它的工作原理,我們可以通過這樣一個例子解釋模塊模式,每個模塊都在私有命名空間下運行,這樣模塊內(nèi)定義的每個變量都不會污染全局命名空間。
自定義模塊系統(tǒng)為了解釋模塊系統(tǒng)的遠離,讓我們從頭開始構建一個類似的模塊系統(tǒng)。下面的代碼創(chuàng)建一個模仿Node.js原始require()函數(shù)的功能。
我們先創(chuàng)建一個加載模塊內(nèi)容的函數(shù),將其包裝到一個私有的命名空間內(nèi):
function loadModule(filename, module, require) { const wrappedSrc = `(function(module, exports, require) { ${fs.readFileSync(filename, "utf8")} })(module, module.exports, require);`; eval(wrappedSrc); }
模塊的源代碼被包裝到一個函數(shù)中,如同自執(zhí)行匿名函數(shù)那樣。這里的區(qū)別在于,我們將一些固有的變量傳遞給模塊,特指module,exports和require。注意導出模塊的參數(shù)是module.exports和exports,后面我們將再討論。
請記住,這只是一個例子,在真實項目中可不要這么做。諸如eval()或vm模塊有可能導致一些安全性的問題,它人可能利用漏洞來進行注入攻擊。我們應該非常小心地使用甚至完全避免使用eval。
我們現(xiàn)在來看模塊的接口、變量等是如何被require()函數(shù)引入的:
const require = (moduleName) => { console.log(`Require invoked for module: ${moduleName}`); const id = require.resolve(moduleName); // 是否命中緩存 if (require.cache[id]) { return require.cache[id].exports; } // 定義module const module = { exports: {}, id: id }; // 新模塊引入,存入緩存 require.cache[id] = module; // 加載模塊 loadModule(id, module, require); // 返回導出的變量 return module.exports; }; require.cache = {}; require.resolve = (moduleName) => { /* 通過模塊名作為參數(shù)resolve一個完整的模塊 */ };
上面的函數(shù)模擬了用于加載模塊的原生Node.js的require()函數(shù)的行為。當然,這只是一個demo,它并不能準確且完整地反映require()函數(shù)的真實行為,但是為了更好地理解Node.js模塊系統(tǒng)的內(nèi)部實現(xiàn),定義模塊和加載模塊。我們的自制模塊系統(tǒng)的功能如下:
模塊名稱被作為參數(shù)傳入,我們首先做的是找尋模塊的完整路徑,我們稱之為id。require.resolve()專門負責這項功能,它通過一個特定的解析算法實現(xiàn)相關功能(稍后將討論)。
如果模塊已經(jīng)被加載,它應該存在于緩存。在這種情況下,我們立即返回緩存中的模塊。
如果模塊尚未加載,我們將首次加載該模塊。創(chuàng)建一個模塊對象,其中包含一個使用空對象字面值初始化的exports屬性。該屬性將被模塊的代碼用于導出該模塊的公共API。
緩存首次加載的模塊對象。
模塊源代碼從其文件中讀取,代碼被導入,如前所述。我們通過require()函數(shù)向模塊提供我們剛剛創(chuàng)建的模塊對象。該模塊通過操作或替換module.exports對象來導出其公共API。
最后,將代表模塊的公共API的module.exports的內(nèi)容返回給調(diào)用者。
正如我們所看到的,Node.js模塊系統(tǒng)的原理并不是想象中那么高深,只不過是通過我們一系列操作來創(chuàng)建和導入導出模塊源代碼。
定義一個模塊通過查看我們的自定義require()函數(shù)的工作原理,我們現(xiàn)在既然已經(jīng)知道如何定義一個模塊。再來看下面這個例子:
// 加載另一個模塊 const dependency = require("./anotherModule"); // 模塊內(nèi)的私有函數(shù) function log() { console.log(`Well done ${dependency.username}`); } // 通過導出API實現(xiàn)共有方法 module.exports.run = () => { log(); };
需要注意的是模塊內(nèi)的所有內(nèi)容都是私有的,除非它被分配給module.exports變量。然后,當使用require()加載模塊時,緩存并返回此變量的內(nèi)容。
定義全局變量即使在模塊中聲明的所有變量和函數(shù)都在其本地范圍內(nèi)定義,仍然可以定義全局變量。事實上,模塊系統(tǒng)公開了一個名為global的特殊變量。分配給此變量的所有內(nèi)容將會被定義到全局環(huán)境下。
注意:污染全局命名空間是不好的,并且沒有充分運用模塊系統(tǒng)的優(yōu)勢。所以,只有真的需要使用全局變量,才去使用它。
module.exports和exports對于許多還不熟悉Node.js的開發(fā)人員而言,他們最容易混淆的是exports和module.exports來導出公共API的區(qū)別。變量export只是對module.exports的初始值的引用;我們已經(jīng)看到,exports本質(zhì)上在模塊加載之前只是一個簡單的對象。
這意味著我們只能將新屬性附加到導出變量引用的對象,如以下代碼所示:
exports.hello = () => { console.log("Hello"); }
重新給exports賦值并不會有任何影響,因為它并不會因此而改變module.exports的內(nèi)容,它只是改變了該變量本身。因此下列代碼是錯誤的:
exports = () => { console.log("Hello"); }
如果我們想要導出除對象之外的內(nèi)容,比如函數(shù),我們可以給module.exports重新賦值:
module.exports = () => { console.log("Hello"); }require函數(shù)是同步的
另一個重要的細節(jié)是上述我們寫的require()函數(shù)是同步的,它使用了一個較為簡單的方式返回了模塊內(nèi)容,并且不需要回調(diào)函數(shù)。因此,對于module.exports也是同步的,例如,下列的代碼是不正確的:
setTimeout(() => { module.exports = function() { // ... }; }, 100);
通過這種方式導出模塊會對我們定義模塊產(chǎn)生重要的影響,因為它限制了我們同步定義并使用模塊的方式。這實際上是為什么核心Node.js庫提供同步API以代替異步API的最重要的原因之一。
如果我們需要定義一個需要異步操作來進行初始化的模塊,我們也可以隨時定義和導出需要我們異步初始化的模塊。但是這樣定義異步模塊我們并不能保證require()后可以立即使用,在第九章,我們將詳細分析這個問題,并提出一些模式來優(yōu)化解決這個問題。
實際上,在早期的Node.js中,曾經(jīng)有一個異步版本的require(),但由于它對初始化時間和異步I/O的性能有巨大影響,很快這個API就被刪除了。
resolve算法依賴地獄描述了軟件的依賴于不同版本的軟件包的依賴關系,Node.js通過加載不同版本的模塊來解決這個問題,具體取決于模塊的加載位置。而都是由npm來完成的,相關算法被稱作resolve算法,被用到require()函數(shù)中。
現(xiàn)在讓我們快速概述一下這個算法。如下所述,resolve()函數(shù)將一個模塊名稱(moduleName)作為輸入,并返回模塊的完整路徑。然后,該路徑用于加載其代碼,并且還可以唯一地標識模塊。resolve算法可以分為以下三種規(guī)則:
文件模塊:如果moduleName以/開頭,那么它已經(jīng)被認為是模塊的絕對路徑。如果以./開頭,那么moduleName被認為是相對路徑,它是從使用require的模塊的位置開始計算的。
核心模塊:如果moduleName不以/或./開頭,則算法將首先嘗試在核心Node.js模塊中進行搜索。
模塊包:如果沒有找到匹配moduleName的核心模塊,則搜索在當前目錄下的node_modules,如果沒有搜索到node_modules,則會往上層目錄繼續(xù)搜索node_modules,直到它到達文件系統(tǒng)的根目錄。
對于文件和包模塊,單個文件和目錄也可以匹配到moduleName。特別地,算法將嘗試匹配以下內(nèi)容:
在
resolve算法的具體文檔
node_modules目錄實際上是npm安裝每個包并存放相關依賴關系的地方。這意味著,基于我們剛剛描述的算法,每個包都有自身的私有依賴關系。例如,看以下目錄結(jié)構:
myApp ├── foo.js └── node_modules ├── depA │ └── index.js └── depB │ ├── bar.js ├── node_modules ├── depA │ └── index.js └── depC ├── foobar.js └── node_modules └── depA └── index.js
在前面的例子中,myApp,depB和depC都依賴于depA;然而,他們都有自己的私有依賴的版本!按照解析算法的規(guī)則,使用require("depA")將根據(jù)需要的模塊加載不同的文件,如下:
在/myApp/foo.js中調(diào)用的require("depA")會加載/myApp/node_modules/depA/index.js
在/myApp/node_modules/depB/bar.js中調(diào)用的require("depA")會加載/myApp/node_modules/depB/node_modules/depA/index.js
在/myApp/node_modules/depC/foobar.js中調(diào)用的require("depA")會加載/myApp/node_modules/depC/node_modules/depA/index.js
resolve算法是Node.js依賴關系管理的核心部分,它的存在使得即便應用程序擁有成百上千包的情況下也不會出現(xiàn)沖突和版本不兼容的問題。
當我們調(diào)用require()時,解析算法對我們是透明的。然而,仍然可以通過調(diào)用require.resolve()直接由任何模塊使用。
模塊緩存每個模塊只會在它第一次引入的時候加載,此后的任意一次require()調(diào)用均從之前緩存的版本中取得。通過查看我們之前寫的自定義的require()函數(shù),可以看到緩存對于性能提升至關重要,此外也具有一些其它的優(yōu)勢,如下:
使得模塊依賴關系的重復利用成為可能
從某種程度上保證了在從給定的包中要求相同的模塊時總是返回相同的實例,避免了沖突
模塊緩存通過require.cache變量查看,因此如果需要,可以直接訪問它。在實際運用中的例子是通過刪除require.cache變量中的相對鍵來使某個緩存的模塊無效,這是在測試過程中非常有用,但在正常情況下會十分危險。
循環(huán)依賴許多人認為循環(huán)依賴是Node.js內(nèi)在的設計問題,但在真實項目中真的可能發(fā)生,所以我們至少知道如何在Node.js中使得循環(huán)依賴有效。再來看我們自定義的require()函數(shù),我們可以立即看到其工作原理和注意事項。
看下面這兩個模塊:
模塊a.js
exports.loaded = false; const b = require("./b"); module.exports = { bWasLoaded: b.loaded, loaded: true };
模塊b.js
exports.loaded = false; const a = require("./a"); module.exports = { aWasLoaded: a.loaded, loaded: true };
然后我們在main.js中寫以下代碼:
const a = require("./a"); const b = require("./b"); console.log(a); console.log(b);
執(zhí)行上述代碼,會打印以下結(jié)果:
{ bWasLoaded: true, loaded: true } { aWasLoaded: false, loaded: true }
這個結(jié)果展現(xiàn)了循環(huán)依賴的處理順序。雖然a.js和b.js這兩個模塊都在主模塊需要的時候完全初始化,但是當從b.js加載時,a.js模塊是不完整的。特別,這種狀態(tài)會持續(xù)到b.js加載完畢的那一刻。這種情況我們應該引起注意,特別要確認我們在main.js中兩個模塊所需的順序。
這是由于模塊a.js將收到一個不完整的版本的b.js。我們現(xiàn)在明白,如果我們失去了首先加載哪個模塊的控制,如果項目足夠大,這可能會很容易發(fā)生循環(huán)依賴。
關于循環(huán)引用的文檔
簡單說就是,為了防止模塊載入的死循環(huán),Node.js在模塊第一次載入后會把它的結(jié)果進行緩存,下一次再對它進行載入的時候會直接從緩存中取出結(jié)果。所以在這種循環(huán)依賴情形下,不會有死循環(huán),但是卻會因為緩存造成模塊沒有按照我們預想的那樣被導出(export,詳細的案例分析見下文)。
官網(wǎng)給出了三個模塊還不是循環(huán)依賴最簡單的情形。實際上,兩個模塊就可以很清楚的表達出這種情況。根據(jù)遞歸的思想,解決了最簡單的情形,這一類任意大小規(guī)模的問題也就解決了一半(另一半還需要探明隨著問題規(guī)模增長,問題的解將會如何變化)。
JavaScript作為一門解釋型的語言,上面的打印輸出清晰的展示出了程序運行的軌跡。在這個例子中,a.js首先require了b.js, 程序進入b.js,在b.js中第一行又require了a.js。
如前文所述,為了避免無限循環(huán)的模塊依賴,在Node.js運行a.js 之后,它就被緩存了,但需要注意的是,此時緩存的僅僅是一個未完工的a.js(an unfinished copy of the a.js)。所以在 b.js中require了a.js時,得到的僅僅是緩存中一個未完工的a.js,具體來說,它并沒有明確被導出的具體內(nèi)容(a.js尾端)。所以b.js中輸出的a是一個空對象。
之后,b.js順利執(zhí)行完,回到a.js的require語句之后,繼續(xù)執(zhí)行完成。
模塊定義模式模塊系統(tǒng)除了自帶處理依賴關系的機制之外,最常見的功能就是定義API。對于定義API,主要需要考慮私有和公共功能之間的平衡。其目的是最大化信息隱藏內(nèi)部實現(xiàn)和暴露的API可用性,同時將這些與可擴展性和代碼重用性進行平衡。
在本節(jié)中,我們將分析一些在Node.js中定義模塊的最流行模式;每個模塊都保證了私有變量的透明,可擴展性和代碼重用。
命名導出暴露公共API的最基本方法是使用命名導出,其中包括將我們想要公開的所有值分配給由export(或module.exports)引用的對象的屬性。以這種方式,生成的導出對象將成為一組相關功能的容器或命名空間。
看下面代碼,是此模式的實現(xiàn):
//file logger.js exports.info = (message) => { console.log("info: " + message); }; exports.verbose = (message) => { console.log("verbose: " + message); };
導出的函數(shù)隨后作為引入其的模塊的屬性使用,如下面的代碼所示:
// file main.js const logger = require("./logger"); logger.info("This is an informational message"); logger.verbose("This is a verbose message");
大多數(shù)Node.js模塊使用這種定義。
CommonJS規(guī)范僅允許使用exports變量來公開public成員。因此,命名的導出模式是唯一與CommonJS規(guī)范兼容的模式。使用module.exports是Node.js提供的一個擴展,以支持更廣泛的模塊定義模式。
函數(shù)導出最流行的模塊定義模式之一包括將整個module.exports變量重新分配給一個函數(shù)。它的主要優(yōu)點是它只暴露了一個函數(shù),為模塊提供了一個明確的入口點,使其更易于理解和使用,它也很好地展現(xiàn)了單一職責原則。這種定義模塊的方法在社區(qū)中也被稱為substack模式,在以下示例中查看此模式:
// file logger.js module.exports = (message) => { console.log(`info: ${message}`); };
該模式也可以將導出的函數(shù)用作其他公共API的命名空間。這是一個非常強大的組合,因為它仍然給模塊一個多帶帶的入口點(exports的主函數(shù))。這種方法還允許我們公開具有次要或更高級用例的其他函數(shù)。以下代碼顯示了如何使用導出的函數(shù)作為命名空間來擴展我們之前定義的模塊:
module.exports.verbose = (message) => { console.log(`verbose: ${message}`); };
這段代碼演示了如何調(diào)用我們剛才定義的模塊:
// file main.js const logger = require("./logger"); logger("This is an informational message"); logger.verbose("This is a verbose message");
雖然只是導出一個函數(shù)也可能是一個限制,但實際上它是一個完美的方式,把重點放在一個單一的函數(shù),它代表著這個模塊最重要的一個功能,同時使得內(nèi)部私有變量屬性更加透明,而只是暴露導出函數(shù)本身的屬性。
Node.js的模塊化鼓勵我們遵循采用單一職責原則(SRP):每個模塊應該對單個功能負責,該職責應完全由該模塊封裝,以保證復用性。
注意,這里講的substack模式,就是通過僅導出一個函數(shù)來暴露模塊的主要功能。使用導出的函數(shù)作為命名空間來導出別的次要功能。
構造器(類)導出導出構造函數(shù)的模塊是導出函數(shù)的模塊的特例。其不同之處在于,使用這種新模式,我們允許用戶使用構造函數(shù)創(chuàng)建新的實例,但是我們也可以擴展其原型并創(chuàng)建新類(繼承)。以下是此模式的示例:
// file logger.js function Logger(name) { this.name = name; } Logger.prototype.log = function(message) { console.log(`[${this.name}] ${message}`); }; Logger.prototype.info = function(message) { this.log(`info: ${message}`); }; Logger.prototype.verbose = function(message) { this.log(`verbose: ${message}`); }; module.exports = Logger;
我們通過以下方式使用上述模塊:
// file main.js const Logger = require("./logger"); const dbLogger = new Logger("DB"); dbLogger.info("This is an informational message"); const accessLogger = new Logger("ACCESS"); accessLogger.verbose("This is a verbose message");
通過ES2015的class關鍵字語法也可以實現(xiàn)相同的模式:
class Logger { constructor(name) { this.name = name; } log(message) { console.log(`[${this.name}] ${message}`); } info(message) { this.log(`info: ${message}`); } verbose(message) { this.log(`verbose: ${message}`); } } module.exports = Logger;
鑒于ES2015的類只是原型的語法糖,該模塊的使用將與其基于原型和構造函數(shù)的方案完全相同。
導出構造函數(shù)或類仍然是模塊的單個入口點,但與substack模式比起來,它暴露了更多的模塊內(nèi)部結(jié)構。然而,另一方面,當想要擴展該模塊功能時,我們可以更加方便。
這種模式的變種包括對不使用new的調(diào)用。這個小技巧讓我們將我們的模塊用作工廠。看下列代碼:
function Logger(name) { if (!(this instanceof Logger)) { return new Logger(name); } this.name = name; };
其實這很簡單:我們檢查this是否存在,并且是Logger的一個實例。如果這些條件中的任何一個都為false,則意味著Logger()函數(shù)在不使用new的情況下被調(diào)用,然后繼續(xù)正確創(chuàng)建新實例并將其返回給調(diào)用者。這種技術允許我們將模塊也用作工廠:
// file logger.js const Logger = require("./logger"); const dbLogger = Logger("DB"); accessLogger.verbose("This is a verbose message");
ES2015的new.target語法從Node.js 6開始提供了一個更簡潔的實現(xiàn)上述功能的方法。該利用公開了new.target屬性,該屬性是所有函數(shù)中可用的元屬性,如果使用new關鍵字調(diào)用函數(shù),則在運行時計算結(jié)果為true。
我們可以使用這種語法重寫工廠:
function Logger(name) { if (!new.target) { return new LoggerConstructor(name); } this.name = name; }
這個代碼完全與前一段代碼作用相同,所以我們可以說ES2015的new.target語法糖使得代碼更加可讀和自然。
實例導出我們可以利用require()的緩存機制來輕松地定義具有從構造函數(shù)或工廠創(chuàng)建的狀態(tài)的有狀態(tài)實例,可以在不同模塊之間共享。以下代碼顯示了此模式的示例:
//file logger.js function Logger(name) { this.count = 0; this.name = name; } Logger.prototype.log = function(message) { this.count++; console.log("[" + this.name + "] " + message); }; module.exports = new Logger("DEFAULT");
這個新定義的模塊可以這么使用:
// file main.js const logger = require("./logger"); logger.log("This is an informational message");
因為模塊被緩存,所以每個需要Logger模塊的模塊實際上總是會檢索該對象的相同實例,從而共享它的狀態(tài)。這種模式非常像創(chuàng)建單例。然而,它并不保證整個應用程序的實例的唯一性,因為它發(fā)生在傳統(tǒng)的單例模式中。在分析解析算法時,實際上已經(jīng)看到,一個模塊可能會多次安裝在應用程序的依賴關系樹中。這導致了同一邏輯模塊的多個實例,所有這些實例都運行在同一個Node.js應用程序的上下文中。在第7章中,我們將分析導出有狀態(tài)的實例和一些可替代的模式。
我們剛剛描述的模式的擴展包括exports用于創(chuàng)建實例的構造函數(shù)以及實例本身。這允許用戶創(chuàng)建相同對象的新實例,或者如果需要也可以擴展它們。為了實現(xiàn)這一點,我們只需要為實例分配一個新的屬性,如下面的代碼所示:
module.exports.Logger = Logger;
然后,我們可以使用導出的構造函數(shù)創(chuàng)建類的其他實例:
const customLogger = new logger.Logger("CUSTOM"); customLogger.log("This is an informational message");
從代碼可用性的角度來看,這類似于將導出的函數(shù)用作命名空間,該模塊導出一個對象的默認實例,這是我們大部分時間使用的功能,而更多的高級功能(如創(chuàng)建新實例或擴展對象的功能)仍然可以通過較少的暴露屬性來使用。
修改其他模塊或全局作用域一個模塊甚至可以導出任何東西這可以看起來有點不合適;但是,我們不應該忘記一個模塊可以修改全局范圍和其中的任何對象,包括緩存中的其他模塊。請注意,這些通常被認為是不好的做法,但是由于這種模式在某些情況下(例如測試)可能是有用和安全的,有時確實可以利用這一特性,這是值得了解和理解的。我們說一個模塊可以修改全局范圍內(nèi)的其他模塊或?qū)ο蟆KǔJ侵冈谶\行時修改現(xiàn)有對象以更改或擴展其行為或應用的臨時更改。
以下示例顯示了我們?nèi)绾蜗蛄硪粋€模塊添加新函數(shù):
// file patcher.js // ./logger is another module require("./logger").customMessage = () => console.log("This is a new functionality");
編寫以下代碼:
// file main.js require("./patcher"); const logger = require("./logger"); logger.customMessage();
在上述代碼中,必須首先引入patcher程序才能使用logger模塊。
上面的寫法是很危險的。主要考慮的是擁有修改全局命名空間或其他模塊的模塊是具有副作用的操作。換句話說,它會影響其范圍之外的實體的狀態(tài),這可能導致不可預測的后果,特別是當多個模塊與相同的實體進行交互時。想象一下,有兩個不同的模塊嘗試設置相同的全局變量,或者修改同一個模塊的相同屬性,效果可能是不可預測的(哪個模塊勝出?),但最重要的是它會對在整個應用程序產(chǎn)生影響。
觀察者模式Node.js中的另一個重要和基本的模式是觀察者模式。與reactor模式,回調(diào)模式和模塊一樣,觀察者模式是Node.js基礎之一,也是使用許多Node.js核心模塊和用戶定義模塊的基礎。
觀察者模式是對Node.js的數(shù)據(jù)響應的理想解決方案,也是對回調(diào)的完美補充。我們給出以下定義:
發(fā)布者定義一個對象,它可以在其狀態(tài)發(fā)生變化時通知一組觀察者(或監(jiān)聽者)。
與回調(diào)模式的主要區(qū)別在于,主體實際上可以通知多個觀察者,而傳統(tǒng)的CPS風格的回調(diào)通常主體的結(jié)果只會傳播給一個監(jiān)聽器。
EventEmitter類在傳統(tǒng)的面向?qū)ο缶幊讨校^察者模式需要接口,具體類和層次結(jié)構。在Node.js中,都變得簡單得多。觀察者模式已經(jīng)內(nèi)置在核心模塊中,可以通過EventEmitter類來實現(xiàn)。 EventEmitter類允許我們注冊一個或多個函數(shù)作為監(jiān)聽器,當特定的事件類型被觸發(fā)時,它的回調(diào)將被調(diào)用,以通知其監(jiān)聽器。以下圖像直觀地解釋了這個概念:
EventEmitter是一個類(原型),它是從事件核心模塊導出的。以下代碼顯示了如何獲得對它的引用:
const EventEmitter = require("events").EventEmitter; const eeInstance = new EventEmitter();
EventEmitter的基本方法如下:
on(event,listener):此方法允許您為給定的事件類型(String類型)注冊一個新的偵聽器(一個函數(shù))
once(event, listener):此方法注冊一個新的監(jiān)聽器,然后在事件首次發(fā)布之后被刪除
emit(event, [arg1], [...]):此方法會生成一個新事件,并提供其他參數(shù)以傳遞給偵聽器
removeListener(event, listener):此方法將刪除指定事件類型的偵聽器
所有上述方法將返回EventEmitter實例以允許鏈接。監(jiān)聽器函數(shù)function([arg1], [...]),所以它只是接受事件發(fā)出時提供的參數(shù)。在偵聽器中,這是指EventEmitter生成事件的實例。
我們可以看到,一個監(jiān)聽器和一個傳統(tǒng)的Node.js回調(diào)有很大的區(qū)別;特別地,第一個參數(shù)不是error,它是在調(diào)用時傳遞給emit()的任何數(shù)據(jù)。
我們來看看我們?nèi)绾卧趯嵺`中使用EventEmitter。最簡單的方法是創(chuàng)建一個新的實例并立即使用它。以下代碼顯示了在文件列表中找到匹配特定正則的文件內(nèi)容時,使用EventEmitter實現(xiàn)實時通知訂閱者的功能:
const EventEmitter = require("events").EventEmitter; const fs = require("fs"); function findPattern(files, regex) { const emitter = new EventEmitter(); files.forEach(function(file) { fs.readFile(file, "utf8", (err, content) => { if (err) return emitter.emit("error", err); emitter.emit("fileread", file); let match; if (match = content.match(regex)) match.forEach(elem => emitter.emit("found", file, elem)); }); }); return emitter; }
由前面的函數(shù)EventEmitter處理將產(chǎn)生的三個事件:
fileread事件:當文件被讀取時觸發(fā)
found事件:當文件內(nèi)容被正則匹配成功時觸發(fā)
error事件:當讀取文件出現(xiàn)錯誤時觸發(fā)
下面看findPattern()函數(shù)是如何被觸發(fā)的:
findPattern(["fileA.txt", "fileB.json"], /hello w+/g) .on("fileread", file => console.log(file + " was read")) .on("found", (file, match) => console.log("Matched "" + match + "" in file " + file)) .on("error", err => console.log("Error emitted: " + err.message));
在前面的例子中,我們?yōu)?b>EventParttern()函數(shù)創(chuàng)建的EventEmitter生成的每個事件類型注冊了一個監(jiān)聽器。
錯誤傳播如果事件是異步發(fā)送的,EventEmitter不能在異常情況發(fā)生時拋出異常,異常會在事件循環(huán)中丟失。相反,而是emit是發(fā)出一個稱為錯誤的特殊事件,Error對象通過參數(shù)傳遞。這正是我們在之前定義的findPattern()函數(shù)中正在做的。
對于錯誤事件,始終是最佳做法注冊偵聽器,因為Node.js會以特殊的方式處理它,并且如果沒有找到相關聯(lián)的偵聽器,將自動拋出異常并退出程序。
讓任意對象可觀察有時,直接通過EventEmitter類創(chuàng)建一個新的可觀察的對象是不夠的,因為原生EventEmitter類并沒有提供我們實際運用場景的拓展功能。我們可以通過擴展EventEmitter類使一個通用對象可觀察。
為了演示這個模式,我們試著在對象中實現(xiàn)findPattern()函數(shù)的功能,如下代碼所示:
const EventEmitter = require("events").EventEmitter; const fs = require("fs"); class FindPattern extends EventEmitter { constructor(regex) { super(); this.regex = regex; this.files = []; } addFile(file) { this.files.push(file); return this; } find() { this.files.forEach(file => { fs.readFile(file, "utf8", (err, content) => { if (err) { return this.emit("error", err); } this.emit("fileread", file); let match = null; if (match = content.match(this.regex)) { match.forEach(elem => this.emit("found", file, elem)); } }); }); return this; } }
我們定義的FindPattern類中運用了核心模塊util提供的inherits()函數(shù)來擴展EventEmitter。以這種方式,它成為一個符合我們實際運用場景的可觀察類。以下是其用法的示例:
const findPatternObject = new FindPattern(/hello w+/); findPatternObject .addFile("fileA.txt") .addFile("fileB.json") .find() .on("found", (file, match) => console.log(`Matched "${match}" in file ${file}`)) .on("error", err => console.log(`Error emitted ${err.message}`));
現(xiàn)在,通過繼承EventEmitter的功能,我們現(xiàn)在可以看到FindPattern對象除了可觀察外,還有一整套方法。
這在Node.js生態(tài)系統(tǒng)中是一個很常見的模式,例如,核心HTTP模塊的Server對象定義了listen(),close(),setTimeout()等方法,并且在內(nèi)部它也繼承自EventEmitter函數(shù),從而允許它在收到新的請求、建立新的連接或者服務器關閉響應請求相關的事件。
擴展EventEmitter的對象的其他示例是Node.js流。我們將在第五章中更詳細地分析Node.js的流。
同步和異步事件與回調(diào)模式類似,事件也支持同步或異步發(fā)送。至關重要的是,我們決不應當在同一個EventEmitter中混合使用兩種方法,但是在發(fā)布相同的事件類型時考慮同步或者異步顯得至關重要,以避免產(chǎn)生因同步與異步順序不一致導致的zalgo。
發(fā)布同步和異步事件的主要區(qū)別在于觀察者注冊的方式。當事件異步發(fā)布時,即使在EventEmitter初始化之后,程序也會注冊新的觀察者,因為必須保證此事件在事件循環(huán)下一周期之前不被觸發(fā)。正如上邊的findPattern()函數(shù)中的情況。它代表了大多數(shù)Node.js異步模塊中使用的常用方法。
相反,同步發(fā)布事件要求在EventEmitter函數(shù)開始發(fā)出任何事件之前就得注冊好觀察者。看下面的例子:
const EventEmitter = require("events").EventEmitter; class SyncEmit extends EventEmitter { constructor() { super(); this.emit("ready"); } } const syncEmit = new SyncEmit(); syncEmit.on("ready", () => console.log("Object is ready to be used"));
如果ready事件是異步發(fā)布的,那么上述代碼將會正常運行,然而,由于事件是同步發(fā)布的,并且監(jiān)聽器在發(fā)送事件之后才被注冊,所以結(jié)果不調(diào)用監(jiān)聽器,該代碼將無法打印到控制臺。
由于不同的應用場景,有時以同步方式使用EventEmitter函數(shù)是有意義的。因此,要清楚地突出我們的EventEmitter的同步和異步性,以避免產(chǎn)生不必要的錯誤和異常。
事件機制與回調(diào)機制的比較在定義異步API時,常見的難點是檢查是否使用EventEmitter的事件機制或僅接受回調(diào)函數(shù)。一般區(qū)分規(guī)則是這樣的:當一個結(jié)果必須以異步方式返回時,應該使用回調(diào)函數(shù),當需要結(jié)果不確定其方式時,應該使用事件機制來響應。
但是,由于這兩者實在太相近,并且可能兩種方式都能實現(xiàn)相同的應用場景,所以產(chǎn)生了許多混亂。以下列代碼為例:
function helloEvents() { const eventEmitter = new EventEmitter(); setTimeout(() => eventEmitter.emit("hello", "hello world"), 100); return eventEmitter; } function helloCallback(callback) { setTimeout(() => callback("hello world"), 100); }
helloEvents()和helloCallback()在其功能上可以被認為是等價的,第一個使用事件機制實現(xiàn),第二個則使用回調(diào)來通知調(diào)用者,而將事件作為參數(shù)傳遞。但是真正區(qū)分它們的是可執(zhí)行性,語義和要實現(xiàn)或使用的代碼量。雖然我們不能給出一套確定性的規(guī)則來選擇一種風格,但我們當然可以提供一些提示來幫助你做出決定。
相比于第一個例子,即觀察者模式而言,回調(diào)函數(shù)在支持不同類型的事件時有一些限制。但是事實上,我們?nèi)匀豢梢酝ㄟ^將事件類型作為回調(diào)的參數(shù)傳遞,或者通過接受多個回調(diào)來區(qū)分多個事件。然而,這樣做的話不能被認為是一個優(yōu)雅的API。在這種情況下,EventEmitter可以提供更好的接口和更精簡的代碼。
EventEmitter更優(yōu)秀的另一種應用場景是多次觸發(fā)同一事件或不觸發(fā)事件的情況。事實上,無論操作是否成功,一個回調(diào)預計都只會被調(diào)用一次。但有一種特殊情況是,我們可能不知道事件在哪個時間點觸發(fā),在這種情況下,EventEmitter是首選。
最后,使用回調(diào)的API僅通知特定的回調(diào),但是使用EventEmitter函數(shù)可以讓多個監(jiān)聽器都接收到通知。
回調(diào)機制和事件機制結(jié)合使用還有一些情況可以將事件機制和回調(diào)結(jié)合使用。特別是當我們導出異步函數(shù)時,這種模式非常有用。node-glob模塊是該模塊的一個示例。
glob(pattern, [options], callback)
該函數(shù)將一個文件名匹配模式作為第一個參數(shù),后面兩個參數(shù)分別為一組選項和一個回調(diào)函數(shù),對于匹配到指定文件名匹配模式的文件列表,相關回調(diào)函數(shù)會被調(diào)用。同時,該函數(shù)返回EventEmitter,它展現(xiàn)了當前進程的狀態(tài)。例如,當成功匹配文件名時可以實時發(fā)布match事件,當文件列表全部匹配完畢時可以實時發(fā)布end事件,或者該進程被手動中止時發(fā)布abort事件。看以下代碼:
const glob = require("glob"); glob("data/*.txt", (error, files) => console.log(`All files found: ${JSON.stringify(files)}`)) .on("match", match => console.log(`Match found: ${match}`));總結(jié)
在本章中,我們首先了解了同步和異步的區(qū)別。然后,我們探討了如何使用回調(diào)機制和回調(diào)機制來處理一些基本的異步方案。我們還了解到兩種模式之間的主要區(qū)別,何時比另一種模式更適合解決具體問題。我們只是邁向更先進的異步模式的第一步。
在下一章中,我們將介紹更復雜的場景,了解如何利用回調(diào)機制和事件機制來處理高級異步控制問題。
文章版權歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/89980.html
摘要:中各種用于讀取數(shù)據(jù)的對象對象描述用于讀取文件代表客戶端請求或服務器端響應代表一個端口對象用于創(chuàng)建子進程的標準輸出流。如果子進程和父進程共享輸入輸出流,則子進程的標準輸出流被廢棄用于創(chuàng)建子進程的標準錯誤輸出流。 9. stream流 fs模塊中集中文件讀寫方法的區(qū)別 用途 使用異步方式 使用同步方式 將文件完整讀入緩存區(qū) readFile readFileSync 將文件部...
摘要:基礎的端到端的基準測試顯示大約比快八倍。所謂單線程,就是指一次只能完成一件任務。在服務器端,異步模式甚至是唯一的模式,因為執(zhí)行環(huán)境是單線程的,如果允許同步執(zhí)行所有請求,服務器性能會急劇下降,很快就會失去響應。 模塊 Node.js 提供了exports 和 require 兩個對象,其中 exports 是模塊公開的接口,require 用于從外部獲取一個模塊的接口,即所獲取模塊的 e...
摘要:事件多路復用器收集資源的事件并且把這些事件放入隊列中,直到事件被處理時都是阻塞狀態(tài)。最后,處理事件多路復用器返回的每個事件,此時,與系統(tǒng)資源相關聯(lián)的事件將被讀并且在整個操作中都是非阻塞的。 本系列文章為《Node.js Design Patterns Second Edition》的原文翻譯和讀書筆記,在GitHub連載更新,同步翻譯版鏈接。 歡迎關注我的專欄,之后的博文將在專欄同步:...
摘要:以為例,編寫來幫助我們完成重復的工作編譯壓縮我只要執(zhí)行一下就可以檢測到文件的變化,然后為你執(zhí)行一系列的自動化操作,同樣的操作也發(fā)生在這些的預處理器上。的使用是針對第三方類庫使用各種模塊化寫法以及語法。 showImg(https://segmentfault.com/img/bVbtZYK); 一:前端工程化的發(fā)展 很久以前,互聯(lián)網(wǎng)行業(yè)有個職位叫做 軟件開發(fā)工程師 在那個時代,大家可能...
閱讀 1816·2021-08-13 15:06
閱讀 3106·2021-08-05 10:02
閱讀 3378·2019-08-30 15:55
閱讀 2393·2019-08-30 13:46
閱讀 2493·2019-08-30 13:01
閱讀 1331·2019-08-29 17:17
閱讀 2830·2019-08-29 15:27
閱讀 1439·2019-08-29 11:12