讓前端覺得如獲神器的不是NodeJS能做網絡編程,而是NodeJS能夠操作文件。小至文件查找,大至代碼編譯,幾乎沒有一個前端工具不操作文件。換個角度講,幾乎也只需要一些數據處理邏輯,再加上一些文件操作,就能夠編寫出大多數前端工具。本章將介紹與之相關的NodeJS內置模塊。
NodeJS提供了基本的文件操作API,但是像文件拷貝這種高級功能就沒有提供,因此我們先拿文件拷貝程序練手。與copy
命令類似,我們的程序需要能接受源文件路徑與目標文件路徑兩個參數。
一、文件拷貝
1、小文件拷貝
我們使用NodeJS內置的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));
以上程序使用fs.readFileSync
從源路徑讀取文件內容,並使用fs.writeFileSync
將文件內容寫入目標路徑。
注意:process
是一個全局變量,可通過process.argv
獲得命令行參數。由於argv[0]
固定等於NodeJS執行程序的絕對路徑,argv[1]
固定等於主模塊的絕對路徑,因此第一個命令行參數從argv[2]
這個位置開始。
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的使用方法,官方文檔已經做得很好了。
1、Buffer(數據塊)
官方文檔: http://nodejs.org/api/buffer.html
JS語言自身只有字符串數據類型,沒有二進制數據類型,因此NodeJS提供了一個與String
對等的全局構造函數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] = 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的數據處理能力從字符串擴展到了任意二進制數據。
2、Stream(數據流)
官方文檔: http://nodejs.org/api/stream.html
當內存中無法一次裝下需要處理的數據時,或者一邊讀取一邊處理更加高效時,我們就需要用到數據流。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) { rs.pause(); doSomething(chunk, function () { 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
方法來做這件事情,其內部實現方式與上邊的代碼類似。
3、File System(文件系統)
官方文檔: http://nodejs.org/api/fs.html
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很多,需要時請自行查閱官方文檔
4、Path(路徑)
官方文檔: http://nodejs.org/api/path.html
操作文件時難免不與文件路徑打交道。NodeJS提供了path
內置模塊來簡化路徑相關操作,並提升代碼可讀性。以下分別介紹幾個常用的API。
path.normalize:將傳入的路徑轉換為標准路徑,具體講的話,除了解析路徑中的.
與..
外,還能去掉多余的斜杠。如果有程序需要使用路徑作為某些數據的索引,但又允許用戶隨意輸入路徑時,就需要使用該方法保證路徑的唯一性。
注意: 標准化之后的路徑里的斜杠在Windows系統下是\
,而在Linux系統下是/
。如果想保證任何系統下都使用/
作為路徑分隔符的話,需要用.replace(/\\/g, '/')
再替換一下標准路徑。
path.extname:當我們需要根據不同文件擴展名做不同操作時,該方法就顯得很好用
path
模塊提供的其余方法也不多,稍微看一下官方文檔就能全部掌握。
三、遍歷目錄
遍歷目錄是操作文件時的一個常見需求。比如寫一個程序,需要找到並處理指定目錄下的所有JS文件時,就需要遍歷整個目錄。
1、遞歸算法
遍歷目錄時一般使用遞歸算法,否則就難以編寫出簡潔的代碼。遞歸算法與數學歸納法類似,通過不斷縮小問題的規模來解決問題。
陷阱: 使用遞歸算法編寫的代碼雖然簡潔,但由於每遞歸一次就產生一次函數調用,在需要優先考慮性能時,需要把遞歸算法轉換為循環算法,以減少函數調用次數。
2、遍歷算法
目錄是一個樹狀結構,在遍歷時一般使用深度優先+先序遍歷算法。
深度優先,意味着到達一個節點后,首先接着遍歷子節點而不是鄰居節點。
先序遍歷,意味着首次到達了某節點就算遍歷完成,而不是最后一次返回某節點才算數。因此使用這種遍歷方式時,下邊這棵樹的遍歷順序是A > B > D > E > C > F
。
A / \ B C / \ \ D E F
3、同步遍歷
了解了必要的算法后,我們可以簡單地實現以下目錄遍歷函數。
function travel(dir, callback) { fs.readdirSync(dir).forEach(function (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 // 使用以下代碼遍歷該目錄時,得到的輸入如下。
travel('/home/user', function (pathname) { console.log(pathname); }); ------------------------
// home/user/foo/x.js // home/user/bar/y.js // home/user/z.css
4、異步遍歷
如果讀取目錄或讀取文件狀態時使用的是異步API,目錄遍歷函數實現起來會有些復雜,但原理完全相同。travel
函數的異步版本如下。
function travel(dir, callback, finish) { fs.readdir(dir, function (err, files) { (function next(i) { if (i < files.length) { var pathname = path.join(dir, files[i]); fs.stat(pathname, function (err, stats) { if (stats.isDirectory()) { travel(pathname, callback, function () { next(i + 1); }); } else { callback(pathname, function () { next(i + 1); }); } }); } else { finish && finish(); } }(0)); }); }
四、文本編碼
使用NodeJS編寫前端工具時,操作得最多的是文本文件,因此也就涉及到了文件編碼的處理問題。我們常用的文本編碼有UTF8
和GBK
兩種,並且UTF8
文件還可能帶有BOM。在讀取不同編碼的文本文件時,需要將文件內容轉換為JS使用的UTF8
編碼字符串后才能正常處理。