点「项目」页面就死:一次客户端无限重定向
站点上线当天遇到的第一个线上问题:点导航栏的「项目」,页面能打开、内容也渲染出来了,然后就整页卡死,Chrome 弹「1 个页面无响应」,F12 都打不开。只有这一个页面这样,其它页面正常。
这个症状本身就是线索:地址栏直接输入能打开、内容也出来了,说明服务端没问题;点导航才卡,说明问题在客户端路由;"内容渲染出来了才卡死"说明不是死在渲染,是死在渲染之后的某个循环里。
查下去发现是我自己加的一条"旧地址跳转"造成的。
之前把目录 pages/Project 改成小写 pages/project。改完担心已经进搜索引擎的旧地址 /Project 变 404,就在 nuxt.config 的 routeRules 里加了一条:
routeRules: {
'/Project': { redirect: '/project' },
}
这条规则不只是服务端的重定向 —— Nuxt 还会把它注册成一条客户端路由。而路由表匹配默认不区分大小写,于是访问 /project 时命中的是 /Project 这条记录,触发它的 redirect,跳到 /project,再命中自己。
开发环境里 Vue Router 检测到环会中止,只留一条警告,所以本地怎么点都正常:
[Vue Router warn]: Detected a possibly infinite redirection
in a navigation guard when going from "/series" to "/project"
生产构建没有这层保护,主线程就死在那儿了。这条警告我是回到本地跑了一遍才看到的,比线上晚了两天。
中间有四次判断是错的,写出来免得下次再犯。
一次是拿截图工具超时当"绘制卡死"的证据。它只能说明页面没到完成态,说明不了为什么。这个区别听起来显然,但当时我拿着它当结论用了半天。
一次是用 display:none 把可疑组件藏起来做排除。display 只影响布局,不阻止 JS 执行,所以这个实验根本没把组件代码排除掉,等于没做。
一次是断言"关掉脚本还能复现"。我用的参数是 --blink-settings=scriptsEnabled=false,键名是错的(应该是 scriptEnabled),那次实验完全没生效,结论也是错的。
还有一次怀疑是某个 CSS 属性触发了渲染死循环。被自己的对照实验否掉了:把出问题页面和正常页面的内联样式抓下来做差集,一条差异都没有。
把范围收窄的是两件事。第一件是把 HTML 里的 script 标签全删掉,用静态服务器喂给浏览器,一秒出图——问题从"渲染"挪到了"JS"。第二件是在构建产物里搜那条路由:
grep -l '"/Project"' .output/public/_nuxt/*.js
搜得到,说明客户端包里确实多了这条路由记录。方向当场就定了。
修法是不让框架把这条跳转变成客户端路由,放到服务端做:
// apps/web/server/middleware/project-redirect.ts
export default defineEventHandler((event) => {
const [path, query] = (event.path || '/').split('?')
if (path !== '/Project') return
return sendRedirect(event, query ? `/project?${query}` : '/project', 301)
})
中间件只看实际进来的请求路径,不参与客户端路由表,没有自跳的机会。查询参数要带过去,我第一版漏了,从带参数的旧地址跳过去参数就没了。
状态码也得说一下:之前那条规则名义上是永久重定向,实际返回的是 307 —— 框架给 redirect 的默认值就是 307,要 301 必须显式传对象。临时重定向不转移权重,搜索端会长期留着两个地址。这个问题不会当天暴露,是几个月后看收录数据才会发现。
改完之后 /Project 返回 301 指向 /project,页面正常,那条警告也没了。验证方式我留了两条:curl 一次看状态码和 redirect_url,以及用无头浏览器跑一次真实绘制,看它能不能出图(改之前固定超时)。
给 URL 改名的时候,"旧地址怎么办"确实是任务的一部分,但跳转要放在服务端做,尤其涉及大小写。另一件我以后再不会省的事:本地跑一遍不等于验完,dev 里那条保护性警告,恰恰就是生产环境打死页面的东西。
还有个跟这个 bug 无关、但同一天发生的失误。我在那条 routeRules 的注释里同时写了两个断言 —— "不会自循环"和"保留 301"。第一个我确实验过,第二个没验就发上去了。一句话里有几个可验证的点,就得分别验完,验过一个不算整句成立。