trailing slash 不一致导致多次重定向

/foo → /foo/ → /foo 这种链——挑一种用,并保持一致。

curl -I https://yourdomain.com/article 看响应头,发现一条请求要 308 跳到 /article/,再 301 跳回 /article,浏览器地址栏来回闪两次——这就是 trailing slash 配置不一致。后果不止”丑”:Google 把两个 URL 算成内容重复,sitemap 里的 URL 和实际 canonical 不匹配,CDN 缓存命中率掉一半,重定向链超过 5 跳直接被搜索引擎拒收。修复要点不是”选哪个对”,而是框架、宿主、内链三处用同一条规则

常见原因

按命中率从高到低:

1. 框架和宿主规则冲突

最经典:Astro 默认 trailingSlash: 'ignore',Vercel 默认把 /foo/ 重写到 /foo(去斜杠),但你在 astro.config 里写了 trailingSlash: 'always'。结果浏览器去 /foo → Astro 想跳 /foo/ → Vercel 又把 /foo/ 跳回 /foo → 无限循环或多跳。

$ curl -sI https://example.com/about | grep -i location
location: /about/
$ curl -sI https://example.com/about/ | grep -i location
location: /about

如何判断:用 curl -sIL https://yourdomain.com/page 看 Location 头连跳几次,超过 1 跳就有问题。

2. 两层重定向(CDN + 应用)方向相反

Cloudflare Page Rules 配了”统一去斜杠”,Vercel/Netlify 项目设置里配了”统一加斜杠”。两边都生效,请求被互相反弹。

如何判断:在 Cloudflare dashboard → Rules → Page Rules / Redirect Rules 里搜 trailing;同时在宿主平台项目设置里搜同样关键词。同时存在就是冲突。

3. 内链有的带斜杠有的不带

模板里 <a href="/about/">,但导航组件写的 <a href="/about">,两种都跑得通但每次点击都触发一次重定向。

如何判断:grep 整个仓库:

grep -rE 'href="/[a-z]+"' src/ | head -20
grep -rE 'href="/[a-z]+/"' src/ | head -20

两个结果都非空就是没统一。

4. sitemap.xml 和 canonical 不一致

sitemap 里写 <loc>https://example.com/post/</loc>,页面里 <link rel="canonical" href="https://example.com/post">。Google 抓到两份 URL 不知道哪个是主,索引覆盖率掉。

如何判断:抓你自己的 sitemap,对比 10 个 URL 的 canonical:

curl -s https://example.com/sitemap.xml | grep -oE '<loc>[^<]+</loc>' | head -3

肉眼对比页面 <link rel="canonical">

5. Next.js / Astro 升级后默认值变了

Next 14+ 移除了 trailingSlash 的旧默认行为;Astro 4.x 把 trailingSlash 默认从 ignore 切到 always(output: static 时)。升级后没改配置就出现新的跳转链。

如何判断:查 release notes 搜 trailingSlash,或回滚一个版本验证。

最短修复路径

Step 1:用 curl 把现状的重定向链画出来

curl -sIL "https://yourdomain.com/about" | grep -E 'HTTP/|location:'

看到几行 HTTP/2 30x + location: ...,就有几跳。理想是 0 跳(直接 200)或 1 跳。

也可以用浏览器 DevTools → Network → 勾选 Preserve log,看一条请求展开成几行。

Step 2:定一条规则——always 还是 never

两个都行,挑一个团队不容易写错的。推荐:

规则优点缺点
always(带斜杠)静态文件友好(/page/index.html),多数 CMS / SSG 默认URL 多一字符
never(不带)URL 更短,REST API 习惯静态导出需要每个 path 落到具体文件,多数 SSG 要额外配置

定完写到 CONVENTIONS.md 里,团队所有人遵守。

Step 3:三处配同一规则

以 Astro + Vercel + always 为例:

// astro.config.mjs
export default defineConfig({
  site: 'https://example.com',
  trailingSlash: 'always',
  build: { format: 'directory' },  // 输出 /about/index.html
});
// vercel.json
{
  "trailingSlash": true,
  "cleanUrls": false
}

Next.js:

// next.config.js
module.exports = { trailingSlash: true };

Cloudflare:进 Rules → Bulk Redirects 或 Page Rules,删掉任何强制去斜杠的规则。

Step 4:内链全部规范化

写一个一次性脚本批量替换:

# 把不带斜杠的内链全部加上(仅 src/ 下的 .astro/.tsx/.mdx)
grep -rlE 'href="/[a-z][a-z0-9-]*"' src/ | \
  xargs sed -i '' -E 's|href="(/[a-z][a-z0-9-]*)"|href="\1/"|g'

再用 lint 规则锁住(ESLint 自定义或简单的 grep 在 CI 里跑):

# CI 里跑:发现不带斜杠的内链就 fail
! grep -rE 'href="/[a-z][a-z0-9-]*"[^/]' src/ --include='*.{tsx,astro,mdx}'

Step 5:sitemap 和 canonical 对齐

sitemap 用框架生成(Astro 的 @astrojs/sitemap、Next 的 next-sitemap)会自动跟 trailingSlash 走。如果是手写的,加个统一函数:

const canonical = (path) => `https://example.com${path.replace(/\/?$/, '/')}`;

部署后再跑一次 Step 1 的 curl,确认 0–1 跳。

预防建议

  • 把规则写到 CONVENTIONS.md / README.md,PR 模板里加一行 “trailing slash check”
  • CI 里加一条 grep 规则,发现不规范内链直接 fail
  • sitemap 用框架自动生成,不要手维护
  • 升级 Astro / Next.js 主版本时,专门搜 release notes 里的 trailingSlash 关键词
  • 发布后用 httpstatus.iocurl -IL 批量跑 20 个核心 URL,确认 0–1 跳

相关阅读

标签: #部署 / 托管 #排查 #排查