背景:首页能开,深链全是 404

某 AI 日记应用的前端是单页应用(SPA)。SPA 有个部署特点:首次打开首页,服务端返回一个 HTML 入口,之后页面切换由前端路由在浏览器里完成;但用户直接打开深链如 /home,或刷新子页面,服务端必须把请求也交给同一个 HTML 入口,否则就因“服务器上没有这个路径的文件”而 404。

所以 SPA 部署总要配一条回退改写规则:除真实静态资源与 API 请求外,其他路径一律改写到 SPA 入口。规则看起来简单,却很容易踩平台差异的坑。2026-10-06,本项目 在 Vercel 上连续踩了三次,提交分别是 、、,每一次都是“以为修好了,一测深链还是 404”。

本次草稿依据提交记录,未逐行核对实现。下文只写提交信息中提到的关键规则片段,不编造 vercel.json 的完整配置。

第一次:否定前瞻在 Vercel 下从不匹配

第一次尝试是凭正则直觉写的做法:用带否定前瞻(negative lookahead)的改写源,把“除某些前缀外的所有路径”捕获下来改写到 SPA 入口。在纯正则世界里,这个表达式本身没错。

问题在 Vercel 的改写源不是纯正则求值器。一次提交 写得很直接:带否定前瞻的改写源在 Vercel 的 path-to-regexp 处理下从不匹配任何路径。结果是深链如 /home 全部 404,而 /api 与 /admin 改写仍工作。

“从不匹配”是关键:不是偶尔失效,而是整条规则直接失效。否定前瞻这种高级正则特性,在 path-to-regexp 管线里没有按直觉被支持。而 /api 与 /admin 仍工作,说明坏的不是整个改写系统,只是这条用了否定前瞻的规则——这也容易误导排查,让人以为是“排除范围写错了”,而不是“这个写法本身不被支持”。

教训:改写规则语法要按目标平台实际用的匹配引擎验证,而不是按通用正则语义想当然。path-to-regexp 与本地测试的正则引擎,不一定是同一个东西。

第二次:正则捕获也不匹配,命名参数却可以

第二次换方向:不用否定前瞻,改用正则捕获。一次提交 说,正则捕获改写 /((?!...).*) 与 /(.*) 在该项目 Vercel 管线下从不匹配,而命名参数改写 /api/:path* 可以工作;于是改用等价的 /:path* 全捕获形式。

对比很有意思:/(.*) 直觉上最“万能”,却同样从不匹配;反倒是 /api/:path* 这种命名参数写法可以工作——:path* 表示零个或多个路径段,是 path-to-regexp 的原生写法,而不是靠正则分组去“抓”文本。

结论与第一次相同:Vercel 改写源优先支持 path-to-regexp 的命名参数语法,而不是任意正则。/(.*) 看似通用,实际绕开了平台期望的写法;/:path* 看似受限,正好落在平台支持的语法里。修复方向因此是“用平台原生写法表达同样意图”:意图没变——所有路径都回退——表达方式换成平台能理解的那一种。

第三次:目标写死 index.html,撞上 cleanUrls 规范化

第三次问题不在“源”怎么写,而在“目标”写什么。一次提交 说:cleanUrls 开启时,改写目标 /index.html 会被 Vercel 的 clean-URL 规范化捕获、永远送达不了,所以每个全捕获都 404;目标 / 经文件系统解析到同一个 index.html,不会重新进入 .html 规范化。

这是“两条机制互相打架”:cleanUrls 开启后,/about.html 会被规范化为 /about,URL 里不出现 .html 后缀。这个功能本身为干净 URL 服务,却与 SPA 回退冲突:回退说“把深链改写到 /index.html”,规范化却在半路拦下它,认为这是需要规范化的 .html 路径,请求永远到不了入口文件。

修复是把目标从 /index.html 改成 /。根路径经文件系统解析,同样落到同一个 index.html,但不带 .html 后缀,不会重新进入规范化流程。目标变了,实际送达的文件没变,绕开的却是那层规范化拦截。隐蔽之处在于:源已对了,全捕获已能匹配,但目标在最后一公里被另一条规则截胡;从现象看还是“深链 404”,很容易让人回去反复改源。

另有一笔相关提交:(2026-09-30,fix Vercel monorepo build and Expo Web asset routing for 某地区域名),处理 Vercel monorepo 构建与 Expo Web 资源路由。同属部署主题:构建产物放哪里、资源路径怎么解析、回退规则怎么写,任何一环对不上,深链或资源就会出问题。

验证方法:每次“修好了”都要用深链实测

三次失败的共同点是:只看首页、只看 /api,都可能觉得修好了;真正暴露问题的永远是深链实测。可复用的验证清单:直接打开首页;直接打开一条深链如 /home,确认不是 404 且返回 SPA 入口;刷新深链页面,确认刷新后仍能回到同一入口;检查静态资源仍能加载,确认回退没把真实文件吞掉;检查 /api 等不应回退的路径仍走自己的逻辑。

一次提交 还有个细节值得记:普通改写在文件系统检查后才运行,所以静态资源不需要排除正则。真实存在的文件会先被命中,不会进入回退规则。理解这一点,就不会为“保护静态资源”把回退规则写得越来越复杂、反而引入新的不匹配。

最后的教训

三次失败递进关系是:第一次是“写法不被匹配引擎支持”(否定前瞻),第二次是“写法绕开了平台原生语法”(正则捕获 vs 命名参数),第三次是“目标撞上另一条平台机制”(/index.html vs cleanUrls)。每次根因都不在“意图错了”,而在“表达方式与平台实际行为有偏差”。

留给后来人两条经验:第一,改写规则要按平台实际匹配引擎验证,不要凭正则直觉;能用命名参数表达的,就不要用通用正则模拟;不确定的语法,先用最小规则实测一条深链再推广。第二,SPA 回退目标最好写根路径而非 index.html——/ 与 /index.html 在文件系统层面指向同一入口,但在 cleanUrls 开启的管线里命运完全不同。下次再改这条规则,先把“深链直接访问 + 刷新 + 静态资源 + API 路径”四项验证跑一遍,再说修好了。首页能开,从来不是 SPA 部署修好的充分条件。


2024 | ixs.im
此情此景, [随机]吟诗一首: