您好,登錄后才能下訂單哦!
這篇文章主要介紹基于Node.js的前端面試題有哪些,文中介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們一定要看完!
Node.js 是一個開源與跨平臺的 JavaScript 運行時環境。在瀏覽器外運行 V8 JavaScript 引擎(Google Chrome 的內核),利用事件驅動、非阻塞和異步輸入輸出模型等技術提高性能。我們可以理解為:Node.js 就是一個服務器端的、非阻塞式I/O的、事件驅動的JavaScript運行環境。
理解Node,有幾個基礎的概念:非阻塞異步和事件驅動。
非阻塞異步: Nodejs采用了非阻塞型I/O機制,在做I/O操作的時候不會造成任何的阻塞,當完成之后,以時間的形式通知執行操作。例如,在執行了訪問數據庫的代碼之后,將立即轉而執行其后面的代碼,把數據庫返回結果的處理代碼放在回調函數中,從而提高了程序的執行效率。
事件驅動: 事件驅動就是當進來一個新的請求的時,請求將會被壓入一個事件隊列中,然后通過一個循環來檢測隊列中的事件狀態變化,如果檢測到有狀態變化的事件,那么就執行該事件對應的處理代碼,一般都是回調函數。比如,讀取一個文件,文件讀取完畢后,就會觸發對應的狀態,然后通過對應的回調函數來進行處理。
Node.js適合用于I/O密集型應用,值的是應用在運行極限時,CPU占用率仍然比較低,大部分時間是在做 I/O硬盤內存讀寫操作。缺點如下:
不適合CPU密集型應用
只支持單核CPU,不能充分利用CPU
可靠性低,一旦代碼某個環節崩潰,整個系統都崩潰
對于第三點,常用的解決方案是,使用Nnigx反向代理,開多個進程綁定多個端口,或者開多個進程監聽同一個端口。
在熟悉了Nodejs的優點和弊端后,我們可以看到它適合以下的應用場景:
善于I/O,不善于計算。因為Nodejs是一個單線程,如果計算(同步)太多,則會阻塞這個線程。
大量并發的I/O,應用程序內部并不需要進行非常復雜的處理。
與 WeSocket 配合,開發長連接的實時交互應用程序。
具體的使用場景如下:
用戶表單收集系統、后臺管理系統、實時交互系統、考試系統、聯網軟件、高并發量的web應用程序。
基于web、canvas等多人聯網游戲。
基于web的多人實時聊天客戶端、聊天室、圖文直播。
單頁面瀏覽器應用程序。
操作數據庫、為前端和移動端提供基于json的API。
在瀏覽器 JavaScript 中,window 是全局對象, 而 Nodejs 中的全局對象則是 global。
在NodeJS里,是不可能在最外層定義一個變量,因為所有的用戶代碼都是當前模塊的,只在當前模塊里可用,但可以通過exports對象的使用將其傳遞給模塊外部。所以,在NodeJS中,用var聲明的變量并不屬于全局的變量,只在當前模塊生效。像上述的global全局對象則在全局作用域中,任何全局變量、函數、對象都是該對象的一個屬性值。
Node常見的全局對象有如下一些:
Class:Buffer
process
console
clearInterval、setInterval
clearTimeout、setTimeout
global
Class:BufferClass:Buffer可以用來處理二進制以及非Unicode編碼的數據,在Buffer類實例化中存儲了原始數據。Buffer類似于一個整數數組,在V8堆原始存儲空間給它分配了內存,一旦創建了Buffer實例,則無法改變大小。
processprocess表示進程對象,提供有關當前過程的信息和控制。包括在執行node程序的過程中,如果需要傳遞參數,我們想要獲取這個參數需要在process內置對象中。比如,我們有如下一個文件:
process.argv.forEach((val, index) => { console.log(`${index}: ${val}`); });
當我們需要啟動一個進程時,可以使用下面的命令:
node index.js 參數...
consoleconsole主要用來打印stdout和stderr,最常用的比如日志輸出:console.log
。清空控制臺的命令為:console.clear
。如果需要打印函數的調用棧,可以使用命令console.trace
。
clearInterval、setIntervalsetInterval用于設置定時器,語法格式如下:
setInterval(callback, delay[, ...args])
clearInterval則用于清除定時器,callback每delay毫秒重復執行一次。
clearTimeout、setTimeout
和setInterval一樣,setTimeout主要用于設置延時器,而clearTimeout則用于清除設置的延時器。
globalglobal是一個全局命名空間對象,前面講到的process、console、setTimeout等可以放到global中,例如:
console.log(process === global.process) //輸出true
除了系統提供的全局對象外,還有一些只是在模塊中出現,看起來像全局變量,如下所示:
__dirname
__filename
exports
module
require
__dirname__dirname主要用于獲取當前文件所在的路徑,不包括后面的文件名。比如,在/Users/mjr
中運行 node example.js
,打印結果如下:
console.log(__dirname); // 打印: /Users/mjr
__filename__filename用于獲取當前文件所在的路徑和文件名稱,包括后面的文件名稱。比如,在/Users/mjr
中運行 node example.js
,打印的結果如下:
console.log(__filename);// 打印: /Users/mjr/example.js
exportsmodule.exports 用于導出一個指定模塊所的內容,然后也可以使用require() 訪問里面的內容。
exports.name = name;exports.age = age; exports.sayHello = sayHello;
requirerequire主要用于引入模塊、 JSON、或本地文件, 可以從 node_modules 引入模塊。可以使用相對路徑引入本地模塊或JSON文件,路徑會根據__dirname定義的目錄名或當前工作目錄進行處理。
我們知道,進程計算機系統進行資源分配和調度的基本單位,是操作系統結構的基礎,是線程的容器。當我們啟動一個js文件,實際就是開啟了一個服務進程,每個進程都擁有自己的獨立空間地址、數據棧,像另一個進程無法訪問當前進程的變量、數據結構,只有數據通信后,進程之間才可以數據共享。
process 對象是Node的一個全局變量,提供了有關當前 Node.js 進程的信息并對其進行控制。 由于JavaScript是一個單線程語言,所以通過node xxx啟動一個文件后,只有一條主線程。
process的常見屬性如下:
process.env:環境變量,例如通過 `process.env.NODE_ENV 獲取不同環境項目配置信息
process.nextTick:這個在談及 EventLoop 時經常為會提到
process.pid:獲取當前進程id
process.ppid:當前進程對應的父進程
process.cwd():獲取當前進程工作目錄
process.platform:獲取當前進程運行的操作系統平臺
process.uptime():當前進程已運行時間,例如:pm2 守護進程的 uptime 值
進程事件: process.on(‘uncaughtException’,cb) 捕獲異常信息、 process.on(‘exit’,cb)進程推出監聽
三個標準流: process.stdout 標準輸出、 process.stdin 標準輸入、 process.stderr 標準錯誤輸出
process.title:用于指定進程名稱,有的時候需要給進程指定一個名稱
fs(filesystem)是文件系統模塊,該模塊提供本地文件的讀寫能力,基本上是POSIX文件操作命令的簡單包裝。可以說,所有與文件的操作都是通過fs核心模塊來實現的。
使用之前,需要先導入fs模塊,如下:
const fs = require('fs');
在計算機中,有關于文件的基礎知識有如下一些:
權限位 mode
標識位 flag
文件描述為 fd
針對文件所有者、文件所屬組、其他用戶進行權限分配,其中類型又分成讀、寫和執行,具備權限位4、2、1,不具備權限為0。如在linux查看文件權限位的命令如下:
drwxr-xr-x?1?PandaShen?197121?0?Jun 28 14:41?core -rw-r--r--?1?PandaShen?197121?293?Jun 23 17:44?index.md
在開頭前十位中,d為文件夾,-為文件,后九位就代表當前用戶、用戶所屬組和其他用戶的權限位,按每三位劃分,分別代表讀(r)、寫(w)和執行(x),- 代表沒有當前位對應的權限。
標識位代表著對文件的操作方式,如可讀、可寫、即可讀又可寫等等,如下表所示:
操作系統會為每個打開的文件分配一個名為文件描述符的數值標識,文件操作使用這些文件描述符來識別與追蹤每個特定的文件。
Window 系統使用了一個不同但概念類似的機制來追蹤資源,為方便用戶,NodeJS 抽象了不同操作系統間的差異,為所有打開的文件分配了數值的文件描述符。
在 NodeJS 中,每操作一個文件,文件描述符是遞增的,文件描述符一般從 3 開始,因為前面有 0、1、2三個比較特殊的描述符,分別代表 process.stdin(標準輸入)、process.stdout(標準輸出)和 process.stderr(錯誤輸出)。
由于fs模塊主要是操作文件的,所以常見的文件操作方法有如下一些:
文件讀取
文件寫入
文件追加寫入
文件拷貝
創建目錄
常用的文件讀取有readFileSync和readFile兩個方法。其中,readFileSync表示同步讀取,如下:
const fs = require("fs"); let buf = fs.readFileSync("1.txt"); let data = fs.readFileSync("1.txt", "utf8"); console.log(buf); // <Buffer 48 65 6c 6c 6f> console.log(data); // Hello
第一個參數為讀取文件的路徑或文件描述符。
第二個參數為 options,默認值為 null,其中有 encoding(編碼,默認為 null)和 flag(標識位,默認為 r),也可直接傳入 encoding。
readFile為異步讀取方法, readFile 與 readFileSync 的前兩個參數相同,最后一個參數為回調函數,函數內有兩個參數 err(錯誤)和 data(數據),該方法沒有返回值,回調函數在讀取文件成功后執行。
const fs = require("fs"); fs.readFile("1.txt", "utf8", (err, data) => { if(!err){ console.log(data); // Hello } });
文件寫入需要用到writeFileSync和writeFile兩個方法。writeFileSync表示同步寫入,如下所示。
const fs = require("fs"); fs.writeFileSync("2.txt", "Hello world"); let data = fs.readFileSync("2.txt", "utf8"); console.log(data); // Hello world
第一個參數為寫入文件的路徑或文件描述符。
第二個參數為寫入的數據,類型為 String 或 Buffer。
第三個參數為 options,默認值為 null,其中有 encoding(編碼,默認為 utf8)、 flag(標識位,默認為 w)和 mode(權限位,默認為 0o666),也可直接傳入 encoding。
writeFile表示異步寫入,writeFile 與 writeFileSync 的前三個參數相同,最后一個參數為回調函數,函數內有一個參數 err(錯誤),回調函數在文件寫入數據成功后執行。
const fs = require("fs"); fs.writeFile("2.txt", "Hello world", err => { if (!err) { fs.readFile("2.txt", "utf8", (err, data) => { console.log(data); // Hello world }); } });
文件追加寫入需要用到appendFileSync和appendFile兩個方法。appendFileSync表示同步寫入,如下。
const fs = require("fs"); fs.appendFileSync("3.txt", " world"); let data = fs.readFileSync("3.txt", "utf8");
第一個參數為寫入文件的路徑或文件描述符。
第二個參數為寫入的數據,類型為 String 或 Buffer。
第三個參數為 options,默認值為 null,其中有 encoding(編碼,默認為 utf8)、 flag(標識位,默認為 a)和 mode(權限位,默認為 0o666),也可直接傳入 encoding。
appendFile表示異步追加寫入,方法 appendFile 與 appendFileSync 的前三個參數相同,最后一個參數為回調函數,函數內有一個參數 err(錯誤),回調函數在文件追加寫入數據成功后執行,如下所示。
const fs = require("fs"); fs.appendFile("3.txt", " world", err => { if (!err) { fs.readFile("3.txt", "utf8", (err, data) => { console.log(data); // Hello world }); } });
創建目錄主要有mkdirSync和mkdir兩個方法。其中,mkdirSync為同步創建,參數為一個目錄的路徑,沒有返回值,在創建目錄的過程中,必須保證傳入的路徑前面的文件目錄都存在,否則會拋出異常。
// 假設已經有了 a 文件夾和 a 下的 b 文件夾 fs.mkdirSync("a/b/c")
mkdir為異步創建,第二個參數為回調函數,如下所示。
fs.mkdir("a/b/c", err => { if (!err) console.log("創建成功"); });
流(Stream)是一種數據傳輸的手段,是一種端到端信息交換的方式,而且是有順序的,是逐塊讀取數據、處理內容,用于順序讀取輸入或寫入輸出。在Node中,Stream分成三部分:source、dest、pipe。
其中,在source和dest之間有一個連接的管道pipe,它的基本語法是source.pipe(dest),source和dest就是通過pipe連接,讓數據從source流向dest,如下圖所示:
在Node,流可以分成四個種類:
可寫流:可寫入數據的流,例如 fs.createWriteStream() 可以使用流將數據寫入文件。
可讀流: 可讀取數據的流,例如fs.createReadStream() 可以從文件讀取內容。
雙工流: 既可讀又可寫的流,例如 net.Socket。
轉換流: 可以在數據寫入和讀取時修改或轉換數據的流。例如,在文件壓縮操作中,可以向文件寫入壓縮數據,并從文件中讀取解壓數據。
在Node的HTTP服務器模塊中,request 是可讀流,response 是可寫流。對于fs 模塊來說,能同時處理可讀和可寫文件流可讀流和可寫流都是單向的,比較容易理解。而Socket是雙向的,可讀可寫。
在Node中,比較的常見的全雙工通信就是websocket,因為發送方和接受方都是各自獨立的方法,發送和接收都沒有任何關系。
基本的使用方法如下:
const { Duplex } = require('stream'); const myDuplex = new Duplex({ read(size) { // ... }, write(chunk, encoding, callback) { // ... } });
流的常見使用場景有:
get請求返回文件給客戶端
文件操作
一些打包工具的底層操作
流一個常見的使用場景就是網絡請求,比如使用stream流返回文件,res也是一個stream對象,通過pipe管道將文件數據返回。
const server = http.createServer(function (req, res) { const method = req.method; // get 請求 if (method === 'GET') { const fileName = path.resolve(__dirname, 'data.txt'); let stream = fs.createReadStream(fileName); stream.pipe(res); } }); server.listen(8080);
文件的讀取也是流操作,創建一個可讀數據流readStream,一個可寫數據流writeStream,通過pipe管道把數據流轉過去。
const fs = require('fs') const path = require('path') // 兩個文件名 const fileName1 = path.resolve(__dirname, 'data.txt') const fileName2 = path.resolve(__dirname, 'data-bak.txt') // 讀取文件的 stream 對象 const readStream = fs.createReadStream(fileName1) // 寫入文件的 stream 對象 const writeStream = fs.createWriteStream(fileName2) // 通過 pipe執行拷貝,數據流轉 readStream.pipe(writeStream) // 數據讀取完成監聽,即拷貝完成 readStream.on('end', function () { console.log('拷貝完成') })
另外,一些打包工具,Webpack和Vite等都涉及很多流的操作。
Node.js 在主線程里維護了一個事件隊列,當接到請求后,就將該請求作為一個事件放入這個隊列中,然后繼續接收其他請求。當主線程空閑時(沒有請求接入時),就開始循環事件隊列,檢查隊列中是否有要處理的事件,這時要分兩種情況:如果是非 I/O 任務,就親自處理,并通過回調函數返回到上層調用;如果是 I/O 任務,就從 線程池 中拿出一個線程來處理這個事件,并指定回調函數,然后繼續循環隊列中的其他事件。
當線程中的 I/O 任務完成以后,就執行指定的回調函數,并把這個完成的事件放到事件隊列的尾部,等待事件循環,當主線程再次循環到該事件時,就直接處理并返回給上層調用。 這個過程就叫 事件循環 (Event Loop),其運行原理如下圖所示。
從左到右,從上到下,Node.js 被分為了四層,分別是 應用層、V8引擎層、Node API層 和 LIBUV層。
應用層: 即 JavaScript 交互層,常見的就是 Node.js 的模塊,比如 http,fs
V8引擎層: 即利用 V8 引擎來解析JavaScript 語法,進而和下層 API 交互
Node API層: 為上層模塊提供系統調用,一般是由 C 語言來實現,和操作系統進行交互 。
LIBUV層: 是跨平臺的底層封裝,實現了 事件循環、文件操作等,是 Node.js 實現異步的核心 。
在Node中,我們所說的事件循環是基于libuv實現的,libuv是一個多平臺的專注于異步IO的庫。上圖的EVENT_QUEUE 給人看起來只有一個隊列,但事實上EventLoop存在6個階段,每個階段都有對應的一個先進先出的回調隊列。
事件循環一共可以分成了六個階段,如下圖所示。
timers階段:此階段主要執行timer(setTimeout、setInterval)的回調。
I/O事件回調階段(I/O callbacks):執行延遲到下一個循環迭代的 I/O 回調,即上一輪循環中未被執行的一些I/O回調。
閑置階段(idle、prepare):僅系統內部使用。
輪詢階段(poll):檢索新的 I/O 事件;執行與 I/O 相關的回調(幾乎所有情況下,除了關閉的回調函數,那些由計時器和 setImmediate() 調度的之外),其余情況 node 將在適當的時候在此阻塞。
檢查階段(check):setImmediate() 回調函數在這里執行
關閉事件回調階段(close callback):一些關閉的回調函數,如:socket.on('close', ...)
每個階段對應一個隊列,當事件循環進入某個階段時, 將會在該階段內執行回調,直到隊列耗盡或者回調的最大數量已執行, 那么將進入下一個處理階段,如下圖所示。
前文說過,Node采用了事件驅動機制,而EventEmitter 就是Node實現事件驅動的基礎。在EventEmitter的基礎上,Node 幾乎所有的模塊都繼承了這個類,這些模塊擁有了自己的事件,可以綁定、觸發監聽器,實現了異步操作。
Node.js 里面的許多對象都會分發事件,比如 fs.readStream 對象會在文件被打開的時候觸發一個事件,這些產生事件的對象都是 events.EventEmitter 的實例,用于將一個或多個函數綁定到命名事件上。
Node的events模塊只提供了一個EventEmitter類,這個類實現了Node異步事件驅動架構的基本模式:觀察者模式。
在這種模式中,被觀察者(主體)維護著一組其他對象派來(注冊)的觀察者,有新的對象對主體感興趣就注冊觀察者,不感興趣就取消訂閱,主體有更新會依次通知觀察者,使用方式如下。
const EventEmitter = require('events') class MyEmitter extends EventEmitter {} const myEmitter = new MyEmitter() function callback() { console.log('觸發了event事件!') } myEmitter.on('event', callback) myEmitter.emit('event') myEmitter.removeListener('event', callback);
在上面的代碼中,我們通過實例對象的on方法注冊一個名為event的事件,通過emit方法觸發該事件,而removeListener用于取消事件的監聽。
除了上面介紹的一些方法外,其他常用的方法還有如下一些:
emitter.addListener/on(eventName, listener) :添加類型為 eventName 的監聽事件到事件數組尾部。
emitter.prependListener(eventName, listener):添加類型為 eventName 的監聽事件到事件數組頭部。
emitter.emit(eventName[, ...args]):觸發類型為 eventName 的監聽事件。
emitter.removeListener/off(eventName, listener):移除類型為 eventName 的監聽事件。
emitter.once(eventName, listener):添加類型為 eventName 的監聽事件,以后只能執行一次并刪除。
emitter.removeAllListeners([eventName]): 移除全部類型為 eventName 的監聽事件。
EventEmitter其實是一個構造函數,內部存在一個包含所有事件的對象。
class EventEmitter { constructor() { this.events = {}; } }
其中,events存放的監聽事件的函數的結構如下:
{ "event1": [f1,f2,f3], "event2": [f4,f5], ... }
然后,開始一步步實現實例方法,首先是emit,第一個參數為事件的類型,第二個參數開始為觸發事件函數的參數,實現如下:
emit(type, ...args) { this.events[type].forEach((item) => { Reflect.apply(item, this, args); }); }
實現了emit方法之后,然后依次實現on、addListener、prependListener這三個實例方法,它們都是添加事件監聽觸發函數的。
on(type, handler) { if (!this.events[type]) { this.events[type] = []; } this.events[type].push(handler); } addListener(type,handler){ this.on(type,handler) } prependListener(type, handler) { if (!this.events[type]) { this.events[type] = []; } this.events[type].unshift(handler); }
移除事件監聽,可以使用方法removeListener/on。
removeListener(type, handler) { if (!this.events[type]) { return; } this.events[type] = this.events[type].filter(item => item !== handler); } off(type,handler){ this.removeListener(type,handler) }
實現once方法, 再傳入事件監聽處理函數的時候進行封裝,利用閉包的特性維護當前狀態,通過fired屬性值判斷事件函數是否執行過。
once(type, handler) { this.on(type, this._onceWrap(type, handler, this)); } _onceWrap(type, handler, target) { const state = { fired: false, handler, type , target}; const wrapFn = this._onceWrapper.bind(state); state.wrapFn = wrapFn; return wrapFn; } _onceWrapper(...args) { if (!this.fired) { this.fired = true; Reflect.apply(this.handler, this.target, args); this.target.off(this.type, this.wrapFn); } }
下面是完成的測試代碼:
class EventEmitter { constructor() { this.events = {}; } on(type, handler) { if (!this.events[type]) { this.events[type] = []; } this.events[type].push(handler); } addListener(type,handler){ this.on(type,handler) } prependListener(type, handler) { if (!this.events[type]) { this.events[type] = []; } this.events[type].unshift(handler); } removeListener(type, handler) { if (!this.events[type]) { return; } this.events[type] = this.events[type].filter(item => item !== handler); } off(type,handler){ this.removeListener(type,handler) } emit(type, ...args) { this.events[type].forEach((item) => { Reflect.apply(item, this, args); }); } once(type, handler) { this.on(type, this._onceWrap(type, handler, this)); } _onceWrap(type, handler, target) { const state = { fired: false, handler, type , target}; const wrapFn = this._onceWrapper.bind(state); state.wrapFn = wrapFn; return wrapFn; } _onceWrapper(...args) { if (!this.fired) { this.fired = true; Reflect.apply(this.handler, this.target, args); this.target.off(this.type, this.wrapFn); } } }
中間件(Middleware)是介于應用系統和系統軟件之間的一類軟件,它使用系統軟件所提供的基礎服務(功能),銜接網絡上應用系統的各個部分或不同的應用,能夠達到資源共享、功能共享的目的。 在Node中,中間件主要是指封裝http請求細節處理的方法。例如,在express、koa等web框架中,中間件的本質為一個回調函數,參數包含請求對象、響應對象和執行下一個中間件的函數,架構示意圖如下。
通常,在這些中間件函數中,我們可以執行業務邏輯代碼,修改請求和響應對象、返回響應數據等操作。
Koa是基于Node當前比較流行的web框架,本身支持的功能并不多,功能都可以通過中間件拓展實現。 Koa 并沒有捆綁任何中間件, 而是提供了一套優雅的方法,幫助開發者快速而愉快地編寫服務端應用程序。
Koa 中間件采用的是洋蔥圈模型,每次執行下一個中間件都傳入兩個參數:
ctx :封裝了request 和 response 的變量
next :進入下一個要執行的中間件的函數
通過前面的介紹,我們知道了Koa 中間件本質上就是一個函數,可以是 async 函數,也可以是普通函數。下面就針對koa進行中間件的封裝:
// async 函數 app.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; console.log(`${ctx.method} ${ctx.url} - ${ms}ms`); }); // 普通函數 app.use((ctx, next) => { const start = Date.now(); return next().then(() => { const ms = Date.now() - start; console.log(`${ctx.method} ${ctx.url} - ${ms}ms`); }); });
當然,我們還可以通過中間件封裝http請求過程中幾個常用的功能:
token校驗
module.exports = (options) => async (ctx, next) { try { // 獲取 token const token = ctx.header.authorization if (token) { try { // verify 函數驗證 token,并獲取用戶相關信息 await verify(token) } catch (err) { console.log(err) } } // 進入下一個中間件 await next() } catch (err) { console.log(err) } }
日志模塊
const fs = require('fs') module.exports = (options) => async (ctx, next) => { const startTime = Date.now() const requestTime = new Date() await next() const ms = Date.now() - startTime; let logout = `${ctx.request.ip} -- ${requestTime} -- ${ctx.method} -- ${ctx.url} -- ${ms}ms`; // 輸出日志文件 fs.appendFileSync('./log.txt', logout + '\n') }
Koa存在很多第三方的中間件,如koa-bodyparser、koa-static等。
koa-bodyparserkoa-bodyparser 中間件是將我們的 post 請求和表單提交的查詢字符串轉換成對象,并掛在 ctx.request.body 上,方便我們在其他中間件或接口處取值。
// 文件:my-koa-bodyparser.js const querystring = require("querystring"); module.exports = function bodyParser() { return async (ctx, next) => { await new Promise((resolve, reject) => { // 存儲數據的數組 let dataArr = []; // 接收數據 ctx.req.on("data", data => dataArr.push(data)); // 整合數據并使用 Promise 成功 ctx.req.on("end", () => { // 獲取請求數據的類型 json 或表單 let contentType = ctx.get("Content-Type"); // 獲取數據 Buffer 格式 let data = Buffer.concat(dataArr).toString(); if (contentType === "application/x-www-form-urlencoded") { // 如果是表單提交,則將查詢字符串轉換成對象賦值給 ctx.request.body ctx.request.body = querystring.parse(data); } else if (contentType === "applaction/json") { // 如果是 json,則將字符串格式的對象轉換成對象賦值給 ctx.request.body ctx.request.body = JSON.parse(data); } // 執行成功的回調 resolve(); }); }); // 繼續向下執行 await next(); }; };
koa-statickoa-static 中間件的作用是在服務器接到請求時,幫我們處理靜態文件,比如。
const fs = require("fs"); const path = require("path"); const mime = require("mime"); const { promisify } = require("util"); // 將 stat 和 access 轉換成 Promise const stat = promisify(fs.stat); const access = promisify(fs.access) module.exports = function (dir) { return async (ctx, next) => { // 將訪問的路由處理成絕對路徑,這里要使用 join 因為有可能是 / let realPath = path.join(dir, ctx.path); try { // 獲取 stat 對象 let statObj = await stat(realPath); // 如果是文件,則設置文件類型并直接響應內容,否則當作文件夾尋找 index.html if (statObj.isFile()) { ctx.set("Content-Type", `${mime.getType()};charset=utf8`); ctx.body = fs.createReadStream(realPath); } else { let filename = path.join(realPath, "index.html"); // 如果不存在該文件則執行 catch 中的 next 交給其他中間件處理 await access(filename); // 存在設置文件類型并響應內容 ctx.set("Content-Type", "text/html;charset=utf8"); ctx.body = fs.createReadStream(filename); } } catch (e) { await next(); } } }
總的來說,在實現中間件時候,單個中間件應該足夠簡單,職責單一,中間件的代碼編寫應該高效,必要的時候通過緩存重復獲取數據。
JWT(JSON Web Token),本質就是一個字符串書寫規范,作用是用來在用戶和服務器之間傳遞安全可靠的,如下圖。
在目前前后端分離的開發過程中,使用token鑒權機制用于身份驗證是最常見的方案,流程如下:
服務器當驗證用戶賬號和密碼正確的時候,給用戶頒發一個令牌,這個令牌作為后續用戶訪問一些接口的憑證。
后續訪問會根據這個令牌判斷用戶時候有權限進行訪問。
Token,分成了三部分,頭部(Header)、載荷(Payload)、簽名(Signature),并以.
進行拼接。其中頭部和載荷都是以JSON格式存放數據,只是進行了編碼,示意圖如下。
每個JWT都會帶有頭部信息,這里主要聲明使用的算法。聲明算法的字段名為alg,同時還有一個typ的字段,默認JWT即可。以下示例中算法為HS256:
{ "alg": "HS256", "typ": "JWT" }
因為JWT是字符串,所以我們還需要對以上內容進行Base64編碼,編碼后字符串如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
載荷即消息體,這里會存放實際的內容,也就是Token的數據聲明,例如用戶的id和name,默認情況下也會攜帶令牌的簽發時間iat,通過還可以設置過期時間,如下:
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
同樣進行Base64編碼后,字符串如下:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
簽名是對頭部和載荷內容進行簽名,一般情況,設置一個secretKey,對前兩個的結果進行HMACSHA25算法,公式如下:
Signature = HMACSHA256(base64Url(header)+.+base64Url(payload),secretKey)
因此,就算前面兩部分數據被篡改,只要服務器加密用的密鑰沒有泄露,得到的簽名肯定和之前的簽名也是不一致的。
通常,Token的使用分成了兩部分:生成token和校驗token。
生成token:登錄成功的時候,頒發token。
驗證token:訪問某些資源或者接口時,驗證token。
借助第三方庫jsonwebtoken,通過jsonwebtoken 的 sign 方法生成一個 token。sign有三個參數:
第一個參數指的是 Payload。
第二個是秘鑰,服務端特有。
第三個參數是 option,可以定義 token 過期時間。
下面是一個前端生成token的例子:
const crypto = require("crypto"), jwt = require("jsonwebtoken"); // TODO:使用數據庫 // 這里應該是用數據庫存儲,這里只是演示用 let userList = []; class UserController { // 用戶登錄 static async login(ctx) { const data = ctx.request.body; if (!data.name || !data.password) { return ctx.body = { code: "000002", message: "參數不合法" } } const result = userList.find(item => item.name === data.name && item.password === crypto.createHash('md5').update(data.password).digest('hex')) if (result) { // 生成token const token = jwt.sign( { name: result.name }, "test_token", // secret { expiresIn: 60 * 60 } // 過期時間:60 * 60 s ); return ctx.body = { code: "0", message: "登錄成功", data: { token } }; } else { return ctx.body = { code: "000002", message: "用戶名或密碼錯誤" }; } } } module.exports = UserController;
在前端接收到token后,一般情況會通過localStorage進行緩存,然后將token放到HTTP 請求頭Authorization 中,關于Authorization 的設置,前面需要加上 Bearer ,注意后面帶有空格,如下。
axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); config.headers.common['Authorization'] = 'Bearer ' + token; // 留意這里的 Authorization return config; })
首先,我們需要使用 koa-jwt 中間件進行驗證,方式比較簡單,在路由跳轉前校驗即可,如下。
app.use(koajwt({ secret: 'test_token' }).unless({ // 配置白名單 path: [/\/api\/register/, /\/api\/login/] }))
使用koa-jwt中間件進行校驗時,需要注意以下幾點:
secret 必須和 sign 時候保持一致。
可以通過 unless 配置接口白名單,也就是哪些 URL 可以不用經過校驗,像登陸/注冊都可以不用校驗。
校驗的中間件需要放在需要校驗的路由前面,無法對前面的 URL 進行校驗。
獲取用戶token信息的方法如下:
router.get('/api/userInfo',async (ctx,next) =>{ const authorization = ctx.header.authorization // 獲取jwt const token = authorization.replace('Beraer ','') const result = jwt.verify(token,'test_token') ctx.body = result }
注意:上述的HMA256加密算法為單秘鑰的形式,一旦泄露后果非常的危險。
在分布式系統中,每個子系統都要獲取到秘鑰,那么這個子系統根據該秘鑰可以發布和驗證令牌,但有些服務器只需要驗證令牌。這時候可以采用非對稱加密,利用私鑰發布令牌,公鑰驗證令牌,加密算法可以選擇RS256等非對稱算法。
除此之外,JWT鑒權還需要注意以下幾點:
payload部分僅僅是進行簡單編碼,所以只能用于存儲邏輯必需的非敏感信息。
需要保護好加密密鑰,一旦泄露后果不堪設想。
為避免token被劫持,最好使用https協議。
Node作為一門服務端語言,性能方面尤為重要,其衡量指標一般有如下幾點:
CPU
內存
I/O
網絡
對于CPU的指標,主要關注如下兩點:
CPU負載:在某個時間段內,占用以及等待CPU的進程總數。
CPU使用率:CPU時間占用狀況,等于 1 - 空閑CPU時間(idle time) / CPU總時間。
這兩個指標都是用來評估系統當前CPU的繁忙程度的量化指標。Node應用一般不會消耗很多的CPU,如果CPU占用率高,則表明應用存在很多同步操作,導致異步任務回調被阻塞。
內存是一個非常容易量化的指標。 內存占用率是評判一個系統的內存瓶頸的常見指標。 對于Node來說,內部內存堆棧的使用狀態也是一個可以量化的指標,可以使用下面的代碼來獲取內存的相關數據:
// /app/lib/memory.js const os = require('os'); // 獲取當前Node內存堆棧情況 const { rss, heapUsed, heapTotal } = process.memoryUsage(); // 獲取系統空閑內存 const sysFree = os.freemem(); // 獲取系統總內存 const sysTotal = os.totalmem(); module.exports = { memory: () => { return { sys: 1 - sysFree / sysTotal, // 系統內存占用率 heap: heapUsed / headTotal, // Node堆內存占用率 node: rss / sysTotal, // Node占用系統內存的比例 } } }
rss:表示node進程占用的內存總量。
heapTotal:表示堆內存的總量。
heapUsed:實際堆內存的使用量。
external :外部程序的內存使用量,包含Node核心的C++程序的內存使用量。
在Node中,一個進程的最大內存容量為1.5GB,因此在實際使用時請合理控制內存的使用。
硬盤的 IO 開銷是非常昂貴的,硬盤 IO 花費的 CPU 時鐘周期是內存的 164000 倍。內存 IO 比磁盤 IO 快非常多,所以使用內存緩存數據是有效的優化方法。常用的工具如 redis、memcached 等。
并且,并不是所有數據都需要緩存,訪問頻率高,生成代價比較高的才考慮是否緩存,也就是說影響你性能瓶頸的考慮去緩存,并且而且緩存還有緩存雪崩、緩存穿透等問題要解決。
關于性能方面的監控,一般情況都需要借助工具來實現,比如Easy-Monitor、阿里Node性能平臺等。
這里采用Easy-Monitor 2.0,其是輕量級的 Node.js 項目內核性能監控 + 分析工具,在默認模式下,只需要在項目入口文件 require 一次,無需改動任何業務代碼即可開啟內核級別的性能監控分析。
Easy-Monitor 的使用也比較簡單,在項目入口文件中按照如下方式引入。
const easyMonitor = require('easy-monitor'); easyMonitor('項目名稱');
打開你的瀏覽器,訪問 http://localhost:12333 ,即可看到進程界面,更詳細的內容請參考官網
關于Node的性能優化的方式有如下幾個:
使用最新版本Node.js
正確使用流 Stream
代碼層面優化
內存管理優化
每個版本的性能提升主要來自于兩個方面:
V8 的版本更新
Node.js 內部代碼的更新優化
在Node中,很多對象都實現了流,對于一個大文件可以通過流的形式發送,不需要將其完全讀入內存。
const http = require('http'); const fs = require('fs'); // 錯誤方式 http.createServer(function (req, res) { fs.readFile(__dirname + '/data.txt', function (err, data) { res.end(data); }); }); // 正確方式 http.createServer(function (req, res) { const stream = fs.createReadStream(__dirname + '/data.txt'); stream.pipe(res); });
合并查詢,將多次查詢合并一次,減少數據庫的查詢次數。
// 錯誤方式 for user_id in userIds let account = user_account.findOne(user_id) // 正確方式 const user_account_map = {} // 注意這個對象將會消耗大量內存。 user_account.find(user_id in user_ids).forEach(account){ user_account_map[account.user_id] = account } for user_id in userIds var account = user_account_map[user_id]
在 V8 中,主要將內存分為新生代和老生代兩代:
新生代:對象的存活時間較短。新生對象或只經過一次垃圾回收的對象。
老生代:對象存活時間較長。經歷過一次或多次垃圾回收的對象。
若新生代內存空間不夠,直接分配到老生代。通過減少內存占用,可以提高服務器的性能。如果有內存泄露,也會導致大量的對象存儲到老生代中,服務器性能會大大降低,比如下面的例子。
const buffer = fs.readFileSync(__dirname + '/source/index.htm'); app.use( mount('/', async (ctx) => { ctx.status = 200; ctx.type = 'html'; ctx.body = buffer; leak.push(fs.readFileSync(__dirname + '/source/index.htm')); }) ); const leak = [];
當leak的內存非常大的時候,就有可能造成內存泄露,應當避免這樣的操作。
減少內存使用,可以明顯的提高服務性能。而節省內存最好的方式是使用池,其將頻用、可復用對象存儲起來,減少創建和銷毀操作。例如有個圖片請求接口,每次請求,都需要用到類。若每次都需要重新new這些類,并不是很合適,在大量請求時,頻繁創建和銷毀這些類,造成內存抖動。而使用對象池的機制,對這種頻繁需要創建和銷毀的對象保存在一個對象池中,從而避免重讀的初始化操作,從而提高框架的性能。
以上是“基于Node.js的前端面試題有哪些”這篇文章的所有內容,感謝各位的閱讀!希望分享的內容對大家有幫助,更多相關知識,歡迎關注億速云行業資訊頻道!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。