上周又碰到一次:某内部报表系统用iframe嵌了一个子页面,src里带了一段中文名称,本地跑得好好的,一上线就白屏,控制台里 iframe 那边直接是一个 404 页面。这就是典型的iframe url 包含中文 导致页面 404 问题——不是服务挂了,也不是权限没了,而是那个中文在一层层传递中被“翻译”错了,服务端最终拿到的路径和它实际拥有的路径对不上。这类问题在嵌入老系统、嵌入带中文标题的文档预览、嵌入按名称寻址的报表页时特别高频,而且症状很迷惑:同一个链接贴到浏览器地址栏里能打开,放进 iframe 就 404;用 Postman 请求也正常,前端一拼就废。
这篇东西面向的是要真正动手解决问题的人:前端要知道src该怎么拼,后端要知道容器在用什么字符集解码,运维要知道网关那一层有没有把编码搞乱。我会把链路上每一处可能出问题的环节拆开讲,给出可以直接抄的配置和命令,也把我自己踩过的几个坑写清楚。看完你至少能做到两件事:十五分钟内定位到是哪一个环节把中文弄坏了,以及在设计阶段就避开“把中文塞进 URL 路径”这个根本性错误。
1. 从一次白屏开始:404 到底是谁返回的
1.1 三种看起来一样、实际完全不同的 404
很多人一看到 iframe 里是 404,第一反应是“服务端找不到这个页面”,然后跑去翻后端代码。但中文 URL 引发的 404,来源至少有三种,处理方式完全不同。
第一种是“真 404”:请求确实到了应用,路径也确实被正确解码了,但服务器上就是没有这个资源。这种情况往往是中文名称本身对不上——比如数据库里存的是全角括号,URL 里传的是半角;或者文件叫“季度报告(终稿).pdf”,前端传的是“季度报告(终稿).pdf”。这类问题跟编码无关,纯粹是字符不一致,但只要涉及中文就特别容易发生,因为中文里有大量形近字、全半角、繁简差异。
第二种是“假 404”,也就是编码问题,这是最常见的一类。请求在链路上某一层被用错误的字符集解码,%E4%B8%AD被解成了ä¸这种乱码,服务端拿着乱码去匹配路由或找文件,自然找不到。它的特征是:把 Network 面板里的 Request URL 原样复制到 curl 里请求,如果返回 200,说明服务端没问题,是前端生成的 URL 和你想的不是同一个;如果 curl 也 404,那就是服务端或网关的解码环节有毛病。
第三种是“框架返回的 404”:请求根本没到业务代码。比如 Spring MVC 里没有匹配的 handler mapping,或者网关的try_files落到了一个不存在的文件上,又或者反向代理把路径重写坏了。这种情况下后端日志里什么都看不到,很容易误判成“请求没发出去”。
注意:还有两种容易被误认成 404 的情况要排除掉。一是
X-Frame-Options或 CSP 的frame-ancestors拒绝嵌入,这时 iframe 是空白的,控制台会有Refused to display ... in a frame的报错,不是 404。二是请求行太长被网关拒绝,返回的是 414 而不是 404,但有些网关会统一包装成 404 页面,这时候你看响应体是网关自带的错误页,不是应用的 404 页,这个细节能帮你快速分层。
区分这三种的最快办法,是看响应的“长相”:应用自己的 404 页面一般有业务系统的样式和 logo,网关的 404 通常是极简的 nginx 默认页,而框架层的 404 往往是 JSON。别小看这一点,我在排查时第一眼就是看这个,能省掉一半时间。
1.2 把链路摊开:一个中文字符要过五道关
要定位问题,得先知道一个中文从代码里到服务端,中间经历了什么。我把这条链路拆成五道关:
第一关是源码里的字符串。你在 JS 里写的"/report/季度报表",在内存里是 Unicode 码点序列,还没有变成字节。这一步不会出错,但要注意源文件的编码——如果 HTML 是 GBK 存的却声明成 UTF-8,浏览器解析出来的字符串在一开始就是错的。
第二关是浏览器序列化 URL。当你执行iframe.src = "/report/季度报表",浏览器会按 URL 规范把它转成百分号编码,现代浏览器统一用 UTF-8,所以线上发出的是/report/%E5%AD%A3%E5%BA%A6%E6%8A%A5%E8%A1%A8。如果你的代码里已经手动encodeURIComponent过一次,浏览器会发现%不是需要编码的字符,于是原样保留,这就埋下了双重编码的雷。
第三关是网关或 CDN。nginx 收到请求后,内部有两个形态:$request_uri是原始未解码的,$uri是解码并规范化过的。做转发、重写、匹配时用哪一个,结果差别巨大。
第四关是应用容器的解码。Tomcat、Jetty、Undertow 各自有 URI 解码字符集的配置,老版本 Tomcat 默认是 ISO-8859-1,新版本是 UTF-8。这一关卡住的情况在存量的老系统里非常普遍。
第五关是框架和业务代码的二次处理。Spring MVC 的@PathVariable拿到的是已解码的值,但如果路径里有.,还可能被后缀匹配截断;有的团队在 Controller 里又调用了一次URLDecoder.decode,直接导致二次解码。
把这五道关列成表,排查的时候从上往下打勾,基本不会漏:
| 环节 | 典型故障 | 现象特征 |
|---|---|---|
| 源码字符集 | HTML 声明与实际保存编码不符 | 页面上的中文本身就是乱码 |
| 前端拼 URL | 手动 encode 后又进 encodeURI | 请求里出现%25E5 |
| 网关转发 | 用了$uri而非$request_uri | 上游日志里是乱码字节 |
| 容器解码 | URIEncoding为 ISO-8859-1 | 后端日志里是ä¸ |
| 框架处理 | @PathVariable后缀截断 | 拿到的值少了.html |
2. 编码原理:中文在 URL 里到底变成了什么
2.1 百分号编码与 UTF-8、GBK 的分岔口
URL 规范里只允许 ASCII 的一个子集直接出现,其余字符一律要转成%XX的形式,XX 是该字符在某个字符集下的字节的十六进制。所以“中文变成什么”这件事,取决于两个变量:字符集是什么,以及字节怎么拆。
同一个“中”字,在 UTF-8 下是三个字节E4 B8 AD,编码后是%E4%B8%AD;在 GBK 下是两个字节D6 D0,编码后是%D6%D0。两者长度不一样、内容也不一样,一旦前端用 UTF-8 编码、后端用 GBK 解码,结果必然是乱码。
| 字符 | UTF-8 字节 | 百分号编码 | GBK 字节 | 百分号编码 |
|---|---|---|---|---|
| 中 | E4 B8 AD | %E4%B8%AD | D6 D0 | %D6%D0 |
| 文 | E6 96 87 | %E6%96%87 | CE C4 | %CEC4 |
| 报 | E6 8A A5 | %E6%8A%A5 | B1 A8 | %B1%A8 |
这张表建议你存下来,排查的时候扫一眼请求里的%序列就能判断用的是哪个字符集:三个百分号一组的是 UTF-8,两个一组的基本是 GBK。这个判断非常快,比翻配置快多了。
顺带算一笔长度账,这直接关系到你会不会撞上 414。一个中文字符在 UTF-8 百分号编码下占 9 个 ASCII 字符,也就是说 100 个汉字的标题会膨胀成 900 个字符。nginx 默认的请求行缓冲是 8KB,一个large_client_header_buffers是 4 个 8KB,看起来够用,但如果你把整段中文摘要、中文文件名、中文查询条件全塞进 URL,再叠加上嵌套 iframe 的多层 URL,超限是迟早的事。我见过一个把整篇文章标题加作者加时间全拼进路径的页面,标题超过 200 字就直接 414,前端还以为是 404。
2.2 encodeURI、encodeURIComponent、escape 三个函数怎么选
这是前端最容易出错的地方,也是我在 code review 里最常纠的点。三个函数的行为差异集中在“哪些字符被保留”上:
| 输入 | encodeURI | encodeURIComponent | escape |
|---|---|---|---|
| 中文 | %E4%B8%AD%E6%96%87 | %E4%B8%AD%E6%96%87 | %u4E2D%u6587 |
| a b | a%20b | a%20b | a%20b |
| a/b?c=1&d=2 | a/b?c=1&d=2 | a%2Fb%3Fc%3D1%26d%3D2 | a/b%3Fc%3D1%26d%3D2 |
| a+b | a+b | a%2Bb | a+b |
规律很清楚:encodeURI保留 URL 的结构字符(/ ? : @ & = + $ , #),适合编码“一整条 URL”;encodeURIComponent把这些结构字符也编码掉,适合编码“URL 里的一个参数值”。escape是上古产物,它对中文产生的是%uXXXX形式,这根本不是合法的 URL 百分号编码,服务端解不出来,任何情况下都不该再用。
判断标准只有一句话:你手里这个东西是“一整条 URL”还是“URL 的一部分”。是整条就用encodeURI,是某个参数值就用encodeURIComponent。实际项目里更安全的做法是压根不手动编码整条 URL,而是在拼接参数时对每个值做encodeURIComponent,然后让浏览器去处理剩下的部分——因为浏览器在赋值src、href时本来就会做一次规范化编码,你多做的每一次都会变成双编码的来源。
2.3 双编码是怎么产生的:%25E5 这个信号
%25是百分号本身编码后的样子。当你在请求里看到%25E5%25AD%25A3,说明这个 URL 被编码了两次:第一次“季”变成%E5%AD%A3,第二次%又变成%25,于是%E5%AD%A3变成%25E5%25AD%25A3。服务端只解码一次,拿到的是字符串%E5%AD%A3,而不是“季”,再去匹配资源,100% 是 404。
双编码的来源我总结了三类。第一类是前端自己编了两次:工具函数里encodeURIComponent了一遍,调用方又包了一层encodeURI。第二类是参数透传时每层都编一次:A 页面把 URL 放在 query 里传给 B 页面,B 页面解码后不做处理又塞进了 iframe 的src,浏览器再规范化一次。第三类是网关或 SDK 做了“保护性编码”:某些链路层组件看到非 ASCII 就再编一遍,这种最难查,因为它不在你的代码里。
提示:判断是否双编码,只看一个地方——请求里有没有
%25。有就是双编码,没有就往下走查字符集。这一条能让你在十秒内排除掉一类问题。
修复双编码的思路不是“再多解一次”,而是“找到多编的那一次并删掉”。在服务端加decodeURIComponent兜底是典型的饮鸩止渴,因为它会让本来正常的请求反过来坏掉。我自己定下的规矩是:编码只在前端生成 URL 的最后一步做,解码只在服务端框架入口自动做,中间任何一层都不许再碰。
3. 前端侧的实操:iframe src 究竟该怎么写
3.1 一劳永逸的 URL 拼装函数
我在项目里推的做法是禁止业务代码直接拼字符串,所有对外 URL 都从一个统一函数出。这样做的好处是编码策略只有一份,改的时候改一处,出问题也只有一处可疑。
// url.js —— 所有 iframe 地址都从这里出 function buildUrl(base, params = {}) { // base 是已经确定的路径,里面不应包含待编码的业务值 const search = Object.entries(params) .filter(([, v]) => v !== undefined && v !== null && v !== '') .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) .join('&'); return search ? `${base}?${search}` : base; } // 用法 const src = buildUrl('/report/view', { name: '季度报表', // 自动变成 %E5%AD%A3%E5%BA%A6%E6%8A%A5%E8%A1%A8 period: '2024-Q3', }); document.getElementById('reportFrame').src = src;这里有几个刻意为之的设计。第一,base里不允许出现中文,路径段保持纯 ASCII,中文只能进 query 参数——原因第 6 节会展开讲。第二,undefined、null、空串一律过滤掉,避免出现name=undefined这种脏参数。第三,String(v)强制转换,数字和布尔都能安全编码。
如果业务上确实无法避免中文进路径(比如对接的是别人家的老系统,路径规则改不了),那就必须对一个路径段单独编码,并且把斜杠干掉:
function encodePathSegment(seg) { // 先编码,再把 %2F 去掉,防止路径穿越 return encodeURIComponent(String(seg)).replace(/%2F/gi, ''); } const src = `/legacy/report/${encodePathSegment(name)}/view`;%2F的处理是必须的,因为很多容器和网关会拒绝或误解码%2F(Tomcat 默认直接返回 400),Spring Security 的StrictHttpFirewall也会拦。与其在服务器上开allowEncodedSlash,不如在源头就把这个字符清掉。
3.2 路径参数和查询参数,到底选哪个
这是设计层面的选择,我列个对比,你在动手前应该先看一眼:
| 维度 | 中文放路径 | 中文放查询参数 |
|---|---|---|
| 浏览器编码行为 | UTF-8 百分号编码 | UTF-8 百分号编码 |
| 容器解码配置敏感度 | 高,URIEncoding直接影响 | 中,多数框架默认按 UTF-8 解 |
| nginx 转发出错概率 | 高,依赖$uri还是$request_uri | 低,query 常被原样透传 |
| 日志可读性 | 差,日志里全是%E5 | 差,但可以单独记 query |
| 缓存与 CDN 兼容性 | 差,部分 CDN 会重新规范化 | 较好 |
| 破坏性字符风险 | 高,/、%2F、.都要处理 | 低,&、=、#用编码即可解决 |
| SEO 与可分享性 | 略好 | 略差 |
结论很明确:只要你能选,中文就放查询参数。路径里出现中文,你等于把整条链路上每一个组件的字符集假设都绑定在了一起,任何一个环节配置不一致就崩。
3.3 hash 与前端路由场景:编码了但不会被发出去
另一个高频场景是 SPA。如果你的应用用 hash 路由,iframe 的地址可能是/app/#/detail/季度报表。这个中文在#之后,浏览器会把它百分号编码,但因为 hash 不会发给服务端,所以服务端不可能因为它返回 404——404 一定来自别的地方,比如#之前的路径部分。
真正要注意的是取 hash 的时候必须解码。location.hash拿到的是编码后的#/detail/%E5%AD%A3%E5%BA%A6%E6%8A%A5%E8%A1%A8,路由库一般会帮你解,但你自己写解析逻辑时如果忘了,页面就会显示一串%E5。
function getHashQuery() { const raw = location.hash.split('?')[1] || ''; const out = {}; raw.split('&').filter(Boolean).forEach(pair => { const [k, v = ''] = pair.split('='); try { out[decodeURIComponent(k)] = decodeURIComponent(v); } catch (e) { // 已经解码过的值再解会抛 URIError,这里兜底 out[k] = v; } }); return out; }那个try/catch不是摆设。decodeURIComponent('%')和decodeURIComponent('%E5')都会抛URIError: URI malformed,而实际数据里出现不完整的%太常见了,一个用户的备注里带个百分号就能让整个页面白屏。这个坑我踩过,整整查了一个下午,因为报错堆栈指向的是框架内部。
如果是 history 路由,中文就在真实路径里,服务端必须有 fallback 到一个入口 HTML,否则刷新或 iframe 加载时会 404。nginx 的写法是:
location / { try_files $uri $uri/ /index.html; }注意这里的$uri是解码后的,中文路径匹配不到任何文件,最终落到/index.html,由前端路由接管——这条路是通的,但前提是try_files的最后一个参数存在,且它本身没被别的location抢走。
4. 服务端与网关:解码在哪一层发生的
4.1 nginx 的 $uri 和 $request_uri 差在哪里
这是我最想强调的一点,因为它是“curl 请求正常、浏览器 iframe 404”这个诡异现象最常见的成因。
$request_uri是客户端发过来的原始未解码的请求行,$uri是 nginx 内部解码并规范化之后的路径。你做 location 匹配、try_files、文件系统查找时用的是$uri;你做转发、日志、透传时用的是$request_uri。
log_format uri_debug '$remote_addr "$request" uri=$uri request_uri=$request_uri up=$upstream_status'; access_log /var/log/nginx/uri_debug.log uri_debug;把这个日志打上,然后用中文 URL 请求一次,你会看到两行长不一样的路径,一眼就知道解码发生在哪。我排查这类问题时第一件事就是开这个日志,比猜快得多。
转发时最容易出问题的是proxy_pass的写法。当proxy_pass后面只有主机名没有 URI 部分时,nginx 会把客户端原始请求行原样转发,编码保持不动,这是最安全的形式:
location /report/ { proxy_pass http://backend_pool; # 注意:结尾没有斜杠 proxy_set_header X-Original-URI $request_uri; }当proxy_pass后面带了 URI(比如http://backend_pool/;),nginx 会用规范化后的$uri去替换匹配到的前缀,这时候中文的那部分会按 nginx 自己的规则重新拼装,一旦上游的解码假设和它不一致,就会出现“原始编码是对的、转发后变了”的情况。我在一个项目里就因为这个多花了半天:直连上游是 200,走网关就 404,最后把proxy_pass的结尾斜杠去掉就好了。
重写同样是雷区:
# 谨慎使用:rewrite 之后路径会以解码后的形态进入下一轮匹配 rewrite ^/old/(.*)$ /new/$1 last;$1里如果含有中文,它拿到的是解码后的字符,nginx 会在生成新请求行时重新编码。只要中间有一次字符集假设不一致,就会前功尽弃。能用proxy_pass加rewrite ... break解决的,就别用多级rewrite ... last绕。
4.2 Java 容器与框架:三层解码别搞混
Java 这边我见过最多的是“改错地方”。把一个中文 URL 的 404 归因于请求体编码、然后去配server.servlet.encoding,是典型的无效操作,因为URI 的解码发生在连接器层,比任何 Filter 都早。
第一层是容器连接器。Tomcat 8 以后URIEncoding默认是 UTF-8,但存量的 6/7 默认是 ISO-8859-1,表现就是日志里出现䏿这种从 UTF-8 字节按 Latin-1 解出来的经典乱码。如果没法升级,就在连接器上显式声明:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" URIEncoding="UTF-8" useBodyEncodingForURI="false" />useBodyEncodingForURI默认是 false,不要为了“统一”把它打开,它会让 URI 解码跟着请求体的编码走,而请求体编码是可以被客户端 header 覆盖的,等于把 URI 解码交给外部控制,风险很大。
Spring Boot 里对应的配置项是:
server: tomcat: uri-encoding: UTF-8第二层是框架的编码过滤器。CharacterEncodingFilter的forceEncoding只影响请求体和响应体,管不到 URI。这一点必须记住,否则你会一直配一直不生效。
第三层是业务代码。如果 Controller 里自己调了URLDecoder.decode,那就等于二次解码。我建议全项目搜一遍URLDecoder,逐个确认用途,绝大多数场景下它是不该出现的。
// 反例:框架已经解过一次,这里再解一次必出乱码 String name = URLDecoder.decode(pathVariable, StandardCharsets.UTF_8.name()); // 正例:直接用 @GetMapping("/report/{name}") public String report(@PathVariable String name) { return service.load(name); }还有一个隐蔽的坑:路径变量里带点号。早期 Spring MVC 的后缀模式匹配会把中文.html的.html当成扩展名截掉,@PathVariable拿到的只有中文。现在这个特性已经废弃,但在老项目里如果发现“值少了后半截”,就去检查这个:
@GetMapping("/report/{name:.+}") // 显式允许点号 public String report(@PathVariable String name) { ... }4.3 静态资源和中文文件名
对象存储、CDN、静态服务器上放中文文件名的资源,是另一大雷区。Linux 文件系统的文件名本质是字节序列,如果上传时用的是 UTF-8 文件名,而请求经网关后到达静态服务时按 GBK 去查,就找不到。macOS 还有一个额外的坑:它会把文件名规范化成 NFD 形式(部分带组合字符的汉字、韩文、带重音的拉丁字母会被拆开),而从 Windows 或 Linux 上传的文件名通常是 NFC,两者字节不同但看起来一模一样,排查时能把人逼疯。
# 检查文件名到底是 NFC 还是 NFD ls /data/static | xxd | head我的处理原则很干脆:静态资源一律不用中文名。上传的时候生成一个短 ID 或哈希作为文件名,中文原名存到数据库里,只用于展示和下载时的Content-Disposition。这样既绕开了所有编码问题,也顺便解决了同名覆盖和特殊字符的问题。如果业务方坚持要中文名(比如要给用户看到可读的下载链接),那就用一层映射表加 302 重定向,把可读链接转到实际资源上,把风险收在可控范围内。
5. 排查工具箱:十五分钟定位到具体环节
5.1 分层验证的固定动作
我处理这类问题的顺序是固定的,基本不会乱。
第一步,拿到真实的请求 URL。在浏览器 Network 面板里右键复制 Request URL,这一步拿到的才是浏览器真正发出去的东西。如果你是从iframe.src打印出来的值,那可能是编码前的,没有参考价值。
第二步,看编码指纹。请求里是%E5%AD%A3(UTF-8)、%BC%BE(GBK)、还是%25E5(双编码)?这一眼就能把问题范围从“整条链路”缩到“某一层”。
第三步,用 curl 重放,分别验证不同编码形态:
# 假设 base 是 http://127.0.0.1:8080 curl -i 'http://127.0.0.1:8080/report/%E5%AD%A3%E5%BA%A6%E6%8A%A5%E8%A1%A8' curl -i 'http://127.0.0.1:8080/report/%BC%BE%B6%C8%B1%A8%B1%ED'如果 UTF-8 版返回 200、GBK 版返回 404,说明服务端期望的是 UTF-8,问题在前端或网关的编码环节;如果两个都 404 但你确认资源存在,那就是路径匹配或重写的问题。用 Python 生成对照编码很方便:
from urllib.parse import quote, unquote s = "季度报表" print("utf-8 :", quote(s)) print("gbk :", quote(s, encoding="gbk")) print("double:", quote(quote(s))) print("decode:", unquote("%E5%AD%A3%E5%BA%A6%E6%8A%A5%E8%A1%A8"))第四步,绕过网关直连上游。如果直连应用是 200、走网关是 404,那结论就锁死在网关层,去看proxy_pass和rewrite,别再翻后端代码了。
第五步,开 debug 日志。nginx 那条uri_debug日志,加上应用的访问日志(确保打印的是解码后的路径),两相对照,解码发生在哪一层一目了然。
5.2 常见问题速查表
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
请求里出现%25E5 | 前端双编码 | 删掉多余的一次 encode,只保留参数值编码 |
后端日志里是ä¸ | 容器按 ISO-8859-1 解码 | 设URIEncoding=UTF-8 |
| 直连上游 200、走网关 404 | proxy_pass带 URI 导致重新拼装 | 去掉proxy_pass结尾的斜杠,或改用$request_uri |
| curl 正常、iframe 404 | 前端生成的 URL 与你以为的不同 | 对比 Network 里的 Request URL |
路径变量少了.pdf | 后缀模式匹配截断 | 用{name:.+}显式匹配 |
| 网关返回 404 但应用日志无记录 | 请求没到应用 | 查location匹配和try_files |
| 长中文标题导致无法加载 | 请求行超限 | 缩短参数,或改 POST,或调大缓冲 |
| 文件名看起来一样但就是找不到 | 全半角 / 繁简 / NFC-NFD 差异 | xxd对比字节,统一规范化 |
URIError: URI malformed | 对已解码字符串再解码 | 加 try/catch,明确解码边界 |
| 部分用户 404、部分正常 | 浏览器或 CDN 规范化策略不同 | 统一走查询参数,避免路径中文 |
这张表我建议打印出来贴在工位上。中文 URL 的问题有九成能在这十行里找到答案,剩下的那一成通常是环境差异,需要具体看日志。
6. 架构层的取舍:为什么我建议彻底别让中文进路径
6.1 几种替代方案的横向对比
把问题解决在源头,比每次出问题再排查划算得多。中文进 URL 的替代方案无非这几种,我按推荐度排序:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| ID 寻址 | 路径用数字或 UUID,中文只做展示 | 最稳,零编码风险 | 链接不可读,需要一次查询换中文 |
| 拼音/英文 slug | 季度报表→jidu-baobiao | 可读且安全 | 需要生成去重,多音字要处理 |
| 哈希短码 | 中文名算 hash 作为路径 | 稳定,天然去重 | 完全不可读 |
| 中文进查询参数 | /view?name=季度报表 | 改动小,兼容现有路由 | 仍需正确编码,日志可读性差 |
| 中文进路径 | /view/季度报表 | 链接好看,可分享 | 全链路字符集耦合,见前六节 |
我自己的默认选择是 ID 寻址,因为绝大多数业务对象本来就有主键,用它做 URL 只多一次查表,成本极低。如果链接需要被人看到、需要可读(比如对外分享),那就用 slug,在保存时生成好、存库、全局唯一。哈希短码适合内部系统,反正用户不关心链接长什么样。
如果因为历史原因必须保留中文路径,那至少要做两件事:一是把编码逻辑收敛到一个函数里,禁止各处手拼;二是给网关和容器都显式配上 UTF-8,并且用监控把 404 率按路径维度盯起来,一旦某个接口的 404 突增能立刻发现。
6.2 老链接的兼容与迁移
已经上线的中文链接不能说改就改,尤其是被外部收藏或嵌在别人 iframe 里的地址。我的迁移路径一般分三步走。
第一步,新老并存。新地址用 ID,老地址保留并 301 重定向到新地址。重定向的时候要注意,Location 头里的中文必须编码,否则部分客户端会拒绝:
// 服务端返回 301 时的目标地址必须先编码 const location = `/report/view?id=${id}`; // 纯 ASCII,天然安全如果目标里不可避免地有中文,那就encodeURI一次,别再手拼。
第二步,埋点观察。给老地址加访问计数,看还有多少流量、来源是哪里。通常一个月内会掉到接近零,这时候再动手。
第三步,收尾。老地址继续保留 301 而不是直接删,因为搜索引擎和外部引用可能滞后很久。一个 301 的成本几乎为零,但直接 404 会让别人的 iframe 一片白,这种“我们改了规则导致别人挂了”的事故,口碑损失远大于省下的那点维护成本。
提示:如果你在用 iframe 嵌入别人家的页面,而对方刚做了一次 URL 规范调整,那你的 404 可能根本不是编码问题,而是对方的路径变了。排查时先确认对方地址本身还通不通,能省掉大量无用功。
最后分享一个小技巧收尾:我在所有涉及 iframe 的组件里都加了一个统一的onload检查,判断contentWindow.location是否可访问以及文档标题是不是错误页,一旦发现异常就把实际加载的src打到日志里。
frame.addEventListener('load', () => { try { const doc = frame.contentDocument; const title = doc && doc.title; if (!title || /404|not found/i.test(title)) { console.warn('[iframe] 疑似加载失败', frame.src); } } catch (e) { // 跨域时访问 contentDocument 会抛错,忽略即可 } });这行日志救过我很多次,因为 iframe 里的 404 是看不见的——用户只说“页面白了”,而你能拿到的第一手信息,就是那一行被编码过的真实地址。