表格乱码怎么解决:3 步分清 UTF-8、GBK 和 BOM
用一个不装依赖的 Node 脚本识别编码和 BOM,把单个文件或整个目录转成 Excel 能正确打开的 UTF-8。默认只预览,不覆盖原文件。
本页目录
30 秒速查
- 先备份原表格,别在 Excel 里点保存
- 看开头 3 个字节:
EF BB BF就是带 BOM 的 UTF-8- 预览:
node fix-csv.mjs 示例- 要 Excel 双击正常:
--write(默认输出 UTF-8 带 BOM)- 要程序读:
--write --no-bom
先备份原表格
复制一份再动。已经乱码的文件在 Excel 里点过保存,原始字节可能已经没了。
预览,看识别结果
node fix-csv.mjs 示例:只读不写,先把识别到的编码和 BOM 看清楚。
确认后再 --write
预览结果对得上再加 --write;要 Excel 双击正常就带 BOM,给程序读加 --no-bom。
这篇教程解决什么问题
Excel 打开表格,中文全变成问号或怪字;另存为、重装 Office 都不一定有用。这篇用一段 Node 代码判断文件是「读错了」还是「已经丢字」,再把能救的转成 Excel 能正常打开的样子。
不需要懂编码。照着预览、确认、写入三步走,原文件不会被改。
先分清:读错,还是已经丢字
文件本身是字节,编码是解释规则。同一串字节换一种读法,结果就不同。

- 读错:
锟斤拷、、一片怪字,通常还能救。 - 丢字:已经变成一串
?,信息在之前的转换里被替换掉了,只能从原始文件重来。
先备份。在 Excel 里点过保存的乱码文件,原始字节可能已经没了。

| 字节 | 按此解释 | 得到 |
|---|---|---|
| D0 D5 C3 FB | GBK | 姓名 |
| D0 D5 C3 FB | UTF-8 | ����(无效序列) |
| E5 A7 93 E5 90 8D | UTF-8 | 姓名 |
| E5 A7 93 E5 90 8D | GB18030 | 濮撳悕(看起来像汉字,但不是原文) |
| D2 BB | GBK | 一 |
| D2 BB | UTF-8 | һ(两个字节被当成一个字母) |
准备三个样本
在 tidy-box 里(上一篇建的那个练习文件夹;没建过就随便建一个,或者直接在放表格的地方建 示例/)新建 示例/。Windows 用 Excel 另存为三种格式:
- 「CSV(逗号分隔)」→ 通常是 GBK
- 「CSV UTF-8(逗号分隔)」→ UTF-8 带 BOM
- VS Code 另存为 UTF-8 → UTF-8 无 BOM
Excel 的「另存为」里选「CSV UTF-8」,不要选「CSV」。
macOS / Linux 直接生成:
mkdir -p 示例
printf '\xd0\xd5\xc3\xfb\x2c\xb3\xc7\xca\xd0\x0a\xd5\xc5\xc8\xfd\x2c\xb1\xb1\xbe\xa9\x0a' > 示例/1-gbk.csv
printf '\xef\xbb\xbf姓名,城市\n张三,北京\n' > 示例/2-utf8-bom.csv
printf '姓名,城市\n张三,北京\n' > 示例/3-utf8.csv
用 Excel 分别双击,应该能看到「一份正常、两份乱」或相反——取决于系统和 Excel 版本。
写脚本
新建 fix-csv.mjs,整段复制进去:
import { access, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
const UTF16LE_BOM = Buffer.from([0xff, 0xfe]);
const UTF16BE_BOM = Buffer.from([0xfe, 0xff]);
const LABELS = {
"utf-8": "UTF-8",
"utf-16le": "UTF-16 LE",
"utf-16be": "UTF-16 BE",
gb18030: "GBK/GB18030",
};
function startsWith(buffer, prefix) {
return prefix.every((byte, index) => buffer[index] === byte);
}
function canDecode(buffer, encoding) {
try {
new TextDecoder(encoding, { fatal: true }).decode(buffer);
return true;
} catch {
return false;
}
}
export function detectEncoding(buffer) {
if (startsWith(buffer, UTF8_BOM)) return { encoding: "utf-8", bom: true };
if (startsWith(buffer, UTF16LE_BOM)) return { encoding: "utf-16le", bom: true };
if (startsWith(buffer, UTF16BE_BOM)) return { encoding: "utf-16be", bom: true };
// 二进制文件通常含 NUL;UTF-16 已经在上面按 BOM 处理。
if (buffer.includes(0)) return { encoding: null, bom: false };
if (canDecode(buffer, "utf-8")) return { encoding: "utf-8", bom: false };
if (canDecode(buffer, "gb18030")) return { encoding: "gb18030", bom: false };
return { encoding: null, bom: false };
}
export function decodeText(buffer, { encoding }) {
const text = new TextDecoder(encoding, { fatal: false }).decode(buffer);
return text.replace(/^\uFEFF/, "");
}
export function encodeUtf8(text, { bom = true } = {}) {
const body = Buffer.from(text, "utf8");
return bom ? Buffer.concat([UTF8_BOM, body]) : body;
}
async function exists(target) {
try {
await access(target);
return true;
} catch {
return false;
}
}
function outputName(file) {
const extension = path.extname(file);
return `${file.slice(0, -extension.length)}.utf8${extension}`;
}
export async function convertFile(file, { write = false, bom = true } = {}) {
const buffer = await readFile(file);
const detected = detectEncoding(buffer);
if (!detected.encoding) {
return { file, status: "skipped", reason: "无法识别编码", detected };
}
if (detected.encoding === "utf-8" && detected.bom === bom) {
return {
file,
status: "skipped",
reason: `已经是 UTF-8${bom ? "(带 BOM)" : "(无 BOM)"}`,
detected,
};
}
const target = outputName(file);
if (await exists(target)) {
return {
file,
status: "skipped",
reason: `输出已存在:${path.basename(target)}`,
detected,
};
}
const text = decodeText(buffer, detected);
if (write) {
await writeFile(target, encodeUtf8(text, { bom }));
}
return { file, status: "converted", target, detected, preview: !write };
}
export async function collectFiles(target) {
const info = await stat(target);
if (!info.isDirectory()) return [target];
const entries = await readdir(target, { withFileTypes: true });
return entries
.filter(
(entry) =>
entry.isFile() &&
entry.name.toLowerCase().endsWith(".csv") &&
!/\.utf8(-\d+)?\.csv$/i.test(entry.name),
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => path.join(target, entry.name));
}
export async function convertAll(target, options = {}) {
const files = await collectFiles(target);
const results = [];
for (const file of files) {
results.push(await convertFile(file, options));
}
return results;
}
function printResults(results, { write, directory, bom }) {
if (results.length === 0) {
console.log("没有找到 CSV 文件。");
return;
}
let converted = 0;
let skipped = 0;
for (const result of results) {
const from = path.basename(result.file);
if (result.status === "skipped") {
skipped += 1;
console.log(`[跳过] ${from}:${result.reason}`);
continue;
}
converted += 1;
const to = path.basename(result.target);
const name = result.detected.encoding ? LABELS[result.detected.encoding] : "未知";
const sourceBom = result.detected.bom ? "带 BOM" : "无 BOM";
const targetBom = bom ? "带 BOM" : "无 BOM";
const prefix = write ? "已转换" : "预览";
console.log(`[${prefix}] ${from} → ${to}(${name},${sourceBom} → UTF-8,${targetBom})`);
}
console.log(`\n共 ${results.length} 个文件:转换 ${converted} 个,跳过 ${skipped} 个。`);
if (write) {
console.log("原文件没有改动;输出文件名固定为 .utf8.csv,已存在时跳过。");
} else {
console.log("这是预览,没有写入任何文件。确认后加 --write 再跑一次。");
if (directory) console.log("目录模式只处理当前层的 .csv,不递归子目录。");
}
}
const isDirectRun =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isDirectRun) {
const args = process.argv.slice(2);
const write = args.includes("--write");
const bom = !args.includes("--no-bom");
const target = args.find((arg) => !arg.startsWith("--"));
if (!target) {
console.error("用法:node fix-csv.mjs <文件或目录> [--write] [--no-bom]");
process.exitCode = 1;
} else {
try {
const info = await stat(target);
const results = await convertAll(target, { write, bom });
printResults(results, { write, directory: info.isDirectory(), bom });
} catch (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
console.error(`打不开路径:${target}`);
console.error("确认文件或目录是否存在,路径里有空格时用引号包住。");
process.exitCode = 1;
} else {
throw error;
}
}
}
}
读代码时只要抓住四件事:
- 先看 BOM。
EF BB BF直接判定 UTF-8;FF FE/FE FF是 UTF-16。BOM 优先级最高,因为它是文件自己声明的。 - 再试解码。 用
fatal: true严格试 UTF-8,能过就是 UTF-8;过不了再试 GB18030。两个都过不了,说明不是正常文本,跳过而不是硬猜。 - 转换前先算出输出名。 固定加
.utf8后缀,永远不写原文件;输出已存在就跳过,避免越转越多。 - 写入时自己拼 BOM。
Buffer.concat([BOM, 正文]),不依赖第三方库。
先预览,不写盘
node fix-csv.mjs 示例
输出:
[预览] 1-gbk.csv → 1-gbk.utf8.csv(GBK/GB18030,无 BOM → UTF-8,带 BOM)
[跳过] 2-utf8-bom.csv:已经是 UTF-8(带 BOM)
[预览] 3-utf8.csv → 3-utf8.utf8.csv(UTF-8,无 BOM → UTF-8,带 BOM)
共 3 个文件:转换 2 个,跳过 1 个。
这是预览,没有写入任何文件。确认后加 --write 再跑一次。
目录模式只处理当前层的 .csv,不递归子目录。
这一步已经把关键信息给全了:源编码、有没有 BOM、目标文件叫什么。2-utf8-bom.csv 被跳过,因为它已经是 Excel 能直接读的格式。

确认后加 –write
node fix-csv.mjs 示例 --write
输出:
[已转换] 1-gbk.csv → 1-gbk.utf8.csv(GBK/GB18030,无 BOM → UTF-8,带 BOM)
[跳过] 2-utf8-bom.csv:已经是 UTF-8(带 BOM)
[已转换] 3-utf8.csv → 3-utf8.utf8.csv(UTF-8,无 BOM → UTF-8,带 BOM)
共 3 个文件:转换 2 个,跳过 1 个。
原文件没有改动;输出文件名固定为 .utf8.csv,已存在时跳过。
现在用 Excel 打开 1-gbk.utf8.csv,中文应该正常。原文件 1-gbk.csv 还在,字节一个没动。

再跑一次同样的命令,会全部跳过:
[跳过] 1-gbk.csv:输出已存在:1-gbk.utf8.csv
[跳过] 2-utf8-bom.csv:已经是 UTF-8(带 BOM)
[跳过] 3-utf8.csv:输出已存在:3-utf8.utf8.csv
共 3 个文件:转换 0 个,跳过 3 个。
原文件没有改动;输出文件名固定为 .utf8.csv,已存在时跳过。
要重新转换,先删掉旧的 .utf8.csv。这是故意的:宁可让你多一步,也不悄悄覆盖已经确认过的结果。
给程序读:加 –no-bom
如果这份 CSV 接下来要进 Node、Python 或数据库,BOM 反而会添乱。加 --no-bom:
node fix-csv.mjs 示例/2-utf8-bom.csv --write --no-bom
[已转换] 2-utf8-bom.csv → 2-utf8-bom.utf8.csv(UTF-8,带 BOM → UTF-8,无 BOM)
共 1 个文件:转换 1 个,跳过 0 个。
原文件没有改动;输出文件名固定为 .utf8.csv,已存在时跳过。
这时输出的文件开头没有 EF BB BF,第一列就是干净的列名。
可选读:UTF-8、GBK、BOM 差在哪
不影响操作,可以跳过。
| 名字 | 中文占几字节 | 常见来源 | 对 Excel 的影响 |
|---|---|---|---|
| UTF-8 | 3 | 网页、脚本、VS Code | 没有 BOM 时,Windows Excel 常按 GBK 猜 |
| GBK | 2 | 老版 Excel「CSV(逗号分隔)」 | 同语言环境双击正常 |
| GB18030 | 1/2/4 | 国标、部分系统导出 | GBK 的超集,脚本用它解码 |
| BOM | 3 字节标记 | Excel「CSV UTF-8」 | Excel 看到它才按 UTF-8 读;程序读会多出 \uFEFF |
一句话:给 Excel 双击,用 UTF-8 带 BOM;给程序读,用 UTF-8 无 BOM。
常见坑
1. 在 Excel 里保存过乱码文件
如果已经按错误编码显示了乱码,又点了保存,错误字符就写回文件了。只能从原始导出重来。看到乱码第一反应是备份,不是「另存为」。
2. 输出已存在
脚本不会覆盖,直接跳过并提示 输出已存在。删掉旧的 .utf8.csv 再跑。
3. 目录只扫一层
node fix-csv.mjs 示例 只处理 示例/ 下面的表格,不进子文件夹。子目录要单独跑。
4. 极少数 Node 不认识 GB18030
精简版 Node 可能不支持 gb18030,报 RangeError。换 Node.js 官网 的安装包,官方包带完整 ICU。
下一步读什么
编码理顺之后,这些 CSV 就可以放心进每周数据复盘了:那篇会读三个平台导出的表格,统一字段、算互动率、出周报。如果字段名或日期格式也对不上,先在那篇的「用配置消化字段差异」一节里改配置。如果要先把多份表格拼成一张,再看两个表格合并。
这篇是「从 0 开始」路线的第 2 步,完整顺序见从 0 开始:第一台自动化工作台。
常见问题
打开表格全是问号和怪字,是文件坏了吗?
多数不是。先看它是「读错了」还是「已经丢字」:还能看到锟斤拷、、一片怪字,通常能救;已经变成一串问号,说明之前的转换把字替换掉了,只能从原始文件重来。先备份,再预览。
为什么不用 Excel 的「CSV UTF-8」另存为?
单个文件可以,但它要求你先在 Excel 里正确打开。如果打开时编码已经错了,再另存为只会把错误字符写回去。脚本直接读原始字节,不经过 Excel,也不会覆盖原文件。
转成 UTF-8 后,第一列列名前面多了个看不见的字符?
那是 BOM,三个字节 EF BB BF。Excel 双击打开时需要它才按 UTF-8 读,但程序读文件时它可能留在第一列列名里。给程序用时加 --no-bom,或者在代码里用 text.replace(/^/, "") 去掉。
GBK 和 GB18030 是什么关系?
GB18030 是 GBK 的超集,GBK 能表示的汉字它基本都能表示,还多了四字节的扩展区。脚本用 gb18030 解码,所以常见的 GBK 文件也能读。
脚本识别失败怎么办?
先确认它是不是文本表格。二进制文件、加密文件、以及同一个文件里混了两种编码,都会识别失败。脚本会直接跳过,不会硬猜;混合编码需要先按来源拆开,再分别转。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。