12k
All articles

如何修复 SPA 部署后出现的 Cannot GET 错误

通过 Nginx、Apache、Netlify、Vercel 和 S3 CloudFront 的服务器重写,修复部署后 SPA 的 Cannot GET 和 404 错误。

OpenReplay Team
OpenReplay Team
如何修复 SPA 部署后出现的 Cannot GET 错误

单页应用部署后出现的 “Cannot GET /route” 或 404 错误,通常是服务器配置问题,而不是路由的 bug。解决办法是让服务器对任何无法匹配到真实文件的请求路径都返回 index.html

这个场景很常见:构建产物发布上线,点击浏览时每个页面都正常,然后有人刷新 /dashboard,或者打开一个指向 /orders/42 的分享链接,却得到一个空白的 404。通常路由本身没有问题,构建也没有问题。问题在于服务器被请求了一个并不存在的文件。

本文将解释为什么这个错误只在硬导航(hard navigation)时出现,然后介绍解决方案,并给出 Nginx、Apache、Netlify、Vercel 以及 CloudFront 后的 S3 的具体配置,以及随后需要处理的副作用。

要点速览

  • SPA 刷新时出现 404,是因为请求到达了服务器,而服务器会在该路径下查找真实文件,却只在根目录找到了 index.html
  • 本地开发服务器掩盖了这个问题,因为它们已经默认对未匹配的路径回退到 index.html
  • 解决办法是 rewrite(重写),而不是 redirect(重定向):以 200 状态码返回 index.html,这样 URL 才能保持原样供路由读取。
  • 在 S3 上,error-document 方案会保留 404 状态码;真正能返回 200 的,是将 403 和 404 都映射到 /index.html 的 CloudFront 自定义错误响应。
  • 通用回退规则意味着错误 URL 也会返回 200,因此应用需要自己的通配路由来渲染 not-found 视图。

Cannot GET 错误何时出现?

该错误只在硬导航时出现:页面刷新、在地址栏中直接输入 URL,或在新标签页中打开分享的深层链接。应用内导航依然正常,因为应用加载完成后,路由完全在浏览器中切换视图,不会与服务器通信。具体的错误信息因宿主环境而异:基于 Express 的服务器会输出 “Cannot GET /route”,而静态托管服务则返回它们自己的 404 页面。

这也是这个 bug 能躲过 QA 的原因。对刚部署的 SPA 进行会话回放(session replay)可以看到,故障总是发生在硬导航时——刷新或从外部打开链接——而从不发生在应用内点击时。因此,只在运行中的应用里点击测试会一路通过,而真实用户却撞上了 404。

为什么 SPA 刷新 404 是服务器问题?

静态服务器会将每个请求路径映射到磁盘上的文件。SPA 构建只产出一个 HTML 文件 index.html,再加上 JS 和 CSS 资源,因此对 /dashboard 的直接请求在该路径下找不到文件,服务器正确地返回了 404。配置为单页应用的 React Router、Vue Router 和 SvelteKit 都会遇到完全相同的问题,因为框架无关紧要:路由只存在于尚未加载的 JavaScript 中。

这个错误在本地开发中从不出现,因为大多数 SPA 开发服务器都默认启用了回退机制:任何匹配不到文件的路径都会自动返回 index.html。你的本地环境一直在默默做着生产服务器没做的那件事。

Cannot GET 错误的解决方案是什么?

将服务器配置为:对任何匹配不到现有文件的请求路径都返回 index.html,这样应用就能加载,并由其路由渲染该 URL 对应的视图。这必须是一个以 200 状态码返回 index.html 的 rewrite,而不是 redirect:redirect 会改变地址栏中的 URL,而路由需要原始路径保持完整。

宿主环境配置位置机制
Nginxservertry_files
Apachevhost 或 .htaccessFallbackResource
Netlify_redirectsnetlify.toml状态码为 200 的 rewrite 规则
Vercelvercel.jsonrewrites 数组
S3 + CloudFrontbucket 网站配置 + distributionerror document + 自定义错误响应

如果确实无法改动服务器,基于 hash 的路由可以完全绕开这些问题,因为片段标识符永远不会离开浏览器,但它会永久性地把每个 URL 变成 /#/about,所以请把它当作最后的手段。

Nginx 与 Apache

Nginx 和 Apache 都可以通过服务器配置中的一条指令来表达 SPA 回退。对于 Nginx,在根 location 中添加一个 try_files 回退。它会先把请求路径当作文件查找,再当作目录查找,两者都找不到时,就以 200 状态码在内部返回 /index.html:

server {
  listen 80;
  root /var/www/app/dist;
  index index.html;

  location / {
    try_files $uri $uri/ /index.html;
  }
}

对于 Apache,mod_dir 中的一条指令即可完成同样的工作。真实文件仍按原样返回,其他一切都落到回退规则上:

FallbackResource /index.html

如果应用部署在子路径下,则需要把它包含进来:FallbackResource /app/index.html。较早的 mod_rewrite 等价写法在 .htaccess 中依然有效:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]

Netlify 与 Vercel

在 Netlify 上,状态码为 200 的 redirect 规则会变成 rewrite:浏览器仍显示访客请求的路径,而响应中返回的是 index.html 的内容。你可以添加一个单行的 _redirects 文件:

/* /index.html 200

或在 netlify.toml 中写入等价配置:

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

_redirects 文件必须位于发布目录内,因此要确保你的构建流程会把它复制到输出文件夹中;netlify.toml 则位于仓库根目录。通配(splat)规则不会接管背后存在真实文件的路径,因此 JS 和 CSS 资源仍能正常加载。

对于 Vercel,在 vercel.json 中添加一个 rewrites 条目:

{
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

建议使用明确的 /index.html 作为 destination,而不是 /:在 Vercel 上两者解析到同一个文件,但显式写法明确说明了实际返回的内容,并且这种心智模型可以迁移到其他任何宿主环境。有一个例外:当设置了 cleanUrls: true 时,destination 不能带 .html 扩展名,而 Vercel 会将 index.html 映射到站点根路径,因此此时应把 destination 设为 /

S3 与 CloudFront

修复 S3 上的 SPA 404 需要两处配置,因为仅靠 bucket 设置会保留错误状态码。在 S3 静态网站托管中,把 index.html 同时设置为索引文档和错误文档:

aws s3 website s3://your-bucket \
  --index-document index.html \
  --error-document index.html

这样对未知路径会返回应用,但会保留错误状态码:浏览器收到的是带 404 状态码的 index.html。要返回 200,需添加 CloudFront 自定义错误响应,将 403 和 404 都映射到 /index.html,响应码设为 200。403 的映射很重要,因为使用 S3 REST 端点作为 origin 的 distribution,对不存在的 key 会收到 403 Access Denied,而不是 404。用 Terraform 表示:

custom_error_response {
  error_code         = 403
  response_code      = 200
  response_page_path = "/index.html"
}

custom_error_response {
  error_code         = 404
  response_code      = 200
  response_page_path = "/index.html"
}

有一个坑:自定义错误响应作用于整个 distribution,所以如果你通过同一个 distribution 代理 /api/*,那么 API 的 403 和 404 也会返回 index.html

代价:真正的 404 消失了

通用回退规则有一个代价:真正错误的 URL 现在会返回带 200 状态码的 index.html,而不是真正的 404。服务器再也无法区分 /orders/42/ordersss/42,因此应用必须定义自己的通配路由来渲染 not-found 视图。每个路由库都有对应的写法;在 React Router 中是这样:

<Route path="*" element={<NotFound />} />

注意这是客户端渲染的 404:HTTP 状态码仍然是 200。如果你在意爬虫如何归类这些页面,这一点就很重要。

总结

刷新时的 404 只是服务器在做静态服务器该做的事,而修复方法就是用你所用宿主环境的”方言”应用一条规则:将所有非文件路径以 200 状态码重写到 index.html。为你的宿主环境加上相应代码片段,重新部署,硬刷新一个深层路由来确认,然后添加通配 not-found 路由,让错误 URL 仍然能告知用户他们走错了地方。

常见问题

SPA 回退方案在 GitHub Pages 上有效吗?

无效。GitHub Pages 不支持服务端 rewrite,因此无法配置 index.html 回退规则。标准的变通方案是提供一个自定义的 404.html 页面,其中包含一段脚本,在保留所请求路径的同时重定向到 index.html,加载完成后由路由恢复该路径。GitHub 依然会以 404 状态码返回该页面。另一个选择是基于 hash 的路由,它永远不会把路由发送到服务器。

像 Next.js 或 Nuxt 这样的服务端渲染框架也有这个问题吗?

在它们运行自己的服务器时不会。服务端渲染框架在服务端处理每个路由,因此刷新或深层链接会直接返回渲染好的 HTML。刷新 404 的问题只影响路由完全存在于客户端 JavaScript 中的静态单页构建。不过,由这类框架静态导出的应用仍可能遇到此问题——当请求的路由在磁盘上没有对应的预渲染 HTML 文件时。

把所有路径重写到 index.html 会破坏我的 JS 和 CSS 资源吗?

不会。每种机制都会先查找真实文件再回退:Nginx 的 try_files 会先尝试请求 URI,Apache 的 FallbackResource 不会干预对真实文件的请求,而 Netlify 的通配 rewrite 也不会接管已存在的路径,除非你用 200! 强制覆盖。如果添加回退后资源仍然加载失败,常见原因是相对资源路径在嵌套路由下被解析,导致浏览器从错误的目录请求它们,从而收到 index.html。

以 200 状态码返回 index.html 会损害 SEO 吗?

有可能。当一个不存在的 URL 以 200 状态码返回 not-found 内容时,Google 可能将其归类为 'soft 404' 并从索引中移除,因为状态码已无法区分真实页面和错误 URL。如果搜索索引对你的路由很重要,预渲染或服务端渲染可以恢复每个路由正确的状态码。对于登录后才能访问的应用,爬虫根本看不到这些路由,因此这个权衡无关紧要。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.