讓前端覺得如獲神器的不是NodeJS能做網絡編程,而是NodeJS能夠操作文件。小至文件查找,大至代碼編譯,幾乎沒有一個前端工具不操作文件。換個角度講,幾乎也隻需要一些數據處理邏輯,再加上一些文件操作,就能夠編寫出大多數前端工具。本章將介紹與之相關的NodeJS內置模塊。
開門紅
NodeJS提供了基本的文件操作API,但是像文件拷貝這種高級功能就沒有提供,因此我們先拿文件拷貝程序練手。與copy命令類似,我們的程序需要能接受源文件路徑與目標文件路徑兩個參數。
小文件拷貝
我們使用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]這個位置開始。
大文件拷貝
上邊的程序拷貝一些小文件沒啥問題,但這種一次性把所有文件內容都讀取到內存中後再一次性寫入磁盤的方式不適合拷貝大文件,內存會爆倉。對於大文件,我們隻能讀一點寫一點,直到完成拷貝。因此上邊的程序需要改造如下。
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(數據塊)
官方文檔: 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與字符串有一個重要區別。字符串是隻讀的,並且對字符串的任何修改得到的都是一個新字符串,原字符串保持不變。至於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,得首先創建一個新的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); // =>
console.log(dup); // =>
總之,Buffer將JS的數據處理能力從字符串擴展到了任意二進製數據。
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方法來做這件事情,其內部實現方式與上邊的代碼類似。
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很多,這裏不一一介紹,需要時請自行查閱官方文檔。
Path(路徑)
官方文檔: http://nodejs.org/api/path.html
操作文件時難免不與文件路徑打交道。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);
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,目錄遍曆函數實現起來會有些複雜,但原理完全相同。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編碼字符串後才能正常處理。
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。以下示例說明了這種方法。
- GBK編碼源文件內容:
var foo = '中文'; - 對應字節:
76 61 72 20 66 6F 6F 20 3D 20 27 D6 D0 CE C4 27 3B - 使用單字節編碼讀取後得到的內容:
var foo = '{亂碼}{亂碼}{亂碼}{亂碼}'; - 替換內容:
var bar = '{亂碼}{亂碼}{亂碼}{亂碼}'; - 使用單字節編碼保存後對應字節:
76 61 72 20 62 61 72 20 3D 20 27 D6 D0 CE C4 27 3B - 使用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模塊。
-
掌握好目錄遍曆和文件編碼處理技巧,很實用。