
一个混了二进制字节的文本文件,按 utf8 读,Node 不会抛错。它把解不出来的位置替换成一个替换字符,然后交给调用方。整个过程没有日志、没有异常,只有内容悄悄变了。
先看这份文件的原始字节:正常的中文、两个不可能是 UTF-8 起始的字节、一个被截掉尾巴的三字节序列。
== 1. 写进文件的原始字节 ==
文件大小 29 字节,UTF-8 序列是否合法:false
前 24 字节 hex:e8 ae a2 e5 8d 95 e5 8f b7 ef bc 9a ff fe e4 bd e9 87 91 e9 a2 9d 20 31
29 字节,混合内容。下面三种写法在很多代码库里都能见到,每一种都会丢掉这里的字节。
错法一:直接按 utf8 读,不做校验
const s = fs.readFileSync(p, 'utf8'); // 不抛错
== 2. 反例一:直接按 utf8 读,不抛错 ==
fs.readFileSync(p, "utf8") 没有抛错,字符串长度 16
字符串里 U+FFFD 的个数:3
JSON.stringify 结果:"订单号:���金额 128.00"
读出来的字符串长度 16,里面三个 U+FFFD。三个替换字符对应三处坏字节:两个单独的坏字节,加上那个被截断的三字节序列。JSON.stringify 之后能看见它们,但一份没有结构校验的数据流里,这三处和正常的问号长得一样。
错法二:读出来再写回去
fs.writeFileSync(p2, fs.readFileSync(p, 'utf8'), 'utf8');
== 3. 反例二:把读出来的字符串写回去,字节数变了 ==
原文件 29 字节 → 写回后 34 字节,差 5 字节
原始 hex:e8 ae a2 e5 8d 95 e5 8f b7 ef bc 9a ff fe e4 bd e9 87 91 e9 a2 9d 20 31 32 38 2e 30 30
写回 hex:e8 ae a2 e5 8d 95 e5 8f b7 ef bc 9a ef bf bd ef bf bd ef bf bd e9 87 91 e9 a2 9d 20 31 32 38 2e 30 30
29 字节进去,34 字节出来。原始的两处坏字节 ff fe 变成了 ef bf bd ef bf bd,六字节;被截断的那个序列也被补成了三字节的替换字符。差出来的 5 字节全是替换字符的开销。这类代码常见于「读配置,改一个字段,再写回」的脚本,跑一次就把文件里原本只是可疑的字节变成了确定的坏数据。
错法三:拿字符串长度当校验值
== 4. 反例三:拿字符串长度当校验值 ==
Buffer.byteLength(src, "utf8") = 29
读出来的字符串 .length = 16
两者相等:false
29 与 16,两个数字都对:一个是对的字节数,一个是被替换过的码元数。拿后者去和文件大小比对、或者写进校验字段,得到的就是一个永远对不上的值。用 Buffer.byteLength(s, 'utf8') 算字节,或者干脆留在 Buffer 里比。
正确写法一:解码时加 fatal,坏字节直接抛错
new TextDecoder('utf-8', { fatal: true }).decode(buf);
== 5. 正确写法一:TextDecoder 加 fatal,坏字节直接抛错 ==
抛出 TypeError: The encoded data was not valid for encoding utf-8
合法的 utf8 串用它解码没问题:订单号:金额
坏字节换成一个 TypeError,好数据照常解出。这一条的用处是:坏在哪一行、哪一次请求,能立刻定位。默认参数的 TextDecoder 不带 fatal,行为和不加参数的 readFile 一样,是静默替换。
正确写法二:要碰二进制就留在 Buffer 里
== 6. 正确写法二:要碰二进制就别解码,留在 Buffer 里 ==
用 indexOf 找「金额」的字节位置:16(原串第 16 字节起)
按字节切出尾部:"金额 128.00"
latin1 往返是否无损:true
indexOf 在 Buffer 上找子串,返回的是字节位置,不涉及解码。需要把整块数据原样搬运时,用 latin1 读进来的串每个字节对应一个码元,往返无损,适合只做搬运不做解析的场合。
写代码时的三条分叉
- 内容确定是文本、来源可信:直接 utf8,省事。
- 内容确定是文本、来源可能是外部文件:fatal 解码,坏字节当错误上报,别静默降级。
- 内容可能是二进制、或者要做字节级搬运:全程 Buffer,只在真正需要展示的片段解码。
判断依据是这块数据的来源,不是它的扩展名。.log、.csv 这些后缀的文本文件里,日志轮转时的截断,磁盘写坏,编码混用,都可能留下不完整的字节序列。
没实测的部分说清楚:解出来的替换字符本身没法区分「原始数据里就是一个问号」和「这里解码失败过」,两种来源在文本层面没有标记。想知道是哪种,只能回到字节层面看,或者从生成端补一个校验字段。