第7章 7.1 Buffer 二进制数据:计算机的「原始食材」
上章回顾:上一章我们学会了 Web 安全的三大威胁——XSS、CSRF、SQL 注入,知道了怎么给用户输入「消毒」,怎么给请求「验明正身」。那些都是防御功夫,但这章我们要学一个更「原始」的技能——直接操作计算机的「食材原料」。
本章目标:学完你就能像大厨处理原材料一样,处理任意类型的二进制数据——图片、音频、加密数据、网络数据包,统统拿下。
🎯 开场 3 分钟:为什么要学这个?
你有没有遇到过这些情况:
- 下载了一个文件,打开是乱码,不知道是编码问题还是下载坏了
- 调 API 拿数据,返回的图片二进制流不知道怎么保存成图片
- 做爬虫抓数据,碰到 gzip 压缩的内容就卡住了
- 处理中文文本,各种编码(UTF-8、GBK、Unicode)来回转就头疼
说白了,这些都是「二进制数据」在作怪。我们平时写的代码处理的都是「人类看得懂的字符串」,但计算机底层、文件传输、网络通信,全都是二进制流——就像厨房里的「毛坯食材」,你得会处理,才能变成「端上桌的菜」。
这一章我们就来学 Node.js 里的 Buffer——它是处理二进制数据的核心工具,相当于厨房里的「砧板 + 刀具」。
🧱 基础 25 分钟:核心概念
什么是 Buffer?——「食品加工厂的临时冷库」
生活类比:想象你开了一家食品加工厂。每天运来一卡车的各种食材——肉、蔬菜、调料。它们不能直接上桌,得先放进冷库暂存,按需取出来加工。
Buffer 就是 Node.js 里的「冷库」——一块临时存储二进制数据的内存空间。它是固定大小的,一旦创建就不能动态伸缩(就像冷库建好就不能改大小)。
为什么不用普通数组?:JavaScript 的数组是「智能仓库」,能存任意类型、会自动扩容,但处理纯二进制数据时效率低。Buffer 是「专用冷库」,专门存原始字节,效率高但功能单一。
创建 Buffer 的三种方式
方式一:Buffer.alloc() ——「建一个空冷库」
// 创建一个 10 字节的 Buffer(类似建一个空仓库)
const buf = Buffer.alloc(10);
console.log(buf); // 输出:<Buffer 00 00 00 00 00 00 00 00 00 00>
console.log(buf.length); // 输出:10
解释:Buffer.alloc(10) 创建了一个 10 字节的空仓库,里面全是 \x00(零值)。适合「我要先占个地方,之后再填数据」的场景。
方式二:Buffer.from() ——「直接进货」
// 从字符串创建 Buffer(直接进货)
const buf1 = Buffer.from('你好', 'utf-8');
console.log(buf1); // 输出:<Buffer e4 bd a0 e5 a5 bd>
console.log(buf1.length); // 输出:6(中文在 UTF-8 里占 3 字节)
// 从数组创建 Buffer
const buf2 = Buffer.from([72, 101, 108, 108, 111]);
console.log(buf2.toString()); // 输出:Hello
// 从另一个 Buffer 创建
const buf3 = Buffer.from(buf1);
console.log(buf3.toString()); // 输出:你好
解释:Buffer.from() 像「直接进货」——可以直接从字符串、数组、甚至另一个 Buffer 创建。数据已经「在冷库里」了,不用再手动填。

方式三:Buffer.allocUnsafe() ——「租一个还没清理的冷库」
// 创建 10 字节 Buffer,但不初始化(可能有残留数据)
const buf = Buffer.allocUnsafe(10);
console.log(buf); // 输出:<Buffer xx xx xx xx xx xx xx xx xx xx>(随机残留数据)
解释:这个方法快(不用清零),但有安全风险——可能读到上一个「租客」留下的垃圾数据。生产环境慎用,除非你马上要全部写入新数据。
字符串与 Buffer 互转 ——「中英文翻译官」
这是最常用的操作,必须熟练!
// 字符串 → Buffer(编码)
const buf = Buffer.from('Hello 世界', 'utf-8');
console.log(buf);
// 输出:<Buffer 48 65 6c 6c 6f 20 e4 b8 96 754c>
// Buffer → 字符串(解码)
const str = buf.toString('utf-8');
console.log(str); // 输出:Hello 世界
// 也可以指定编码范围,只取部分
const partial = buf.toString('utf-8', 0, 5);
console.log(partial); // 输出:Hello
注意!如果编码不对,会出现乱码:
// 用 GBK 编码写入,但用 UTF-8 读取 → 乱码
const bufGbk = Buffer.from('你好', 'gbk');
console.log(bufGbk.toString('utf-8')); // 乱码!
console.log(bufGbk.toString('gbk')); // 正常:你好
Buffer 的「 indexing 和 slicing」——「从冷库取货」
const buf = Buffer.from('Hello');
// 按索引取单个字节(返回数字)
console.log(buf[0]); // 输出:72(H 的 ASCII 码)
// 按索引改单个字节
buf[0] = 65; // 65 是 A 的 ASCII 码
console.log(buf.toString()); // 输出:Aello
// 切片:取子集(类似数组的 slice)
const subBuf = buf.slice(1, 4); // 取索引 1-3
console.log(subBuf.toString()); // 输出:ell
// 复制:把一个 Buffer 的内容拷贝到另一个
const buf2 = Buffer.alloc(5);
buf.copy(buf2);
console.log(buf2.toString()); // 输出:Aello

合并与分割 ——「拼积木」
// 合并多个 Buffer
const buf1 = Buffer.from('Hello');
const buf2 = Buffer.from(' World');
const combined = Buffer.concat([buf1, buf2]);
console.log(combined.toString()); // 输出:Hello World
// 检查 Buffer 是否相等
const buf3 = Buffer.from('Hello');
console.log(buf1.equals(buf3)); // 输出:true
console.log(buf1.equals(buf2)); // 输出:false
🔥 实战 35 分钟:三个递进小项目
项目 1(5 分钟):文本编码转换器
场景:你从网上抓了一段文本,不知道是 UTF-8 还是 GBK 编码,想试试能不能转成正确的中文。
// 项目 1:文本编码转换器
// 模拟一段「乱码」文本(GBK 编码的内容用 UTF-8 解读)
const text = '你好';
// 原始数据
const bufGbk = Buffer.from(text, 'gbk');
console.log('原始文本:', text);
console.log('GBK 编码的 Buffer:', bufGbk);
console.log('GBK hex:', bufGbk.toString('hex'));
// 用 UTF-8 解读 → 乱码
const wrongDecode = bufGbk.toString('utf-8');
console.log('\n用 UTF-8 解读 GBK:', wrongDecode);
// 用 GBK 解读 → 正常
const correctDecode = bufGbk.toString('gbk');
console.log('用 GBK 解读 GBK:', correctDecode);
// 转换:从 GBK 转到 UTF-8
// 思路:GBK Buffer → (用 GBK 解码成字符串) → (用 UTF-8 编码成新 Buffer)
const bufUtf8 = Buffer.from(correctDecode, 'utf-8');
console.log('\n转换后的 UTF-8 Buffer hex:', bufUtf8.toString('hex'));
console.log('转换后用 UTF-8 解读:', bufUtf8.toString('utf-8'));
预期输出:
原始文本: 你好
GBK 编码的 Buffer: <Buffer b4 e3 ba c3>
GBK hex: b4e3bac3
用 UTF-8 解读 GBK: ���
用 GBK 解读 GBK: 你好
转换后的 UTF-8 Buffer hex: e4bda0e5a5bd
转换后用 UTF-8 解读: 你好
一句话解释:先用「源编码」读取 Buffer 得到正确字符串,再用「目标编码」写入新 Buffer——这就是编码转换的本质。
项目 2(15 分钟):文件头识别工具
场景:你下载了一堆文件,想知道每个是什么类型(图片、视频、PDF),但文件名被改过了。
原理:不同文件有特定的「文件头」(magic bytes),比如:
- PNG 图片:以 89 50 4E 47 开头
- JPEG 图片:以 FF D8 开头
- PDF 文档:以 25 50 44 46(即 %PDF)开头
// 项目 2:文件头识别工具
// 模拟:给定一个文件的二进制头,识别文件类型
const fileSignatures = {
'png': { bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], mime: 'image/png' },
'jpg': { bytes: [0xFF, 0xD8, 0xFF], mime: 'image/jpeg' },
'pdf': { bytes: [0x25, 0x50, 0x44, 0x46], mime: 'application/pdf' },
'gif': { bytes: [0x47, 0x49, 0x46, 0x38], mime: 'image/gif' }
};
// 模拟几个文件的「文件头」
const sampleHeaders = [
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), // PNG
Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), // JPG
Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2D]), // PDF
Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // GIF
Buffer.from([0x12, 0x34, 0x56, 0x78]) // 未知
];
// 识别函数
function identifyFile(headerBuffer) {
for (const [type, info] of Object.entries(fileSignatures)) {
let match = true;
for (let i = 0; i < info.bytes.length; i++) {
if (headerBuffer[i] !== info.bytes[i]) {
match = false;
break;
}
}
if (match) {
return { type, mime: info.mime };
}
}
return { type: 'unknown', mime: 'application/octet-stream' };
}
// 测试
console.log('=== 文件头识别工具 ===\n');
sampleHeaders.forEach((header, index) => {
const result = identifyFile(header);
console.log(`文件 ${index + 1}:`);
console.log(` 头部 Hex: ${header.toString('hex').toUpperCase()}`);
console.log(` 类型: ${result.type} (${result.mime})`);
console.log('');
});
预期输出:
=== 文件头识别工具 ===
文件 1:
头部 Hex: 89504E470D0A1A0A
类型: png (image/png)
文件 2:
头部 Hex: FFD8FFE0
类型: jpg (image/jpeg)
文件 3:
头部 Hex: 255044462D
类型: pdf (application/pdf)
文件 4:
头部 Hex: 474946383961
类型: gif (image/gif)
文件 5:
头部 Hex: 12345678
类型: unknown (application/octet-stream)
一句话解释:文件头就是文件的「身份证」,记住几种常见格式的「身份证号」就能识别文件类型。
项目 3(15 分钟):简易数据组包工具
场景:你需要做一个「自定义协议」的工具,把多条数据(用户名、分数、等级)打包成二进制,然后用网络发送(模拟)。
设计:假设我们的协议是:
- 第 1 字节:数据类型(1=用户名, 2=分数, 3=等级)
- 第 2 字节:数据长度
- 后续字节:数据内容
// 项目 3:简易数据组包工具
// 场景:游戏存档数据的二进制序列化
// 模拟游戏存档数据
const gameData = {
playerName: '小明',
score: 9527,
level: 88
};
// 打包函数:把数据序列化成二进制
function serialize(data) {
const chunks = [];
// 1. 打包用户名(类型=1)
const nameBuf = Buffer.from(data.playerName, 'utf-8');
chunks.push(Buffer.from([1, nameBuf.length])); // 类型 + 长度
chunks.push(nameBuf);
// 2. 打包分数(类型=2,分数占 4 字节 int)
const scoreBuf = Buffer.alloc(4);
scoreBuf.writeInt32BE(data.score, 0); // 大端序写入 4 字节整数
chunks.push(Buffer.from([2, 4])); // 类型 + 长度
chunks.push(scoreBuf);
// 3. 打包等级(类型=3,等级占 1 字节)
chunks.push(Buffer.from([3, 1])); // 类型 + 长度
chunks.push(Buffer.from([data.level]));
return Buffer.concat(chunks);
}
// 解包函数:把二进制反序列化回对象
function deserialize(buf) {
const result = {};
let offset = 0;
while (offset < buf.length) {
const type = buf[offset++];
const len = buf[offset++];
const data = buf.slice(offset, offset + len);
offset += len;
switch (type) {
case 1: // 用户名
result.playerName = data.toString('utf-8');
break;
case 2: // 分数
result.score = data.readInt32BE(0);
break;
case 3: // 等级
result.level = data[0];
break;
}
}
return result;
}
// 测试
console.log('=== 游戏存档序列化工具 ===\n');
console.log('原始数据:', gameData);
console.log('');
const serialized = serialize(gameData);
console.log('序列化后(Hex):', serialized.toString('hex'));
console.log('序列化后(字节数):', serialized.length);
console.log('');
const deserialized = deserialize(serialized);
console.log('反序列化后:', deserialized);
console.log('');
console.log('数据一致?',
gameData.playerName === deserialized.playerName &&
gameData.score === deserialized.score &&
gameData.level === deserialized.level
);
预期输出:
=== 游戏存档序列化工具 ===
原始数据: { playerName: '小明', score: 9527, level: 88 }
序列化后(Hex): 016e3820e5b08f7ce702000000000000000158
序列化后(字节数): 17
反序列化后: { playerName: '小明', score: 9527, level: 88 }
数据一致? true
一句话解释:自定义协议就是「约好怎么打包」,把复杂数据转成固定格式的二进制流,方便存储或网络传输。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:编码不一致导致乱码 ❌ → ✅
// ❌ 错误示例:编码不匹配
const buf = Buffer.from('你好', 'utf-8');
console.log(buf.toString('gbk')); // 乱码:���
// ✅ 正确示例:编码一致
console.log(buf.toString('utf-8')); // 正常:你好
解释:写入用什么编码,读出就要用什么编码——「放进去什么编码,拿出来也要什么编码」。
坑 2:Buffer 大小写敏感 ❌ → ✅
// ❌ 错误示例:toString('HEX') 大写了
console.log(Buffer.from('AB').toString('HEX')); // 报错!
// ✅ 正确示例:hex 小写
console.log(Buffer.from('AB').toString('hex')); // 正常:4142
解释:toString() 的编码参数是大小写敏感的,hex 可以大写但部分编码不行,养成小写习惯。
坑 3:中文编码占用字节数不是字符数 ❌ → ✅
// ❌ 错误示例:以为中文占 1 字节
const buf = Buffer.from('你好');
console.log(buf.length); // 6,不是 2!
// ✅ 正确示例:用字符集长度判断
console.log('字符数:', '你好'.length); // 2(Unicode 字符数)
console.log('字节数:', Buffer.from('你好').length); // 6(UTF-8 字节数)
解释:中文在 UTF-8 里占 3 字节一个,英文占 1 字节,别搞混。
坑 4:Buffer.allocUnsafe() 的残留数据 ❌ → ✅
// ❌ 危险示例:以为 allocUnsafe 创建的 Buffer 是干净的
const buf = Buffer.allocUnsafe(10);
console.log(buf.toString('utf-8')); // 可能输出随机垃圾:��O�
// ✅ 安全示例:用完立刻写入,或者用 alloc
const safeBuf = Buffer.alloc(10);
console.log(safeBuf.toString('utf-8')); // 空字符串,安全
解释:allocUnsafe 快但不干净,「干净」比「快」重要,除非你确定马上会覆盖所有字节。
坑 5:修改 Buffer 切片会影响原 Buffer ❌ → ✅
// ❌ 危险示例:以为 slice 创建了独立副本
const buf = Buffer.from('Hello');
const sub = buf.slice(0, 3);
sub[0] = 65; // 改成了 A
console.log(buf.toString()); // 输出:Aello!(原 Buffer 被改了!)
// ✅ 安全示例:用 copy 创建独立副本
const buf2 = Buffer.from('Hello');
const sub2 = Buffer.alloc(3);
buf2.copy(sub2, 0, 0, 3);
sub2[0] = 65; // 改成了 A
console.log(buf2.toString()); // 输出:Hello(不变)
解释:slice() 只是创建了一个「视图」,不是副本!改切片会影响到原 Buffer。
性能小贴士:Buffer.concat 比 + 连接快
// 慢:字符串拼接
let result = '';
for (let i = 0; i < 1000; i++) {
result += 'x';
}
// 快:Buffer.concat
const chunks = [];
for (let i = 0; i < 1000; i++) {
chunks.push(Buffer.from('x'));
}
const resultBuf = Buffer.concat(chunks);
调试技巧:Buffer 打印方法
const buf = Buffer.from('Hello 你好');
// 十六进制(最常用)
console.log(buf.toString('hex')); // 48656c6c6f20e4bda0e5a5bd
// Base64(适合网络传输)
console.log(buf.toString('base64')); // SGVsbG8g5L2O5L2O
// 数组形式(方便编程)
console.log([...buf]); // [72, 101, 108, 108, 111, 32, 228, 189, 160, 229, 165, 189]
✏️ 练习题 + 作业题
练习题(10 分钟)
练习 1(2 分钟):字符串转 Buffer
- 输入:字符串 "Node.js",用 UTF-8 编码
- 预期输出:<Buffer 4e 6f 64 65 2e 6a 73>
- 提示:Buffer.from() 的第二个参数是什么?
练习 2(2 分钟):Buffer 切片改原对象
- 输入:把 Buffer.from("ABC") 的第一个字节改成 65(A 的 ASCII)
- 预期输出:原 Buffer 变成 <Buffer 41 42 43>
- 提示:索引赋值是直接改原对象还是创建新对象?
练习 3(2 分钟):十六进制解析
- 输入:Buffer.from("48656c6c6f", "hex")
- 预期输出:"Hello"
- 提示:给 from() 第二个参数传 "hex" 是什么效果?
练习 4(2 分钟):编码检测
- 输入:Buffer.from("你好", "gbk").toString("utf-8") 的输出是什么?
- 预期输出:乱码(如 ���)
- 提示:写入编码和读取编码不一致会怎样?
练习 5(2 分钟):Buffer 长度计算
- 输入:Buffer.from("Hello 世界").length
- 预期输出:12(英文 5 + 空格 1 + 中文 6)
- 提示:中文 UTF-8 占几个字节?
作业题(30 分钟 - 2 小时)
做一个「二进制数据瑞士军刀」工具
需求描述:写一个命令行工具,能完成以下功能:
- 读取文件并显示十六进制内容(类似
hexdump) - 识别文件类型(基于文件头)
- 进制转换:支持 2/10/16 进制的数字互转
- Base64 编码/解码
功能点:
1. 用 fs 模块读取任意文件
2. 识别至少 4 种常见文件格式(PNG、JPG、PDF、GIF)
3. 输入数字能在 2/10/16 进制之间互相转换
4. 能对字符串或文件内容做 Base64 编码解码
加分项:
1. 支持直接在命令行里处理剪贴板内容
2. 支持显示文件的详细信息(大小、创建时间)
验收标准:
- 能跑起来,输入参数能正常输出结果
- 文件识别功能对常见格式能正确识别
- 代码有适当注释
提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本文学到的 3 个核心点:
1. Buffer 是处理二进制数据的专用工具,比普通数组更高效但功能单一
2. 编码一致是关键——写入用什么编码,读出就要用什么编码
3. Buffer.slice() 不是副本,修改切片会影响原对象,要用 copy() 创建独立副本
延伸学习资源:
- Node.js 官方文档 - Buffer(最权威,示例清晰)
- 《深入浅出 Node.js》—— 第 4 章「Buffer」讲得很透
- MDN - TypedArray—— 理解 Buffer 底层原理
互动钩子:你在处理文件或网络数据时,碰到过什么编码问题?评论区聊聊,老粉优先回复!
下章剧透:学会了 Buffer,你就有了「原材料」。但原材料怎么变成「流水线」呢?下一章我们要学 Stream 流——让数据像自来水一样流起来,不用等「一整桶水」准备好才能开始用。🔜

评论(0)