做中文内容站的人都知道 href 拼接是个高频操作:从数据库读出的 slug、从 URL 里取的查询参数、从配置里读的静态资源前缀。这些字符串最终都会被拼进链接。麻烦在于,中文站的 URL 里常常同时存在中文、空格、斜杠、问号和已编码的百分号,一旦拼接方式不一致,就会出现"链接看着对、点开 404"或者"同一个页面产生两个入口、收录分裂"的怪现象。
这篇文章把 href 拼接的边界条件拆开讲:浏览器怎么解析、服务端怎么解析、两者在什么情况下不一致,以及 PHP 站里最常见的几类 404 该怎么归因和修复。
一、先建立一个模型:URL 不只是字符串
很多人把 URL 当普通字符串处理,$href = $base . '/' . $slug . '.html',看起来天衣无缝。但 URL 在进入网络请求之前,会被浏览器做一遍标准化(normalization),服务端拿到之后再解析一遍。两遍处理都可能改写你的字符串,所以拼接时的"原文"和最终请求的"实际值"并不是一回事。
浏览器标准化至少包含这几种动作:
- 把
\当作/(在特殊协议下) - 把路径里的
.和..段折叠掉 - 把连续的空格去掉、把制表符和换行删掉
- 对非 ASCII 字符按 UTF-8 进行百分号编码
- 把
%2F之类在路径段的保留字符,多数情况下不再解码 - 把
#之后的内容视为片段,不发往服务端
关键在于,这些动作在拼接之后才发生。你在拼接时认为在传一个参数,浏览器可能把它当成了路径分隔符。
二、六类典型的拼接事故
事故一:slug 里带斜杠,路径多了一层
内容站的 slug 如果允许从标题自动生成,中文标题里出现 / 是常事(比如「Nginx/MySQL 调优」)。如果生成 slug 时没把斜杠替换掉,最终 href 就是 /nginx/mysql-调优.html,浏览器把它当成两级目录,请求落到一个不存在的路径。
修复要在数据层做,不要在展示层做。slug 生成函数里明确规定白名单字符集:
function slugify($s) {
$s = trim($s);
$s = preg_replace('/[\s\x{3000}]+/u', '-', $s); // 各类空格
$s = preg_replace('#[\\\\/|:*?"<>]+#u', '-', $s); // URL 保留字符
$s = preg_replace('/-+/', '-', $s);
return trim($s, '-');
}
已经入库的脏 slug 不要直接改(会破坏已有收录),而应该在输出时检测并用 301 跳到规范地址,同时给这一批页面加上 canonical。
事故二:中文没有编码,或被编码了两次
直接在 HTML 里写中文路径,浏览器会自动按 UTF-8 编码,通常能用。但如果这段 href 是先经过 urlencode 再经过模板转义的,就会变成 %25E4%25B8%25AD(百分号被编码了一次),服务端解码后得到字面量 %E4%B8%AD,文件名对不上。
判断方法:抓一个坏链,看实际请求的路径是什么。用 curl 加 -v 能看到请求行里的原始路径:
curl -sv -o /dev/null "https://www.example.com/%25E4%25B8%25AD.html" 2>&1 | grep '^> GET'
如果请求行里出现 %25,基本就是双重编码。修法是:数据库里只存原始中文或只存编码后的单一形式,输出时只编码一次,模板转义和 URL 编码不要叠加。
事故三:查询参数里拼了未编码的值
?tag=Nginx & MySQL 这样拼出来的链接,& 会被当成参数分隔符,服务端只收到 tag=Nginx ,后面的内容变成了一个名叫 MySQL 的空参数。
正确做法是用 http_build_query,不要手工拼:
$url = '/search?' . http_build_query([
'tag' => $tag,
'page' => $page,
], '', '&', PHP_QUERY_RFC3986);
PHP_QUERY_RFC3986 会把空格编成 %20 而不是 +,对中文站更稳——某些老旧服务端或 CDN 会把 + 当字面加号处理,导致搜索词里的空格搜不到东西。
事故四:base 前缀重复或缺失
站点从根目录搬到子目录,或者临时加了 CDN 前缀,就容易出现 /blog/blog/post.html 或者 https://cdn.example.com//img/x.jpg 这种双斜杠。双斜杠在多数服务器上不会 404(服务器会把空段忽略),但会让缓存键分裂、让相对路径解析出错。
约定一条规则:base 不带结尾斜杠,路径以斜杠开头,并且用一个函数统一处理:
function join_url(string $base, string $path): string {
return rtrim($base, '/') . '/' . ltrim($path, '/');
}
项目里所有 href/src 拼接都走这个函数,不要各处手写点号。
事故五:相对路径的基准被 <base> 或路径层级改变
<base href="/"> 一旦加上,页面内所有相对链接的基准就变成了根目录,原本写 ./img/a.png 的地方全部失效。Typecho、WordPress 的某些主题会加 base 标签,迁移主题时最容易撞上。
排查方式:在浏览器里打开控制台,执行:
[...document.querySelectorAll('a[href],img[src]')]
.map(e => [e.href || e.src])
.filter(([u]) => !u.startsWith(location.origin))
.slice(0, 20)
如果原本应该是站内的链接跑到了别的域名下,就是 base 的问题。彻底一点的方案是不用 base 标签,改用根相对路径 /img/a.png。
事故六:链接里有不可见字符
从 Excel、Word 复制过来的标题,经常带着零宽空格(U+200B)、不换行空格(U+00A0)或者全角空格(U+3000)。这些字符肉眼看不见,但会被编码进 URL,导致页面 404,而你在日志里怎么看都正常。
批量清洗时用正则把不可见字符全删掉:
$s = preg_replace('/[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}\x{FEFF}\x{00A0}\x{3000}]/u', '', $s);
这三个范围分别是零宽空格族、方向控制符族、BOM,加不换行空格和全角空格。入库前统一跑一遍,能省掉大量莫名其妙的 404。
三、Nginx 侧为什么会返回 404
前端拼对了,还要看 Nginx 怎么解析。最常见的规则是:
location / {
try_files $uri $uri/ /index.php?$query_string;
}
这里有个容易误解的地方:$uri 是已经解码的路径,而 $request_uri 是未解码的原始路径。当路径里包含 %2F 时,$uri 会把它解成 /,然后 Nginx 把路径切成多一段;而文件系统上的文件名里是真的有个字面 %2F 或者根本没有。这就导致"浏览器请求了一个地址,Nginx 去找了另一个文件"。
排查时对比两者最直接:
add_header X-Debug-URI "$uri" always;
add_header X-Debug-Request-URI "$request_uri" always;
(只在测试环境加,生产会泄露路径信息。)在响应头里看到两者不一致,就是编码层面的问题了。
另一个高频原因是没有处理大小写。Linux 文件系统区分大小写,Post.html 和 post.html 是两个文件。Windows 上开发时没发现,上线就 404。内容站的 permalink 应该统一小写,在生成层强制 strtolower。
四、排查 404 的可复现流程
不要凭感觉改。按下面四步走,每一步都有可验证的输出:
第一步,拿真实坏链。从 GSC 的「网页」报告里导出 404,或者从访问日志里筛:
awk '$9==404 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -30
第二步,看请求行原文。对每个坏链执行 curl,抓请求行和响应头,确认是编码问题还是路径问题。
第三步,定位生成该链的地方。在模板和代码里搜 href 的拼法。用 grep 找所有 href= 出现的位置,重点看那些直接点号拼接的。
grep -rn "href=" /var/www/html/theme/ | grep -v 'http_build_query' | head -40
第四步,修复后回归验证。用一个脚本把目标链接批量请求一遍,只输出非 200 的:
while read -r u; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "$u")
[ "$code" != "200" ] && echo "$code $u"
done < urls.txt
五、收录层面的连带影响
href 拼接问题不只是用户体验问题,它直接影响收录。同一篇文章如果存在两个可访问地址(一个带参数、一个不带),搜索引擎会认为是重复内容,权重被稀释。这时候需要三个动作同时做:
- 页面里所有内链统一指向规范地址,不给搜索引擎发现旧地址的机会;
- 旧地址返回 301 到规范地址,而不是 200 或 404;
- 规范页面加
rel="canonical",指向自己。
判断站内是否存在多入口,可以用最简单的办法:搜站点地图里的 URL 数量,和 Nginx 日志里被访问的不同路径数量对比。如果后者远大于前者,说明有大量非规范入口在被访问。
# 一天内被访问的不同路径数
awk '{print $7}' /var/log/nginx/access.log | sort -u | wc -l
# 站点地图里的 URL 数
curl -s https://www.example.com/sitemap.xml | grep -o '<loc>' | wc -l
两者比例超过 3:1 就值得查一查了,尤其是在站点还没做站内搜索的情况下。
六、一套可落地的编码规范
把这些经验固化成几条团队约定,能避免 90% 的重复踩坑:
- 数据库里存储的 slug 只允许
[a-z0-9-],中文标题用拼音或语义英文,写入前强制过滤。 - URL 拼接统一走
join_url()和http_build_query(),禁止点号手拼。 - 查询参数一律 RFC3986 编码,空格用
%20。 - 路径统一小写,结尾是否带斜杠全站二选一,另一形式 301。
- 不用
<base>标签,资源路径用根相对。 - 入库前跑一次不可见字符清洗。
- 上线前用脚本回归全部内链,非 200 即阻断发布。
七、小结
URL 拼接看起来是最没有技术含量的部分,但它同时踩在浏览器标准化、Nginx 解析、文件系统语义和搜索引擎收录四条线上。中文站因为字符集的原因,踩坑概率比英文站高得多。
一旦出现"链接看着对但打不开",最快的路径不是猜,而是把请求行原文打出来,对比 $uri 和 $request_uri,再回到代码里找是哪一处拼接没有走统一函数。把这个流程走顺了,这类问题一般十分钟内能定位。