node.js事件驅動
by Samer Buna
通過Samer Buna
了解Node.js事件驅動架構 (Understanding Node.js Event-Driven Architecture)
Update: This article is now part of my book “Node.js Beyond The Basics”.
更新:這篇文章現在是我的書《超越基礎的Node.js》的一部分。
Read the updated version of this content and more about Node at jscomplete.com/node-beyond-basics.
在jscomplete.com/node-beyond-basics中閱讀此內容的更新版本以及有關Node的更多信息。
Most of Node’s objects — like HTTP requests, responses, and streams — implement the EventEmitter
module so they can provide a way to emit and listen to events.
Node的大多數對象(例如HTTP請求,響應和流)都實現EventEmitter
模塊,因此它們可以提供一種發出和偵聽事件的方式。
The simplest form of the event-driven nature is the callback style of some of the popular Node.js functions — for example, fs.readFile
. In this analogy, the event will be fired once (when Node is ready to call the callback) and the callback acts as the event handler.
事件驅動性質的最簡單形式是一些流行的Node.js函數的回調樣式,例如fs.readFile
。 以此類推,事件將被觸發一次(當Node準備好調用回調時),并且回調充當事件處理程序。
Let’s explore this basic form first.
讓我們首先探討這種基本形式。
準備好后給我打電話,Node! (Call me when you’re ready, Node!)
The original way Node handled asynchronous events was with callback. This was a long time ago, before JavaScript had native promises support and the async/await feature.
Node處理異步事件的原始方式是使用回調。 這是很久以前的事情,當時JavaScript還沒有原生的Promise支持和異步/等待功能。
Callbacks are basically just functions that you pass to other functions. This is possible in JavaScript because functions are first class objects.
回調基本上只是傳遞給其他函數的函數。 在JavaScript中這是可能的,因為函數是第一類對象。
It’s important to understand that callbacks do not indicate an asynchronous call in the code. A function can call the callback both synchronously and asynchronously.
重要的是要了解回調不會在代碼中指示異步調用。 函數可以同步和異步調用回調。
For example, here’s a host function fileSize
that accepts a callback function cb
and can invoke that callback function both synchronously and asynchronously based on a condition:
例如,下面是一個宿主函數fileSize
,它接受一個回調函數cb
并可以根據條件同步和異步調用該回調函數:
function fileSize (fileName, cb) {if (typeof fileName !== 'string') {return cb(new TypeError('argument should be string')); // Sync}fs.stat(fileName, (err, stats) => {if (err) { return cb(err); } // Asynccb(null, stats.size); // Async});
}
Note that this is a bad practice that leads to unexpected errors. Design host functions to consume callback either always synchronously or always asynchronously.
請注意,這是一種不良做法,會導致意外錯誤。 設計主機函數以始終同步或始終異步使用回調。
Let’s explore a simple example of a typical asynchronous Node function that’s written with a callback style:
讓我們研究一下用回調樣式編寫的典型異步Node函數的簡單示例:
const readFileAsArray = function(file, cb) {fs.readFile(file, function(err, data) {if (err) {return cb(err);}const lines = data.toString().trim().split('\n');cb(null, lines);});
};
readFileAsArray
takes a file path and a callback function. It reads the file content, splits it into an array of lines, and calls the callback function with that array.
readFileAsArray
采用文件路徑和回調函數。 它讀取文件內容,將其拆分為一個行數組,然后使用該數組調用回調函數。
Here’s an example use for it. Assuming that we have the file numbers.txt
in the same directory with content like this:
這是一個示例用法。 假設我們在同一目錄中有文件numbers.txt
,其內容如下:
10
11
12
13
14
15
If we have a task to count the odd numbers in that file, we can use readFileAsArray
to simplify the code:
如果我們有一個任務來計算該文件中的奇數,則可以使用readFileAsArray
簡化代碼:
readFileAsArray('./numbers.txt', (err, lines) => {if (err) throw err;const numbers = lines.map(Number);const oddNumbers = numbers.filter(n => n%2 === 1);console.log('Odd numbers count:', oddNumbers.length);
});
The code reads the numbers content into an array of strings, parses them as numbers, and counts the odd ones.
該代碼將數字內容讀入字符串數組,將其解析為數字,然后對奇數進行計數。
Node’s callback style is used purely here. The callback has an error-first argument err
that’s nullable and we pass the callback as the last argument for the host function. You should always do that in your functions because users will probably assume that. Make the host function receive the callback as its last argument and make the callback expect an error object as its first argument.
純粹在這里使用Node的回調樣式。 回調函數的錯誤優先參數err
可為空,我們將回調函數作為主機函數的最后一個參數傳遞。 您應該始終在函數中執行此操作,因為用戶可能會假設這樣做。 使主機函數將回調作為其最后一個參數接收,并使回調將錯誤對象作為其第一個參數。
替代回調的現代JavaScript (The modern JavaScript alternative to Callbacks)
In modern JavaScript, we have promise objects. Promises can be an alternative to callbacks for asynchronous APIs. Instead of passing a callback as an argument and handling the error in the same place, a promise object allows us to handle success and error cases separately and it also allows us to chain multiple asynchronous calls instead of nesting them.
在現代JavaScript中,我們有promise對象。 承諾可以替代異步API的回調。 Promise對象無需將回調作為參數傳遞并在同一位置處理錯誤,而是使我們可以分別處理成功和錯誤情況,還可以鏈接多個異步調用而不是嵌套它們。
If the readFileAsArray
function supports promises, we can use it as follows:
如果readFileAsArray
函數支持promise,則可以按以下方式使用它:
readFileAsArray('./numbers.txt').then(lines => {const numbers = lines.map(Number);const oddNumbers = numbers.filter(n => n%2 === 1);console.log('Odd numbers count:', oddNumbers.length);}).catch(console.error);
Instead of passing in a callback function, we called a .then
function on the return value of the host function. This .then
function usually gives us access to the same lines array that we get in the callback version, and we can do our processing on it as before. To handle errors, we add a .catch
call on the result and that gives us access to an error when it happens.
我們沒有傳遞回調函數,而是在宿主函數的返回值上調用了.then
函數。 這個.then
函數通常使我們能夠訪問與回調版本中相同的lines數組,并且我們可以像以前一樣對其進行處理。 為了處理錯誤,我們在結果上添加了.catch
調用,使我們可以在錯誤發生時對其進行訪問。
Making the host function support a promise interface is easier in modern JavaScript thanks to the new Promise object. Here’s the readFileAsArray
function modified to support a promise interface in addition to the callback interface it already supports:
由于有了新的Promise對象,在現代JavaScript中使宿主函數支持Promise接口更加容易。 這是已修改的readFileAsArray
函數,除了已經支持的回調接口之外,還支持Promise接口:
const readFileAsArray = function(file, cb = () => {}) {return new Promise((resolve, reject) => {fs.readFile(file, function(err, data) {if (err) {reject(err);return cb(err);}const lines = data.toString().trim().split('\n');resolve(lines);cb(null, lines);});});
};
So we make the function return a Promise object, which wraps the fs.readFile
async call. The promise object exposes two arguments, a resolve
function and a reject
function.
因此,我們使函數返回一個Promise對象,該對象包裝了fs.readFile
異步調用。 Promise對象公開兩個參數,一個resolve
函數和一個reject
函數。
Whenever we want to invoke the callback with an error we use the promise reject
function as well, and whenever we want to invoke the callback with data we use the promise resolve
function as well.
每當我們想用錯誤調用回調函數時,我們也會使用promise reject
函數,每當我們想對數據調用回調函數時,我們也將使用promise resolve
函數。
The only other thing we needed to do in this case is to have a default value for this callback argument in case the code is being used with the promise interface. We can use a simple, default empty function in the argument for that case: () =>
{}.
在這種情況下,我們唯一需要做的另一件事就是為該回調參數設置一個默認值,以防代碼與promise接口一起使用。 在這種情況下,我們可以在參數中使用一個簡單的默認空函數: () =>
{}。
使用async / await消費諾言 (Consuming promises with async/await)
Adding a promise interface makes your code a lot easier to work with when there is a need to loop over an async function. With callbacks, things become messy.
當需要循環異步功能時,添加一個promise接口會使您的代碼更容易使用。 使用回調,事情變得混亂。
Promises improve that a little bit, and function generators improve on that a little bit more. This said, a more recent alternative to working with async code is to use the async
function, which allows us to treat async code as if it was synchronous, making it a lot more readable overall.
承諾會有所改善,函數生成器會有所改善。 這就是說,使用異步代碼的另一種替代方法是使用async
函數,該函數使我們可以將異步代碼視為同步代碼,從而使整體可讀性更高。
Here’s how we can consume the readFileAsArray
function with async/await:
這是我們如何在async / await中使用readFileAsArray
函數:
async function countOdd () {try {const lines = await readFileAsArray('./numbers');const numbers = lines.map(Number);const oddCount = numbers.filter(n => n%2 === 1).length;console.log('Odd numbers count:', oddCount);} catch(err) {console.error(err);}
}
countOdd();
We first create an async function, which is just a normal function with the word async
before it. Inside the async function, we call the readFileAsArray
function as if it returns the lines variable, and to make that work, we use the keyword await
. After that, we continue the code as if the readFileAsArray
call was synchronous.
我們首先創建一個異步函數,這只是一個普通函數,其前面帶有單詞async
。 在async函數內部,我們調用readFileAsArray
函數,就好像它返回lines變量一樣,為了使其正常工作,我們使用關鍵字await
。 之后,我們繼續執行代碼,就像readFileAsArray
調用是同步的一樣。
To get things to run, we execute the async function. This is very simple and more readable. To work with errors, we need to wrap the async call in a try
/catch
statement.
為了使事情運行,我們執行異步功能。 這非常簡單并且可讀性強。 要處理錯誤,我們需要將異步調用包裝在try
/ catch
語句中。
With this async/await feature, we did not have to use any special API (like .then and .catch). We just labeled functions differently and used pure JavaScript for the code.
使用此異步/等待功能,我們不必使用任何特殊的API(例如.then和.catch)。 我們只是對函數進行了不同的標記,并對代碼使用了純JavaScript。
We can use the async/await feature with any function that supports a promise interface. However, we can’t use it with callback-style async functions (like setTimeout for example).
我們可以將async / await功能與任何支持promise接口的功能一起使用。 但是,我們不能將其與回調樣式的異步函數(例如setTimeout)一起使用。
EventEmitter模塊 (The EventEmitter Module)
The EventEmitter is a module that facilitates communication between objects in Node. EventEmitter is at the core of Node asynchronous event-driven architecture. Many of Node’s built-in modules inherit from EventEmitter.
EventEmitter是一個模塊,可促進Node中對象之間的通信。 EventEmitter是Node異步事件驅動的體系結構的核心。 Node的許多內置模塊都繼承自EventEmitter。
The concept is simple: emitter objects emit named events that cause previously registered listeners to be called. So, an emitter object basically has two main features:
這個概念很簡單:發射器對象發出命名事件,這些事件導致先前注冊的偵聽器被調用。 因此,發射器對象基本上具有兩個主要功能:
- Emitting name events. 發出名稱事件。
- Registering and unregistering listener functions. 注冊和注銷偵聽器功能。
To work with the EventEmitter, we just create a class that extends EventEmitter.
要使用EventEmitter,我們只需創建一個擴展EventEmitter的類。
class MyEmitter extends EventEmitter {}
Emitter objects are what we instantiate from the EventEmitter-based classes:
發射器對象是我們從基于EventEmitter的類中實例化的:
const myEmitter = new MyEmitter();
At any point in the lifecycle of those emitter objects, we can use the emit function to emit any named event we want.
在這些發射器對象的生命周期中的任何時候,我們都可以使用發出函數來發出我們想要的任何命名事件。
myEmitter.emit('something-happened');
Emitting an event is the signal that some condition has occurred. This condition is usually about a state change in the emitting object.
發出事件是已發生某種情況的信號。 該條件通常與發射物體的狀態變化有關。
We can add listener functions using the on
method, and those listener functions will be executed every time the emitter object emits their associated name event.
我們可以使用on
方法添加偵聽器函數,這些偵聽器函數將在每次發射器對象發出其關聯的名稱事件時執行。
事件!==異步 (Events !== Asynchrony)
Let’s take a look at an example:
讓我們看一個例子:
const EventEmitter = require('events');class WithLog extends EventEmitter {execute(taskFunc) {console.log('Before executing');this.emit('begin');taskFunc();this.emit('end');console.log('After executing');}
}const withLog = new WithLog();withLog.on('begin', () => console.log('About to execute'));
withLog.on('end', () => console.log('Done with execute'));withLog.execute(() => console.log('*** Executing task ***'));
Class WithLog
is an event emitter. It defines one instance function execute
. This execute
function receives one argument, a task function, and wraps its execution with log statements. It fires events before and after the execution.
WithLog
類是事件發射器。 它定義了一個實例函數execute
。 該execute
函數接收一個參數,一個task函數,并用log語句包裝其執行。 它在執行前后觸發事件。
To see the sequence of what will happen here, we register listeners on both named events and finally execute a sample task to trigger things.
要查看此處發生的順序,我們在兩個命名事件上注冊偵聽器,最后執行一個示例任務來觸發事件。
Here’s the output of that:
這是輸出:
Before executing
About to execute
*** Executing task ***
Done with execute
After executing
What I want you to notice about the output above is that it all happens synchronously. There is nothing asynchronous about this code.
我希望您注意到上面的輸出,所有操作都是同步發生的。 此代碼沒有異步的。
- We get the “Before executing” line first. 我們首先得到“執行之前”這一行。
The
begin
named event then causes the “About to execute” line.然后,
begin
命名事件將導致“關于要執行”行。- The actual execution line then outputs the “*** Executing task ***” line. 然后,實際執行行將輸出“ ***執行任務***”行。
The
end
named event then causes the “Done with execute” line然后,以命名
end
事件導致“完成并執行”行- We get the “After executing” line last. 我們最后得到“執行后”行。
Just like plain-old callbacks, do not assume that events mean synchronous or asynchronous code.
就像普通的回調一樣,不要假定事件表示同步或異步代碼。
This is important, because if we pass an asynchronous taskFunc
to execute
, the events emitted will no longer be accurate.
這很重要,因為如果我們傳遞異步taskFunc
來execute
,則發出的事件將不再準確。
We can simulate the case with a setImmediate
call:
我們可以使用setImmediate
調用來模擬這種情況:
// ...withLog.execute(() => {setImmediate(() => {console.log('*** Executing task ***')});
});
Now the output would be:
現在的輸出將是:
Before executing
About to execute
Done with execute
After executing
*** Executing task ***
This is wrong. The lines after the async call, which were caused the “Done with execute” and “After executing” calls, are not accurate any more.
錯了 異步調用之后的行(導致“執行完成”和“執行后”調用)不再準確。
To emit an event after an asynchronous function is done, we’ll need to combine callbacks (or promises) with this event-based communication. The example below demonstrates that.
為了在異步函數完成后發出事件,我們需要將回調(或promise)與基于事件的通信結合起來。 下面的示例演示了這一點。
One benefit of using events instead of regular callbacks is that we can react to the same signal multiple times by defining multiple listeners. To accomplish the same with callbacks, we have to write more logic inside the single available callback. Events are a great way for applications to allow multiple external plugins to build functionality on top of the application’s core. You can think of them as hook points to allow for customizing the story around a state change.
使用事件而不是常規回調的好處之一是,我們可以通過定義多個偵聽器來對同一信號進行多次響應。 為實現回調,我們必須在單個可用回調內編寫更多邏輯。 事件是應用程序允許多個外部插件在應用程序核心之上構建功能的好方法。 您可以將它們視為掛鉤點,以允許圍繞狀態更改自定義故事。
異步事件 (Asynchronous Events)
Let’s convert the synchronous sample example into something asynchronous and a little bit more useful.
讓我們將同步示例示例轉換為異步示例,然后再使用一些示例。
const fs = require('fs');
const EventEmitter = require('events');class WithTime extends EventEmitter {execute(asyncFunc, ...args) {this.emit('begin');console.time('execute');asyncFunc(...args, (err, data) => {if (err) {return this.emit('error', err);}this.emit('data', data);console.timeEnd('execute');this.emit('end');});}
}const withTime = new WithTime();withTime.on('begin', () => console.log('About to execute'));
withTime.on('end', () => console.log('Done with execute'));withTime.execute(fs.readFile, __filename);
The WithTime
class executes an asyncFunc
and reports the time that’s taken by that asyncFunc
using console.time
and console.timeEnd
calls. It emits the right sequence of events before and after the execution. And also emits error/data events to work with the usual signals of asynchronous calls.
該WithTime
類執行的asyncFunc
和報告,是采取由時間asyncFunc
使用console.time
和console.timeEnd
電話。 它在執行前后發出正確的事件序列。 并且還會發出錯誤/數據事件以與異步調用的通常信號一起工作。
We test a withTime
emitter by passing it an fs.readFile
call, which is an asynchronous function. Instead of handling file data with a callback, we can now listen to the data event.
我們通過傳遞一個fs.readFile
調用來測試withTime
發射器,這是一個異步函數。 現在,我們可以偵聽數據事件,而不是使用回調處理文件數據。
When we execute this code , we get the right sequence of events, as expected, and we get a reported time for the execution, which is helpful:
當我們執行此代碼時,我們將按預期獲得正確的事件序列,并獲得執行的報告時間,這很有幫助:
About to execute
execute: 4.507ms
Done with execute
Note how we needed to combine a callback with an event emitter to accomplish that. If the asynFunc
supported promises as well, we could use the async/await feature to do the same:
請注意,我們需要如何結合使用回調和事件發射器來實現這一點。 如果asynFunc
支持promise,我們可以使用async / await功能執行相同的操作:
class WithTime extends EventEmitter {async execute(asyncFunc, ...args) {this.emit('begin');try {console.time('execute');const data = await asyncFunc(...args);this.emit('data', data);console.timeEnd('execute');this.emit('end');} catch(err) {this.emit('error', err);}}
}
I don’t know about you, but this is much more readable to me than the callback-based code or any .then/.catch lines. The async/await feature brings us as close as possible to the JavaScript language itself, which I think is a big win.
我不了解您,但是比起基于回調的代碼或任何.then / .catch行,這對我而言更具可讀性。 異步/等待功能使我們盡可能接近JavaScript語言本身,我認為這是一個巨大的勝利。
事件參數和錯誤 (Events Arguments and Errors)
In the previous example, there were two events that were emitted with extra arguments.
在前面的示例中,有兩個帶有額外參數的事件。
The error event is emitted with an error object.
錯誤事件與錯誤對象一起發出。
this.emit('error', err);
The data event is emitted with a data object.
數據事件與數據對象一起發出。
this.emit('data', data);
We can use as many arguments as we need after the named event, and all these arguments will be available inside the listener functions we register for these named events.
在命名事件之后,我們可以根據需要使用任意數量的參數,并且所有這些參數都將在我們為這些命名事件注冊的偵聽器函數中可用。
For example, to work with the data event, the listener function that we register will get access to the data argument that was passed to the emitted event and that data object is exactly what the asyncFunc
exposes.
例如,要處理數據事件,我們注冊的偵聽器函數將可以訪問傳遞給發出的事件的數據參數,而該數據對象正是asyncFunc
公開的。
withTime.on('data', (data) => {// do something with data
});
The error
event is usually a special one. In our callback-based example, if we don’t handle the error event with a listener, the node process will actually exit.
error
事件通常是一個特殊的事件。 在基于回調的示例中,如果不使用偵聽器處理錯誤事件,則節點進程實際上將退出。
To demonstrate that, make another call to the execute method with a bad argument:
為了證明這一點,請使用錯誤的參數再次調用execute方法:
class WithTime extends EventEmitter {execute(asyncFunc, ...args) {console.time('execute');asyncFunc(...args, (err, data) => {if (err) {return this.emit('error', err); // Not Handled}console.timeEnd('execute');});}
}const withTime = new WithTime();withTime.execute(fs.readFile, ''); // BAD CALL
withTime.execute(fs.readFile, __filename);
The first execute call above will trigger an error. The node process is going to crash and exit:
上面的第一個execute調用將觸發錯誤。 節點進程將崩潰并退出:
events.js:163throw er; // Unhandled 'error' event^
Error: ENOENT: no such file or directory, open ''
The second execute call will be affected by this crash and will potentially not get executed at all.
第二次執行調用將受到此崩潰的影響,并且可能根本無法執行。
If we register a listener for the special error
event, the behavior of the node process will change. For example:
如果我們為特殊error
事件注冊一個偵聽器,則節點進程的行為將改變。 例如:
withTime.on('error', (err) => {// do something with err, for example log it somewhereconsole.log(err)
});
If we do the above, the error from the first execute call will be reported but the node process will not crash and exit. The other execute call will finish normally:
如果執行上述操作,將報告來自第一個執行調用的錯誤,但節點進程不會崩潰并退出。 另一個執行調用將正常完成:
{ Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT', syscall: 'open', path: '' }
execute: 4.276ms
Note that Node currently behaves differently with promise-based functions and just outputs a warning, but that will eventually change:
請注意,Node當前與基于promise的功能的行為有所不同,只是輸出警告,但最終會改變:
UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: ENOENT: no such file or directory, open ''
DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
The other way to handle exceptions from emitted errors is to register a listener for the global uncaughtException
process event. However, catching errors globally with that event is a bad idea.
處理來自發出的錯誤的異常的另一種方法是為全局uncaughtException
流程事件注冊一個偵聽器。 但是,在該事件中全局捕獲錯誤不是一個好主意。
The standard advice about uncaughtException
is to avoid using it, but if you must do (say to report what happened or do cleanups), you should just let the process exit anyway:
關于uncaughtException
的標準建議是避免使用它,但是,如果必須這樣做(例如報告所發生的事情或進行清理),則無論如何都要讓該過程退出:
process.on('uncaughtException', (err) => {// something went unhandled.// Do any cleanup and exit anyway!console.error(err); // don't do just that.// FORCE exit the process too.process.exit(1);
});
However, imagine that multiple error events happen at the exact same time. This means the uncaughtException
listener above will be triggered multiple times, which might be a problem for some cleanup code. An example of this is when multiple calls are made to a database shutdown action.
但是,想象一下,多個錯誤事件恰好同時發生。 這意味著上面的uncaughtException
偵聽器將被多次觸發,這對于某些清理代碼可能是一個問題。 例如,當多次調用數據庫關閉操作時。
The EventEmitter
module exposes a once
method. This method signals to invoke the listener just once, not every time it happens. So, this is a practical use case to use with the uncaughtException because with the first uncaught exception we’ll start doing the cleanup and we know that we’re going to exit the process anyway.
EventEmitter
模塊公開once
方法。 此方法發出信號僅一次調用偵聽器,而不是每次都調用。 因此,這是一個與uncaughtException一起使用的實際用例,因為對于第一個未捕獲的異常,我們將開始進行清理,并且我們知道無論如何都將退出該過程。
聽眾順序 (Order of Listeners)
If we register multiple listeners for the same event, the invocation of those listeners will be in order. The first listener that we register is the first listener that gets invoked.
如果我們為同一事件注冊多個偵聽器,則這些偵聽器的調用將是有序的。 我們注冊的第一個偵聽器是被調用的第一個偵聽器。
// ?????
withTime.on('data', (data) => {console.log(`Length: ${data.length}`);
});// ?????
withTime.on('data', (data) => {console.log(`Characters: ${data.toString().length}`);
});withTime.execute(fs.readFile, __filename);
The above code will cause the “Length” line to be logged before the “Characters” line, because that’s the order in which we defined those listeners.
上面的代碼將導致“長度”行記錄在“字符”行之前,因為這是我們定義這些偵聽器的順序。
If you need to define a new listener, but have that listener invoked first, you can use the prependListener
method:
如果您需要定義一個新的偵聽器,但首先要調用該偵聽器,則可以使用prependListener
方法:
// ?????
withTime.on('data', (data) => {console.log(`Length: ${data.length}`);
});// ?????
withTime.prependListener('data', (data) => {console.log(`Characters: ${data.toString().length}`);
});withTime.execute(fs.readFile, __filename);
The above will cause the “Characters” line to be logged first.
以上將導致“字符”行被首先記錄。
And finally, if you need to remove a listener, you can use the removeListener
method.
最后,如果需要刪除偵聽器,則可以使用removeListener
方法。
That’s all I have for this topic. Thanks for reading! Until next time!
這就是我要做的所有事情。 謝謝閱讀! 直到下一次!
Learning React or Node? Checkout my books:
學習React還是Node? 結帳我的書:
Learn React.js by Building Games
通過構建游戲學習React.js
Node.js Beyond the Basics
超越基礎的Node.js
翻譯自: https://www.freecodecamp.org/news/understanding-node-js-event-driven-architecture-223292fcbc2d/
node.js事件驅動