百度工程師帶你體驗引擎中的 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 解析:在這個過程之前,瀏覽器會進行 DNS 解析及 TCP 握手等網絡協議相關的操作,來與用戶需要訪問的域名服務器建議連接,域名服務器會給用戶返回一個 HTML 文本用於後面的渲染 (這一點很關鍵,要注意)。
-
渲染樹的構建:瀏覽器客戶端在收到服務端返回的 HTML 文本後,會對 HTML 的文本進行相關的解析,其中 DOM 會用於生成 DOM 樹來決定頁面的佈局結構,CSS 則用於生成 CSSOM 樹來決定頁面元素的樣式。如果在這個過程遇到腳本或是靜態資源,會執行預加載對靜態資源進行提前請求,最後將它們生成一個渲染樹。
-
佈局:瀏覽器在拿到渲染樹後,會進行佈局操作,來確定頁面上每個對象的大小和位置,再進行渲染。
-
渲染:我們電腦的視圖都是通過 GPU 的圖像幀來顯示出來的,渲染的過程其實就是將上面拿到的渲染樹轉化成 GPU 的圖像幀來顯示。
首先,瀏覽器會根據佈局樹的位置進行柵格化(用過組件庫的同學應該不陌生,就是把頁面按行列分成對應的層,比如 12 柵格,根據對應的格列來確定位置),最後得到一個合成幀,包括文本、顏色、邊框等;其次,將合成幀提升到 GPU 的圖像幀,進而顯示到頁面中,就可以在電腦上看到我們的頁面了。
相信看到這裏,大家對瀏覽器怎麼渲染出一個頁面已經有了大致的瞭解。頁面的繪製其實就是瀏覽器將 HTML 文本轉化爲對應頁面幀的過程,頁面的內容及渲染過程與第一步拿到的 HTML 文本是緊密相關的。
03 事件循環和異步 IO
首先要了解事件循環是什麼? 那麼我們先了解進程和線上的概念。
-
進程和線程:都是操作系統的概念。
-
進程:計算機已經運行的程序,線程:操作系統能夠調度的最小單位啓動一個應用默認是開啓一個進程(也可能是多進程)。
每一個進程中都會啓動一個線程用來執行程序中的代碼,這個線程稱爲主線程。
舉例子:比如工廠相當於操作系統,工廠裏的車間相當於進程,車間裏的工人相當於是線程,所以進程相當於是線程的容器。
那麼瀏覽器是一個進程嗎?它裏面是隻有一個線程嗎?
目前瀏覽器一般都是多進程的,一般開啓一個 tab 就會開啓一個新的進程,這個是防止一個頁面卡死而造成的所有頁面都無法響應,整個瀏覽器需要強制退出。
其中每一個進程當中有包含了多個線程,其中包含了執行 js 代碼的線程。
js 代碼是在一個單獨的線程中執行的,單線程同一時間只能做一件事,如果這件事非常耗時,就意味着當前的線程會被阻塞,瀏覽器時間循環維護兩個隊列:宏任務隊列和微任務隊列。
-
宏任務隊列 (macrotask queue): ajax、setTimeout、setInterval、DOM 監聽、UI Rendering 等;
-
微任務隊列 (microtask queue): Promise 的 then 回調。
那麼事件循環對於兩個隊列的優先級是怎麼樣的呢?
-
main script 中的代碼優先執行 (編寫的頂層 script 代碼);
-
在執行任何一個宏任務之前 (不是隊列,是一個宏任務),都會先查看微任務隊列中是否有任務需要執行也就是宏任務執行之前,必須保證微任務隊列是空的;
-
如果不爲空,那麼久優先執行微任務隊列中的任務 (回調)。
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 個部分組成的。
-
Application: nodejs 應用,就是我們寫的 js 代碼。
-
V8: JavaScript 引擎,分析 js 代碼後去調用 Node api。
-
Node.js bindings:Node api,這些 API 最後由 libuv 驅動。
-
Libuv:異步 I/O,實現異步非阻塞式的核心模塊,libuv 這個庫提供兩個最重要的東西是事件循環和線程池,兩者共同構建了異步非阻塞 I/O 模型。
-
以線程爲緯度來劃分,可以分爲 Node.js 線程和其他 C++ 線程。
-
應用程序啓動一個線程,在一個 Node.js 線程裏完成,Node.js 的 I/O 操作都是非阻塞式的,把大量的計算能力分發到其他的 C++ 線程,C++ 線程完成計算後,再把結果回調到 Node.js 線程,Node.js 線程再把內容返回給應用程序。
瀏覽器中的事件循環是根據 HTML5 規範來實現的,不同的瀏覽器可能有不同的實現,而 node 中是 libuv 實現的
因爲 Node.js 不是瀏覽器,所以它不具有瀏覽器提供的 DOM API。
-
比如 Window 對象、Location 對象、Document 對象、HTMLElement 對象、Cookie 對象等等。
-
但是,Node.js 提供了自己特有的 API,比如全局的 global 對象,
-
也提供了當前進程信息的 Process 對象,操作文件的 fs 模塊,以及創建 Web 服務的 http 模塊等等。這些 API 能夠讓我們使用 JavaScript 操作計算機,所以我們可以用 Node.js 平臺開發 web 服務器。
也有一些對象是 Node.js 和瀏覽器共有的,如 JavaScript 引擎的內置對象,它們由 V8 引擎提供。常見的還有:
-
基本的常量 undefined、null、NaN、Infinity;
-
內置對象 Boolean、Number、String、Object、Symbol、Function、Array、Regexp、Set、Map、Promise、Proxy;
-
全局函數 eval、encodeURIComponent、decodeURIComponent 等等。
此外,還有一些方法不屬於引擎內置 API,但是兩者都能實現,比如 setTimeout、setInterval 方法,Console 對象等等。
5.1 阻塞 IO 和非阻塞 IO
如果我們希望在程序中對一個文件進行操作,那麼我們就需要打開這個文件:通過文件描述符。
我們思考:JavaScript 可以直接對一個文件進行操作嗎?
看起來是可以的,但是事實上我們任何程序中的文件操作都是需要進行系統調用(操作系統的文件系統);事實上對文件的操作,是一個操作系統的 IO 操作(輸入、輸出)。
操作系統爲我們提供了阻塞式調用和非阻塞式調用:
-
阻塞式調用: 調用結果返回之前,當前線程處於阻塞態(阻塞態 CPU 是不會分配時間片的),調用線程只有在得到調用結果之後纔會繼續執行。
-
非阻塞式調用: 調用執行之後,當前線程不會停止執行,只需要過一段時間來檢查一下有沒有結果返回即可。
所以我們開發中的很多耗時操作,都可以基於這樣的 非阻塞式調用:
比如網絡請求本身使用了 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