1. 程式人生 > 實用技巧 >Node.js入門文件閱讀

Node.js入門文件閱讀

Node.js簡介

Node.js是開源跨平臺的JS執行環境,Node.js執行在V8 JS引擎,Chrome的核心,獨立於瀏覽器,這使得Node.js效能很好。Node.js應用是單程序執行,對每個請求不會建立新執行緒。Node.js在標準庫中提供了一系列非同步IO特性,來防止阻塞式執行的JS程式碼,Node.js的庫使用非阻塞正規化實現,避免大量的阻塞行為。當Node.js做IO操作時,例如在網路上讀取資料,訪問資料庫或者檔案系統,並不會阻塞執行緒浪費CPU週期,Node.js會在接收到響應之後再恢復操作。這種方式允許Node.js處理數千個和單個伺服器的併發連線,且不需要去管理執行緒的併發。Node.js的優勢是前端開發人員在不需要學習新語言的前提下可以寫服務端的程式碼。Node.js支援新的ECMA標準,通過改變Node.js的版本可以決定使用哪版ECMA標準,也可以通過引數來執行Node的特殊的實驗特性

一個簡單的例子

// 引入Node.js的http模組
const http = require('http');

const host = '127.0.0.1';
const port = 3000;

// 建立伺服器並且返回
// 接收到新的請求後,request事件被呼叫,兩個引數(req(http.IncomingMessage) res(http.ServerResponse))
// req提供了請求的詳情(請求頭和請求體)
// resp向呼叫者返回資料
const server = http.createServer((req, res) => {
    // 這是statusCode屬性,表示返回成功
    res.statusCode = 200;
    // 設定響應頭的Content-Type
    res.setHeader('Content-Type', 'text/plain');
    // 關閉響應,可以將返回內容作為引數返回
    res.end('Hello world!');
})

// 伺服器要監聽指定的埠和主機名,當server準備好會呼叫回撥函式
server.listen(port, host, () => {
    console.log(`Server running at http://${host}:${port}`);
})

Node.js的框架和工具

Node.js是一個底層平臺,為了方便開發者,在Node.js的社群中有數千個庫以供提高開發效率。以下列一些值得學習的庫

  1. AdonisJs:全棧框架,專注於開發者的人機工程、穩定性和自信(?confidence)的需求。Node.js的最快的web框架之一
  2. Express:建立一個web伺服器的最簡單的方式之一。專注於一個伺服器的核心特性
  3. Fastify:web框架,專注於通過最少的程式碼和強大的外掛架構來給開發者提供最好的開發體驗。最快的web框架之一
  4. Gatsby:基於React,支援GraphQL,有豐富的外掛和starter的生態的靜態站點生成器(static site generator)
  5. hapi:內容豐富的框架,構建應用和服務,讓開發者專注於寫可重用的應用邏輯程式碼而不是構建指令碼
  6. koa:Express的團隊開發的,目標是更簡單和更小,為了滿足開發一些不影響現有社群的改變的需求
  7. Loopback.io:讓構建需要複雜整合(complex integration)的新應用更簡單
  8. Meteor:全棧框架,使用JS來同構地構建app,在客戶端和伺服器共享程式碼。一旦有現成的工具提供了所有功能,那麼可以將它和任何前端庫整合(Vue、Angular、React)。也能用來建立手機app
  9. Micro:提供了輕量級的伺服器來建立非同步HTTP微服務
  10. NestJS:基於progressive Node.js的TS框架,用來建立企業級高效、可靠和可擴充套件的服務端應用
  11. Next.js:React框架,提供所有的在開發過程中需要的所有特性(hybrid static,server rendering,支援TS,smart bundling,route pre-fetching,等等)
  12. Nx:使用NestJS,Express,React,Angular等全棧框架開發的工具箱。Nx可以幫助將一個單團隊開發的應用擴充套件到多團隊協作的專案
  13. Sapper:建立各種量級的web應用的框架,漂亮的開發體驗和靈活的基於檔案系統的路由。提供了SSR和其它
  14. Socket.io:實時通訊引擎
  15. Strapi:裡能夠獲得,開源的Headless CMS,讓開發者自由選擇他們喜歡的工具和框架來讓編輯者管理和分發他們的創作內容。通過admin控制檯和API擴充套件,它可以讓大公司提高內容分發的速度

Node.js的歷史回顧

Node.js截至2020年僅僅11歲,JS24歲,Web31歲。JS是為了在處理(網景)瀏覽器中的web頁面而建立的。網景的Netscape LiveWire是可以使用服務端JS來生成動態網頁的環境。然而賣的並不好,所以服務端JS也不受歡迎。Node.js的崛起的一個核心因素是時機,就在幾年前,JS開始想成為一門嚴肅的語言,且“Web2.0”應用(Gmail等)也證明了瀏覽器應用很受歡迎。JS引擎也因為瀏覽器之間的競爭變得更好,V8(Chrome V8)引擎就是競爭的結果,Node.js就是執行在這個引擎上面。Node.js是在正確的地方以正確的時間點出現的,但是它的設計思想和對於服務端JS開發也做了很多貢獻

2009

  • Node.js誕生
  • 第一版npm被建立

2010

  • Express誕生
  • Socket.io誕生

2011

  • npm版本到1.0
  • 大公司開始應用Node.js,例如linkedin,Uber等
  • hapi誕生

2012

  • 應用更加廣泛

2013

  • Ghost(大型部落格平臺)應用Node.js
  • Koa誕生

2014

  • io.js是Node.js的一個最大的fork,為了支援ES6

2015

  • Node.js Foundation誕生
  • IO.js merge回Node.js
  • npm出現了私有模組(private module)
  • Node.js4誕生

2016

2017

  • npm主要關注於安全主題
  • Node.js8誕生
  • HTTP/2
  • V8將Node.js放入了測試套件,官方將Node.js作為JS引擎的目標,不止Chrome
  • 每週30億npm下載次數

2018

  • Node.js10
  • 支援ES modules.mjs實驗版
  • Node.js11

2019

  • Node.js12
  • Node.js13

2020

  • Node.js14
  • Node.js15

安裝Node.js

Node.js有很多種安裝方式,本post只展示最常用且最方便的一種

對所有平臺的官方包都在node下載

安裝Node.js最簡單的方式是通過包管理器,不同的作業系統有各自的包管理器

MacOS:Homebrew -- brew install node

其它Linux和Windows下的包管理器列表在這裡Windows和Linux下的包管理器

nvm是執行Node.js的一種受歡迎的方式,它可以轉換Node.js的版本,安裝新版本或者回滾到老版本。用老版本的Node.js測試程式碼也是很有必要的

nvm詳情

本post主建議使用官方安裝包,如果是新手或者已經不(能)用Homebrew了,否則Homebrew是最佳選擇

無論以何種方式安裝,一旦Node.js安裝好,就可以在命令列來訪問node可執行程式了

要使用node需要什麼JavaScript基礎?

應該掌握的JS基本概念:

  1. Lexical Structure(語法結構)
  2. 表示式
  3. 型別
  4. 變數
  5. 函式
  6. this
  7. Arrow Functions
  8. 迴圈
  9. scopes(作用域)
  10. Arrays
  11. Template Literals(模板字串,通過著重號包起來的字串)
  12. Semicolons
  13. Strict mode
  14. ES6,2016,2017

這些內容保證你成為一個熟練的JS開發人員,無論是針對瀏覽器還是Node.js

要理解非同步程式設計下面這些概念也很重要,它們是Node.js的基礎

  1. 非同步程式設計和回撥
  2. Timers
  3. Promises
  4. Async和Await
  5. Closures
  6. The Event Loop

Node.js和瀏覽器的不同

瀏覽器和Node.js都是將JS作為它們的程式語言。構建執行在瀏覽器上的app和構建Node.js應用是完全不一樣的

從使用JS的前端程式設計師角度看,Node.js開發的app給他們帶來極大優勢:可以使用一種語言編寫任何程式(即前後端)

學習另外一種語言很困難,所以能用一種語言實現客戶端和服務端的功能是非常有優勢的(淘汰後端程式設計師【手動滑稽】)

Node.js改變的是生態。在瀏覽器中,大部分時間是在和DOM互動,或者其它的Web平臺API,例如Cookies。這些在Node.js中都不存在。當然,瀏覽器提供的document、window等物件也都不存在。在瀏覽器中我們也沒有Node.js通過它的模組提供的各種好用的API,例如檔案系統訪問功能

另一個重大區別是,在Node.js中,你控制環境。除非你在開發開源應用,要滿足使用的人可以在任意平臺部署應用,否則你來決定使用什麼版本的Node.js執行程式。和不得不去適配的瀏覽器相比,這是一個很奢侈的享受。這也意味著你可以寫Node.js支援的ES6-7-8-9標準的JS

因為JS更新很快,但是瀏覽器會稍慢一些,使用者更新可能會更慢,有時在web方面,你不得不使用比較老版本的JS/ESMAScript版本。 你可以使用Babel將自己的程式碼在移植到其它瀏覽器之前轉為ES5相容的版本,但是Node.js中,不需要這樣做

另一個區別是Node.js使用CommonJS模組系統,然而在瀏覽器中,我們開始看到ES模組標準正在被實現,實踐中,這表示你在Node.js中使用require()的時候,在瀏覽器中要使用import

V8 JavaScript引擎

V8是Chrome的核心JS引擎的名字。它做的事情是在我們通過瀏覽Chrome是載入和執行JavaScript。V8提供了JS執行的執行時環境(runtime)。DOM和瀏覽器提供的其它的Web平臺API。JS引擎是獨立於它所寄生的瀏覽器的,這個特性使得Node.js得以崛起。在2009年,V8被選來驅動Node.js,由於Node.js影響力擴大,V8變成了驅動不可計數的JS寫的服務端程式碼的引擎

Node.js生態很大,因此V8也可以驅動桌面app,例如Electron專案

其它的瀏覽器有自己的引擎,Firefox有SpiderMonkey,Safari有JavaScriptCore(Nitro),Edge開始基於Chakra但是最近使用Chromium和V8引擎重構,此外還存在其它的引擎。這些引擎都實現了ECMA ES-262標準,也叫ECMAScript,即JS使用的標準

對效能的需求。V8是用C++編寫,並且還在持續改進。它是相容多個系統平臺(Mac,Windows,Linux...),這裡我們忽略V8實現的具體細節(官網介紹)。V8一直在進化,就像其它的JS引擎,為了加速Web速度和改進Node.js生態。在Web上,效能上的競爭已經很多年了,作為使用者和開發者,競爭導致的我們的使用者體驗和開發體驗都有很大提升

JS通常被認為是解釋型語言,但是現在的JS引擎不再只是簡單解釋JS,而是去編譯它。在2009年這件事情就發生了,當時SpiderMonkey JS編譯器被新增到了Firefox 3.5,然後所有人都開始跟進。JS被V8使用JIT編譯器內部編譯後可以加速其執行。看起來有一些反直覺,但是當Google Maps在2004年出現,JS已經從順序執行幾百行程式碼到可以在瀏覽器執行上萬行的完整程式

我們的應用現在可以在瀏覽器內執行幾個小時。而不再是一些表單驗證的簡單指令碼。在這個新世界中,編譯的JS很重要,因為儘管需要一些事件來讓JS編譯好,但是一旦編譯完成,便會獲得比單純解釋程式碼多得多的效能

從命令列執行Node.js程式

執行Node.js程式的一般方法是,通過全域性可用命令node和你想執行的檔名。如果你的main Node.js應用檔案叫做app.js,你可以這樣呼叫

node app.js

執行時要確保你的命令行當前目錄和執行的檔案在同一目錄

如何退出Node.js程式

有很多種方式來中斷Node.js程式。在命令列中執行的程式可用通過ctrl-C來關閉,但是這裡討論的是以程式設計方式退出

首先展示最drastic的方式,然後來看為什麼最好不用它:

// process core模組中提供了方便的方法,可在程式內退出程式
process.exit();

當Node.js執行到這一行,程序立即被強制終止。這代表任何pending狀態的回撥函式,任何還在傳送的網路請求,任何檔案系統的訪問,或者在向stdout和stderr寫資料的程序,全部都會以ungracefully的方式退出

如果這些情況對你來說沒有任何問題,那麼可以給這個方法傳入一個整數值來告訴作業系統這個程式的退出碼process.exit(1)。預設退出碼是0,代表成功。不同的退出碼有不同的含義,這些含義在不同的程式中互動時是有意義的(退出碼的介紹)。也可以通過process.exitCode=1的方式來設定屬性,當程式退出時Node.js會返回這個退出碼

gracefully的程式退出方式是當所有的處理過程都做完時退出

我們通過Node.js多次啟動伺服器,例如

const express = require('express')
const app = express()

app.get('/', (req, res) => {
  res.send('Hi!')
})

app.listen(3000, () => console.log('Server ready'))

這個程式永遠不會結束。如果呼叫process.exit(),任何當前處在pending和running的請求都會被拋棄,這並不是好方法。這種情況下,你應該傳送給command一個SIGTERM標識,然後通過process signal handler來處理

process不需要require(),它是Node.js自動包含的

const express = require('express')

const app = express()

app.get('/', (req, res) => {
  res.send('Hi!')
})

const server = app.listen(3000, () => console.log('Server ready'))

process.on('SIGTERM', () => {
  server.close(() => {
    console.log('Process terminated')
  })
})

什麼是signals?它是POSIX的相互通訊系統,一個傳送到某程序的通知,來告訴這個程序某個發生了的事件。SIGKILL告訴程序立即終止,就像process.exit()SIGTERM告訴程序要gracefully終止,它是從程序管理器(upstart或者supervisord等)傳送出的signal

你可以從程式內部發送這個signal,通過另一個函式process.kill(process.pid, 'SIGTERM'),或者從另一個執行中的Node.js程式,或任何執行在系統中的其它程式(知道你想終止的程序的PID)

Node.js中如何讀取環境變數

process核心模組中提供了env屬性來存放在程式執行時系統具有的所有環境變數。例如process.env.NODE_ENV // "development"。在程式執行前將其設定為“production”可以告訴Node.js現在處於生產環境

如何使用Node.js REPL

node命令是我們執行Node.js指令碼的命令之一,如果我們不提供檔名引數,會進入REPL(Read Evaluate Print Loop,指程式語言環境,接收單行使用者輸入表示式並在控制檯返回結果)模式

使用tab來進行自動補全,

將JS類的名字,例如Number,加一個.,再按tab,會打印出這個類的所有屬性和方法

可以通過global.tab來檢視所有的全域性物件

如果在一些程式碼之後加上了_,那麼會列印上一次表示式的結果

REPL有一些特別的命令,都以點.開頭

  • .help 顯示.命令幫助資訊
  • .editor 進入editor模式,可以寫多行JS程式碼,一旦進入這個模式,需要使用ctrl-D來執行程式碼
  • .break 當輸入多行表示式時,輸入.break命令會丟棄更多的輸入,和ctrl-C效果相同
  • .clear 重置REPL上下文成為一個空物件,並且清除正在輸入的所有多行表示式
  • .load 載入JS檔案,路徑相對於當前工作目錄
  • .save 將所有在REPL session中的輸入儲存在檔案中,需要指定檔名
  • .exit 退出REPL,效果和兩次ctrl-C相同

當你輸入多行表示式時,REPL不需要你呼叫.editor,在輸入enter後,會進入新行而不是列印結果。輸入.break會退出多行輸入模式

Node.js從命令列接收引數

在使用node app.js執行Node.js應用時可以傳入任意數量的引數。引數可以是單個的也可以是k-v形式。即

node app.js joe
node app.js name=joe

這兩種方式下,在程式中取值的方法不一樣。使用內建的process物件來取命令列引數。它有一個argv屬性,是一個數組,包含所有呼叫程式的引數。第一個元素是node命令的全路徑,第二個引數是執行程式碼檔案的全路徑,所有的其它引數從第三個位置開始往後

process.argv.forEach((val, index) => {
  console.log(`${index}: ${val}`)
})

可以通過建立一個新陣列來僅獲取額外引數,const args = process.argv.slice(2),在這種情況下,如果是未命名引數,直接使用args[0]獲取,如果是命名引數,args[0]name=joe,需要轉換,通常使用minimist庫

const args = require('minimist')(process.argv.slice(2))
args['name'] //joe

如果使用這種方式,在給定引數時要加雙橫槓 node app.js --name=joe

使用Node.js輸出到命令列

Node.js提供了console模組,其中有很多和命令列互動的方式。基本上和瀏覽器中的console物件差不多。最基本和最常用的方法是console.log(),它將字串列印到控制檯中。如果你傳入一個物件,會被改變為string。可以將多個變數傳入本方法,例如

const x = 'x'
const y = 'y'
// Node.js會全部列印
console.log(x, y)

可以通過變數和格式符來美化字串

console.log('My %s has %d years', 'cat', 2)
// %s format a variable as a string
// %d format a variable as a number
// %i format a variable as its integer part only
// %o format a variable as an object

console.log('%o', Number)
// 清空控制檯,它的行為可能會依賴使用的控制檯
console.clear()
// 計算一個字串被列印的次數並且打印出來
console.count()

// 輸出效果
// orange: 1
// orange: 2
// apple: 1
const oranges = ['orange', 'orange'];
const anApple = ['apple']

oranges.forEach((v, i) => {
    console.count(v);
})

anApple.forEach((v, i) => {
    console.count(v);
})

列印堆疊資訊。列印一個函式的呼叫堆疊追蹤資訊很有用,這可以顯示出,你是如何到達程式碼的某個部分

const function2 = () => console.trace()
const function1 = () => function2()
// 這會列印所有堆疊資訊,就是從哪裡呼叫到了console.trace這個語句
// Trace
//     at function2 (repl:1:33)
//     at function1 (repl:1:25)
//     at repl:1:1
//     at ContextifyScript.Script.runInThisContext (vm.js:44:33)
//     at REPLServer.defaultEval (repl.js:239:29)
//     at bound (domain.js:301:14)
//     at REPLServer.runBound [as eval] (domain.js:314:12)
//     at REPLServer.onLine (repl.js:440:10)
//     at emitOne (events.js:120:20)
//     at REPLServer.emit (events.js:210:7)
function1()

計算度過的時間。通過time() timeEnd()可以輕鬆計算一個函式執行花費的時間

// 會打印出的內容
// test
// doSomething(): 3.243ms
const doSomething = () => console.log('test')
const measureDoingSomething = () => {
  console.time('doSomething()')
  //do something, and measure the time it takes
  doSomething()
  console.timeEnd('doSomething()')
}
measureDoingSomething()

stdout stderrconsole.log列印資料到標準輸出(stdout)。console.error列印到了stderr流,這種方式列印的資訊不在控制檯,而會在錯誤日誌中

給輸出內容上色,通過轉義序列(escape sequence)可以給列印到控制檯的內容上色。一個轉義序列是一組標識了顏色的字元

// 文件說是黃色,powershell是紅色
console.log('\x1b[33m%s\x1b[0m', 'hi!')

這是一種底層實現方式。簡單的方式是使用庫。Chalk就是這樣的庫,除了上色也有其它的格式化工具,例如加粗、斜體和下劃線

通過npm安裝好後,可以這麼使用

const chalk = require('chalk')
console.log(chalk.yellow('hi!'))

建立一個進度條。Progress可以在控制檯建立一個進度條。通過npm安裝好之後。以下程式碼段建立了10步進度條,每100ms完成1步,當進度條完成我們清除了interval

const ProgressBar = require('progress')

const bar = new ProgressBar(':bar', { total: 10 })
const timer = setInterval(() => {
  bar.tick()
  if (bar.complete) {
    clearInterval(timer)
  }
}, 100)

Node.js從命令列來接收輸入

怎麼使用Node.js來開發命令列工具?

從Node.js7開始,它提供了readline模組來做一些工作:從readable流(process.stdin)獲取輸入,在Node.js程式執行的時候標準輸入流就是終端輸入,一次一行

const readline = require('readline').createInterface({
  input: process.stdin,
  output: process.stdout
});

readline.question("What's your name?", name => {
  console.log(`Hi, ${name}!`);
  readline.close();
});

這些程式碼詢問了使用者名稱,一旦文字輸入並且按下回車,那麼程式會發送一個問候資訊。question()方法的第一個引數提出問題並且等待回覆,一旦回車被按下,會呼叫回撥函式。回撥函式中我們關閉了readline介面

readline提供了一些其它的方法,在readline模組文件檢視

如果需要輸入密碼,直接將輸入列印到控制檯並不合適,應該顯示*號,最方便的方法是使用readline-sync包,提供了開箱即用的API。最完整和抽象的解決方案是使用Inquirer.js

使用npm安裝完成之後,可以通過以下方式使用

const inquirer = require('inquirer')

var questions = [
  {
    type: 'input',
    name: 'name',
    message: "What's your name?"
  }
]

inquirer.prompt(questions).then(answers => {
  console.log(`Hi ${answers['name']}!`)
})

這個模組提供了很多功能(詢問多個選項,包括單選、確認等等)。如果要將CLI輸入作為目標,那麼更應該學習Inquirer.js庫而不是自帶的readline

從Node.js檔案中使用export來暴露功能

一個Node.js檔案可以引入其它Node.js檔案暴露出來的功能。當你想引入一些東西時使用const library = require('./library')來引入library.js檔案暴露出來的功能(這個檔案和當前檔案在同一目錄)。在這個檔案中,功能在它可以被引入之前必須先暴露出去。任何定義在檔案中的物件或變數預設都是私有(private)的。是module.exports的API允許我們可以這樣做。當你將一個物件或一個函式賦值給一個暴露(export)屬性,它們就能被其它的檔案引入

有兩種方式可以做這件事

  1. 將物件賦值給module.exports,這是模組系統提供的盒子,可以保證這個檔案只暴露在這裡面存的物件
const car = {
  brand: 'Ford',
  model: 'Fiesta'
}

module.exports = car

// other file
const car = require('./car')
  1. 第二種方式是將要暴露的物件當作exports的屬性,這種方式讓你可以暴露多個物件,函式或資料
const car = {
  brand: 'Ford',
  model: 'Fiesta'
}

exports.car = car
// or directly
exports.car = {
  brand: 'Ford',
  model: 'Fiesta'
}

// other file
const items = require('./items')
items.car
// or
const car = require('./items').car

這兩種方式的區別是,第一種暴露指定的物件,第二種方式暴露的是指定的物件的屬性(相當於每個檔案有一個exports物件可以暴露,要麼直接給它賦值替代(module.exports才能修改值,直接改exports沒有用),要麼就通過給它加屬性來暴露更多的資料)

要注意,當你在其它檔案引入這個檔案時,這個檔案中其它的全域性域內的語句會執行

NPM介紹

npm是Node.js的標準包管理器。在2017年1月,npm倉庫列出的包超過350000個,它變成了最大的單一語言程式碼庫,這些包(幾乎)包含了一切功能

開始npm是用來下載和管理Node.js包的依賴,但是現在它已經變成了前端JS的工具。npm可以做很多事情,Yarn是npm的一個替代品,最好也去了解它一下

npm管理你專案依賴包的下載,如果一個專案有package.json檔案,只要執行npm install,它會下載專案需要的所有包,下載到node_modules資料夾,如果不存在會建立此目錄

安裝單個包,通過執行npm install <package-name>可以安裝單個包。這個命令經常伴有不少標誌

  • --save 安裝並且將entry加入到package.json檔案依賴
  • --save-dev 安裝並且將entry加入到package.json檔案的devDependencies

它們之間的區別是devDependencies通常是開發的工具,例如測試庫,並不需要繫結到生產app上

通過npm update來更新包,npm會檢查所有包的新版本(滿足版本限制),可以指定單個包來更新npm update <package-name>

除了直接下載,npm也可以管理版本,所以你可以指定一個包的任意版本,使用更高或更低的版本來滿足需求。通常你會發現一個庫只和另一個庫的主要發行版本相容,或者最新版本還沒有修復bug。顯式指定庫的版本可以讓所有人在同一套包環境下開發(在package.json檔案更新之前)

npm符合semantic versioning(semver)標準

package.json檔案支援一種可以被執行的特殊命令列任務(npm run <task-name>)的格式

例如;

{
  "scripts": {
    "start-dev": "node lib/server-development",
    "start": "node lib/server-production"
  },
}
// It's very common to use this feature to run Webpack:
{
  "scripts": {
    "watch": "webpack --watch --progress --colors --config webpack.conf.js",
    "dev": "webpack --progress --colors --config webpack.conf.js",
    "prod": "NODE_ENV=production webpack -p --config webpack.conf.js",
  },
}

此時,不需要輸入長命令,而是直接通過一下命令執行應用

$ npm run watch
$ npm run dev
$ npm run prod

npm安裝的包在什麼位置?

當你使用npm安裝包時有兩種模式

  1. local install
  2. global install

預設,當你執行安裝命令時,包會被安裝到當前的檔案樹下,在node_module子資料夾下。此時,npm也會在當前資料夾下的package.json檔案中的dependencies屬性下新增一個安裝包的入口資訊(entry)

通過-g標識來進行全域性安裝。npm install -g lodash。此時,npm會使用全域性位置來作為下載路徑。使用npm root -g命令來檢視本機的全域性目錄在哪。macOS或者Linux,位置應該是/usr/local/lib/node_modules,在Windows環境下,應該是C:\Users\YOU\AppData\Roaming\npm\node_modules。如果使用nvm來管理Node.js版本,那麼這個位置可能會有變化

如何通過npm來使用或者執行一個包

當你使用npm安裝好一個包後,怎麼在Node.js程式碼中使用呢?以lodash包(JS工具包)為例

// 使用require來引入包
const _ = require('lodash')

如果你的包是可執行的,它的可執行檔案會被放在node_modules/.bin資料夾。以cowsay包(命令列工具--讓一頭奶牛說一些東西)為例,使用npm安裝它之後,它本身和一些依賴會安裝到node_modules目錄下,可以通過./node_modules/.bin/cowsay來執行它,但是在npm(5.2之後),npx是一個更好的選項,即npx cowsay,那麼npx會找到這個包(可執行檔案)的位置

關於package.json檔案

package.json檔案是你的專案的展示(manifest)。它可以做很多事情,是對各種工具配置的中央倉庫。它也是npm和yarn儲存安裝包的名字和版本的地方

檔案的結構,{},對於這個檔案沒有任何影響要求的內容。唯一的要求就是符合JSON檔案的格式,否則它無法被嘗試訪問它包含的屬性的程式讀取。如果你正在構建一個Node.js包,要把它釋出到npm上,那麼必須指定一系列屬性來幫助別人使用它

{
  "name": "test-project"
}

它定義了一個名字的屬性,聲明瞭這個專案(app/package)的名字,這個專案包含在同目錄下的一個同名資料夾下

以下是一個稍顯複雜的例子,是從一個Vue.js應用中抽取出來的

{
  "name": "test-project",
  "version": "1.0.0",
  "description": "A Vue.js project",
  "main": "src/main.js",
  "private": true,
  "scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "unit": "jest --config test/unit/jest.conf.js --coverage",
    "test": "npm run unit",
    "lint": "eslint --ext .js,.vue src test/unit",
    "build": "node build/build.js"
  },
  "dependencies": {
    "vue": "^2.5.2"
  },
  "devDependencies": {
    "autoprefixer": "^7.1.2",
    "babel-core": "^6.22.1",
    "babel-eslint": "^8.2.1",
    "babel-helper-vue-jsx-merge-props": "^2.0.3",
    "babel-jest": "^21.0.2",
    "babel-loader": "^7.1.1",
    "babel-plugin-dynamic-import-node": "^1.2.0",
    "babel-plugin-syntax-jsx": "^6.18.0",
    "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
    "babel-plugin-transform-runtime": "^6.22.0",
    "babel-plugin-transform-vue-jsx": "^3.5.0",
    "babel-preset-env": "^1.3.2",
    "babel-preset-stage-2": "^6.22.0",
    "chalk": "^2.0.1",
    "copy-webpack-plugin": "^4.0.1",
    "css-loader": "^0.28.0",
    "eslint": "^4.15.0",
    "eslint-config-airbnb-base": "^11.3.0",
    "eslint-friendly-formatter": "^3.0.0",
    "eslint-import-resolver-webpack": "^0.8.3",
    "eslint-loader": "^1.7.1",
    "eslint-plugin-import": "^2.7.0",
    "eslint-plugin-vue": "^4.0.0",
    "extract-text-webpack-plugin": "^3.0.0",
    "file-loader": "^1.1.4",
    "friendly-errors-webpack-plugin": "^1.6.1",
    "html-webpack-plugin": "^2.30.1",
    "jest": "^22.0.4",
    "jest-serializer-vue": "^0.3.0",
    "node-notifier": "^5.1.2",
    "optimize-css-assets-webpack-plugin": "^3.2.0",
    "ora": "^1.2.0",
    "portfinder": "^1.0.13",
    "postcss-import": "^11.0.0",
    "postcss-loader": "^2.0.8",
    "postcss-url": "^7.2.1",
    "rimraf": "^2.6.0",
    "semver": "^5.3.0",
    "shelljs": "^0.7.6",
    "uglifyjs-webpack-plugin": "^1.1.1",
    "url-loader": "^0.5.8",
    "vue-jest": "^1.0.2",
    "vue-loader": "^13.3.0",
    "vue-style-loader": "^3.0.1",
    "vue-template-compiler": "^2.5.2",
    "webpack": "^3.6.0",
    "webpack-bundle-analyzer": "^2.9.0",
    "webpack-dev-server": "^2.9.1",
    "webpack-merge": "^4.1.0"
  },
  "engines": {
    "node": ">= 6.0.0",
    "npm": ">= 3.0.0"
  },
  "browserslist": ["> 1%", "last 2 versions", "not ie <= 8"]
}
  • version - 表示當前的版本
  • name - 應用/包的名字
  • description - 對應用/包的簡單介紹
  • main - 應用的入口
  • private - 如果是true則阻止應用/包被意外發布到npm上
  • scripts - 定義了一系列可以執行的node指令碼
  • dependencies - 作為依賴的一系列npm包
  • devDependencies - 作為開發時依賴的一系列npm包
  • engines - 設定應用/包執行的Node.js版本
  • browserslist - 告訴你想支援的瀏覽器(對應版本)

這些屬性會被npm或其它可以使用的工具應用

(包和單機應用的區別就是釋出/不釋出的區別)

大部分的屬性只會在Node.js上使用,還有一些跟你的程式碼互動的工具使用(npm等)

name

設定包名,"name": "test-project"。名字必須少於214個字元,不能包含空格,只能包含小寫字母,橫槓(-)或者下劃線(_)。這是因為當包釋出到npm上後,會生成它自己的連結。如果要把這個包釋出到Github,那麼把這個屬性設定為倉庫名是個不錯的選擇

author

列出作者名單

{
  "author": "Joe <[email protected]> (https://whatever.com)"
}

{
  "author": {
    "name": "Joe",
    "email": "[email protected]",
    "url": "https://whatever.com"
  }
}

contributors

和作者相同,一個專案可能有一個或多個貢獻者,這個屬性包含一個數組來列出他們的名字

{
  "contributors": ["Joe <[email protected]> (https://whatever.com)"]
}

{
  "contributors": [
    {
      "name": "Joe",
      "email": "[email protected]",
      "url": "https://whatever.com"
    }
  ]
}

bugs

放這個包的issue tracker連結(類似Github的issue頁)

{
  "bugs": "https://github.com/whatever/package/issues"
}

homepage

設定包的主頁


{
  "homepage": "https://whatever.com/package"
}

version

表示這個包的當前版本。這個屬性遵循(semver),即版本總是通過3個數字來表示x.x.x。第一個數字是主要版本,第二個是副版本,第三個是補丁版本。修復bug的版本是補丁版本,推出向下相容的更新內容是副版本,推出重大更新是主要版本
version

licence

表示這個包採取的協議型別"license": "MIT"

keywords

包括一些和這個包的功能相關的一些關鍵字

"keywords": [
  "email",
  "machine learning",
  "ai"
]

當人們瀏覽包頁面時,這個屬性幫助網站更容易導航到這個包

description

包含對這個包的簡要介紹。"description": "A package to work with strings",當你決定將包發不到npm上時,人們可以直到你的包是幹什麼的

repository

表明這個包的倉庫在哪裡。"repository": "github:whatever/testing",這是github的字首,還有一些其它系統的連結。或者通過以下形式

"repository": {
  "type": "git",
  "url": "https://github.com/whatever/testing.git"
}

main

設定這個包的執行入口位置。當你在應用中引用這個包時,這個屬性會告訴應用到哪裡去尋找模組暴露的位置"main": "src/main.js"

private

如果設為true,那麼這個應用/包不會意外發布到npm。"private": true
main

scripts

定義了一系列可以執行的node指令碼

"scripts": {
  "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
  "start": "npm run dev",
  "unit": "jest --config test/unit/jest.conf.js --coverage",
  "test": "npm run unit",
  "lint": "eslint --ext .js,.vue src test/unit",
  "build": "node build/build.js"
}

這些指令碼都是命令列應用。可以通過npm run xxxx yarn xxx來執行,xxx就是這個屬性的key

dependencies

設定了一系列作為依賴的npm包。當你使用npm或yarn安裝包時,這個包自動被插入到這個屬性的列表中

"dependencies": {
  "vue": "^2.5.2"
}

devDependencies

設定了一系列作為開發階段依賴的npm包。它和dependencies不同,因為它們只在開發機器上被安裝,在生產環境是不需要的

當你使用npm install --save-dev <PACKAGENAME> yarn add --dev <PACKAGENAME>安裝包時,這個包的資訊自動插入到這個屬性的列表中

engines

設定Node.js和其它這個包支援執行的工具的版本

"engines": {
  "node": ">= 6.0.0",
  "npm": ">= 3.0.0",
  "yarn": "^0.13.0"
}

browserslist

曾經被用來說明你想支援的瀏覽器(和它們的版本)。它會被Babel,Autoprefixer和其它工具引用,只會在你指定的瀏覽器(如果需要)新增polyfill和fallback

"browserslist": [
  "> 1%",
  "last 2 versions",
  "not ie <= 8"
]

上述內容表明你想支援所有瀏覽器(1%的使用率--CanIUse.com的資料)的最近2個版本,不支援IE8及以下

針對命令列的屬性

package.json檔案也可以放針對命令列的配置,例如對於Babel、ESLint等工具。每個都有特殊的屬性,例如eslintConfigbabel和其它。通過檢視對應的文件來了解相關內容

上述的包的版本中包括~3.0.0^0.13.0是什麼意思?這些符號制定了哪個版本是你的包支援的。假如使用semver的版本管理,你可以將多個版本放在一個範圍中1.0.0 || >= 1.1.0 < 1.2.0,即使用1.0.0或大於1.1.0,小於1.2.0

關於package-lock.json檔案

在npm5中,推出了package-lock.json檔案。這個檔案是為了追蹤安裝的所有包的準確版本資訊,這樣即使包更新了,應用也可以100%以同樣方式再現。在package.json中,你可以設定你想升級的版本,使用semver版本管理

如果~0.13.0,那麼你只希望更新補丁版本(0.13.1可以,但是0.14.0不行)。如果^0.13.0,那麼你只希望升級補丁版本和副版本(0.13.1和0.14.0等)。如果0.13.0,那麼會使用這個版本。你不會commit自己的node_modules資料夾到vcs中,當你想在其它機器上覆制自己的程式(使用npm下載包),那麼會下載你指定的版本中更新的且是你的應用可以接收的版本

如果指定了指定版本,那麼不會被這個問題影響。package-lock.json將你當前安裝的每個包的版本固定下來,npm在執行install時會使用這些版本。這個檔案需要被commit到你的vcs中,依賴的版本資訊會在package-lock.json檔案中更新,當你執行npm update

一個例子。這是一個package-lock.json檔案,當我們在一個空資料夾使用npm安裝cowsay是獲得的

{
  "requires": true,
  "lockfileVersion": 1,
  "dependencies": {
    "ansi-regex": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.
0.0.tgz",
      "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
    },
    "cowsay": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/cowsay/-/cowsay-1.3.1.tgz"
,
      "integrity": "sha512-3PVFe6FePVtPj1HTeLin9v8WyLl+VmM1l1H/5P+BTTDkM
Ajufp+0F9eLjzRnOHzVAYeIYFF5po5NjRrgefnRMQ==",
      "requires": {
        "get-stdin": "^5.0.1",
        "optimist": "~0.6.1",
        "string-width": "~2.1.1",
        "strip-eof": "^1.0.0"
      }
    },
    "get-stdin": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.
1.tgz",
      "integrity": "sha1-Ei4WFZHiH/TFJTAwVpPyDmOTo5g="
    },
    "is-fullwidth-code-point": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/
is-fullwidth-code-point-2.0.0.tgz",
      "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
    },
    "minimist": {
      "version": "0.0.10",
      "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10
.tgz",
      "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8="
    },
    "optimist": {
      "version": "0.6.1",
      "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
      "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=",

      "requires": {
        "minimist": "~0.0.1",
        "wordwrap": "~0.0.2"
      }
    },
    "string-width": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
      "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
      "requires": {
        "is-fullwidth-code-point": "^2.0.0",
        "strip-ansi": "^4.0.0"
      }
    },
    "strip-ansi": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
      "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
      "requires": {
        "ansi-regex": "^3.0.0"
      }
    },
    "strip-eof": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
      "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8="
    },
    "wordwrap": {
      "version": "0.0.3",
      "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz",
      "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc="
    }
  }
}

cowsay依賴瞭如下包

  • get-stdin
  • optimist
  • string-width
  • strip-eof

同樣這些包也依賴了其它包,我們可以在requires屬性中看到

  • ansi-regex
  • is-fullwidth-code-point
  • minimist
  • wordwrap
  • strip-eof

這些都按字母順序新增進了檔案,每個包都有版本欄位,resolved欄位指向包的位置,integrity字串可以用來做包驗證

找到安裝好的npm包的版本

要檢視所有安裝好的包的最新版本和它們的依賴,可以通過npm list來實現

❯ npm list
/Users/joe/dev/node/cowsay
└─┬ [email protected]
  ├── [email protected]
  ├─┬ [email protected]
  │ ├── [email protected]
  │ └── [email protected]
  ├─┬ [email protected]
  │ ├── [email protected]
  │ └─┬ [email protected]
  │   └── [email protected]
  └── [email protected]

也可以檢視package-lock.json檔案,但是檢視不清晰。npm list -g的功能沒有區別,只是檢視全域性安裝的包

如果只想檢視頂層包(在package.json中展示的包),那麼使用npm list --depth=0

可以通過指定包名來獲取它的版本,npm list cowsay。這種方式對於安裝的包也適用npm list minimist

如果你想檢視npm中某個包的最新可用版本,可以使用npm view [package_name] version

安裝npm包的比較老的版本

可以通過npm install <package>@<version>的方式來安裝老版本的npm包。例如npm install [email protected](最新版本是1.3.1--發post時間)。使用同樣的方式全域性安裝老版本的npm包。可以通過npm view <package> versions來檢視某個包在npm上所有的版本npm view cowsay versions

更新所有的Node.js依賴到最新版本

當你使用npm install安裝某個包時,安裝的是這個包的最新版本,這個包被下載到了node_modules目錄並且在package.json和package-lock.json中添加了對應的entry。npm會分析依賴並且也安裝它們的最新版本

cowsay為例,當你npm install cowsay,package.json檔案出現

{
  "dependencies": {
    "cowsay": "^1.3.1"
  }
}

package-lock.info檔案出現

{
  "requires": true,
  "lockfileVersion": 1,
  "dependencies": {
    "cowsay": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/cowsay/-/cowsay-1.3.1.tgz",
      "integrity": "sha512-3PVFe6FePVtPj1HTeLin9v8WyLl+VmM1l1H/5P+BTTDkMAjufp+0F9eLjzRnOHzVAYeIYFF5po5NjRrgefnRMQ==",
      "requires": {
        "get-stdin": "^5.0.1",
        "optimist": "~0.6.1",
        "string-width": "~2.1.1",
        "strip-eof": "^1.0.0"
      }
    }
  }
}

這兩個檔案告訴我們安裝了cowsay的1.3.1版本,我們的更新規則是^1.3.1,即支援補丁版本和副版本的更新。如果有了新版本,我們可以輸入npm update,已安裝的版本就會更新,package-lock.json檔案也會更新,但是package.json不會改變

要查詢包的新發行版本,可以執行npm outdated。其中一些更新可能是主要版本更新,npm update不會更新這些版本,由於主要版本基本上是大更新(不相容),所以不能以這種方式更新。要更新主要版本的包,先全域性安裝npm-check-updates npm install -g npm-check-updates,然後執行ncu -u,這會更新package.json中所有包的版本,dependencies和devDependencies,因此npm可以安裝新的主要版本

如果你下載了一個沒有node_modules目錄的專案,然後你需要先下載新的包,只需要執行npm install即可

使用npm進行Semantic Versioning

Node.js包都遵循semver規則。這個規則之前已經介紹過了。當你要做一個新的發行版,需要按照規則來更新版本

  • major version - 改變包括不相容API
  • minor version - 向後相容的情況下增加功能
  • patch version - 向後相容的bug修復過程

npm定義了一些我們可以在package.json檔案來選擇使用的規則,用以通過npm update能更新的版本

  • ^ - 在不改變非零值的情況下更新版本。^0.13.0只會在13的副版本下更新補丁版本,^1.13.0不能更新到2.0.0
  • ~ - ~0.13.0只能更新補丁版本
  • > - 接受到任何一個大於給定版本的版本
  • >= - 接受到任何一個等於或大於給定版本的版本
  • < - 接受到任何一個小於給定版本的版本
  • <= - 接受到任何一個等於或小於給定版本的版本
  • = - 接受到指定版本
  • - - 一個版本範圍
  • || - 組合接受範圍

還有一些其它的規則,不寫標識(只接受給定版本),latest使用最新可用版本

解除安裝npm包

要解除安裝本地安裝(npm install <package-name>)過的包,通過npm uninstall <package-name>,會從專案根目錄(包含node_mudoles)中解除安裝包。使用-S --save標識會移除在package.json檔案中的引用。如果包是個開發依賴,那麼必須使用-D --save-dev標識來從檔案(package.json)中移除它

npm uninstall -S <package-name>
npm uninstall -D <package-name>

如果是全域性安裝的包,需要加-g --global標識。npm uninstall -g <package-name>。這個命令可以在任何目錄下執行

npm global or local packages

在區域性和全域性包之間的主要區別是:

  1. 區域性包安裝到了你執行npm install <package-name>命令的目錄,它們被放到了這個目錄下的node_modules目錄
  2. 全域性包放在了系統中的一個單獨位置(安裝時設定),無論在哪裡執行npm install -g <package-name>

在你的程式碼中,你只需要(require)區域性包

require('package-name');

因此,哪種包在何時應該被安裝呢?通常,所有的包都應該區域性安裝。這確保你可以在電腦中擁有多個應用,並且它們執行在各自需要的不同版本包環境下。更新全域性包會讓你所有專案使用新的發行版,這會導致嚴重的維護性問題。所有專案都有它們自己的區域性包的版本,即使看起來浪費了一些空間,但是比起使用全域性包的副作用,是可以忽略的。當一個包提供了命令列工具(CLI),它可以全域性安裝,被所有專案重用。你也可以區域性安裝可執行的命令,通過npx來執行,但是一些包更適合全域性安裝,以下是一些例子

  • npm
  • create-react-app
  • vue-cli
  • grunt-cli
  • mocha
  • react-native-cli
  • gatsby-cli
  • forever
  • nodemon

可能有一些包已經全域性安裝在系統中,通過npm list -g --depth 0來檢視

npm的依賴和開發依賴

當你使用npm install <package-name>安裝了npm包時,它作為依賴安裝。這個包會自動列入package.json檔案,在dependencies屬性中(npm5之前需要手動指定--save標識)。當你添加了-D --save-dev標識後,它作為開發依賴安裝,並且在devDependencies屬性被新增

開發依賴是僅在開發時用到的包,生產環境不需要,例如測試包、webpack或Babel。當你在生產環境中,如果輸入npm install和包含package.json檔案的目錄,它們都會被安裝,因為npm認為這是一個開發環境的部署。需要設定--production標識來避免安裝這些依賴

npx -- Node.js Package Runner

npx是一個很有用的命令,在5.2版本(2017.7)以後的npm中可用.如果你不想安裝npm,你可以單獨安裝npx。npx讓你執行通過Node.js構建的程式碼並且釋出到npm倉庫

更容易執行區域性命令。Node.js開發者曾經將很多可執行命令全域性安裝,使得它們可以在當前目錄快速執行。當npx commandname,會在專案的node_modules目錄自動尋找正確的命令引用。不需要直到確切路徑,也不需要全域性安裝包

npx的另一個好用的特性是,不需要安裝也可以執行命令。即你不需要安裝任何東西。使用@version語法可以執行任何版本的命令

npx cowsay "hello"

可以在沒有安裝cowsay包的情況下工作。執行npx @vue/cli create my-vue-app可以建立一個vue的app,npx create-react-app my-react-app可以建立一個react的app

一旦下載完成,下載的程式碼會被清楚。使用不同的Node.js版本來執行程式碼。使用@來指定版本,結合node的npm包

npx node@10 -v #v10.18.1
npx node@12 -v #v12.14.1

這樣可以避免使用nvm或其它的Node.js版本管理工具。它還可以直接執行URL上的程式碼片段(要小心運行了自己無法控制的程式碼)

Node.js的Event Loop

Event Loop是理解Node.js最重要的概念之一。它解釋了Node.js是如何是非同步的並且有非阻塞I/O。Node.js程式碼在單執行緒執行,這個限制其實是個優點,因為它不需要你考慮很多併發的問題。你只需要關注你的程式碼並且保證它們避免阻塞執行緒(同步網路呼叫或者無限迴圈)。通常,大多數瀏覽器中,每個tab都有一個event loop,讓每一個程序(process,這裡應該指的是每個頁面的js程式碼)保持獨立,避免存在無限迴圈的網頁和處理任務非常長的程式將整個瀏覽器阻塞

環境管理多個併發的event loop,例如處理各種API呼叫。Web Worker也執行在它們各自的event loop中。你需要關注的點就是你的程式碼執行在一個單獨的event loop中,並且要避免自己的程式碼阻塞它

任何花費很長時間來返回控制權給event loop的程式碼都會阻塞其它執行在此頁面的JS程式碼。甚至阻塞UI執行緒,使用者不能做點選、滑動網頁等操作。在JS中,幾乎所有的I/O基本都是非阻塞的(網路請求,檔案操作等)。阻塞的是例外,這也是為什麼JS如此依賴回撥函式,現在是promiseasync/await

呼叫棧(LIFO佇列)。event loop持續檢查呼叫棧,檢查是否有函式需要執行。在這樣做的時候,它將呼叫的函式放入棧中並且按順序執行它們。錯誤棧(error stack)就是瀏覽器在呼叫棧中查詢函式名來通知你當前呼叫來源於哪個函式

舉一個例子

const bar = () => console.log('bar')
const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  bar()
  baz()
}

foo()

當這個程式碼執行時,首先foo()被呼叫,它中我們先呼叫bar(),然後呼叫baz()。這裡呼叫棧的變化是

  1. foo()入棧
  2. console.log('foo')入棧
  3. console.log('foo')出棧
  4. bar()入棧
  5. console.log('bar')入棧
  6. console.log('bar')出棧
  7. bar()出棧
  8. baz()入棧
  9. console.log('baz')入棧
  10. console.log('baz')出棧
  11. baz()出棧
  12. foo()出棧

上面的例子很普通(JS按照順序執行程式碼)

下面是一個延遲函式執行(直到棧清空後)的例子

setTimeout(() => {}, 0)是呼叫了一個函式,但是隻有執行了其它的程式碼之後才會執行

const bar = () => console.log('bar')
const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  baz()
}

foo()

// result:
// foo
// baz
// bar

當上述程式碼執行時,首先foo()呼叫,在foo()中我們先呼叫了setTimeout,將bar作為引數傳入,然後我們讓它儘可能快執行(第二個引數是0),然後呼叫baz()

此時的呼叫棧資訊類似:

  1. foo()入棧
  2. console.log('foo')入棧
  3. console.log('foo')出棧
  4. setTimeout()入棧
  5. setTimeout()出棧
  6. baz()入棧
  7. console.log('baz')入棧
  8. console.log('baz')出棧
  9. baz()出棧
  10. foo()出棧
  11. bar()入棧
  12. console.log('bar')入棧
  13. console.log('bar')出棧
  14. bar()出棧

為什麼是上述的執行方式?當setTimeout()被呼叫,瀏覽器或者Node.js開啟了一個timer,一旦timer國旗,回撥函式會放在Message Queue中。Message Queue中放的是使用者觸發的事件(click/keyboard events),或者在你的程式碼可以訪問它們之前的拉去資料的過程。或者onLoad類似的DOM事件

loop給呼叫棧優先順序,它先處理在呼叫棧中找到的所有東西,一旦沒有找到(棧空),它會在message queue中找東西來執行。我們不需要等待setTimeout這種函式,或者拉取資料的函式等,因為它們是由瀏覽器提供的,它們在自己的執行緒中執行。即,如果你設定引數為2s,那麼setTimeout等待的2s不在當前執行緒發生

ES2015介紹了Job Queue的概念,它通過Promises來應用。這是儘可能快的執行一個非同步函式的方式,而不是將它們放到呼叫棧。在當前函式結束之前定義的Promises,會在當前函式結束後呼叫

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  new Promise((resolve, reject) =>
    resolve('should be right after baz, before bar')
  ).then(resolve => console.log(resolve))
  baz()
}

foo()

// result:
// foo
// baz
// should be right after baz, before bar
// bar

Promise(async/await)和老式的(setTimeout)非同步方法的主要區別就在於它是在當前方法呼叫結束之後立刻呼叫,而後者則要在呼叫棧空後才有機會呼叫

理解 process.nextTick()

在理解event loop的過程中,一個重要部分就是process.nextTick()。每次event loop走過一個完整過程,我們稱其為tick。當我們將一個函式傳入process.nextTick,我們構建的引擎(engine)會在當前操作結尾呼叫這個函式,在下一個event loop tick開始之前

process.nextTick(() => {
  // do something
})

event loop在持續處理當前函式的程式碼。當這個操作結束後,JS引擎會執行所有在這個函式執行過程中加入到nextTick中的函式。這就是我們可以稱JS引擎是非同步處理函式(當前函式結束之後),並且儘可能不佔用佇列(呼叫棧)

呼叫setTimeout(() => {}, 0)會在下一個tick結束後執行,比nextTick()(優先呼叫並且在下一個tick開始之前執行)更晚。當你確定某個操作在下一個tick開始前需要完成,那麼使用nextTick()

理解 setImmediate()

當你想非同步執行一些程式碼,還可以使用setImmediate()函式(Node.js提供)

setImmediate(() => {
  // run something
})

任何傳入setImmediate()的函式引數都是會在event loop的下一次迭代中執行的回撥函式。那麼setImmediate()setTimeout(() => {}, 0)process.nextTick()有什麼區別?傳入process.nextTick()會在eventloop的當前迭代中執行,只是在當前函式結束之後,這代表它總是在setTimeout setImmediate之前執行。setTimeout()伴有0s回撥和setImmediate()比較相似。它們的執行順序和很多因素有關,但是它們都會在下一個event loop迭代中執行

探索JS Timers

setTimeout()

在寫程式碼時,你可能希望延時執行一個函式,這是setTimeout的工作,你指定一個之後執行的回撥函式和一個你希望多久之後執行的時間值(ms)

// 這種語法定義了一個新函式,你可以將任何希望呼叫的函式放在這個位置
setTimeout(() => {
  // runs after 2 seconds
}, 2000)

setTimeout(() => {
  // runs after 50 milliseconds
}, 50)

// 或者可以將已有的函式名傳入,還有一系列引數
const myFunction = (firstParam, secondParam) => {
  // do something
}

// runs after 2 seconds
setTimeout(myFunction, 2000, firstParam, secondParam)

setTimeout返回一個timer id,一般情況下不會使用,但是你可以儲存這個id,如果想刪除這個定時任務可以清除(clear)它

const id = setTimeout(() => {
  // should run after 2 seconds
}, 2000)

// I changed my mind
clearTimeout(id)

如果你指定延遲為0,那麼回撥函式會盡可能快地執行,但是要在當前函式執行結束之後

setTimeout(() => {
  console.log('after ')
}, 0)

console.log(' before ')

// result:
// before
// after

這是一個防止CPU阻塞在一個重任務上面,讓其它的函式也可以執行,通過將函式放入排程器中。一些瀏覽器(IE和Edge)實現了setImmediate()方法,它做的事情和setTimeout是一樣的,但是它不標準且在其它瀏覽器不可用,但是它在Node.js中是標準函式

setInterval()

setTimeout類似,區別是它會永遠執行,以一個指定的時間間隔(ms)

// 除非你停止它(clearInterval),否則它會每隔2s呼叫一次
setInterval(() => {
  // runs every 2 seconds
}, 2000)

const id = setInterval(() => {
  // runs every 2 seconds
}, 2000)

clearInterval(id)

setInterval的回撥函式中呼叫clearInterval是很普遍的,可以讓它自動檢測是繼續執行還是停止

const interval = setInterval(() => {
  if (App.somethingIWait === 'arrived') {
    clearInterval(interval)
    return
  }
  // otherwise do things
}, 100)

遞迴的setTimeoutsetInterval每隔n毫秒會開始執行一個函式,不需要考慮什麼時候會有一個函式來結束它

如果一個函式總是花費一樣的時間,setInterval沒有問題。可能一個函式由於其它原因(網路因素)執行時間會變化,setInterval的執行可能會有重疊的情況。為了避免這個問題,你可以通過遞迴setTimeout來實現,即當回撥函式結束之後,繼續呼叫這個setTimeout

const myFunction = () => {
  // do something

  setTimeout(myFunction, 1000)
}

setTimeout(myFunction, 1000)

setTimeout setInterval在Node.js中都可以用,通過Timers模組。Node.js也提供了setImmediate(),它和setTimeout(() => {}, 0)是等價的,大部分是和Node.js Event Loop一起使用

JS非同步程式設計和回撥

在設計上,計算機是非同步的。非同步代表事情發生可以獨立於主要的程式流。在當前的消費者電腦中,每個程式執行一段時間,然後停止執行讓其它程式繼續執行。這件事情執行在一個很快的迴圈(cycle)之中,所以使用者感知不到。我們認為我們的計算機同時運行了很多程式,但這只是一個假象(除非是多處理器)。程式內部使用中斷(給處理器發出的訊號,獲取系統的注意)

通常程式語言是同步的,一些語言會通過語言特性或者庫的方式來提供對非同步程式的管理。C、Java、C#、PHP、Go、Ruby、Swift、Python預設都是同步執行,一些語言是使用執行緒、開啟新程序來處理非同步

JS預設就是非同步的並且是單執行緒執行。這代表JS程式不能建立新執行緒並且並行執行。由於JS是在瀏覽器內部誕生的,它的任務最開始就是響應使用者動作,例如onClick onMouseOver onChange onSubmit等,使用同步模型怎麼實現這些功能呢?答案就在它的環境中,瀏覽器提供了一種方式,通過提供一系列API來處理這些事件

目前,Node.js提供了一種非阻塞I/O環境來擴充套件這個概念到檔案訪問、網路呼叫等

回撥函式。你不知道什麼時候使用者會點選這個按鈕,所以,你定義了一個處理點選事件的handler。這個handler接收一個函式,當事件觸發時會被呼叫

document.getElementById('button').addEventListener('click', () => {
  //item clicked
})

這就是所謂的callback(回撥函式)。一個回撥函式是一個簡單的函式,它作為值傳入到另一個函式中,只有當事件被觸發時才會發生。這樣做的前提是JS有first-class的函式,即函式可以被賦值給變數並且傳入到其它的函式中(higher-order functions)。通常你會將所有的客戶端程式碼放在一個load事件的(window物件)監聽器中。當頁面載入完成,會呼叫回撥函式

window.addEventListener('load', () => {
  // window loaded
  // do what you want
})

回撥隨處可見,除了在DOM事件中,一個常見的例子就是timer

setTimeout(() => {
  // run after 2 seconds
}, 2000)

XHR請求也會接收一個回撥函式,通過將一個函式賦值給當一個特定事件發生(請求狀態改變)會被呼叫的屬性

const xhr = new XMLHttpRequest()
xhr.onreadystatechange = () => {
  if (xhr.readyState === 4) {
    xhr.status === 200 ? console.log(xhr.responseText) :
      console.error('error')
  }
}
xhr.open('GET', 'https://yoursite.com')
xhr.send()

在回撥函式中處理錯誤。如何在回撥函式中處理錯誤?一個Node.js適用的通用方法是,任何回撥函式的第一個引數是錯誤物件(error-first callbacks)。如果沒有錯誤,這個物件為null。如果有一個錯誤,它會包含錯誤資訊和一些其它資訊

fs.readFile('/file.json', (err, data) => {
  if (err !== null) {
    // handle error
    console.log(err)
    return
  }

  // no errors, process data
  console.log(data)
})

回調適用於簡單的情況!每個回撥都可以新增巢狀層次,當你有很多回調函式的時候,程式碼很快變得非常複雜

window.addEventListener('load', () => {
  document.getElementById('button').addEventListener('click', () => {
    setTimeout(() => {
      items.forEach(item => {
        //your code here
      })
    }, 2000)
  })
})

這只是一個簡單的4層程式碼,但實際上有很多層次更多的程式碼。我們如何解決這個問題?適用回撥函式的替代品,從ES6開始,JS介紹不包括回撥函式的幫助實現非同步程式碼的工具(Promise(ES6)和Async/Await(ES2017))

理解JS的 Promise

一個promise通常被定義為是一個最終會可用的值的代理。它是處理非同步程式碼的一種方式,不需要陷入“回撥地獄”。自從ES2015將其標準化和推出,promise已經成為(JS)語言的一部分,自從ES2017推出async/await之後,整合度更高了

async函式背後就是promise,所以要理解async的基礎就是要理解promise。一旦一個promise被呼叫,它開始處於pending狀態,這代表直到promise被處理(提供給呼叫函式需要的資料)之前,呼叫的函式一直在執行。建立好的promise最終會以resolved狀態終止或者是rejected狀態,呼叫其對應的回撥函式(then或者catch

除了你自己的程式碼和庫的程式碼,promise被應用在標準的現代Web API中,例如

  • Battery API
  • Fetch API
  • Service Workers

在現在的JS中不太可能發現不使用promise,以下介紹使用的詳情

PromiseAPI暴露出了Promise構造器,可以通過new來建立

let done = true

const isItDoneYet = new Promise((resolve, reject) => {
  if (done) {
    const workDone = 'Here is the thing I built'
    resolve(done)
  } else {
    const why = 'Still working on something else'
    reject(thy)
  }
})

上例中,promise檢查了done全域性常量,如果它是true,promise會進入resolve狀態(resolve的回撥會執行),否則使promise進入rejected狀態(reject回撥會執行),如果哪個函式都沒有在執行路徑中被呼叫,那麼這個promise仍然處於pending狀態

使用resolvereject,我們可以和呼叫者互動(返回的資料或出現異常)。上例我們已經建立了promise,所以它已經開始執行。一個更常見的例子是叫做Promisifying的技術。這個技術是可以使用經典JS函式(function)來接收回調,並且讓它返回一個Promise的方式

const fs = require('fs')

const getFile = (filename) => {
  return new Promise((resolve, reject)) => {
    fs.readFile(filename, (err, data) => {
      if (err) {
        reject(err) // calling reject 會使得promise失敗(無論是否傳入err引數)
        return
      }
      resolve(data)
    })
  }
}

getFile('/etc/passwd')
  .then(data => console.log(data))
  .catch(err => console.error(err))

最近版本的Node.js,你不需要手寫這麼多的程式碼。有一個promisifying的函式在util模組中可用,只要保證你promisifying的函式有正確的簽名即可

一個promise是如何被建立的?首先,讓我們看看promise是如何被消費(consume)或使用的

const isItDoneYet = new Promise(/* as above */)

const checkIfItsDone = () => {
  isItDoneYet
    .then(ok => {
      console.log(ok)
    })
    .catch(err => {
      console.error(err)
    })
}

執行checkIfItsDone(),當上述的promiseresolve或者reject,這個函式指定的回撥函式會執行(thencatch

一個promise可以被另一個promise返回,建立一個promise鏈。Fetch API是一個promise鏈的非常好的例子,通過它我們可以用來獲取資源並且當資源獲取到時繼續執行鏈中的下一部分

Fetch API是基於promise的機制,呼叫fetch()和使用new Promise()是等價的

const status = response => {
  if (response.status >= 200 && response.statusText < 300) {
    return Promise.resolve(response)
  }
  return Promise.reject(new Error(response.statusText))
}

const json = response => response.json()

fetch('/todos.json')
  .then(status) // status 函式是在這裡呼叫
  .then(json) // json 函式來返回一個promise,處理response中的資料
  .then(data => {
    console.log('Request succeeded with JSON response', data)
  })
  .catch(error => {
    console.log('Request failed', error)
  })

本例中,fetch()todos.json檔案獲取一系列的TODO事項,然後建立了promise鏈。執行fetch()返回一個response,它有很多屬性,從這裡面我們引用了status,一個數值(如果請求成功返回‘OK'--類似HTTP狀態碼),然後通過json()方法,返回一個處理資料體的資料(轉換為JSON)的promise

第一個promise是我們定義的函式,叫做status(),它會檢查返回的狀態碼,看它是否成功(200~299),如果失敗則reject這個promise。這個操作會導致promise鏈跳過所有剩下的promise而直接到catch(),記錄請求失敗的日誌

如果成功,它會呼叫json()函式,因為之前的promise如果成功會返回一個response,所以我們把它作為下一個方法接收的輸入。本例中我們返回的是處理的JSON,所以第三個promise直接接收到了JSON

當promise鏈中的任意地方失敗並且丟擲了error或者reject了promise,那麼程式碼控制權會到最近的catch()語句

new Promise((resolve, reject) => {
  throw new Error('Error')
}).catch(err => {
  console.error(err)
})

new Promise((resolve, reject) => {
  reject('error')
}).catch(err => {
  console.error(err)
})

級聯(cascading)的error

如果在catch()中,你繼續丟擲錯誤,你可以繼續在後面追加catch()

new Promise((resolve, reject) => {
  throw new Error('Error')
})
  .catch(err => {
    throw new Error('Error')
  })
  .catch(err => {
    console.error(err)
  })

如果你需要同步不同的promise,Promise.all()可以幫助你定義一組promise,當它們全部resolve之後,會執行一些程式碼

const f1 = fetch('/something.json')
const f2 = fetch('/something2.json')

Promise.all([f1, f2])
  .then(res => {
    console.log('Array of results', res)
  })
  .catch(err => {
    console.error(err)
  })

ES2015破壞性賦值(destructing assignment)語法可以這麼寫

Promise.all([f1, f2]).then(([res1, res2]) => {
  console.log('Results', res1, res2)
})

Promise.race()當你傳入的第一個promise是resolve的時候執行程式碼,並且它只會執行之後的程式碼一次,使用第一個resolve的promise的結果

const first = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, 'first')
})

const second = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'second')
})

Promise.race([first, second]).then(result => {
  console.log(result)
})

現代非同步JS,通過Async/Await

JS從回撥到promise的進化時間並不長,從ES2017開始,非同步的JS通過async/await語法變得更簡單了。async函式是promise和generator的組合,它們是promise之上的更高層級的抽象。這裡要重複“async/await是基於promise上的”

為什麼要推出async/await?它們減少了promise的大量模板程式碼,並且不打破promise鏈的限制。當promise在ES2015被推出,它們被認為解決了非同步程式碼的問題,但是在ES2017和ES2015之間,人們發現它並不是最終方案。promise自身有其複雜性,並且語法也很複雜。而async函式的出現,看起來像是同步程式碼,但是背後依舊是非同步和非阻塞的

一個async函式會返回一個promise,例如

const doSomethingAsync = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 3000)
  })
}

// 當你想呼叫這個函式,你已經準備好等待,然後呼叫程式碼當promise被resolve或reject時停止執行。警告:客戶端函式必須被定義為async
const doSomething = async () => {
  console.log(await doSomethingAsync())
}

async關鍵字放在任何函式之前都意味著這個函式返回一個promise。即使這個函式沒有這樣做,內部也會讓它返回一個promise

const aFunction = async () => {
  return 'test'
}

// alert test
aFuntion().then(alert)

// 上面的程式碼等同於
cosnt aFunction = () => {
  return Promise.resolve('test')
}

aFunction().then(alert)

這種寫法比使用普通的promise和鏈、回撥函式組合的方式更易讀,當代碼更加複雜的時候,差別會更大

const getFirstUserData = () => {
  return fetch('/users.json')
    .then(response => response.json())
    .then(users => users[0])
    .then(user => fetch(`/users/${user.name}`))
    .then(userResponse => userResponse.json())
}

getFirstUserData()

// 如果使用async/await方式來寫
const getFirstUserData = async () => {
  const response = await fetch('/users.json')
  const users = await response.json()
  const user = await users[0]
  const userResponse = await fetch(`/users/${user.name}`)
  const userData = await userResponse.json()
  return userData
}

getFirstUserData()

async函式可以很容易被連結,語法比promise更易讀

const promiseToDoSomething = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 10000)
  })
}

const watchOverSomeoneDoingSomething = async () => {
  const something = await promiseToDoSomething()
  return something + '\nand I watched'
}

const watchOverSomeoneWatchingSomeoneDoingSomething = async () => {
  const something = await watchOverSomeoneDoingSomething()
  return something + '\nand I watched as well'
}

watchOverSomeoneWatchingSomeoneDoingSomething().then(res => {
  console.log(res)
})

promise上debug很困難,因為debugger不會進入非同步程式碼,然而對於async/await卻很簡單,因為它看起來像是同步程式碼

Node.js的Event emitter

在瀏覽器的JS中,存在非常多的和使用者互動的事件處理。在後端,Node.js給提供了events模組來構建一個類似的系統。這個模組提供了EventEmitter類,我們可以通過它來處理事件

const EventEmitter = require('events')
const eventEmitter = new EventEmitter()

這個物件暴露出來很多方法,包括on emit

emit用來觸發一個事件,on用來新增一個事件被觸發之後執行的回撥函式。例如,建立一個start事件,互動事件中列印一個字串

eventEmitter.on('start', () => {
  console.log('started')
})

當我們執行eventEmitter.emit('start'),事件的handler函式被呼叫,我們得到了控制檯的輸出,你可以作為emit()的額外引數來給handler方法傳遞引數,例如

eventEmitter.on('start', number => {
  console.log(`started ${number}`)
})

eventEmitter.emit('start', 23)

// 多個引數
eventEmitter.on('start', (start, end) => {
  console.log(`started from ${start} to ${end}`)
})

eventEmitter.emit('start', 1, 100)

EventEmitter物件還暴露了其它一些方法

  • once() -- 新增一個一次性的監聽器
  • removeListener()/off() -- 從一個事件中移除一個監聽器
  • removeAllListeners() -- 移除一個事件中所有的監聽器

EventEmitter文件

建立一個HTTP Server

const http = require('http')

const port = process.env.PORT

const server = http.createServer((req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/html')
  res.end('<h1>Hello world</h1>)
})

server.listen(port, () => {
  console.log(`Server run at port ${port}`)
})

分析一下上面這個程式碼結構

  1. 包含進http模組
  2. 使用這個模組來建立HTTP Server
  3. server被設定為監聽某個指定埠號(3000),當server準備好後,listen的回撥函式會被呼叫
  4. 無論何時請求到達,請求事件會被呼叫,提供了兩個物件:request(http.IncomingMessage)和response(http.serverResponse)。request提供了請求的詳細資訊,通過它我們可以訪問請求頭和請求資料。response用來製造我們將要返回給客戶端的資料

res.statusCode = 200,設定了statusCode屬性為200,表示這是一個成功的響應。res.setHeader('Content-Type', 'text/plain')設定了一個頭,然後將一些內容作為引數傳入end()結束了響應過程

通過Node.js製作一個HTTP請求

GET請求示例:

const https = require('https')
const options = {
  hostname: 'whatever.com',
  port: 443,
  path: '/todos',
  method: 'GET'
}

const req = https.request(options, res => {
  console.log(`statusCode: ${res.statusCode}`)

  res.on('data', d => {
    process.stdout.write(d)
  })
})

req.on('error', error => {
  console.error(error)
})

req.end()

POST請求示例:

const https = require('https')

const data = JSON.stringify({
  todo: 'Buy the milk'
})

const options = {
  hostname: 'whatever.com',
  port: 443,
  path: '/todos',
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Content-Length': data.length
  }
}

const req = https.request(options, res => {
  console.log(`statusCode: ${res.statusCode}`)

  res.on('data', d => {
    process.stdout.write(d)
  })
})

req.on('error', error => {
  console.error(error)
})

req.write(data)
req.end()

PUT和DELETE請求和POST請求的格式相同,只是改變了options.method的值

使用Node.js來製作HTTP POST請求

在Node.js中有很多種發起POST請求的方式,取決你想使用的抽象級別。在Node.js中最簡單的方式使用Axios

const axios = require('axios')

axios
  .post('https://whatever.com/todos', {
    todo: 'Buy the milk'
  })
  .then(res => {
    console.log(`statusCode: ${res.statusCode}`)
    console.log(res)
  })
  .catch(error => {
    console.error(error)
  })

axios需要使用第三方庫。一個POST請求可以使用Node.js的標準模組,就是比較累贅(上一章中的例子)

使用Node.js獲取HTTP請求體的資料

這一章主要說的是如何將請求體中作為JSON傳送的資料取出來

如果你正在使用Express,那麼非常簡單,使用body-parser模組

// Client
const axios = require('axios')

axios.post('https://whatever.com/todos', {
  todo: 'Buy the milk'
})

// Server
const express = require('express')
const app = express()

app.use(
  express.urlencoded({
    extended: true
  })
)

app.use(express.json())

app.post('/todos', (req, res) => {
  console.log(req.body.todo)
})

如果你沒有使用Express,並且想使用普通的Node.js方式,那麼需要多做一些事情。要理解的關鍵點是,當你使用http.createServer()來初始化HTTP伺服器時,當伺服器獲得所有的HTTP頭,你傳入的回撥函式就會被呼叫,而不是接受完請求體

傳入連接回調函式的請求物件是一個流。所以,我們必須要監聽將會被處理的body內容,並且它使用chunk的方式被處理。我們首先要監聽流資料事件,當資料結束(end)時,流的end事件被呼叫

const server = http.createServer((req, res) => {
  // access HTTP header
  req.on('data', chunk => {
    console.log(`Data chunk available: ${chunk}`)
  })

  req.on('end', () => {
    // end of data
  })
})

所以如果要訪問資料,假設是一個字串,我們必須將其放入一個數組

const server = http.createServer((req, res) => {
  let data = ''
  req.on('data', chunk => {
    data += chunk
  })
  req.on('end', () => {
    JSON.parse(data).todo // 'Buy the milk'
  })
})

處理Node.js中的file descriptor

在你可以和你的檔案系統中的檔案互動之前,你必須得到一個檔案描述符(file descriptor)。一個檔案描述符是使用fs模組提供的open()方法開啟檔案後返回的內容

const fs = require('fs')

fs.open('/Users/joe/test.txt', 'r', (err, fd) => {
  // fd就是檔案操作符
})

上例中的r,是一個標識,含義是以讀的方式來開啟檔案。還有一些其它的方式

  • r+ -- 讀寫開啟檔案
  • w+ -- 讀寫開啟檔案,將流放在檔案的開頭。如果此檔案不存在,那麼會被建立
  • a -- 寫開啟檔案,將流放在檔案的末尾,如果檔案不存在,那麼會被建立
  • a+ -- 讀寫開啟檔案,將流放在檔案的末尾,如果檔案不存在,那麼會被建立

也可以通過fs.openSync方法來開啟一個檔案,它會返回一個檔案描述符,不需要提供一個回撥函式

const fs = require('fs')

try {
  const fd = fs.openSync('/Users/joe/test.txt', 'r')
} catch (err) {
  console.error(err)
}

當你獲得檔案描述符之後,無論你選擇哪種方式,你可以做所有需要它的操作,例如呼叫fs.open()和其它與檔案系統互動的操作

Node.js file stats

我們可以使用Node.js來檢視每個檔案的一系列詳情。特別的,使用fs模組提供的stat()方法。通過傳入一個檔案路徑來呼叫它,一旦Node.js獲取到了檔案詳情,它會呼叫你傳入的回撥函式,有兩個引數(一個錯誤資訊,一個檔案狀態)

const fs = require('fs')
fs.stat('/Users/joe/test.txt', (err, stats) => {
  if (err) {
    console.error(err)
    return
  }
  // 通過stats來訪問檔案的stats
})

Node.js也提供了同步的方法,直到檔案狀態準備好前都會阻塞執行緒

const fs = require('fs')
try {
  const stats = fs.statSync('/Users/joe/test.txt')
} catch(err) {
  console.error(err)
}

檔案的資訊包含在stats變數中,那麼從中我們可以獲取哪些資訊?包括,這個檔案是目錄還是檔案,使用stats.isFile()stats.isDirectory(),一個檔案是否是連結,使用stats.isSymbolicLink(),檔案的位元組數,使用stats.size(屬性而非方法)

還有很多的高階方法,但是這些是你在日常程式設計中經常會用到的

const fs = require('fs')
fs.stat('/Users/joe/test.txt', (err, stats) => {
  if (err) {
    console.error(err)
    return
  }

  stats.isFile()
  stats.isDirectory()
  stats.isSymbolicLink()
  stats.size
})

Node.js的檔案路徑

在系統中的每個檔案都有其路徑,在Linux和macOS中,例如/users/joe/file.txt,Windows稍有不同,例如C:\users\joe\file.txt。在自己的應用中需要關注這一點不同。使用const path = require('path')來包含這個模組,然後就可以使用它裡面的方法

通過一個路徑,你可以使用以下這些方法來獲取資訊

  • dirname -- 獲得這個檔案的父目錄
  • basename -- 獲取檔名的部分
  • extname -- 獲取檔案的副檔名(型別)
const notes = '/users/joe/notes.txt'

path.dirname(notes) // /users/joe
path.basename(notes) // notes.txt
path.extname(notes) // .txt
// 只獲取到檔名字的部分,而不帶副檔名
path.basename(notes, path.extname(notes)) // notes

通過path.join()可以合併兩個甚至多個路徑的部分

const name = 'joe'
path.join('/', 'users', name, 'notes.txt') // /users/joe/notes.txt

使用path.resolve()方法可以獲得一個相對路徑計算出來的絕對路徑

path.resolve('joe.txt') // 如果從home目錄執行這個程式,那麼它返回 /users/joe/joe.txt
path.resolve('tmp', 'joe.txt') // /users/joe/tmp/joe.txt

如果第一個引數是斜槓開始(Linux/macOS),那麼代表這是一個絕對路徑

path.resolve('/etc', 'joe.txt') // /etc/joe.txt
// normalize函式會嘗試計算真實路徑,當路徑中包含.. .或者//的時候
path.normalize('/users/joe/..//text.txt') // /users/text.txt

resolvenormalize不會檢查這個路徑是否存在,它們只是計算基於得到的資訊的結果路徑

使用Node.js來讀取檔案

在Node.js中最簡單的讀取檔案的方式是fs.readFile(),引數分別是檔案路徑,編碼方式和一個回撥函式(處理讀取的資料或者錯誤)

const fs = requrie('fs')

fs.readFile('/users/joe/test.txt', 'utf8', (err, data) => {
  if (err) {
    console.error(err)
    return
  }
  console.log(data)
})

同樣的,可以使用同步版本的fs.readFileSync()

const fs = require('fs')

try {
  const data = fs.readFileSync('/users/joe/test.txt', 'utf8')
  console.log(data)
} catch(err) {
  console.error(err)
}

fs.readFile()fs.readFileSync()在返回資料之前都會將檔案的全部內容讀取到記憶體。這代表,大檔案可能對記憶體消耗有很大的影響,並且影響程式的執行速度。這種情況下,更好的讀取資料的方式是使用stream

通過Node.js來寫檔案

使用Node.js寫檔案最簡單的方式是fs.writeFile()API

const fs = require('fs')

const content = 'Some content'

fs.writeFile('/users/joe/test.txt', content, err => {
  if (err) {
    console.error(err)
    return
  }
  // fill written successfully
})

此外還可以使用同步寫入的版本

const fs = require('fs')

const content = 'Some content'

try {
  const data = fs.writeFileSync('/users/joe/test.txt', content)
} catch(err) {
  console.error(err)
}

預設,如果檔案已經存在,那麼API會用指定內容替代已經存在的內容。可以通過指定標識來修改這個設定。fs.writeFile('/users/joe/test.txt', content, { flag: 'a+' }, err => {}),這裡的flag就是之前記錄過的一些開啟檔案的方式。更多的flag詳情

將內容追加到檔案末尾的方便辦法是fs.appendFile()(對應的有fs.appendFileSync()

const content = 'Some content'

fs.appendFile('file.log', content, err => {
  if (err) {
    console.error(err)
    return
  }
  // done
})

在將控制權返回到程式之前,這些方法會將所有的內容都寫入檔案。在這種情況下,大檔案寫入應該使用stream方式

在Node.js中處理資料夾

Node.js的fs模組提供了很多處理資料夾相關的API

fs.access()檢查某個資料夾是否存在和Node.js是否有訪問它的許可權

fs.mkdir() fs.mkdirSync()來建立一個新的資料夾

const fs = require('fs')

const folderName = '/Users/joe/test'

try {
  if (!fs.existsSync(folderName)) {
    fs.mkdirSync(folderName)
  }
} catch (err) {
  console.error(err)
}

讀取目錄中的內容

使用fs.readdir() fs.readdirSync()來讀取目錄中的內容。以下程式碼讀取資料夾得內容,包括資料夾和子資料夾,返回它們的相對路徑

const fs = require('fs')
const path = require('path')

const folderPath = '/Users/joe'

fs.readdirSync(folderPath)
// 可以獲取它們的全路徑
fs.readdirSync(folderPath).map(fileName => {
  return path.join(folderPath, fileName)
})
// 只返回檔案,而不返回資料夾
const isFile = fileName => {
  return fs.lstatSync(fileName).isFile()
}

fs.readdirSync(folderPath).map(fileName => {
  return path.join(folderPath, fileName)
})
.filter(isFile)

使用fs.rename() fs.renameSync()來重新命名資料夾。第一個引數是當前路徑(檔名),第二個引數是新的路徑(新的檔名)

const fs = require('fs')

fs.rename('/Users/joe', '/Users/roger', err => {
  if (err) {
    console.error(err)
    return
  }
  // done
})

// 同步版本
const fs = require('fs')

try {
  fs.renameSync('/Users/joe', '/Users/roger')
} catch (err) {
  console.error(err)
}

使用fs.rmdirfs.rmdirSync()來移除一個資料夾。移除一個有內容的資料夾會比較複雜。這種情況下,最好是安裝fs-extra模組,它很受歡迎且維護的很好。它是一個drop-in(插入式、適配)的fs模組的替代品,此時remove()方法是你需要的

通過npm install fs-extra安裝,然後使用

const fs = require('fs-extra')

const folder = '/Users/joe'

fs.remove(folder, err => {
  console.error(err)
})

// 使用Promise的方式來使用
fs.remove(folder)
  .then(() => {
    // done
  })
  .catch(err => {
    console.error(err)
  })
// 使用async/await
async function removeFolder(folder) {
  try {
    await fs.remove(folder)
    // done
  } catch (err) {
    console.error(err)
  }
}

const folder = '/Users/joe'
removeFolder(folder)

Node.js fs 模組

fs模組提供了很多訪問和與檔案系統互動的功能。這個模組不需要安裝,是Node.js的核心部分,通過const fs = require('fs')使用

  • fs.access() -- 檢查檔案是否存在,並且Node.js是否有許可權訪問它
  • fs.appendFile() -- 將資料追加在檔案中,如果檔案不存在,會被建立
  • fs.chmod() -- 改變傳入的檔名的檔案的許可權(相關:fs.lchmod() fs.fchmod()
  • fs.chown() -- 改變傳入的檔名的檔案的持有者(owner)和組(group)(相關:fs.fchown() fs.lchown()
  • fs.close() -- 關閉一個檔案描述符(file descriptor)
  • fs.copyFile() -- 複製一個檔案
  • fs.createReadStream() -- 建立一個可讀檔案流
  • fs.createWriteStream() -- 建立一個可寫檔案流
  • fs.link() -- 對某個檔案建立一個硬連結(hard link)
  • fs.mkdir() -- 建立一個新資料夾
  • fs.mkdtemp() -- 建立一個臨時資料夾
  • fs.open() -- 設定檔案模式(file mode)
  • fs.readdir() -- 讀取一個目錄的內容
  • fs.readFile() -- 讀取一個檔案的內容(相關:fs.read()
  • fs.readlink() -- 讀取一個連結(symbolic link)的值
  • fs.realpath() -- 將帶有相對路徑(., ..)的檔案路徑轉為全路徑
  • fs.rename() -- 重新命名檔案或資料夾
  • fs.rmdir() -- 移除一個資料夾
  • fs.stat() -- 返回傳入的檔名的檔案的狀態(相關:fs.fstat() fs.lstat()
  • fs.symlink() -- 建立一個檔案的新連結(symbolic link)
  • fs.truncate() -- truncate掉傳入檔名的檔案的指定長度(相關:fs.ftruncate()
  • fs.unlink() -- 移除一個檔案或一個symbolic link
  • fs.unwatchFile() -- 停止檢視一個檔案的變化
  • fs.utimes() -- 改變傳入檔名的檔案的時間戳(相關:fs.futimes
  • fs.watchFile() -- 開始檢視一個檔案的變化(相關:fs.watch()
  • fs.writeFile() -- 將資料寫入檔案(相關:fs.write()

fs模組中的所有方法預設都是非同步的,但是通過追加Sync字尾可以以同步方式來完成同樣的工作。Node.js10包括了基於promise的API的實驗性支援

// callback
const fs = require('fs')

fs.rename('before.json', 'after.json', err => {
  if (err) {
    return console.error(err)
  }
  //done
})

// async
const fs = require('fs')

try {
  fs.renameSync('before', 'after.json')
  // done
} catch(err) {
  console.error(err)
}

兩者的區別是你的指令碼檔案會阻塞在第二種形式,直到檔案操作成功(或失敗)

Node.js path 模組

path模組(也)提供了很多訪問和與檔案系統互動的功能。(也)不需要安裝,(也)是Node.js的核心部分,(也)是通過const path = require('path')來使用。這個模組提供了path.sep來對不同的平臺對映路徑分隔符,path.delimiter提供了全域性變數path的分隔符(Windows為;,Linux、macOS為:

path.basename() -- 返回一個路徑的最後一部分,第二個引數可以過濾掉副檔名

require('path').basename('/test/something') // something
require('path').basename('/test/something.txt') // something.txt
require('path').basename('/test/something.txt', 'txt') // something

path.dirname() -- 返回路徑的目錄部分

require('path').dirname('/test/something') // /test
require('path').dirname('/test/something/file.txt') // /test/something

path.extname() -- 返回一個路徑的副檔名

require('path').extname('/test/something') // ''
require('path').extname('/test/something/file.txt') // '.txt'

path.isAbsolute() -- 如果是絕對路徑返回true

require('path').isAbsolute('/test/something') // true
require('path').isAbsolute('./test/something') // false

path.join() -- 講一個路徑的兩個或多個部分連起來

const name = 'joe'
require('path').join('/', 'users', name, 'notes.txt') // '/users/joe/notes.txt'

path.normalize() -- 當一個路徑包含. .. //時,嘗試計算它的真實路徑

require('path').normalize('/users/joe/..//test.txt') // '/users/test.txt'

path.parse() -- 將路徑轉為一個物件,包含以下內容

  • root:根路徑
  • dir:從根路徑計算出來的資料夾路徑
  • base:檔名+副檔名
  • name:檔名
  • ext:副檔名
require('path').parse('/users/test.txt')

// result
{
  root: '/',
  dir: '/users',
  base: 'test.txt',
  ext: '.txt',
  name: 'test'
}

path.relative() -- 接收兩個路徑作為引數,返回第二個引數的路徑相對於第一個引數的路徑的相對路徑,基於當前的工作目錄

require('path').relative('/users/joe', '/users/joe/test.txt') // 'test/txt'
require('path').relative('/users/joe', '/users/joe/something/test.txt') // 'somethign/test.txt'

path.resolve() -- 通過一個相對路徑來得到絕對路徑

path.resolve('joe.txt') // '/users/joe/joe.txt' 如果這個程式從home目錄執行會得到這個路徑,通過指定為第二個引數,那麼會以第一個引數為基準目錄
path.resolve('tmp', 'joe.txt') // 'users/joe/tmp/joe.txt'
// 如果第一個引數以 / 開始,那麼返回路徑是一個絕對路徑
path.resolve('/etc', 'joe.txt') // '/etc/joe.txt'

Node.js os 模組

這個模組提供的很多函式,你能用來獲取作業系統和執行此程式的計算機的資訊,並且和這些資訊做互動

const os = require('os')

os.EOL // 獲取行分隔符,Linux、maxOS為 \n Windows為 \r\n
os.constants.signals // 和處理程序訊號的所有相關常量,例如SIGHUP SIGKILL等
os.constants.errno // 設定錯誤報告的常量,例如 EADDRINUSE,EOVERFLOW等,在https://nodejs.org/api/os.html#os_signal_constants獲取更多內容
  • os.arch() -- 返回底層架構的標識,例如 arm、x64、arm64
  • os.cpus() -- 返回你係統可用的CPU資訊,例如
[
  {
    model: 'Intel(R) Core(TM)2 Duo CPU     P8600  @ 2.40GHz',
    speed: 2400,
    times: {
      user: 281685380,
      nice: 0,
      sys: 187986530,
      idle: 685833750,
      irq: 0
    }
  },
  {
    model: 'Intel(R) Core(TM)2 Duo CPU     P8600  @ 2.40GHz',
    speed: 2400,
    times: {
      user: 282348700,
      nice: 0,
      sys: 161800480,
      idle: 703509470,
      irq: 0
    }
  }
]
  • os.endianness() -- 返回BE或LE,依賴於Node.js是以Big Endian還是Little Endian編譯的
  • os.freemem() -- 返回系統中自由記憶體的位元組數
  • os.homedir() -- 返回當前使用者的home目錄的路徑
  • os.hostname() -- 返回主機名
  • os.loadavg() -- 返回作業系統在load average的計算(Load Average 就是一段時間 (1 分鐘、5分鐘、15分鐘) 內平均 Load )。它只會在Linux和macOS返回有意義的值
  • os.networkInterfaces() -- 返回你的系統可用的網路介面的詳情
{ lo0:
   [ { address: '127.0.0.1',
       netmask: '255.0.0.0',
       family: 'IPv4',
       mac: 'fe:82:00:00:00:00',
       internal: true },
     { address: '::1',
       netmask: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff',
       family: 'IPv6',
       mac: 'fe:82:00:00:00:00',
       scopeid: 0,
       internal: true },
     { address: 'fe80::1',
       netmask: 'ffff:ffff:ffff:ffff::',
       family: 'IPv6',
       mac: 'fe:82:00:00:00:00',
       scopeid: 1,
       internal: true } ],
  en1:
   [ { address: 'fe82::9b:8282:d7e6:496e',
       netmask: 'ffff:ffff:ffff:ffff::',
       family: 'IPv6',
       mac: '06:00:00:02:0e:00',
       scopeid: 5,
       internal: false },
     { address: '192.168.1.38',
       netmask: '255.255.255.0',
       family: 'IPv4',
       mac: '06:00:00:02:0e:00',
       internal: false } ],
  utun0:
   [ { address: 'fe80::2513:72bc:f405:61d0',
       netmask: 'ffff:ffff:ffff:ffff::',
       family: 'IPv6',
       mac: 'fe:80:00:20:00:00',
       scopeid: 8,
       internal: false } ] }
  • os.platform() -- 返回Node.js被編譯的平臺(darwin、freebsd、linux、openbsd、win32等)
  • os.release() -- 返回標識作業系統發行版本號的字串
  • os.tmpdir() -- 返回被賦值為temp的資料夾路徑
  • os.totalmem() -- 返回系統全部可用記憶體的位元組數
  • os.type() -- 標識作業系統(Linux、Darwin on macOS、Windows_NT on Windows)
  • os.uptime() -- 返回計算機從上次重啟到現在已經運行了多少s
  • os.userInfo() -- 返回一個物件,包含當前的使用者名稱,uid,gid,shell,還有homedir

Node.js events 模組

event模組提供了EventEmitter類,它是在Node.js中處理事件的核心要素

const EventEmitter = require('events')
const door = new EventEmitter()

要增加一個listener使用newListener,要移除一個listener使用removeListener

  • emitter.addListener() -- 是emitter.on()的別名
  • emitter.emit() -- 發出(emit)事件,它會按照註冊順序非同步呼叫每一個event listener
door.emit('slam') // emit slam事件
  • emitter.eventNames() -- 返回一個字串陣列,表示在當前EventEmitter物件上註冊的事件
door.eventNames()
  • emitter.getMaxListeners() -- 獲取可用新增到EventEmitter物件的最大數量的listener,預設是10,但是可以使用setMaxListeners()來增加和減少
door.getMaxListeners()
  • emitter.listenerCount() -- 獲取作為引數傳入的事件的數量
door.listenerCount('open')
  • emitter.listners() -- 獲取作為引數傳入的事件的listener陣列
door.listeners('open')
  • emitter.off() -- emitter.removeListener()的別名,Node.js10加入
  • emitter.on() -- 新增一個當一個事件被髮出(emit)時,呼叫的回撥函式
door.on('open', () => {
  console.log('Door was opened')
})
  • emitter.once() -- 新增一個當事件在註冊之後第一次發出會呼叫的回撥函式。這個回撥函式只會被呼叫一次
const EventEmitter = require('events')
const ee = new EventEmitter()

ee.once('my-event', () => {
  // call callback function once
})
  • emitter.prependListener() -- 當你使用on addListener時,它會插入到listener佇列的尾部,然後最後呼叫,使用prependListener它會插入到其它的listener之前
  • emitter.prependOnceListener() -- 當你使用once來新增listener時,它會插入到listener佇列的尾部,然後最後呼叫,使用prependOnceListener它會插入到其它的listener之前
  • emitter.removeAllListener() -- 移除一個EventEmitter物件監聽某事件的所有listener
door.removeAllListeners('open')
  • emitter.removeListener() -- 移除一個指定的listener,你可以在新增listener時通過將回調函式儲存到一個變數中來實現,這樣之後可以引用這個listener
const doSomething = () => {}
door.on('open', doSomething)
door.removeListener('open', doSomething)
  • emitter.setMaxListeners() -- 設定可以被新增進EventEmitter物件的listener的最大數量,預設是10,可以增加或減少
door.setMaxListeners(50)

Node.js的 http 模組

// include
const http = require('http')

這個模組提供了一些屬性和方法,還有一些類

http.METHODS屬性列出了所有HTTP支援的方法:

> require('http').METHODS
[ 'ACL',
  'BIND',
  'CHECKOUT',
  'CONNECT',
  'COPY',
  'DELETE',
  'GET',
  'HEAD',
  'LINK',
  'LOCK',
  'M-SEARCH',
  'MERGE',
  'MKACTIVITY',
  'MKCALENDAR',
  'MKCOL',
  'MOVE',
  'NOTIFY',
  'OPTIONS',
  'PATCH',
  'POST',
  'PROPFIND',
  'PROPPATCH',
  'PURGE',
  'PUT',
  'REBIND',
  'REPORT',
  'SEARCH',
  'SUBSCRIBE',
  'TRACE',
  'UNBIND',
  'UNLINK',
  'UNLOCK',
  'UNSUBSCRIBE' ]

http.STATUS_CODES列出了所有的HTTP狀態碼和它們的描述

> require('http').STATUS_CODES
{ '100': 'Continue',
  '101': 'Switching Protocols',
  '102': 'Processing',
  '200': 'OK',
  '201': 'Created',
  '202': 'Accepted',
  '203': 'Non-Authoritative Information',
  '204': 'No Content',
  '205': 'Reset Content',
  '206': 'Partial Content',
  '207': 'Multi-Status',
  '208': 'Already Reported',
  '226': 'IM Used',
  '300': 'Multiple Choices',
  '301': 'Moved Permanently',
  '302': 'Found',
  '303': 'See Other',
  '304': 'Not Modified',
  '305': 'Use Proxy',
  '307': 'Temporary Redirect',
  '308': 'Permanent Redirect',
  '400': 'Bad Request',
  '401': 'Unauthorized',
  '402': 'Payment Required',
  '403': 'Forbidden',
  '404': 'Not Found',
  '405': 'Method Not Allowed',
  '406': 'Not Acceptable',
  '407': 'Proxy Authentication Required',
  '408': 'Request Timeout',
  '409': 'Conflict',
  '410': 'Gone',
  '411': 'Length Required',
  '412': 'Precondition Failed',
  '413': 'Payload Too Large',
  '414': 'URI Too Long',
  '415': 'Unsupported Media Type',
  '416': 'Range Not Satisfiable',
  '417': 'Expectation Failed',
  '418': 'I\'m a teapot',
  '421': 'Misdirected Request',
  '422': 'Unprocessable Entity',
  '423': 'Locked',
  '424': 'Failed Dependency',
  '425': 'Unordered Collection',
  '426': 'Upgrade Required',
  '428': 'Precondition Required',
  '429': 'Too Many Requests',
  '431': 'Request Header Fields Too Large',
  '451': 'Unavailable For Legal Reasons',
  '500': 'Internal Server Error',
  '501': 'Not Implemented',
  '502': 'Bad Gateway',
  '503': 'Service Unavailable',
  '504': 'Gateway Timeout',
  '505': 'HTTP Version Not Supported',
  '506': 'Variant Also Negotiates',
  '507': 'Insufficient Storage',
  '508': 'Loop Detected',
  '509': 'Bandwidth Limit Exceeded',
  '510': 'Not Extended',
  '511': 'Network Authentication Required' }

http.globalAgent指向Agent物件的全域性(global)例項,它是http.Agent類的一個例項。是用來管理對HTTP客戶端的連線的維持和重用,它是Node.js的HTTP網路的元件

http.createServer()返回一個http.Server類的例項。應用:

const server = http.createServer((req, res) => {
  // handler every single request with this callback
})

http.request()製作一個對伺服器的HTTP請求,建立一個http.clientRequest類的例項

http.get()http.request()類似,但是它自動將HTTP的method設定為GET,然後自動呼叫req.end()

HTTP模組提供了5個類

  • http.Agent
  • http.ClientRequest
  • http.Server
  • http.ServerResponse
  • http.IncomingMessage
  • http.Agent

Node.js建立的全域性的http.Agent的例項物件,保證了對伺服器的每個請求被排隊,然後一個單一的socket被重用。它也維護了一個socket池,這是改善效能的核心

一個http.ClientRequest物件在http.request()http.get()呼叫的時候建立

當接收到返回之後,response事件通過response來呼叫,其中會以http.IncomingMessage例項作為引數

一個response返回的資料可以以兩種方式讀取

  1. response.read(),在response的事件handler
  2. 你可以對data event設定一個event listener,這樣可以監聽資料流的接入

http.Server物件通常在使用http.createServer()建立一個新伺服器的時候例項化和返回。一旦你有了server物件,你可以訪問它的方法

  • close() -- server不再接收新連線
  • listen() -- 開啟HTTP伺服器,監聽連線

http.ServerResponsehttp.Server建立的,作為第二個引數傳入它服務的request事件,一般應用:

const server = http.createServer((req, res) => {
  // res就是http.ServerResponse物件
})

在handler中你總會呼叫的方法是end(),它關閉了response,組成資訊完成並且伺服器會將資訊傳送給客戶端。它必須在每個response上呼叫

以下這些方法是和HTTP頭做互動的

  • getHeaderNames() -- 獲取已經設定好的HTTP頭的所有名字
  • getHeaders() -- 獲取已經設定好的HTTP頭的複製
  • getHeader('headername', value) -- 設定HTTP頭的值
  • getHeader('headername') -- 獲取已經設定好的HTTP頭
  • removeHeader('headername') -- 移除已經設定好的HTTP頭
  • hasHeader('headername') -- 如果response有這個頭則返回true
  • headersSent() -- 如果這個頭已經發送到客戶端返回true

在處理完頭之後,你可以通過呼叫response.writeHead()傳送給客戶端,這個方法接收statusCode為第一個引數,還有一個可選的status message,和header物件。要在response body中給客戶端傳送資料,應該使用write(),它會將buffered data傳送到HTTP response stream。如果還沒有使用response.writeHead()傳送頭資訊,那麼會先發送頭,伴隨request中設定的狀態碼和狀態資訊,你可以通過設定statusCode statusMessage來設定這些值

response.statusCode = 500
response.statusMessage = 'Internal Server Error'

一個http.IncomingMessage物件會在以下情況被建立:

  1. http.Server建立,當監聽請求事件時
  2. http.ClientRequest建立,當監聽response事件時

它可以用來訪問response的資訊

  • status -- 使用它的statusCode statusMessage方法
  • headers -- 使用它的headers rawHeaders方法
  • HTTP method -- 使用它的method方法
  • HTTP version -- 使用它的httpVersion方法
  • URL -- 使用它的url方法
  • underlying socket -- 使用它的socket方法

使用stream來訪問它的資料,因為http.IncomingMessage實現了Readable Stream介面

Node.js Buffer

一個buffer是記憶體的一部分,JS開發者不太熟悉這個概念(相比後端語言開發者,經常和記憶體互動)。它代表記憶體中的一塊固定大小的記憶體(不能被重新修改大小)(分配在V8JS引擎之外)。可以將buffer看作一個整數陣列,每個整數表示資料的一個位元組。它是通過Node.js的Buffer類實現的

buffer的推出時幫助開發者處理二進位制資料的,曾經的生態基本上只處理字串而非二進位制資料。buffer和stream聯絡密切。當一個stream處理器接收的資料的速度比消費的速度快,那麼它會將資料放進buffer

使用Buffer.from() Buffer.alloc() Buffer.allocUnsafe()方法建立buffer

const buf = Buffer.from('Hey!')
Buffer.from(array)
Buffer.from(arrayBuffer[, byteOffset[, length]])
Buffer.from(buffer)
Buffer.from(string[, encoding])
// 你可以通過傳入大小來初始化一個buffer,這建立了1kb的buffer
const buf = Buffer.alloc(1024)
// or
const buf = Buffer.allocUnsafe(1024)

儘管alloc allocUnsafe都分配了一個指定大小的位元組buffer,alloc建立的buffer會用0(zero)初始化,而allocUnsafe不會被初始化。這意味著allocUnsafe建立buffer會比較快,但是它分配的記憶體片段可能會存在遺留的(敏感)舊資料

當buffer記憶體被讀取,上面的舊資料,可能會被訪問或洩露。這就是allocUnsafe方法不安全的原因,並且在使用的時候需要格外注意

一個buffer就是一個位元組陣列,可以像一個數組一樣被訪問

const buf = Buffer.from('Hey!')
console.log(buf[0]) //72
console.log(buf[1]) //101
console.log(buf[2]) //121

這些數字是Unicode碼,標識著buffer不同位置的字元。你可以使用toString()來列印buffer中的內容console.log(buf.toString())。如果你使用一個數字來初始化buffer,即設定它的大小,那麼你只能訪問被提前初始化的記憶體(包含隨機資料,不是一個空buffer),使用length屬性來獲得buffer的長度

const buf = Buffer.from('Hey!')
console.log(buf.length)
// 遍歷buffer
const buf = Buffer.from('Hey!')
for (const item of buf) {
  console.log(item)
}

你可以通過write()方法將一整個字串的資料寫入buffer

const buf = Buffer.alloc(4)
buf.write('Hey!')
// 使用陣列的方式來設定值
const buf = Buffer.from('Hey!')
buf[1] = 111 //o
console.log(buf.toString()) // Hoy!

複製一個buffer需要使用copy()方法

const buf = Buffer.from('Hey!')
let bufcopy = Buffer.alloc(4)
buf.copy(bufcopy)
// 預設copy整個buffer,有3個額外引數可以定義開始位置,結束位置,新buffer的長度
const buf = Buffer.from('Hey!')
let bufcopy = Buffer.alloc(2)
buf.copy(bufcopy, 0, 0, 2)
bufcopy.toString() // 'He'

如果你想建立一個buffer的部分檢視(visualization),你可以建立一個slice,它並不是一個copy,原始的buffer仍然是資料來源,如果源改變了,那麼你的slice也會改變。使用slice()方法來建立,第一個引數是開始位置,你可以指定可選的第二個引數(截至位置)

const buf = Buffer.from('Hey!')
buf.slice(0).toString() // Hey!
const slice = buf.slice(0, 2)
console.log(slice.toString()) // He
buf[1] = 111 // o
console.log (slice.toString()) // Ho

Node.js Stream

Stream是Node.js應用強大能力的一個基本概念。它是一種處理讀寫檔案、網路通訊或者任何端到端的資訊交換的一種高效方式。Stream並不是Node.js獨有的概念,它是在Unix作業系統中介紹的概念,程式之間可以通過pipe(|)來傳輸流。傳統模式中,當程式需要讀取一個檔案,這個檔案會被讀到記憶體,從頭到尾,然後你在處理它。使用流的時候,你會分段讀取,在不需要把檔案全部讀取到記憶體中的前提下就可以處理它

Node.js的stream模組,提供了所有流API的功能,所有的流都是EventEmitter的例項。流相對於其它的資料處理方法有兩個顯著優點(其實這兩點說的一個事)

  1. 高效使用記憶體:在處理資料之前,你不需要將大量資料全部讀取到記憶體
  2. 節省時間:在開始處理資料之前消耗極少的時間

一個從磁碟讀取檔案的例子:

const http = require('http')
const fs = require('fs')

const server = http.createServer(function(req, res) {
  fs.readFile(__dirname + '/data.txt', (err, data) => {
    res.end(data)
  })
})

server.listen(3000)

readFile()讀取了一個檔案中所有的內容,然後在完成這個操作之後呼叫回撥函式res.end()會返回給HTTP客戶端所有的檔案內容。如果檔案很大,這個操作會耗費很長時間,以下是使用流的寫法:

const http = require('http')
const fs = require('fs')

const server = http.createServer((req, res) => {
  const stream = fs.createReadStream(__dirname + '/data.txt')
  stream.pipe(res)
})

server.listen(3000)

和之前不一樣,只要我們獲得了可以傳送給客戶端的資料的一部分,那麼我們直接傳送它。pipe()方法在檔案流中被呼叫。它從源中獲取資料,然後傳送(pipe)到一個目的地。本例中,在檔案源上呼叫了pipe(),所以檔案流被髮送到了HTTP響應物件。pipe()方法的返回值是目的流,這樣可以讓我們非常方便的連結多個pipe()呼叫

src.pipe(dest1).pipe(dest2)
// same as
src.pipe(dest1)
dest1.pipe(dest2)

強調Stream的Node.js的API

  • process.stdin -- 返回一個連線stdin的流
  • process.stdout -- 返回一個連線stdout的流
  • process.stderr -- 返回一個連線stderr的流
  • fs.createReadStream() -- 建立一個檔案的可讀流
  • fs.createWriteStream() -- 建立一個檔案的可寫流
  • net.connect() -- 初始化一個基於流的連線
  • http.request() -- 返回一個http.ClientRequest類的物件,並放入流中
  • zlib.createGzip() -- 使用gzip壓縮資料,並放入流中
  • zlib.createGunzip() -- 解壓一個gzip流
  • zlib.createDeflate() -- 使用deflate壓縮資料,並放入流中
  • zlib.createInflate() -- 解壓一個deflate流

關於流的類有4種

  1. Readable -- 你可以從這個流pipe資料,但是不能將資料pipe進這個流(你可以從這個流種獲取資料,但是你不能給這個流發資料),當你將資料放進(這裡的放進並不是程式設計師將資料放進去,而是cpu將資料放進流中)可讀流時,它會被快取(buffer),直到一個消費者來消費資料
  2. Writable -- 你可以將資料pipe進這個流,但是不能從這個流中pipe資料
  3. Duplex -- 既可以從這個流pipe資料,也可以將資料pipe進這個流,基本上是前兩者的結合
  4. Transform -- 和Duplex流類似,但是它的輸出就是它的輸入轉化而來的(可能前者有輸入和輸出,而這種實現只有一個實體實現輸入輸出)

我們通過stream模組來獲得一個可讀流,我們初始化它然後實現readable._read()方法

const Stream = require('stream')
const readableStream = new Stream.Readable()

readableStream._read = () => {}
// 使用 read 選項來實現此方法
const readableStream = new Stream.Readable({
  read() {}
})
// 傳送資料
readableStream.push('hi!')
readableStream.push('ho!')

要建立一個可寫流,我們需要繼承Writable物件,然後實現_write()方法

const Stream = require('stream')
const writableStream = new Stream.Writable()

writableStream._write = (chunk, encoding, next) => {
  console.log(chunk.toString())
  next()
}

你現在可以將一個可讀流pipe進這個可寫流,process.stdin.pipe(writableStream)

使用一個可寫流來從可讀流讀取資料

const stream = require('stream')

const readableStream = new Stream.Readable({
  read() {}
})
const writableStream = new Stream.Writable()

writableStream._write = (chunk, encoding, next) => {
  console.log(chunk.toString())
  next()
}

readableStream.pipe(writableStream)
readableStream.push('hi')
readableStream.push('ho')

你也可以直接消費一個可寫流,使用readable事件

readableStream.on('readable', () => {
  console.log(readableStream.read())
})

使用可寫流的write()方法來發送資料

writableStream.write('hey!\n')

// 終止可寫流的輸出
writableStream.end()

Node.js中,開發和生產的區別

對於開發環境和生產環境你可能有不同的配置。Node.js假設它總是執行在開發環境。你可以通過設定NODE_ENV=production環境變數來告訴Node.js這是在生產環境。通常它是通過命令列來實現的export NODE_ENV=production。但是更好的選擇是將其放入你的shell配置檔案中(.bash_profile),否則伺服器重啟這個設定會丟失。你也可以將這個環境變數作為啟動引數向前追加到執行應用的命令中NODE_ENV=producton node app.js。這個環境變數也被廣泛應用在外部庫中

設定生產環境主要保證:

  1. logging保持最小、最必要的級別
  2. 在優化效能的時候會開啟更多快取級別

例如PugExpress使用的模板庫,如果NODE_ENV不設定生產環境,那麼會在debug模式下編譯。Express的views在每個請求都會編譯(開發環境),但是生產環境下會快取

// 使用程式的方式
if (process.env.NODE_ENV === 'development') {
  // ...
}
if (process.env.NODE_ENV === 'production') {
  // ...
}
if (['production', 'staging'].indexOf(process.env.NODE_ENV) >= 0) {
  // ...
}

例如,在Expressapp中,你可以使用這種方式來設定不同環境下的錯誤handler

if (process.env.NODE_ENV === 'development') {
  app.use(express.errorHandler({ dumpExceptions: true, showStack: true }))
}
if (process.env.NODE_ENV === 'production') {
  app.use(express.errorHandler())
})

Node.js 中的錯誤處理

使用throw關鍵字來建立一個異常。throw value,當JS執行到這一行,正常的程式流終止,程式的控制權會移到最近的異常handler。在客戶端程式碼中,這個丟擲的值可以是JS中的任何值(string、number或object)。在Node.js中,我們不會丟擲字串,只丟擲Error物件

一個error物件既可以是Error類的一個例項,也可以是Error的子類的例項(Error核心模組中提供的類)

throw new Error('Ran out of coffee')
// or
class NotEnoughCoffeeError extends Error {
  // ...
}
throw new NotEnoughCoffeeError()

一個異常handler是try/catch語句。在try語句塊中任何丟擲的異常都會被對應的catch語句塊捕獲並處理

try {
  // line of code
} catch (e) {}

可以新增多個handler,可以捕獲不同型別的error。如果一個未捕獲的異常在程式執行的過程中被丟擲,那麼程式會崩潰。為了解決這個問題,你可以監聽在processuncaughtException事件

process.on('uncaughtException', err => {
  console.error('There was an uncaught error', err)
  process.exit(1)
})

PS:不需要手動引入process模組

使用promise你可以連結不同的操作,然後在最後處理錯誤

doSomething1()
  .then(doSomething2)
  .then(doSomething3)
  .catch(err => console.error(err))

你不需要知道錯誤發生在哪裡,但是你可以在呼叫的任何一個方法中處理異常,然後在處理程式碼中丟擲一個新錯誤,這個錯誤會呼叫外部catchhandler

const doSomething1 = () => {
  // ...
  try {
    // ...
  } catch (err) {
    // ... handle it locally
    throw new Error(err.message)
  }
  // ...
}

如果不想在呼叫的函式中處理異常,可以直接丟擲到最外層(會破壞丟擲異常之後的所有方法的正常呼叫)

doSomething1()
  .then(() => {
    return doSomething2().catch(err => {
      // handle error
      throw err // break the chain!
    })
  })
  .then(() => {
    return doSomething2().catch(err => {
      // handler error
      throw err //break the chain!
    })
  })
  .catch(err => console.error(err))

使用async/await也需要捕獲錯誤,可以用以下這種方式

async function someFunction() {
  try {
    await someOtherFunction()
  } catch(err) {
    console.error(err.message)
  }
}

在Node.js中log一個物件

當你在瀏覽器中使用console.log(),它會將這個物件的資訊列印到控制檯。在Node.js中,也會這樣。當我們要將一些東西列印到控制檯(或者日誌檔案),你會得到一個物件的字串展示

最好的列印整個物件的方式是使用console.log(JSON.stringify(obj, null, 2)),2是縮排使用的空格數。另一個方式是require('util').inspect.defaultOptions.depth = null console.log(obj)這裡的問題是level2之後的巢狀物件會被摺疊,對於複雜物件可能是個問題