// Created At 2026-08-15// P3
// TypeScript · Engineering · Encoding

当 GB18030 解码 UTF-8 时,你看到的可能不是乱码

前段时间给 story-cli(一个 Git 原生的 Markdown 故事管理 CLI)添加文件编码检测功能时,写了一个测试用例,结果发现了一个非常反直觉的现象。

一次失败的测试

story-cli 需要处理 Windows 用户用记事本保存的 GBK/GB2312 编码文件,因此我实现了一个零依赖的编码检测方案:

// 第一步:严格检测是否合法 UTF-8
function isUtf8(buffer: Uint8Array): boolean {
  try {
    new TextDecoder("utf-8", { fatal: true }).decode(buffer)
    return true
  } catch {
    return false
  }
}

// 第二步:非 UTF-8 时,尝试用 GB18030 反检测
function isLikelyGb18030(buffer: Uint8Array): boolean {
  const decoded = new TextDecoder("gb18030").decode(buffer)
  const chineseCount = (decoded.match(/[\u4e00-\u9fa5]/g) || []).length
  return chineseCount > 0
}

📌 大文件可采样前 2KB 判断编码特征,无需扫描全量文本。

整个方案的关键在于:isUtf8 先做确定性检查(合法就是合法,不合法就是不合法),isLikelyGb18030 只对「已确认非法 UTF-8」的文件运行

我写了一个测试来验证这个方案的可靠性:

test("isLikelyGb18030 拒绝合法 UTF-8 中文", () => {
  const buf = Buffer.from("# 第一章\n\n这是正文。", "utf-8")
  // 期望:UTF-8 中文被 GB18030 解码后是乱码,不含汉字
  assert.strictEqual(isLikelyGb18030(buf), false)
})

结果,测试失败了。实际执行:

输入(UTF-8 编码):  "# 第一章\n\n这是正文。"

用 GB18030 解码后:   "# 杩欐槸姝f枃銆�"
                      ^^^^^^^^^^^^^^^^
                      9 个汉字!

isLikelyGb18030 返回: true

(注:末尾的 � 是因为「这是正文。」共 15 个字节、是奇数,GB18030 解码器处理末字节落单时产生了替换字符——恰好侧面印证了编码边界的复杂性。)

我的直觉「UTF-8 中文被 GB18030 解码后应该是乱码」——完全错误

值得反思的是,这里「失败的」其实是测试预期,而非代码逻辑isLikelyGb18030 的职责是「判断缓冲区中是否包含汉字」——对于 UTF-8 编码的正常汉字,它返回 true忠于规格的正确行为。真正的问题在于:这个函数无法区分「真的汉字」和「GB18030 视角下的虚假汉字」,而这一点恰好只有站在 isUtf8 门控外侧才能看清。测试驱动发现的本质,有时不是「代码有 Bug」,而是「你还没想清楚这个函数到底该验证什么」。

为什么?——编码空间的字节碰撞

这不是巧合,而是多字节编码之间的字节空间重叠导致的必然结果。

UTF-8 中文字符的字节结构

"第"  = UTF-8 三字节序列:  E7 AC AC
       ↑ 首字节范围 0xE0-0xEF
       ↑ 后续字节范围 0x80-0xBF

GB18030 的字节结构

GB18030 合法序列至少包含双字节:
  首字节 0x81-0xFE + 尾字节 0x40-0xFE
(实际上 GB18030 是变长编码,还有 1 字节和 4 字节序列——
  但双字节的字节范围已经足以解释我们的碰撞场景。)

碰撞发生

UTF-8 中文字符的起始字节 0xE0-0xEF 恰好落在 GB18030 首字节范围(0x81-0xFE)内,后续字节也在其合法尾字节范围内。当这些字节被 GB18030 重新解释时,映射到了 GB18030 字库中真实存在的汉字

UTF-8 字节:      E7 AC AC
                 ↓ 被 GB18030 重新解释
GB18030 解码:   "杩" ← 一个真实的汉字

当然,GB18030 的双字节映射码位并非连续——0x81-0xFE 首字节和 0x40-0xFE 尾字节的组合中存在间隔。只是「第」的 UTF-8 字节恰好落入了有映射的区间,这才是碰撞发生的充要条件。

这不是概率上的巧合——GB18030 覆盖了 2 万多个汉字,UTF-8 的多字节组合空间同样巨大,两个空间的重叠区域必然产生大量「虚假但合法」的汉字

这个问题的另一面:为什么 \uFFFD 检测不够用

一开始有人建议用 \uFFFD(替换字符)来检测非 UTF-8 文件——UTF-8 解码失败时会产生这个字符。但这个方案同样存在盲区:

GBK 编码的中文字节有可能恰好构成合法的 UTF-8 序列,解码后输出错误的字符但不产生 \uFFFD

GBK 编码的"中文"字节:  D6 D0 CE C4
                      ↓ 被 UTF-8 重新解释(恰好合法)
UTF-8 解码结果:       "Ր΄" ← 亚美尼亚字母,不产生 U+FFFD

结论:无论哪个方向,编码检测都不可能做到 100% 准确。

值得一提的是,\uFFFD 并非只是「检测盲区」——它本身就是编码碰撞的参与者。我做了个小实验:

BOM 字节(EF BB BF)          → GB18030 解码 → "锘"    ← 意外的汉字
U+FFFD 的 UTF-8 编码(EF BF BD) → GB18030 解码 → "锟"   ← 意外的汉字

这恰好揭开了「锟斤拷」这个经典乱码的谜底:GBK 文件被误当 UTF-8 读取时,无法解码的字节变成 U+FFFD;U+FFFD 的 UTF-8 编码(EF BF BD)再被 GBK/GB18030 读取时,就变成了「锟」。 这不是「更乱的乱码」——而是「乱码的乱码」,每一层转换都在字节空间里找到了新的合法映射。单个 U+FFFD 产生「锟」;当连续两个时(EF BF BD EF BF BD),GB18030 会把它解码为「锟斤拷」——这就是老开发者熟悉的那个经典乱码的完整字节链。

方向 可能的结果 \uFFFD 检测 GB18030 反向检测
GBK 字节 → UTF-8 解码 乱码但不产生 \uFFFD ❌ 漏检 ✅ 可识别
UTF-8 字节 → GB18030 解码 产生「虚假汉字」 ❌ 不适用 ⚠️ 可能误报
GBK 字节 → GB18030 解码 正常汉字 ✅ 可识别 ✅ 可识别
纯 ASCII 无乱码 ✅ 无风险 ✅ 无风险

需要注意的是,上表展示的是字节流在任意解码器下的原始方向性风险。而在我们实际的代码中,通过确定性门控(先跑 isUtf8,已经将「UTF-8 → GB18030」这一方向的误报排除在检测流水线之外——只有当文件已确认不是合法 UTF-8 时,才会走到第二道门。因此第二道门可以放心使用启发式方法。

正确的设计:确定性门控 + 启发式确认

最终方案不是「用一个检测搞定一切」,而是分层

export function detectEncodingIssue(
  filePath: string,
  buffer: Uint8Array,
): EncodingIssue | null {
  // 采样前 2KB 判断编码特征(大文件性能优化)
  const sample = buffer.length > 2048 ? buffer.subarray(0, 2048) : buffer

  // 第一道门:确定性检测,100% 准确
  if (isUtf8(sample)) return null

  // 第二道门:启发式检测,只在确定区域工作
  const encoding = isLikelyGb18030(sample) ? "GBK/GB18030" : "unknown"
  return { filePath, encoding }
}

关键设计决策:

  1. 第一道门是确定性的——TextDecoder(fatal: true) 要么成功要么抛错,没有模糊地带
  2. 第二道门只在「已确认非法 UTF-8」时运行——此时 GB18030 解码产生汉字是强信号,因为合法 UTF-8 中文已被排除
  3. 警告不阻断构建——即使极端情况下误判为 GBK,用户看到的也只是「可能」的提示,不会阻塞工作流

可推广的启示

  1. 「检测」不等于「识别」——编码检测永远无法完美。好的设计是让确定性检查做门控,让启发式检查在确定区域内提供附加信息
  2. 测试驱动发现的真实价值——如果我没写那条「期望失败」的断言,永远不会知道 GB18030 反解码 UTF-8 会得到汉字。这个发现直接影响了测试策略(改为记录已知限制而非断言)
  3. 多字节编码的碰撞无处不在——同样的现象在 Shift-JIS、Big5、EUC-KR 之间也存在。任何涉及多语言编码的国际化项目都值得注意

一句话总结: 编码检测没有银弹。用 fatal: true 做硬门控,用启发式方法在门控之后做软提示,别试图让一个函数解决所有问题。


附录:复现实验

你可以亲手跑两个方向的「反直觉」案例:

# 方向一:UTF-8 "第" → GB18030 解码 → "杩"(一个真实汉字)
node -e "console.log(new TextDecoder('gb18030').decode(Buffer.from('第', 'utf-8')))"

# 方向二:GBK "中文" → UTF-8 解码 → 亚美尼亚字母(无 U+FFFD)
node -e "console.log(new TextDecoder('utf-8').decode(Buffer.from('中文', 'gbk')))"

输出分别是 Ր΄——两行代码,就能看到编码碰撞的双向迷局。

如果这篇文章对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay
© 2026 MOONGATE