讓前端覺得如獲神器的不是NodeJS能做網絡編程,而是NodeJS能夠操作文件。小至文件查找,大至代碼編譯,幾乎沒有一個前端工具不操作文件。換個角度講,幾乎也只需要一些數據處理邏輯,再加上一些文件操作,就能夠編寫出大多數前端工具。
[TOC]
## 3.1 文件拷貝示例
NodeJS提供了基本的文件操作API,但是像文件拷貝這種高級功能就沒有提供。
### 3.1.1 小文件拷貝
我們使用NodeJS內置的fs模塊簡單實現這個程序如下。
~~~javascript
var fs = require('fs');
function copy(src, dst) {
//使用 fs.readFileSync 從源路徑讀取文件內容,并使用 fs.writeFileSync 將文件內容寫入目標路徑
fs.writeFileSync(dst, fs.readFileSync(src));
}
function main(argv) {
copy(argv[0], argv[1]);
}
main(process.argv.slice(2));
~~~
> process 對象是一個 global (全局變量),提供有關信息,控制當前 Node.js 進程,可通過`process.argv`獲得命令行參數。由于argv[0]固定等于NodeJS執行程序的絕對路徑,argv[1]固定等于主模塊的絕對路徑,因此第一個命令行參數從argv[2]這個位置開始。
### 3.1.2 大文件拷貝
上邊的程序拷貝一些小文件沒啥問題,但這種一次性把所有文件內容都讀取到內存中后再一次性寫入磁盤的方式不適合拷貝大文件,內存會溢出。對于大文件,我們只能讀一點寫一點,直到完成拷貝。
修改上面的程序如下:
~~~javascript
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`方法把兩個數據流連接了起來。連接起來后發生的事情,說得抽象點的話,水順著水管從一個桶流到了另一個桶。
## 3.2 相關API
這里只做簡單的介紹,詳細可以查閱[官方文檔](https://nodejs.org/en/docs/)
### 3.2.1 Buffer(數據塊)
在 ECMAScript 2015 (ES6) 引入 TypedArray 之前,JavaScript 語言自身只有字符串數據類型,沒有讀取或操作二進制數據流的機制, 因此NodeJS提供了一個與String對等的全局構造函數Buffer來提供對二進制數據的操作。Buffer 類被引入作為 Node.js API 的一部分,使其可以在 TCP 流或文件系統操作等場景中處理二進制數據流。
Buffer 類的實例類似于整數數組,但 Buffer 的大小是固定的、且在 V8 堆外分配物理內存。 Buffer 的大小在被創建時確定,且無法調整。Buffer 類在 Node.js 中是一個全局變量,因此無需使用 require('buffer').Buffer。
**(1)`Buffer.from()、Buffer.alloc()、和 Buffer.allocUnsafe() `**
為了使 Buffer 實例的創建更可靠、更不容易出錯,各種` new Buffer() `構造函數已被 廢棄,并由 `Buffer.from()、Buffer.alloc()、和 Buffer.allocUnsafe() `方法替代。
~~~javascript
// 創建一個長度為 10、且用 0 填充的 Buffer。
const buf1 = Buffer.alloc(10);
// 創建一個長度為 10、且用 0x1 填充的 Buffer。
const buf2 = Buffer.alloc(10, 1);
// 創建一個長度為 10、且未初始化的 Buffer。這個方法比調用 Buffer.alloc() 更快
// 但返回的 Buffer 實例可能包含舊數據。
// 因此需要使用 fill() 或 write() 重寫。
const buf3 = Buffer.allocUnsafe(10);
// 創建一個包含 [0x1, 0x2, 0x3] 的 Buffer。
const buf4 = Buffer.from([1, 2, 3]);
// 創建一個包含 UTF-8 字節 [0x74, 0xc3, 0xa9, 0x73, 0x74] 的 Buffer。
const buf5 = Buffer.from('tést');
// 創建一個包含 Latin-1 字節 [0x74, 0xe9, 0x73, 0x74] 的 Buffer。
const buf6 = Buffer.from('tést', 'latin1');
~~~
* `Buffer.from(array) `返回一個新建的包含所提供的字節數組的副本的 Buffer。
* `Buffer.from(arrayBuffer[, byteOffset [, length]]) `返回一個新建的與給定的 ArrayBuffer 共享同一內存的 Buffer。
* `Buffer.from(buffer) `返回一個新建的包含所提供的 Buffer 的內容的副本的 Buffer。
* `Buffer.from(string[, encoding]) `返回一個新建的包含所提供的字符串的副本的 Buffer。
* `Buffer.alloc(size[, fill[, encoding]]) `返回一個指定大小的被填滿的 Buffer 實例。 這個方法會明顯地比 `Buffer.allocUnsafe(size)` 慢,但可確保新創建的 Buffer 實例絕不會包含舊的和潛在的敏感數據。
* B`uffer.allocUnsafe(size)` 與 `Buffer.allocUnsafeSlow(size) `返回一個新建的指定 size 的 Buffer,但它的內容必須被初始化,可以使用 `buf.fill(0)` 或完全寫滿。
* 如果 size 小于或等于 `Buffer.poolSize `的一半,則 `Buffer.allocUnsafe() `返回的 Buffer 實例可能會被分配進一個共享的內部內存池。
**(2)`Buffer.toString([encoding[, start[, end]]])`**
Buffer與字符串類似,除了可以用`.length`屬性得到字節長度外,還可以用`[index]`方式讀取指定位置的字節,例如:
~~~javascript
var bin = Buffer.from('hello', 'utf-8'); // => <Buffer 68 65 6c 6c 6f>
bin[0]; // => 0x68;
~~~
根據 encoding 指定的字符編碼解碼 buf 成一個字符串。 start 和 end 可傳入用于只解碼 buf 的一部分。
~~~javascript
const buf1 = Buffer.allocUnsafe(26);
for (let i = 0; i < 26; i++) {
// 97 是 'a' 的十進制 ASCII 值
buf1[i] = i + 97;
}
// 輸出: abcdefghijklmnopqrstuvwxyz
console.log(buf1.toString('ascii'));
// 輸出: abcde
console.log(buf1.toString('ascii', 0, 5));
const buf2 = Buffer.from('tést');
// 輸出: 74c3a97374
console.log(buf2.toString('hex'));
// 輸出: té
console.log(buf2.toString('utf8', 0, 3));
// 輸出: té
console.log(buf2.toString(undefined, 0, 3));
~~~
Buffer與字符串有一個重要區別。字符串是只讀的,并且對字符串的任何修改得到的都是一個新字符串,原字符串保持不變。至于Buffer,更像是可以做指針操作的C語言數組。例如,可以用`[index]`方式直接修改某個位置的字節。
~~~
bin[0] = 0x48;
~~~
而.slice方法也不是返回一個新的Buffer,而更像是返回了指向原Buffer中間的某個位置的指針,如下所示。
~~~
[ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]
^ ^
| |
bin bin.slice(2)
~~~
因此對.slice方法返回的Buffer的修改會作用于原Buffer,例如:
~~~javascript
var bin = Buffer.from([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var sub = bin.slice(2);
sub[0] = 0x65;
console.log(bin); // => <Buffer 68 65 65 6c 6f>
~~~
也因此,如果想要拷貝一份Buffer,得首先創建一個新的Buffer,并通過.copy方法把原Buffer中的數據復制過去。這個類似于申請一塊新的內存,并把已有內存中的數據復制過去。以下是一個例子。
~~~javascript
//創建兩個 Buffer 實例 buf1 與 buf2 ,并拷貝 buf1 中第 16 個至第 19 個字節到 buf2 第 8 個字節起。
const buf1 = Buffer.allocUnsafe(26);
const buf2 = Buffer.allocUnsafe(26).fill('!');
for (let i = 0; i < 26; i++) {
// 97 是 'a' 的十進制 ASCII 值
buf1[i] = i + 97;
}
buf1.copy(buf2, 8, 16, 20);
// 輸出: !!!!!!!!qrst!!!!!!!!!!!!!
console.log(buf2.toString('ascii', 0, 25));
~~~
總之,Buffer將JS的數據處理能力從字符串擴展到了任意二進制數據。
### 3.2.2 Stream(數據流)
流(stream)在 Node.js 中是處理流數據的抽象接口(abstract interface)。流可以是可讀的、可寫的,或是可讀寫的。Stream基于事件機制工作,所有的流都是 EventEmitter 的實例。
當內存中無法一次裝下需要處理的數據時,或者一邊讀取一邊處理更加高效時,我們就需要用到數據流。
以上邊的大文件拷貝程序為例,我們可以為數據來源創建一個只讀數據流,示例如下:
~~~javascript
var rs = fs.createReadStream(pathname);
rs.on('data', function (chunk) {
doSomething(chunk);
});
rs.on('end', function () {
cleanUp();
});
~~~
上邊的代碼中data事件會源源不斷地被觸發,不管doSomething函數是否處理得過來。代碼可以繼續做如下改造,以解決這個問題。
~~~javascript
var rs = fs.createReadStream(src);
rs.on('data', function (chunk) {
rs.pause();
doSomething(chunk, function () {
rs.resume();
});
});
rs.on('end', function () {
cleanUp();
});
~~~
以上代碼給doSomething函數加上了回調,因此我們可以在處理數據前暫停數據讀取,并在處理數據后繼續讀取數據。
此外,我們也可以為數據目標創建一個只寫數據流,示例如下:
~~~javascript
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`事件來判斷什么時候只寫數據流已經將緩存中的數據寫入目標,可以傳入下一個待寫數據了。因此代碼可以改造如下:
~~~javascript
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`方法來做這件事情,其內部實現方式與上邊的代碼類似。
幾乎所有的 Node.js 應用,不管多么簡單,都在某種程度上使用了流。 下面是在 Node.js 應用中使用流實現的一個簡單的 HTTP 服務器:
~~~javascript
const http = require('http');
const server = http.createServer((req, res) => {
// req 是 http.IncomingMessage 的實例,這是一個 Readable Stream
// res 是 http.ServerResponse 的實例,這是一個 Writable Stream
let body = '';
// 接收數據為 utf8 字符串,
// 如果沒有設置字符編碼,將接收到 Buffer 對象。
req.setEncoding('utf8');
// 如果監聽了 'data' 事件,Readable streams 觸發 'data' 事件
req.on('data', (chunk) => {
body += chunk;
});
// end 事件表明整個 body 都接收完畢了
req.on('end', () => {
try {
const data = JSON.parse(body);
// 發送一些信息給用戶
res.write(typeof data);
res.end();
} catch (er) {
// json 數據解析失敗
res.statusCode = 400;
return res.end(`error: ${er.message}`);
}
});
});
server.listen(1337);
~~~
### 3.2.3 File System(文件系統)
NodeJS通過fs內置模塊提供對文件的操作。通過 require('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為例:
~~~javascript
fs.readFile(pathname, (err, data)=> {
if (err) {
// Deal with error.
} else {
// Deal with data.
}
});
~~~
如上邊代碼所示,基本上所有fs模塊API的回調參數都有兩個。第一個參數在有錯誤發生時等于異常對象,第二個參數始終用于返回API方法執行結果。
此外,fs模塊的所有異步API都有對應的同步版本,用于無法使用異步操作時,或者同步操作更方便時的情況。同步API除了方法名的末尾多了一個Sync之外,異常對象與執行結果的傳遞方式也有相應變化。同樣以fs.readFileSync為例:
~~~javascript
try {
var data = fs.readFileSync(pathname);
// Deal with data.
} catch (err) {
// Deal with error.
}
~~~
### 3.2.4 Path(路徑)
path 模塊提供了一些工具函數,用于處理文件與目錄的路徑。下面介紹幾個常用的API。
**(1)path.normalize**
將傳入的路徑轉換為標準路徑,具體講的話,除了解析路徑中的`.`與`..`外,還能去掉多余的斜杠\。如果有程序需要使用路徑作為某些數據的索引,但又允許用戶隨意輸入路徑時,就需要使用該方法保證路徑的唯一性。以下是一個例子:
~~~javascript
const path = require('path')
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, '/')再替換一下標準路徑。
**(2)path.join**
將傳入的多個路徑拼接為標準路徑。該方法可避免手工拼接路徑字符串的繁瑣,并且能在不同系統下正確使用相應的路徑分隔符。以下是一個例子:
~~~javascript
path.join('/foo', 'bar', 'baz/asdf', 'quux', '..');
// 返回: '/foo/bar/baz/asdf'
path.join('foo', {}, 'bar');
// 拋出 'TypeError: Path must be a string. Received {}'
~~~
**(3)path.extname**
path.extname() 方法返回 path 的擴展名,即從 path 的最后一部分中的最后一個 `.`(句號)字符到字符串結束。 如果 path 的最后一部分沒有` . `或 path 的文件名(見 path.basename())的第一個字符是` .`,則返回一個空字符串。
當我們需要根據不同文件擴展名做不同操作時,該方法就顯得很好用。以下是一個例子:
~~~javascript
path.extname('index.html');
// 返回: '.html'
path.extname('index.coffee.md');
// 返回: '.md'
path.extname('index.');
// 返回: '.'
path.extname('index');
// 返回: ''
path.extname('.index');
// 返回: ''
如果 path 不是一個字符串,則拋出 TypeError。
~~~
## 3.3 遍歷目錄
遍歷目錄是操作文件時的一個常見需求。比如寫一個程序,需要找到并處理指定目錄下的所有JS文件時,就需要遍歷整個目錄。
### 3.3.1 遞歸算法
遍歷目錄時一般使用遞歸算法,否則就難以編寫出簡潔的代碼。遞歸算法與數學歸納法類似,通過不斷縮小問題的規模來解決問題。以下示例說明了這種方法。
~~~javascript
function factorial(n) {
if (n === 1) {
return 1;
} else {
return n * factorial(n - 1);
}
}
~~~
上邊的函數用于計算N的階乘(N!)。可以看到,當N大于1時,問題簡化為計算N乘以N-1的階乘。當N等于1時,問題達到最小規模,不需要再簡化,因此直接返回1。
> 使用遞歸算法編寫的代碼雖然簡潔,但由于每遞歸一次就產生一次函數調用,在需要優先考慮性能時,需要把遞歸算法轉換為循環算法,以減少函數調用次數。
### 3.3.2 遍歷算法
目錄是一個樹狀結構,在遍歷時一般使用**深度優先+先序遍歷算法**。深度優先,意味著到達一個節點后,首先接著遍歷子節點而不是鄰居節點。先序遍歷,意味著首次到達了某節點就算遍歷完成,而不是最后一次返回某節點才算數。因此使用這種遍歷方式時,下邊這棵樹的遍歷順序是A > B > D > E > C > F。
~~~
A
/ \
B C
/ \ \
D E F
~~~
### 3.3.3同步遍歷
了解了必要的算法后,我們可以簡單地實現以下目錄遍歷函數。
~~~javascript
function travel(dir, callback) {
fs.readdirSync(dir).forEach((file)=> {
var pathname = path.join(dir, file);
if (fs.statSync(pathname).isDirectory()) {
travel(pathname, callback);
} else {
callback(pathname);
}
});
}
~~~
可以看到,該函數以某個目錄作為遍歷的起點。遇到一個子目錄時,就先接著遍歷子目錄。遇到一個文件時,就把文件的絕對路徑傳給回調函數。回調函數拿到文件路徑后,就可以做各種判斷和處理。因此假設有以下目錄:
~~~
- /home/user/
- foo/
x.js
- bar/
y.js
z.css
~~~
使用以下代碼遍歷該目錄時,得到的輸入如下。
~~~javascript
travel('/home/user', (pathname)=> {
console.log(pathname);
});
------------------------
/home/user/foo/x.js
/home/user/bar/y.js
/home/user/z.css
~~~
### 3.3.4 異步遍歷
如果讀取目錄或讀取文件狀態時使用的是異步API,目錄遍歷函數實現起來會有些復雜,但原理完全相同。travel函數的異步版本如下。
~~~javascript
function travel(dir, callback, finish) {
fs.readdir(dir, (err, files) => {
(function next(i) {
if (i < files.length) {
var pathname = path.join(dir, files[i]);
fs.stat(pathname, (err, stats) => {
if (stats.isDirectory()) {
travel(pathname, callback, function () {
next(i + 1);
});
} else {
callback(pathname, () => {
next(i + 1);
});
}
});
} else {
finish && finish();
}
}(0));
});
}
~~~