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 描述,更新的版本没有回归验证。