让前端觉得如获神器的不是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));
});
}
~~~