跳到正文
跟我做
自媒体自动化12 分钟Node.js

表格乱码怎么解决:3 步分清 UTF-8、GBK 和 BOM

用一个不装依赖的 Node 脚本识别编码和 BOM,把单个文件或整个目录转成 Excel 能正确打开的 UTF-8。默认只预览,不覆盖原文件。

约 12 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 先分清:读错,还是已经丢字
  3. 准备三个样本
  4. 写脚本
  5. 先预览,不写盘
  6. 确认后加 –write
  7. 给程序读:加 –no-bom
  8. 可选读:UTF-8、GBK、BOM 差在哪
  9. 常见坑
  10. 1. 在 Excel 里保存过乱码文件
  11. 2. 输出已存在
  12. 3. 目录只扫一层
  13. 4. 极少数 Node 不认识 GB18030
  14. 下一步读什么

30 秒速查

  • 先备份原表格,别在 Excel 里点保存
  • 看开头 3 个字节:EF BB BF 就是带 BOM 的 UTF-8
  • 预览:node fix-csv.mjs 示例
  • 要 Excel 双击正常:--write(默认输出 UTF-8 带 BOM)
  • 要程序读:--write --no-bom
  1. 先备份原表格

    复制一份再动。已经乱码的文件在 Excel 里点过保存,原始字节可能已经没了。

  2. 预览,看识别结果

    node fix-csv.mjs 示例:只读不写,先把识别到的编码和 BOM 看清楚。

  3. 确认后再 --write

    预览结果对得上再加 --write;要 Excel 双击正常就带 BOM,给程序读加 --no-bom。

图 1:三步走,顺序不能换——先备份,再预览,最后才写盘。

这篇教程解决什么问题

Excel 打开表格,中文全变成问号或怪字;另存为、重装 Office 都不一定有用。这篇用一段 Node 代码判断文件是「读错了」还是「已经丢字」,再把能救的转成 Excel 能正常打开的样子。

不需要懂编码。照着预览、确认、写入三步走,原文件不会被改。

先分清:读错,还是已经丢字

文件本身是字节,编码是解释规则。同一串字节换一种读法,结果就不同。

表格里两列全是乱码,濮撳悕、閲戰��,数字 100 和 200 正常
① 中文全成了怪字,一个也认不出(这是 3-utf8.csv 被按 GBK 打开的真实结果)
  • 读错:锟斤拷、、一片怪字,通常还能救。
  • 丢字:已经变成一串 ?,信息在之前的转换里被替换掉了,只能从原始文件重来。

先备份。在 Excel 里点过保存的乱码文件,原始字节可能已经没了。

上面是正常的姓名金额表格,下面乱码状态下保存后再读,变成婵鏆虫倳、闁叉垿锟斤拷,原文找不回来
① 错误字符被写回文件,出现「锟斤拷」 ② 原文再也找不回来(本机真实二次错转)
同一串字节,换一种解释规则,就变成另一段文字或 �——文件没坏,解释错了。
字节按此解释得到
D0 D5 C3 FBGBK姓名
D0 D5 C3 FBUTF-8����(无效序列)
E5 A7 93 E5 90 8DUTF-8姓名
E5 A7 93 E5 90 8DGB18030濮撳悕(看起来像汉字,但不是原文)
D2 BBGBK一
D2 BBUTF-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 能直接读的格式。

终端运行 node fix-csv.mjs 示例,三行预览分别识别出 GBK 和 UTF-8,带不带 BOM
① 识别出的源编码和 BOM(本机真实输出)

确认后加 –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说「退订」就删掉。详见隐私政策。

同方向继续

全部教程