第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 创建。数据已经「在冷库里」了,不用再手动填。

配图1 - 配图1

方式三: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

配图2 - 配图2

合并与分割 ——「拼积木」

// 合并多个 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 小时)

做一个「二进制数据瑞士军刀」工具

需求描述:写一个命令行工具,能完成以下功能:

  1. 读取文件并显示十六进制内容(类似 hexdump
  2. 识别文件类型(基于文件头)
  3. 进制转换:支持 2/10/16 进制的数字互转
  4. 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 流——让数据像自来水一样流起来,不用等「一整桶水」准备好才能开始用。🔜

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。