
导出功能上线后,下载下来的文件叫 订å确认å.pdf,双击能打开,名字没人认得。服务端代码里写的是 订单确认单.pdf,请求日志里的头部也确实是那串中文。名字在中途被重新解释了一次。
为什么中文文件名会变成乱码
头部在网络上是一串字节,怎么读这串字节要有约定。filename 这个参数按 ISO-8859-1 解读,中文的 UTF-8 字节被一个字节对一个字符地读过去,就得到开头那种带重音符的字母。实测把这一步摊开:
== 1. 把中文直接写进 filename=(很多导出接口的做法)==
原始文件名: 订单确认单.pdf
字符数 9,UTF-8 字节数 19
Buffer.from(name, 'utf8') 的字节: 232 174 162 229 141 149 231 161 174 232 174 164 229 141 149 46 112 100 102
头部字面量: Content-Disposition: attachment; filename="订单确认单.pdf"
接收端按 ISO-8859-1 逐字节解码,拿到的是: 订å确认å.pdf
落盘文件名长度从 9 变成 19,扩展名还在,正文不见了
九个字符变成十九个字节,按 ISO-8859-1 读回来就是十九个字符。文件名长度翻了倍,扩展名还留着,所以文件本身还能打开,只有名字废掉了。规范在生成侧也提醒过这一点。
o Avoid using non-ASCII characters in the filename parameter.
Although most existing implementations will decode them as
ISO-8859-1, some will apply heuristics to detect UTF-8, and thus
might fail on certain names.
「most existing implementations will decode them as ISO-8859-1」,这句话解释了为什么有些浏览器看着正常、有些就是乱码:不规范,只能碰运气,各家可能还自己加了猜 UTF-8 的启发式。
filename* 里那两个单引号是什么
非 ASCII 的名字要放进 filename*,取值用 RFC 5987 定义的 ext-value:先标字符集和语言,中间用两个单引号隔开,后面跟百分号编码的名字。
The parameters "filename" and "filename*" differ only in that
"filename*" uses the encoding defined in [RFC5987], allowing the use
of characters not present in the ISO-8859-1 character set
([ISO-8859-1]).
Many user agent implementations predating this specification do not
understand the "filename*" parameter. Therefore, when both
"filename" and "filename*" are present in a single header field
value, recipients SHOULD pick "filename*" and ignore "filename".
This way, senders can avoid special-casing specific user agents by
sending both the more expressive "filename*" parameter, and the
"filename" parameter as fallback for legacy recipients (see Section 5
for an example).
规范例子里给的是欧元符号,换成本机的中文名,编码出来是这样:
== 2. RFC 5987 的 ext-value 写法 ==
filename*=UTF-8''%E8%AE%A2%E5%8D%95%E7%A1%AE%E8%AE%A4%E5%8D%95.pdf
完整头部: attachment; filename="download.pdf"; filename*=UTF-8''%E8%AE%A2%E5%8D%95%E7%A1%AE%E8%AE%A4%E5%8D%95.pdf
按 UTF-8 百分号解码回来: 订单确认单.pdf
UTF-8'' 后面跟的是 %E8%AE%A2...,解码回原样。两个单引号的位置不能省,中间那一段是语言标记,空着也要留两个引号。少写一个引号,整串会被当成文件名本身,% 原样留在名字里,实测的第四组输出就是这么回事。
两个参数都写了,取哪一个
规范的口径很明确:同时出现时取 filename*,忽略 filename。后者留给不支持 RFC 5987 的老客户端做回退。
== 3. 解析:两段都在时取哪一段 ==
写法 取到的段 结果
attachment; filename=download.pdf; filename*=UTF-8''%E8%AE%A2%E5%8D%95%E7%A1… filename* 订单确认单.pdf
attachment; filename*=UTF-8''%E8%AE%A2%E5%8D%95%E7%A1…; filename=download.pdf filename* 订单确认单.pdf
attachment; filename=订单确认单.pdf filename 订单确认单.pdf
attachment; filename*=UTF-8''%E8%AE%A2%E5%8D%95%E7%A1… filename* 订单确认单.pdf
== 3 结束 ==
四种写法都取到了中文名,包括把 filename* 写在后面那种。规范里另有一条注记,说老客户端遇到 filename* 排在 filename 后面会直接忽略它,所以回退参数写在前面更稳妥。本机这次的解析器按规范实现,两种顺序的结果一样,这一点没在真实浏览器上测。
文件名里带分号、括号怎么办
Content-Disposition 的参数用分号分隔,值里出现分号就必须带引号,解析器还得处理转义引号。朴素地按分号切开,字段自己就散了。
== 5. 分号与引号:文件名里本身带分号 ==
文件名: a;b"c.pdf
不加引号: attachment; filename=a;b"c.pdf → 解析出 {"filename":"a","b\"c.pd":"b\"c.pdf"}
加引号: attachment; filename="a;b"c.pdf" → 解析出 {"filename":"\"a","b\"c.pdf":"b\"c.pdf\""}
== 5 结束 ==
百分号编码那边还有一条容易漏的:ext-value 里只有一小撮字符允许原样出现,括号和星号不在里面。encodeURIComponent 默认不转义它们,直接拿来拼 filename* 会得到一个不合规的值,能不能解出来取决于收件方宽容到什么程度。
== 6. 用 filename* 时,括号与星号也必须转义 ==
文件名: 报告(最终版)*.pdf
encodeURIComponent 原样保留的字符: ( ) * . p d f
attr-char 安全编码: %E6%8A%A5%E5%91%8A%28%E6%9C%80%E7%BB%88%E7%89%88%29%2A.pdf
解回来: 报告(最终版)*.pdf
前端拿到链接之后还能补救吗
能做的很少。<a download="名字"> 给的那个名字会被响应头里的 Content-Disposition 覆盖,浏览器在这个字段存在时以头部为准。想在页面上改名,只能自己把响应读成 Blob 再另存,大文件要先整个进内存,代价比改一行响应头大得多。所以这一处只能在服务端解决。
什么时候只写 filename 就够
文件名全是 ASCII 时,一个 filename 参数就够了,引号按需加。要兼容不支持 ext-value 的客户端,就两个参数都给:filename 放一个替换过非 ASCII 字符的回退名,filename* 放完整编码,并且把回退那个写在前面。
反过来,如果服务端框架只暴露了一个 filename 参数,我不建议把中文直接塞进去。要么在导出时就把文件名换成 ASCII 的短名,要么自己拼一个 filename* 加上去。前端能碰到的只有下载链接,解决不了响应头里的编码问题。