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