案例 Excel 双击 UTF-8 CSV 全是「涓枃」:BOM 写在哪一步,写了为什么还是乱码

2026-10-18 00:04:03

Excel 双击 UTF-8 CSV 全是「涓枃」:BOM 写在哪一步,写了为什么还是乱码

后端用 UTF-8 导出的 CSV,用 Sublime、VS Code 或程序读取都正常,但用 Excel 双击打开后,中文会变成「涓枃」「中文」这类乱码。

这不是文件内容坏了。Excel 双击打开时不根据文件内容判断编码,而是按系统 ANSI 代码页解码;简体中文 Windows 上是 GBK/936。「涓枃」正是「中文」的 UTF-8 字节被按 GBK 解码后的结果。要让 Excel 认 UTF-8,唯一可靠的信号是文件开头三个字节的 BOM:EF BB BF。

先证明字节是对的

不要急着改代码,先看文件头:

xxd export.csv | head -n 1
# 00000000: e4b8 ade6 9687 ...   ← 没有 BOM,但字节是正确的 UTF-8

再看 HTTP 层:

curl -sI https://example.com/export.csv     # 看 Content-Type、Content-Encoding、Content-Disposition
curl -s  https://example.com/export.csv -o out.csv
xxd out.csv | head -n 1                     # 落盘后再验一遍字节

这两步能把「文件本身错」和「Excel 解码方式错」区分开。

各语言写 BOM 的位置

PHP,写入 php://output 之前:

header('Content-Type: text/csv; charset=UTF-8');
$out = fopen('php://output', 'w');
fputs($out, "\xEF\xBB\xBF");
fputcsv($out, $row);

Python,用 utf-8-sig,注意 newline='' 否则 Windows 上会多出空行:

open(path, 'w', encoding='utf-8-sig', newline='')

Django 返回 bytes:

HttpResponse(output.getvalue().encode('utf-8-sig'), content_type='text/csv; charset=utf-8')

Node,fast-csv 从 v3.4.0 起有 writeBOM: true(对应 issue #180);手写的话:

res.write(Buffer.from('EFBBBF', 'hex'));

Go:

w.Write([]byte{0xEF, 0xBB, 0xBF})
csv.NewWriter(w).Write(row)

加了 BOM 还是乱码,或者第一列多出字符

以下四种情况我都遇到过,前三种是 BOM 没真正落进文件,第四种根本不是写文件的问题。

1. 文本流编码把 BOM 变成了三个可见字符

在按 GBK 编码的输出流里写 '\ufeff',它不会被转成 EF BB BF,而是按 GBK 编成  三个字符。文件开头就多了「」,Excel 依旧乱码。BOM 必须在字节层面写入,或者确保输出流的编码是 UTF-8。验证方式:xxd file.csv | head -n 1,如果开头不是 efbb bf 而是 c3af c2bb c2bf 之类,就是被重编码了。

2. BOM 写在框架 flush 之后

某些框架或中间件在进入 handler 之前就已经写出响应头甚至 body,此时再 fputs BOM,它落在文件中间,而不是开头。表现是前几行正常、后面某处开始乱,或者整段乱码。验证:下载文件后查看总长度和开头几行,确认 BOM 是否真的在第 0 字节。

3. 中间层把 BOM 吃掉或重编码

gzip 压缩本身没有问题,但不正确的解压再压、网关或 CDN 的内容转换、对象存储的 metadata 处理都可能改动开头字节。前端链路更常见:

const text = await res.text();              // 按 UTF-8 解码,BOM 变成 U+FEFF 字符
new Blob([text], {type: 'text/csv'});       // 再编码时 BOM 就没了

要透传就改用 res.arrayBuffer() 直接构造 Blob,不要让中间的 .text() 解码介入。验证:curl -sI 看 Content-Encoding,curl -s --compressed -o out.csv 和不带 --compressed 各存一份,比对前三个字节。

4. 用「数据 → 自文本」导入时没选 UTF-8

Excel 的手动导入向导第二步要显式选文件原始编码 65001(UTF-8)。不选就还是按 ANSI 走,BOM 也帮不上忙。这条和双击打开是两条不同的代码路径。

中文文件名乱码是另一个问题

文件内容全对、中文还是乱码,多半出在 Content-Disposition。HTTP 响应头不能直接放中文,多数容器和框架按 latin-1 编码 header,会抛错或者截断成问号。

按 RFC 6266 用 filename*=UTF-8'' 加 percent-encoding,同时保留 ASCII 的 filename 兜底:

Content-Disposition: attachment; filename="export.csv"; filename*=UTF-8''%E4%B8%AD%E6%96%87.csv

各语言的拼法:

$cd = 'attachment; filename="export.csv"; filename*=UTF-8\'\'' . rawurlencode('中文.csv');
from urllib.parse import quote
cd = "attachment; filename=\"export.csv\"; filename*=UTF-8''" + quote('中文.csv')
cd := `attachment; filename="export.csv"; filename*=UTF-8''` + url.PathEscape("中文.csv")

注意这是 header 的编码问题,和文件内容编码互不相干。两个都要处理,只改一个另一个仍然是乱的。

什么时候干脆别导 CSV

CSV 是没有类型信息的纯文本。日期格式、前导零(00123 被 Excel 变成 123)、超长数字(身份证、订单号变成科学计数法)、单元格换行,每一项都要额外打补丁。如果导出目标是给人看的,直接用 xlsx 更省事:openpyxl、PhpSpreadsheet、xlsxwriter 都没有 BOM 的概念,单元格类型和格式都可控。

代价也要算清楚:xlsx 本质是 zip 包,流式导出时内存和速度都不如直接写 CSV,尤其几十万行以上需要走 write-only 模式;依赖体积也比拼字符串大。数据量大且下游是程序时,CSV 仍然是更合适的选择。

反过来说,给程序读的 CSV 不要加 BOM。第一个字段名会带上 \ufeff,于是 headers[0] == '\ufeffid',字符串比较、字典取值全部对不上。同一个导出接口如果同时服务于人和机器,最好分开两个端点,或者加一个 ?bom=0 之类的开关。读取端用 encoding='utf-8-sig' 可以兜底,但那是补救,不是设计。

未实测的部分

Excel for Mac 各版本、LibreOffice Calc、WPS 对 BOM 和「自文本导入」的处理并不完全一致,本文只覆盖简体中文 Windows 上 Excel 双击打开这条路径,其他环境没有逐一实测。fast-csv 的 writeBOM 行为依据其 v3.4.0 的 issue #180 描述,更新的版本没有回归验证。

复制全文 生成海报 乱码 CSV导出 字符集 UTF-8 BOM Excel PHP Python Go

推荐文章

程序员茄子在线接单