Node.js簡介

Creabine發表於2016-08-01

閒來無事研究下Node.js,結合一些其他工具可以搭建自己自己的部落格。目前雖然有個小網站的demo但是太簡陋了,如果要有留言等功能的話後臺是不能缺少的。目前對它還沒什麼瞭解,但是等這篇部落格寫完的時候應該就能用它來做簡單的程式設計了。

學習資料:七天學會node.js

Node.js版本號最後一位是偶數的為穩定版本,奇數的為非穩定版本。

Windows下的Node.js安裝(在官網安裝下載)
1.安裝git bash
2.安裝node.js

node.js基礎

編寫稍大一點的程式時一般都會將程式碼模組化。在NodeJS中,一般將程式碼合理拆分到不同的JS檔案中,每一個檔案就是一個模組,而檔案路徑就是模組名。

在編寫每個模組時,都有require、exports、module三個預先定義好的變數可供使用。

require

require函式用於在當前模組中載入和使用別的模組,傳入一個模組名,返回一個模組匯出物件。模組名可使用相對路徑(以./開頭),或者是絕對路徑(以/或C:之類的碟符開頭),以 / 開頭的絕對路徑表示從該碟符的根目錄開始查詢。另外,模組名中的.js副檔名可以省略。以下是一個例子:

// foo1至foo4中儲存的是同一個模組的匯出物件。
var foo1 = require('./foo');
var foo2 = require('./foo.js');
var foo3 = require('/home/user/foo');
var foo4 = require('/home/user/foo.js');
//還可以使用require載入json檔案:
var data = require('./data.json')

exports
exports物件是當前模組的匯出物件,用於匯出模組公有方法和屬性。別的模組通過require函式使用當前模組時得到的就是當前模組的exports物件。以下例子中匯出了一個公有方法:

exports.hello = function () {
    console.log('Hello World!');
};

module
通過module物件可以訪問到當前模組的一些相關資訊,但最多的用途是替換當前模組的匯出物件。例如模組匯出物件預設是一個普通物件,如果想改成一個函式的話,可以使用以下方式。

//以下程式碼中,模組預設匯出物件被替換為一個函式。
module.exports = function () {
    console.log('Hello World!');
};

模組初始化
一個模組中的js程式碼僅在模組第一次被使用時執行一次,並在執行過程中初始化模組的匯出物件。之後,快取起來的匯出物件被重複利用。

主模組
通過命令列引數傳遞給NodeJS以啟動程式的模組被稱為主模組。主模組負責排程組成整個程式的其他模組完成工作,例如:

//通過命令啟動程式,main.js是主模組
$ node main.js

完整示例
例如有如下目錄:

- /home/user/hello/
    - util/
        counter.js
    main.js

其中counter.js內容如下:

var i = 0;

function count() {
    return ++i;
}
//該模組內部定義了一個私有變數i,並在exports物件匯出了一個公有方法count。
exports.count = count;

主模組main.js內容如下:

var counter1 = require('./util/counter');
var    counter2 = require('./util/counter');

console.log(counter1.count());
console.log(counter2.count());
console.log(counter2.count());

執行該程式的結果如下:

$ node main.js
1
2
3

可以看到,counter.js並沒有因為被require了兩次而初始化兩次(因為上邊的模組初始化。)。

二進位制模組
雖然一般我們使用JS編寫模組,但NodeJS也支援使用C/C++編寫二進位制模組。編譯好的二進位制模組除了副檔名是.node外,和JS模組的使用方式相同。雖然二進位制模組能使用作業系統提供的所有功能,擁有無限的潛能,但對於前端同學而言編寫過於困難,並且難以跨平臺使用,因此不在本教程的覆蓋範圍內。

小結
本章介紹了有關NodeJS的基本概念和使用方法,總結起來有以下知識點:

  • NodeJS是一個JS指令碼解析器,任何作業系統下安裝NodeJS本質上做的事情都是把NodeJS執行程式複製到一個目錄,然後保證這個目錄在系統PATH環境變數下,以便終端下可以使用node命令。
  • 終端下直接輸入node命令可進入命令互動模式,很適合用來測試一些JS程式碼片段,比如正規表示式。
  • NodeJS使用CMD模組系統,主模組作為程式入口點,所有模組在執行過程中只初始化一次。
  • 除非JS模組不能滿足需求,否則不要輕易使用二進位制模組,否則你的使用者會叫苦連天。

程式碼的組織和部署

有經驗的C程式設計師在編寫一個新程式時首先從make檔案寫起。同樣的,使用NodeJS編寫程式前,為了有個良好的開端,首先需要準備好程式碼的目錄結構和部署方式,就如同修房子要先搭腳手架。本章將介紹與之相關的各種知識。

模組路徑解析規則
我們已經知道,require函式支援斜槓(/)或碟符(C:)開頭的絕對路徑,也支援 ./ 開頭的相對路徑。但這兩種路徑在模組之間建立了強耦合關係,一旦某個模組檔案的存放位置需要變更,使用該模組的其它模組的程式碼也需要跟著調整,變得牽一髮動全身。因此,require函式支援第三種形式的路徑,寫法類似於foo/bar,並依次按照以下規則解析路徑,直到找到模組位置:

1.內建模組
如果傳遞給require函式的是NodeJS內建模組名稱,不做路徑解析,直接返回內部模組的匯出物件,例如require(‘fs’)。

2.node_modules目錄
NodeJS定義了一個特殊的node_modules目錄用於存放模組。例如某個模組的絕對路徑是/home/user/hello.js,在該模組中使用require(‘foo/bar’)方式載入模組時,則NodeJS依次嘗試使用以下路徑。

 /home/user/node_modules/foo/bar
 /home/node_modules/foo/bar
 /node_modules/foo/bar

3.NODE_PATH環境變數
與PATH環境變數類似,NodeJS允許通過NODE_PATH環境變數來指定額外的模組搜尋路徑。NODE_PATH環境變數中包含一到多個目錄路徑,路徑之間在Linux下使用:分隔,在Windows下使用;分隔。例如定義了以下NODE_PATH環境變數:

 NODE_PATH=/home/user/lib:/home/lib

當使用require(‘foo/bar’)的方式載入模組時,則NodeJS依次嘗試以下路徑。

 /home/user/lib/foo/bar
 /home/lib/foo/bar

包(package)

我們已經知道了JS模組的基本單位是單個JS檔案,但複雜些的模組往往由多個子模組組成。為了便於管理和使用,我們可以把由多個子模組組成的大模組稱做包,並把所有子模組放在同一個目錄裡。

在組成一個包的所有子模組中,需要有一個入口模組,入口模組的匯出物件被作為包的匯出物件。例如有以下目錄結構:

- /home/user/lib/
    - cat/
        head.js
        body.js
        main.js

其中cat目錄定義了一個包,其中包含了3個子模組。main.js作為入口模組,其內容如下:

var head = require('./head');
var body = require('./body');

exports.create = function (name) {
    return {
        name: name,
        head: head.create(),
        body: body.create()
    };
};

在其它模組裡使用包的時候,需要載入包的入口模組。接著上例,使用require(‘/home/user/lib/cat/main’)能達到目的,但是入口模組名稱出現在路徑裡看上去不是個好主意。因此我們需要做點額外的工作,讓包使用起來更像是單個模組。
index
當模組的檔名是index.js,載入模組時可以使用模組所在目錄的路徑代替模組檔案路徑,因此接著上例,以下兩條語句等價:

//cat包的入口模組名為index的時候,可以不寫index直接寫檔案cat目錄路徑,像第一行這樣
var cat = require('/home/user/lib/cat');
var cat = require('/home/user/lib/cat/index');

這樣處理後,就只需要把包目錄路徑傳遞給require函式,感覺上整個目錄被當作單個模組使用,更有整體感。

package.json
如果想自定義入口模組的檔名和存放位置,就需要在包目錄下包含一個package.json檔案,並在其中指定入口模組的路徑。上例中的cat模組可以重構如下。

- /home/user/lib/
    - cat/
        + doc/
        - lib/
            head.js
            body.js
            main.js
        + tests/
        package.json

其中package.json內容如下。

{
    "name": "cat",
    "main": "./lib/main.js"
}

如此一來,就同樣可以使用require(‘/home/user/lib/cat’)的方式載入模組。NodeJS會根據包目錄下的package.json找到入口模組所在位置。

命令列程式
使用NodeJS編寫的東西,要麼是一個包,要麼是一個命令列程式,而前者最終也會用於開發後者。因此我們在部署程式碼時需要一些技巧,讓使用者覺得自己是在使用一個命令列程式。

例如我們用NodeJS寫了個程式,可以把命令列引數原樣列印出來。該程式很簡單,在主模組內實現了所有功能。並且寫好後,我們把該程式部署在/home/user/bin/node-echo.js這個位置。為了在任何目錄下都能執行該程式,我們需要使用以下終端命令。

$ node /home/user/bin/node-echo.js Hello World
Hello World

這種使用方式看起來不怎麼像是一個命令列程式,下邊的才是我們期望的方式。

$ node-echo Hello World

Windows
在Windows系統下,我們得靠.cmd檔案來解決問題。假設node-echo.js存放在C:\Users\user\bin目錄,並且該目錄已經新增到PATH環境變數裡了。接下來需要在該目錄下新建一個名為node-echo.cmd的檔案,檔案內容如下:

@node "C:\User\user\bin\node-echo.js" %*

這樣處理後,我們就可以在任何目錄下使用node-echo命令了。

工程目錄
瞭解了以上知識後,現在我們可以來完整地規劃一個工程目錄了。以編寫一個命令列程式為例,一般我們會同時提供命令列模式和API模式兩種使用方式,並且我們會藉助三方包來編寫程式碼。除了程式碼外,一個完整的程式也應該有自己的文件和測試用例。因此,一個標準的工程目錄都看起來像下邊這樣。

- /home/user/workspace/node-echo/   # 工程目錄
    - bin/                          # 存放命令列相關程式碼
        node-echo
    + doc/                          # 存放文件
    - lib/                          # 存放API相關程式碼
        echo.js
    - node_modules/                 # 存放三方包
        + argv/
    + tests/                        # 存放測試用例
    package.json                    # 後設資料檔案
    README.md                       # 說明檔案

其中部分檔案內容如下:

/* bin/node-echo */
var argv = require('argv'),
    echo = require('../lib/echo');
console.log(echo(argv.join(' ')));

/* lib/echo.js */
module.exports = function (message) {
    return message;
};

/* package.json */
{
    "name": "node-echo",
    "main": "./lib/echo.js"
}

以上例子中分類存放了不同型別的檔案,並通過node_moudles目錄直接使用三方包名載入模組。此外,定義了package.json之後,node-echo目錄也可被當作一個包來使用。

NPM
NPM是隨同NodeJS一起安裝的包管理工具,能解決NodeJS程式碼部署上的很多問題,常見的使用場景有以下幾種:

  • 允許使用者從NPM伺服器下載別人編寫的三方包到本地使用。
  • 允許使用者從NPM伺服器下載並安裝別人編寫的命令列程式到本地使用。
  • 允許使用者將自己編寫的包或命令列程式上傳到NPM伺服器供別人使用。

可以看到,NPM建立了一個NodeJS生態圈,NodeJS開發者和使用者可以在裡邊互通有無。以下分別介紹這三種場景下怎樣使用NPM。

下載三方包
需要使用三方包時,首先得知道有哪些包可用。雖然npmjs.org提供了個搜尋框可以根據包名來搜尋,但如果連想使用的三方包的名字都不確定的話,就請百度一下吧。知道了包名後,比如上邊例子中的argv,就可以在工程目錄下開啟終端,使用以下命令來下載三方包。

$ npm install argv
...
argv@0.0.2 node_modules\argv

下載好之後,argv包就放在了工程目錄下的node_modules目錄中,因此在程式碼中只需要通過require(‘argv’)的方式就好,無需指定三方包路徑。

以上命令預設下載最新版三方包,如果想要下載指定版本的話,可以在包名後邊加上@,例如通過以下命令可下載0.0.1版的argv。

$ npm install argv@0.0.1
...
argv@0.0.1 node_modules\argv

如果使用到的三方包比較多,在終端下一個包一條命令地安裝未免太人肉了。因此NPM對package.json的欄位做了擴充套件,允許在其中申明三方包依賴。因此,上邊例子中的package.json可以改寫如下:

{
    "name": "node-echo",
    "main": "./lib/echo.js",
    "dependencies": {
        "argv": "0.0.2"
    }
}

這樣處理後,在工程目錄下就可以使用npm install命令批量安裝三方包了。更重要的是,當以後node-echo也上傳到了NPM伺服器,別人下載這個包時,NPM會根據包中申明的三方包依賴自動下載進一步依賴的三方包。例如,使用npm install node-echo命令時,NPM會自動建立以下目錄結構。

- project/
    - node_modules/
        - node-echo/
            - node_modules/
                + argv/
            ...
    ...

如此一來,使用者只需關心自己直接使用的三方包,不需要自己去解決所有包的依賴關係。

安裝命令列程式
從NPM服務上下載安裝一個命令列程式的方法與三方包類似。例如上例中的node-echo提供了命令列使用方式,只要node-echo自己配置好了相關的package.json欄位,對於使用者而言,只需要使用以下命令安裝程式。

$ npm install node-echo -g

引數中的-g表示全域性安裝,因此node-echo會預設安裝到以下位置,並且NPM會自動建立好Linux系統下需要的軟鏈檔案或Windows系統下需要的.cmd檔案。

- /usr/local/               # Linux系統下
    - lib/node_modules/
        + node-echo/
        ...
    - bin/
        node-echo
        ...
    ...

- %APPDATA%\npm\            # Windows系統下
    - node_modules\
        + node-echo\
        ...
    node-echo.cmd
    ...

釋出程式碼
第一次使用NPM釋出程式碼前需要註冊一個賬號。終端下執行npm adduser,之後按照提示做即可。賬號搞定後,接著我們需要編輯package.json檔案,加入NPM必需的欄位。接著上邊node-echo的例子,package.json裡必要的欄位如下。

{
    "name": "node-echo",           # 包名,在NPM伺服器上須要保持唯一
    "version": "1.0.0",            # 當前版本號
    "dependencies": {              # 三方包依賴,需要指定包名和版本號
        "argv": "0.0.2"
      },
    "main": "./lib/echo.js",       # 入口模組位置
    "bin" : {
        "node-echo": "./bin/node-echo"      # 命令列程式名和主模組位置
    }
}

之後,我們就可以在package.json所在目錄下執行npm publish釋出程式碼了。

版本號
使用NPM下載和釋出程式碼時都會接觸到版本號。NPM使用語義版本號來管理程式碼,這裡簡單介紹一下。

語義版本號分為X.Y.Z三位,分別代表主版本號、次版本號和補丁版本號。當程式碼變更時,版本號按以下原則更新。

+ 如果只是修復bug,需要更新Z位。

+ 如果是新增了功能,但是向下相容,需要更新Y位。

+ 如果有大變動,向下不相容,需要更新X位。

版本號有了這個保證後,在申明三方包依賴時,除了可依賴於一個固定版本號外,還可依賴於某個範圍的版本號。例如”argv”: “0.0.x”表示依賴於0.0.x系列的最新版argv。NPM支援的所有版本號範圍指定方式可以檢視官方文件。

機靈一點

除了本章介紹的部分外,NPM還提供了很多功能,package.json裡也有很多其它有用的欄位。除了可以在npmjs.org/doc/檢視官方文件外,這裡再介紹一些NPM常用命令。

  • NPM提供了很多命令,例如install和publish,使用npm help可檢視所有命令。
  • 使用npm help 可檢視某條命令的詳細幫助,例如npm help install。
  • 在package.json所在目錄下使用npm install . -g可先在本地安裝當前命令列程式,可用於釋出前的本地測試。
  • 使用npm update 可以把當前目錄下node_modules子目錄裡邊的對應模組更新至最新版本。
  • 使用npm update -g可以把全域性安裝的對應命令列程式更新至最新版。
  • 使用npm cache clear可以清空NPM本地快取,用於對付使用相同版本號釋出新版本程式碼的人。
  • 使用npm unpublish @可以撤銷釋出自己釋出過的某個版本程式碼。

小結
本章介紹了使用NodeJS編寫程式碼前需要做的準備工作,總結起來有以下幾點:

  • 編寫程式碼前先規劃好目錄結構,才能做到有條不紊。
  • 稍大些的程式可以將程式碼拆分為多個模組管理,更大些的程式可以使用包來組織模組。
  • 合理使用node_modules和NODE_PATH來解耦包的使用方式和物理路徑。
  • 使用NPM加入NodeJS生態圈互通有無。

檔案操作

讓前端覺得如獲神器的不是NodeJS能做網路程式設計,而是NodeJS能夠操作檔案。小至檔案查詢,大至程式碼編譯,幾乎沒有一個前端工具不操作檔案。換個角度講,幾乎也只需要一些資料處理邏輯,再加上一些檔案操作,就能夠編寫出大多數前端工具。本章將介紹與之相關的NodeJS內建模組。

開門紅

NodeJS提供了基本的檔案操作API,但是像檔案拷貝這種高階功能就沒有提供,因此我們先拿檔案拷貝程式練手。與copy命令類似,我們的程式需要能接受原始檔路徑與目標檔案路徑兩個引數。

小檔案拷貝
我們使用NodeJS內建的fs模組簡單實現這個程式如下。

//引入內建的操作檔案模組fs
var fs = require('fs');
//拷貝函式,先從源路徑讀取檔案內容,然後再寫入目標目錄
function copy(src, dst) {
    fs.writeFileSync(dst, fs.readFileSync(src));
}

function main(argv) {
    copy(argv[0], argv[1]);
}

main(process.argv.slice(2));

這裡注意:process是一個全域性變數,可通過process.argv獲得命令列引數。由於argv[0]固定等於NodeJS執行程式的絕對路徑,argv[1]固定等於主模組的絕對路徑,因此第一個命令列引數從argv[2]這個位置開始。

大檔案拷貝
上邊的程式拷貝一些小檔案沒啥問題,但這種一次性把所有檔案內容都讀取到記憶體中後再一次性寫入磁碟的方式不適合拷貝大檔案,記憶體會爆倉。對於大檔案,我們只能讀一點寫一點,直到完成拷貝。因此上邊的程式需要改造如下。

var fs = require('fs');

function copy(src, dst) {
    fs.createReadStream(src).pipe(fs.createWriteStream(dst));
}

function main(argv) {
    copy(argv[0], argv[1]);
}

main(process.argv.slice(2));

以上程式使用fs.createReadStream建立了一個原始檔的只讀資料流,並使用fs.createWriteStream建立了一個目標檔案的只寫資料流,並且用pipe方法把兩個資料流連線了起來。連線起來後發生的事情,說得抽象點的話,水順著水管從一個桶流到了另一個桶。

API走馬觀花

我們先大致看看NodeJS提供了哪些和檔案操作有關的API。這裡並不逐一介紹每個API的使用方法,官方文件已經做得很好了。

Buffer資料塊

JS語言自身只有字串資料型別,沒有二進位制資料型別,因此NodeJS提供了一個與String對等的全域性建構函式Buffer來提供對二進位制資料的操作。除了可以讀取檔案得到Buffer的例項外,還能夠直接構造,例如:

//除了可以讀取檔案得到Buffer的例項外,還能夠直接構造,例如:
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
//Buffer與字串類似,除了可以用.length屬性得到位元組長度外,還可以用[index]方式讀取指定位置的位元組,例如:
bin[0]; // => 0x68;
//Buffer與字串能夠互相轉化,例如可以使用指定編碼將二進位制資料轉化為字串:
var str = bin.toString('utf-8'); // => "hello"
//或者反過來,將字串轉換為指定編碼下的二進位制資料:
var bin = new Buffer('hello', 'utf-8'); // => <Buffer 68 65 6c 6c 6f>
//Buffer與字串有一個重要區別。字串是隻讀的,並且對字串的任何修改得到的都是一個新字串,原字串保持不變。至於Buffer,更像是可以做指標操作的C語言陣列。例如,可以用[index]方式直接修改某個位置的位元組。
bin[0] = 0x48;
//而.slice方法也不是返回一個新的Buffer,而更像是返回了指向原Buffer中間的某個位置的指標,如下所示。
[ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]
    ^           ^
    |           |
   bin     bin.slice(2)
//因此對.slice方法返回的Buffer的修改會作用於原Buffer,例如:
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var sub = bin.slice(2);
//這裡改了sub[0],然後bin也變了,說明是指標指向,而不是新的資料。
sub[0] = 0x65;
console.log(bin); // => <Buffer 68 65 65 6c 6f>
//也因此,如果想要拷貝一份Buffer,得首先建立一個新的Buffer,並通過.copy方法把原Buffer中的資料複製過去。這個類似於申請一塊新的記憶體,並把已有記憶體中的資料複製過去。以下是一個例子。
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var dup = new Buffer(bin.length);

bin.copy(dup);
dup[0] = 0x48;
console.log(bin); // => <Buffer 68 65 6c 6c 6f>
console.log(dup); // => <Buffer 48 65 65 6c 6f>

總之,Buffer將JS的資料處理能力從字串擴充套件到了任意二進位制資料。

Stream(資料流)
當記憶體中無法一次裝下需要處理的資料時,或者一邊讀取一邊處理更加高效時,我們就需要用到資料流。NodeJS中通過各種Stream來提供對資料流的操作。

以上邊的大檔案拷貝程式為例,我們可以為資料來源建立一個只讀資料流,示例如下:

var rs = fs.createReadStream(pathname);

rs.on('data', function (chunk) {
    doSomething(chunk);
});

rs.on('end', function () {
    cleanUp();
});

Stream基於事件機制工作,所有Stream的例項都繼承於NodeJS提供的EventEmitter。

上邊的程式碼中data事件會源源不斷地被觸發,不管doSomething函式是否處理得過來。程式碼可以繼續做如下改造,以解決這個問題:

var rs = fs.createReadStream(src);

rs.on('data', function (chunk) {
    //read stream 暫停
    rs.pause();
    doSomething(chunk, function () {
        //rs.resume 重新開始
        rs.resume();
    });
});

rs.on('end', function () {
    cleanUp();
});

以上程式碼給doSomething函式加上了回撥,因此我們可以在處理資料前暫停資料讀取,並在處理資料後繼續讀取資料。

此外,我們也可以為資料目標建立一個只寫資料流,示例如下:

var rs = fs.createReadStream(src);
var ws = fs.createWriteStream(dst);

rs.on('data', function (chunk) {
    ws.write(chunk);
});

rs.on('end', function () {
    ws.end();
});

我們把doSomething換成了往只寫資料流裡寫入資料後,以上程式碼看起來就像是一個檔案拷貝程式了。但是以上程式碼存在上邊提到的問題,如果寫入速度跟不上讀取速度的話,只寫資料流內部的快取會爆倉。我們可以根據.write方法的返回值來判斷傳入的資料是寫入目標了,還是臨時放在了快取了,並根據drain事件來判斷什麼時候只寫資料流已經將快取中的資料寫入目標,可以傳入下一個待寫資料了。因此程式碼可以改造如下:

var rs = fs.createReadStream(src);
var ws = fs.createWriteStream(dst);
//當只讀資料流傳入的目標是放在快取的時候,暫停讀取
rs.on('data', function (chunk) {
    if (ws.write(chunk) === false) {
        rs.pause();
    }
});

rs.on('end', function () {
    ws.end();
});
//當只寫資料流已經將快取中的資料寫入目標的時候,只讀資料流紅心開始讀取
ws.on('drain', function () {
    rs.resume();
});

以上程式碼實現了資料從只讀資料流到只寫資料流的搬運,幷包括了防爆倉控制。因為這種使用場景很多,例如上邊的大檔案拷貝程式,NodeJS直接提供了.pipe方法來做這件事情,其內部實現方式與上邊的程式碼類似。

File System(檔案系統)
NodeJS通過fs內建模組提供對檔案的操作。fs模組提供的API基本上可以分為以下三類:

  • 檔案屬性讀寫。
    -其中常用的有fs.stat、fs.chmod、fs.chown等等。
  • 檔案內容讀寫。
    -其中常用的有fs.readFile、fs.readdir、fs.writeFile、fs.mkdir等等。
  • 底層檔案操作。
    -其中常用的有fs.open、fs.read、fs.write、fs.close等等。

NodeJS最精華的非同步IO模型在fs模組裡有著充分的體現,例如上邊提到的這些API都通過回撥函式傳遞結果。以fs.readFile為例:

fs.readFile(pathname, function (err, data) {
    if (err) {
        // Deal with error.
    } else {
        // Deal with data.
    }
});

如上邊程式碼所示,基本上所有fs模組API的回撥引數都有兩個。第一個引數在有錯誤發生時等於異常物件,第二個引數始終用於返回API方法執行結果。

此外,fs模組的所有非同步API都有對應的同步版本,用於無法使用非同步操作時,或者同步操作更方便時的情況。同步API除了方法名的末尾多了一個Sync之外,異常物件與執行結果的傳遞方式也有相應變化。同樣以fs.readFileSync為例:

try {
    var data = fs.readFileSync(pathname);
    // Deal with data.
} catch (err) {
    // Deal with error.
}

fs模組提供的API很多,這裡不一一介紹,需要時請自行查閱官方文件。

Path(路徑)
操作檔案時難免不與檔案路徑打交道。NodeJS提供了path內建模組來簡化路徑相關操作,並提升程式碼可讀性。以下分別介紹幾個常用的API。

  • path.normalize
    將傳入的路徑轉換為標準路徑,具體講的話,除了解析路徑中的.與..外,還能去掉多餘的斜槓。如果有程式需要使用路徑作為某些資料的索引,但又允許使用者隨意輸入路徑時,就需要使用該方法保證路徑的唯一性。以下是一個例子:
var cache = {};

  function store(key, value) {
      cache[path.normalize(key)] = value;
  }

  store('foo/bar', 1);
  store('foo//baz//../bar', 2);
  console.log(cache);  // => { "foo/bar": 2 }

坑出沒注意: 標準化之後的路徑裡的斜槓在Windows系統下是\,而在Linux系統下是/。如果想保證任何系統下都使用/作為路徑分隔符的話,需要用.replace(/\/g, ‘/’)再替換一下標準路徑。

  • path.join
    將傳入的多個路徑拼接為標準路徑。該方法可避免手工拼接路徑字串的繁瑣,並且能在不同系統下正確使用相應的路徑分隔符。以下是一個例子:
  path.join('foo/', 'baz/', '../bar'); // => "foo/bar"
  • path.extname
    當我們需要根據不同副檔名做不同操作時,該方法就顯得很好用。以下是一個例子:
 path.extname('foo/bar.js'); // => ".js"

path模組提供的其餘方法也不多,稍微看一下官方文件就能全部掌握。

遍歷目錄

遍歷目錄是操作檔案時的一個常見需求。比如寫一個程式,需要找到並處理指定目錄下的所有JS檔案時,就需要遍歷整個目錄。

遞迴演算法

遍歷目錄時一般使用遞迴演算法,否則就難以編寫出簡潔的程式碼。遞迴演算法與數學歸納法類似,通過不斷縮小問題的規模來解決問題。以下示例說明了這種方法。

function factorial(n) {
    if (n === 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

上邊的函式用於計算N的階乘(N!)。可以看到,當N大於1時,問題簡化為計算N乘以N-1的階乘。當N等於1時,問題達到最小規模,不需要再簡化,因此直接返回1。
陷阱: 使用遞迴演算法編寫的程式碼雖然簡潔,但由於每遞迴一次就產生一次函式呼叫,在需要優先考慮效能時,需要把遞迴演算法轉換為迴圈演算法,以減少函式呼叫次數。

遍歷演算法
目錄是一個樹狀結構,在遍歷時一般使用深度優先+先序遍歷演算法。深度優先,意味著到達一個節點後,首先接著遍歷子節點而不是鄰居節點。先序遍歷,意味著首次到達了某節點就算遍歷完成,而不是最後一次返回某節點才算數。因此使用這種遍歷方式時,下邊這棵樹的遍歷順序是A > B > D > E > C > F。

          A
         / \
        B   C
       / \   \
      D   E   F

同步遍歷
瞭解了必要的演算法後,我們可以簡單地實現以下目錄遍歷函式。

function travel(dir, callback) {
    fs.readdirSync(dir).forEach(function (file) {
        var pathname = path.join(dir, file);
        //如果該路徑還有下級目錄,則再travel
        if (fs.statSync(pathname).isDirectory()) {
            travel(pathname, callback);
        } else {
            callback(pathname);
        }
    });
}

可以看到,該函式以某個目錄作為遍歷的起點。遇到一個子目錄時,就先接著遍歷子目錄。遇到一個檔案時,就把檔案的絕對路徑傳給回撥函式。回撥函式拿到檔案路徑後,就可以做各種判斷和處理。因此假設有以下目錄:

- /home/user/
    - foo/
        x.js
    - bar/
        y.js
    z.css

使用以下程式碼遍歷該目錄時,得到的輸入如下。

travel('/home/user', function (pathname) {
    console.log(pathname);
});

------------------------
/home/user/foo/x.js
/home/user/bar/y.js
/home/user/z.css

非同步遍歷
如果讀取目錄或讀取檔案狀態時使用的是非同步API,目錄遍歷函式實現起來會有些複雜,但原理完全相同。這裡不詳細介紹非同步遍歷函式的編寫技巧,在後續章節中會詳細介紹這個。總之非同步程式設計還是蠻複雜的。

文字編碼

使用NodeJS編寫前端工具時,操作得最多的是文字檔案,因此也就涉及到了檔案編碼的處理問題。我們常用的文字編碼有UTF8和GBK兩種,並且UTF8檔案還可能帶有BOM。在讀取不同編碼的文字檔案時,需要將檔案內容轉換為JS使用的UTF8編碼字串後才能正常處理。

BOM的移除

BOM用於標記一個文字檔案使用Unicode編碼,其本身是一個Unicode字元(”\uFEFF”),位於文字檔案頭部。在不同的Unicode編碼下,BOM字元對應的二進位制位元組如下:

 Bytes      Encoding
----------------------------
 FE FF       UTF16BE
 FF FE       UTF16LE
 EF BB BF    UTF8

因此,我們可以根據文字檔案頭幾個位元組等於啥來判斷檔案是否包含BOM,以及使用哪種Unicode編碼。但是,BOM字元雖然起到了標記檔案編碼的作用,其本身卻不屬於檔案內容的一部分,如果讀取文字檔案時不去掉BOM,在某些使用場景下就會有問題。例如我們把幾個JS檔案合併成一個檔案後,如果檔案中間含有BOM字元,就會導致瀏覽器JS語法錯誤。因此,使用NodeJS讀取文字檔案時,一般需要去掉BOM。例如,以下程式碼實現了識別和去除UTF8 BOM的功能。

function readText(pathname) {
    var bin = fs.readFileSync(pathname);

    if (bin[0] === 0xEF && bin[1] === 0xBB && bin[2] === 0xBF) {
        bin = bin.slice(3);
    }

    return bin.toString('utf-8');
}

GBK轉UTF8

NodeJS支援在讀取文字檔案時,或者在Buffer轉換為字串時指定文字編碼,但遺憾的是,GBK編碼不在NodeJS自身支援範圍內。因此,一般我們藉助iconv-lite這個三方包來轉換編碼。使用NPM下載該包後,我們可以按下邊方式編寫一個讀取GBK文字檔案的函式。

var iconv = require('iconv-lite');

function readGBKText(pathname) {
    var bin = fs.readFileSync(pathname);

    return iconv.decode(bin, 'gbk');
}

單位元組編碼
有時候,我們無法預知需要讀取的檔案採用哪種編碼,因此也就無法指定正確的編碼。比如我們要處理的某些CSS檔案中,有的用GBK編碼,有的用UTF8編碼。雖然可以一定程度可以根據檔案的位元組內容猜測出文字編碼,但這裡要介紹的是有些侷限,但是要簡單得多的一種技術。

首先我們知道,如果一個文字檔案只包含英文字元,比如Hello World,那無論用GBK編碼或是UTF8編碼讀取這個檔案都是沒問題的。這是因為在這些編碼下,ASCII0~128範圍內字元都使用相同的單位元組編碼。

反過來講,即使一個文字檔案中有中文等字元,如果我們需要處理的字元僅在ASCII0~128範圍內,比如除了註釋和字串以外的JS程式碼,我們就可以統一使用單位元組編碼來讀取檔案,不用關心檔案的實際編碼是GBK還是UTF8。以下示例說明了這種方法。

1. GBK編碼原始檔內容:
    var foo = '中文';
2. 對應位元組:
    76 61 72 20 66 6F 6F 20 3D 20 27 D6 D0 CE C4 27 3B
3. 使用單位元組編碼讀取後得到的內容:
    var foo = '{亂碼}{亂碼}{亂碼}{亂碼}';
4. 替換內容:
    var bar = '{亂碼}{亂碼}{亂碼}{亂碼}';
5. 使用單位元組編碼儲存後對應位元組:
    76 61 72 20 62 61 72 20 3D 20 27 D6 D0 CE C4 27 3B
6. 使用GBK編碼讀取後得到內容:
    var bar = '中文';

這裡的訣竅在於,不管大於0xEF的單個位元組在單位元組編碼下被解析成什麼亂碼字元,使用同樣的單位元組編碼儲存這些亂碼字元時,背後對應的位元組保持不變。

NodeJS中自帶了一種binary編碼可以用來實現這個方法,因此在下例中,我們使用這種編碼來演示上例對應的程式碼該怎麼寫。

function replace(pathname) {
    var str = fs.readFileSync(pathname, 'binary');
    str = str.replace('foo', 'bar');
    fs.writeFileSync(pathname, str, 'binary');
}

小結
本章介紹了使用NodeJS操作檔案時需要的API以及一些技巧,總結起來有以下幾點:

  • 學好檔案操作,編寫各種程式都不怕。
  • 如果不是很在意效能,fs模組的同步API能讓生活更加美好。
  • 需要對檔案讀寫做到位元組級別的精細控制時,請使用fs模組的檔案底層操作API。
  • 不要使用拼接字串的方式來處理路徑,使用path模組。
  • 掌握好目錄遍歷和檔案編碼處理技巧,很實用。

網路操作

不瞭解網路程式設計的程式設計師不是好前端,而NodeJS恰好提供了一扇瞭解網路程式設計的視窗。通過NodeJS,除了可以編寫一些服務端程式來協助前端開發和測試外,還能夠學習一些HTTP協議與Socket協議的相關知識,這些知識在優化前端效能和排查前端故障時說不定能派上用場。本章將介紹與之相關的NodeJS內建模組。

開門紅

NodeJS本來的用途是編寫高效能Web伺服器。我們首先在這裡重複一下官方文件裡的例子,使用NodeJS內建的http模組簡單實現一個HTTP伺服器。

var http = require('http');

http.createServer(function (request, response) {
    response.writeHead(200, { 'Content-Type': 'text-plain' });
    response.end('Hello World\n');
}).listen(8124);

以上程式建立了一個HTTP伺服器並監聽8124埠,開啟瀏覽器訪問該埠http://127.0.0.1:8124/就能夠看到效果。

API走馬觀花

我們先大致看看NodeJS提供了哪些和網路操作有關的API。這裡並不逐一介紹每個API的使用方法,官方文件已經做得很好了。

HTTP
‘http’模組提供兩種使用方式:

  • 作為服務端使用時,建立一個HTTP伺服器,監聽HTTP客戶端請求並返回響應。
  • 作為客戶端使用時,發起一個HTTP客戶端請求,獲取服務端響應。

首先我們來看看服務端模式下如何工作。如開門紅中的例子所示,首先需要使用.createServer方法建立一個伺服器,然後呼叫.listen方法監聽埠。之後,每當來了一個客戶端請求,建立伺服器時傳入的回撥函式就被呼叫一次。可以看出,這是一種事件機制。

HTTP請求本質上是一個資料流,由請求頭(headers)和請求體(body)組成。例如以下是一個完整的HTTP請求資料內容。

//請求頭
POST / HTTP/1.1
User-Agent: curl/7.26.0
Host: localhost
Accept: */*
Content-Length: 11
Content-Type: application/x-www-form-urlencoded
//請求體
Hello World

HTTP請求在傳送給伺服器時,可以認為是按照從頭到尾的順序一個位元組一個位元組地以資料流方式傳送的。而http模組建立的HTTP伺服器在接收到完整的請求頭後,就會呼叫回撥函式。

在回撥函式中,除了可以使用request物件訪問請求頭資料外,還能把request物件當作一個只讀資料流來訪問請求體資料。

接下來我們看看客戶端模式下如何工作。為了發起一個客戶端HTTP請求,我們需要指定目標伺服器的位置併傳送請求頭和請求體,以下示例演示了具體做法。

var options = {
        hostname: 'www.example.com',
        port: 80,
        path: '/upload',
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    };

var request = http.request(options, function (response) {});

request.write('Hello World');
request.end();

可以看到,.request方法建立了一個客戶端,並指定請求目標和請求頭資料。之後,就可以把request物件當作一個只寫資料流來寫入請求體資料和結束請求。另外,由於HTTP請求中GET請求是最常見的一種,並且不需要請求體,因此http模組也提供了以下便捷API。

http.get('http://www.example.com/', function (response) {});

HTTPS
https模組與http模組極為類似,區別在於https模組需要額外處理SSL證照。

在服務端模式下,建立一個HTTPS伺服器的示例如下。

var options = {
        key: fs.readFileSync('./ssl/default.key'),
        cert: fs.readFileSync('./ssl/default.cer')
    };

var server = https.createServer(options, function (request, response) {
        // ...
    });

可以看到,與建立HTTP伺服器相比,多了一個options物件,通過key和cert欄位指定了HTTPS伺服器使用的私鑰和公鑰。

另外,NodeJS支援SNI技術,可以根據HTTPS客戶端請求使用的域名動態使用不同的證照,因此同一個HTTPS伺服器可以使用多個域名提供服務。接著上例,可以使用以下方法為HTTPS伺服器新增多組證照。

server.addContext('foo.com', {
    key: fs.readFileSync('./ssl/foo.com.key'),
    cert: fs.readFileSync('./ssl/foo.com.cer')
});

server.addContext('bar.com', {
    key: fs.readFileSync('./ssl/bar.com.key'),
    cert: fs.readFileSync('./ssl/bar.com.cer')
});

在客戶端模式下,發起一個HTTPS客戶端請求與http模組幾乎相同,示例如下。

var options = {
        hostname: 'www.example.com',
        port: 443,
        path: '/',
        method: 'GET'
    };

var request = https.request(options, function (response) {});

request.end();

但如果目標伺服器使用的SSL證照是自制的,不是從頒發機構購買的,預設情況下https模組會拒絕連線,提示說有證照安全問題。在options里加入rejectUnauthorized: false欄位可以禁用對證照有效性的檢查,從而允許https模組請求開發環境下使用自制證照的HTTPS伺服器。

URL
處理HTTP請求時url模組使用率超高,因為該模組允許解析URL、生成URL,以及拼接URL。首先我們來看看一個完整的URL的各組成部分。

                           href
 -----------------------------------------------------------------
                            host              path
                      --------------- ----------------------------
 http: // user:pass @ host.com : 8080 /p/a/t/h ?query=string #hash
 -----    ---------   --------   ---- -------- ------------- -----
protocol     auth     hostname   port pathname     search     hash
                                                ------------
                                                   query

我們可以使用.parse方法來將一個URL字串轉換為URL物件,示例如下。

url.parse('http://user:pass@host.com:8080/p/a/t/h?query=string#hash');
/* =>
{ protocol: 'http:',
  auth: 'user:pass',
  host: 'host.com:8080',
  port: '8080',
  hostname: 'host.com',
  hash: '#hash',
  search: '?query=string',
  query: 'query=string',
  pathname: '/p/a/t/h',
  path: '/p/a/t/h?query=string',
  href: 'http://user:pass@host.com:8080/p/a/t/h?query=string#hash' }
*/

傳給.parse方法的不一定要是一個完整的URL,例如在HTTP伺服器回撥函式中,request.url不包含協議頭和域名,但同樣可以用.parse方法解析。

.parse方法還支援第二個和第三個布林型別可選引數。第二個引數等於true時,該方法返回的URL物件中,query欄位不再是一個字串,而是一個經過querystring模組轉換後的引數物件。第三個引數等於true時,該方法可以正確解析不帶協議頭的URL,例如//www.example.com/foo/bar。

另外,.resolve方法可以用於拼接URL,示例如下。

querystring.parse('foo=bar&baz=qux&baz=quux&corge');
/* =>
{ foo: 'bar', baz: ['qux', 'quux'], corge: '' }
*/

querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: '' });
/* =>
'foo=bar&baz=qux&baz=quux&corge='
*/

Query String
querystring模組用於實現URL引數字串與引數物件的互相轉換,示例如下。

querystring.parse('foo=bar&baz=qux&baz=quux&corge');
/* =>
{ foo: 'bar', baz: ['qux', 'quux'], corge: '' }
*/

querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: '' });
/* =>
'foo=bar&baz=qux&baz=quux&corge='
*/

Zlib
zlib模組提供了資料壓縮和解壓的功能。當我們處理HTTP請求和響應時,可能需要用到這個模組。

Net
net模組可用於建立Socket伺服器或Socket客戶端。由於Socket在前端領域的使用範圍還不是很廣,這裡先不涉及到WebSocket的介紹,僅僅簡單演示一下如何從Socket層面來實現HTTP請求和響應。

靈機一點

使用NodeJS操作網路,特別是操作HTTP請求和響應時會遇到一些驚喜,這裡對一些常見問題做解答。

  • 問: 為什麼通過headers物件訪問到的HTTP請求頭或響應頭欄位不是駝峰的?

    答: 從規範上講,HTTP請求頭和響應頭欄位都應該是駝峰的。但現實是殘酷的,不是每個HTTP服務端或客戶端程式都嚴格遵循規範,所以NodeJS在處理從別的客戶端或服務端收到的頭欄位時,都統一地轉換為了小寫字母格式,以便開發者能使用統一的方式來訪問頭欄位,例如headers[‘content-length’]。

  • 問: 為什麼http模組建立的HTTP伺服器返回的響應是chunked傳輸方式的?

    答: 因為預設情況下,使用.writeHead方法寫入響應頭後,允許使用.write方法寫入任意長度的響應體資料,並使用.end方法結束一個響應。由於響應體資料長度不確定,因此NodeJS自動在響應頭裡新增了Transfer-Encoding: chunked欄位,並採用chunked傳輸方式。但是當響應體資料長度確定時,可使用.writeHead方法在響應頭裡加上Content-Length欄位,這樣做之後NodeJS就不會自動新增Transfer-Encoding欄位和使用chunked傳輸方式。

  • 問: 為什麼使用http模組發起HTTP客戶端請求時,有時候會發生socket hang up錯誤?

    答: 發起客戶端HTTP請求前需要先建立一個客戶端。http模組提供了一個全域性客戶端http.globalAgent,可以讓我們使用.request或.get方法時不用手動建立客戶端。但是全域性客戶端預設只允許5個併發Socket連線,當某一個時刻HTTP客戶端請求建立過多,超過這個數字時,就會發生socket hang up錯誤。解決方法也很簡單,通過http.globalAgent.maxSockets屬性把這個數字改大些即可。另外,https模組遇到這個問題時也一樣通過https.globalAgent.maxSockets屬性來處理。

小結

本章介紹了使用NodeJS操作網路時需要的API以及一些坑迴避技巧,總結起來有以下幾點:

  • http和https模組支援服務端模式和客戶端模式兩種使用方式。
  • request和response物件除了用於讀寫頭資料外,都可以當作資料流來操作。
  • url.parse方法加上request.url屬性是處理HTTP請求時的固定搭配。
  • 使用zlib模組可以減少使用HTTP協議時的資料傳輸量。
  • 通過net模組的Socket伺服器與客戶端可對HTTP協議做底層操作。

程式管理

相關文章