百度工程師帶你體驗引擎中的 nodejs

作者 | 糖果 candy

導讀 

如果你是一個前端程序員,你不懂得像 PHP、Python 或 Ruby 等動態編程語言,然後你想創建自己的服務,那麼 Node.js 是一個非常好的選擇。

Node.js 是運行在服務端的 JavaScript,如果你熟悉 Javascript,那麼你將會很容易學會 Node.js。

當然,如果你是後端程序員,想部署一些高性能的服務,那麼學習 Node.js 也是一個非常好的選擇。

01 什麼是 Node.js?

我們先看一下官方對 Node.js 的定義:Node.js 是一個基於 V8 JavaScript 引擎的 JavaScript 運行時環境。

但是這句話可能有點籠統:

1、什麼是 JavaScript 運行環境?

2、爲什麼 JavaScript 需要特別的運行環境呢?

3、什麼又是 JavaScript 引擎?

4、什麼是 V8?

帶着這些疑問我們先了解一下 nodejs 的歷史,在 Node.js 出現之前,最常見的 JavaScript 運行時環境是瀏覽器,也叫做 JavaScript 的宿主環境。瀏覽器爲 JavaScript 提供了 DOM API,能夠讓 JavaScript 操作瀏覽器環境(JS 環境)。

2009 年初 Node.js 出現了,它是基於 Chrome V8 引擎開發的 JavaScript 運行時環境,所以 Node.js 也是 JavaScript 的一種宿主環境。而它的底層就是我們所熟悉的 Chrome 瀏覽器的 JavaScript 引擎,因此本質上和在 Chrome 瀏覽器中運行的 JavaScript 並沒有什麼區別。但是,Node.js 的運行環境和瀏覽器的運行環境還是不一樣的。

通俗點講,也就是說 Node.js 基於 V8 引擎來執行 JavaScript 的代碼,但是不僅僅只有 V8 引擎。

我們知道 V8 可以嵌入到任何 C++ 應用程序中,無論是 Chrome 還是 Node.js,事實上都是嵌入了 V8 引擎來執行 JavaScript 代碼,但是在 Chrome 瀏覽器中,還需要解析、渲染 HTML、CSS 等相關渲染引擎,另外還需要提供支持瀏覽器操作的 API、瀏覽器自己的事件循環等。

另外,在 Node.js 中我們也需要進行一些額外的操作,比如文件系統讀 / 寫、網絡 IO、加密、壓縮解壓文件等操作。

那麼接下來我們來看一下瀏覽器是如何解析渲染的。

02 瀏覽器是怎麼渲染一個頁面的?

瀏覽器渲染一個網頁,簡單來說可以分爲以下幾個步驟:

相信看到這裏,大家對瀏覽器怎麼渲染出一個頁面已經有了大致的瞭解。頁面的繪製其實就是瀏覽器將 HTML 文本轉化爲對應頁面幀的過程,頁面的內容及渲染過程與第一步拿到的 HTML 文本是緊密相關的。

03 事件循環和異步 IO

首先要了解事件循環是什麼? 那麼我們先了解進程和線上的概念。

每一個進程中都會啓動一個線程用來執行程序中的代碼,這個線程稱爲主線程。

舉例子:比如工廠相當於操作系統,工廠裏的車間相當於進程,車間裏的工人相當於是線程,所以進程相當於是線程的容器。

那麼瀏覽器是一個進程嗎?它裏面是隻有一個線程嗎?

目前瀏覽器一般都是多進程的,一般開啓一個 tab 就會開啓一個新的進程,這個是防止一個頁面卡死而造成的所有頁面都無法響應,整個瀏覽器需要強制退出。

其中每一個進程當中有包含了多個線程,其中包含了執行 js 代碼的線程。

js 代碼是在一個單獨的線程中執行的,單線程同一時間只能做一件事,如果這件事非常耗時,就意味着當前的線程會被阻塞,瀏覽器時間循環維護兩個隊列:宏任務隊列和微任務隊列。

那麼事件循環對於兩個隊列的優先級是怎麼樣的呢?

  1. main script 中的代碼優先執行 (編寫的頂層 script 代碼);

  2. 在執行任何一個宏任務之前 (不是隊列,是一個宏任務),都會先查看微任務隊列中是否有任務需要執行也就是宏任務執行之前,必須保證微任務隊列是空的;

  3. 如果不爲空,那麼久優先執行微任務隊列中的任務 (回調)。

new promise() 是同步,promise.then,promise.catch,resolve,reject 是微任務。

04 使用事件驅動程序

Node.js 使用事件驅動模型,當 web server 接收到請求,就把它關閉然後進行處理,然後去服務下一個 web 請求。

當這個請求完成,它被放回處理隊列,當到達隊列開頭,這個結果被返回給用戶。

這個模型非常高效可擴展性非常強,因爲 webserver 一直接受請求而不等待任何讀寫操作。(這也被稱之爲非阻塞式 IO 或者事件驅動 IO)

在事件驅動模型中,會生成一個主循環來監聽事件,當檢測到事件時觸發回調函數。

整個事件驅動的流程就是這麼實現的,非常簡潔。有點類似於觀察者模式,事件相當於一個主題 (Subject),而所有註冊到這個事件上的處理函數相當於觀察者 (Observer)。

Node.js 有多個內置的事件,我們可以通過引入 events 模塊,並通過實例化 EventEmitter 類來綁定和監聽事件,如下實例:

// 引入 events 模塊
var events = require('events');
// 創建 eventEmitter 對象
var eventEmitter = new events.EventEmitter();

以下程序綁定事件處理程序:

// 綁定事件及事件的處理程序
eventEmitter.on('eventName', eventHandler);

我們可以通過程序觸發事件:

// 觸發事件
eventEmitter.emit('eventName');

實例

創建 main.js 文件,代碼如下所示:

// 引入 events 模塊var events = require('events');
// 創建 eventEmitter 對象var eventEmitter = new events.EventEmitter();
// 創建事件處理程序var connectHandler = functionconnected() {
   console.log('連接成功~~~');
   // 觸發 data_received 事件 
   eventEmitter.emit('data_received');
}
// 綁定 connection 事件處理程序
eventEmitter.on('connection', connectHandler);
// 使用匿名函數綁定 data_received 事件
eventEmitter.on('data_received', function(){
   console.log('數據接收完畢。');
});
// 觸發 connection 事件 
eventEmitter.emit('connection');
console.log("程序執行完畢。");

接下來讓我們執行以上代碼:

$ node main.js
連接成功~~~
數據接收完畢
程序執行完畢

05 Node.js 架構以及與瀏覽器的區別

上圖是 Node.js 的基本架構,我們可以看到,(Node.js 是運行在操作系統之上的),它底層由 V8 JavaScript 引擎,以及一些 C/C++ 寫的庫構成,包括 libUV 庫、c-ares、llhttp/http-parser、open-ssl、zlib 等等。

其中,libUV 負責處理事件循環,c-ares、llhttp/http-parser、open-ssl、zlib 等庫提供 DNS 解析、HTTP 協議、HTTPS 和文件壓縮等功能。

在這些模塊的上一層是中間層,中間層包括 Node.js Bindings、Node.js Standard Library 以及 C/C++ AddOns。Node.js Bindings 層的作用是將底層那些用 C/C++ 寫的庫接口暴露給 JS 環境,而 Node.js Standard Library 是 Node.js 本身的核心模塊。至於 C/C++ AddOns,它可以讓用戶自己的 C/C++ 模塊通過橋接的方式提供給 Node.js。

中間層之上就是 Node.js 的 API 層了,我們使用 Node.js 開發應用,主要是使用 Node.js 的 API 層,所以 Node.js 的應用最終就運行在 Node.js 的 API 層之上。

總結一下:Node.js 系統架構圖,主要就是 application、V8 javascript 引擎、Node.js bindings, libuv 這 4 個部分組成的。

瀏覽器中的事件循環是根據 HTML5 規範來實現的,不同的瀏覽器可能有不同的實現,而 node 中是 libuv 實現的

因爲 Node.js 不是瀏覽器,所以它不具有瀏覽器提供的 DOM API。

  1. 比如 Window 對象、Location 對象、Document 對象、HTMLElement 對象、Cookie 對象等等。

  2. 但是,Node.js 提供了自己特有的 API,比如全局的 global 對象,

  3. 也提供了當前進程信息的 Process 對象,操作文件的 fs 模塊,以及創建 Web 服務的 http 模塊等等。這些 API 能夠讓我們使用 JavaScript 操作計算機,所以我們可以用 Node.js 平臺開發 web 服務器。

也有一些對象是 Node.js 和瀏覽器共有的,如 JavaScript 引擎的內置對象,它們由 V8 引擎提供。常見的還有:

此外,還有一些方法不屬於引擎內置 API,但是兩者都能實現,比如 setTimeout、setInterval 方法,Console 對象等等。

5.1 阻塞 IO 和非阻塞 IO

如果我們希望在程序中對一個文件進行操作,那麼我們就需要打開這個文件:通過文件描述符。 

我們思考:JavaScript 可以直接對一個文件進行操作嗎?

看起來是可以的,但是事實上我們任何程序中的文件操作都是需要進行系統調用(操作系統的文件系統);事實上對文件的操作,是一個操作系統的 IO 操作(輸入、輸出)。

操作系統爲我們提供了阻塞式調用和非阻塞式調用:

所以我們開發中的很多耗時操作,都可以基於這樣的 非阻塞式調用:
比如網絡請求本身使用了 Socket 通信,而 Socket 本身提供了 select 模型,可以進行非阻塞方式的工作;
比如文件讀寫的 IO 操作,我們可以使用操作系統提供的基於事件的回調機制。

5.2 非阻塞 IO 的問題

但是非阻塞 IO 也會存在一定的問題:我們並沒有獲取到需要讀取(我們以讀取爲例)的結果,那麼就意味着爲了可以知道是否讀取到了完整的數據,我們需要頻繁的去確定讀取到的數據是否是完整的。

這個過程我們稱之爲輪訓操作。

那麼這個輪訓的工作由誰來完成呢?
如果我們的主線程頻繁的去進行輪訓的工作,那麼必然會大大降低性能,並且開發中我們可能不只是一個文件的讀寫,可能是多個文件,而且可能是多個功能:網絡的 IO、數據庫的 IO、子進程調用。
libuv 提供了一個線程池(Thread Pool):線程池會負責所有相關的操作,並且會通過輪訓等方式等待結果。當獲取到結果時,就可以將對應的回調放到事件循環(某一個事件隊列)中。事件循環就可以負責接管後續的回調工作,告知 JavaScript 應用程序執行對應的回調函數。

5.3 阻塞和非阻塞,同步和異步的區別?

首先阻塞和非阻塞是對於被調用者來說的;在我們這裏就是系統調用,操作系統爲我們提供了阻塞調用和非阻塞調用,同步和異步是對於調用者來說的。

Libuv 採用的就是非阻塞異步 IO 的調用方式。

5.4 Node 事件循環的階段

我們最前面就強調過,事件循環像是一個橋樑,是連接着應用程序的 JavaScript 和系統調用之間的通道: 

無論是我們的文件 IO、數據庫、網絡 IO、定時器、子進程,在完成對應的操作後,都會將對應的結果和回調
函數放到事件循環(任務隊列)中;
事件循環會不斷的從任務隊列中取出對應的事件(回調函數)來執行;
但是一次完整的事件循環 Tick 分成很多個階段:

定時器(Timers):本階段執行已經被 setTimeout() 和 setInterval() 的調度回調函數。
待定回調(Pending Callback):對某些系統操作(如 TCP 錯誤類型)執行回調,比如 TCP 連接時接收

idle, prepare:僅系統內部使用。
輪詢(Poll):檢索新的 I/O 事件;執行與 I/O 相關的回調;
檢測:setImmediate() 回調函數在這裏執行。
關閉的回調函數:一些關閉的回調函數,如:socket.on('close', ...)。

5.5 Node 事件循環的階段圖解

06 Node.js 常見的內置模塊與全局變量

如想了解更多全局對象可參考以下鏈接 :https://m.runoob.com/nodejs/nodejs-global-object.html

參考資料:

[1]https://juejin.cn/post/6844903504931209224

[2]https://nodejs.org/zh-cn/docs/guides/

[3] 部分圖片來源於稀土掘金網站

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/QI9l4kKCbJxifNZt3QP3Lg