[译]解决使用React Router中遇到的Cannot GET /URL错误

3,924 阅读4分钟

原文:Fixing the "cannot GET /URL" error on refresh with React router and Reach Router(or how client side routers work)


你最近在用React Router开发一个React程序,一切都很顺利,“再把这个button调圆润点就完美了”,你很快调好了样式,看看效果,刷新页面,然后程序崩了。你很惊讶,如果报错信息是你见过无数次的Cannot read property 'state' of undefined,你根本不慌。但是这次不一样,你盯着控制台,隐隐感觉问题不一般:你刚才只是刷新了页面,然后程序就崩了,而且报错信息只有三个字:

Cannot GET /dashboard

冷静,我们再试一次,重启项目,“很好首页正常,导航没问题,再刷新试试”。shift!

Cannot GET /settings

你可能一脸懵逼,完全不知道哪里出了错,但是还好你遇到了我 :)

我们先来分析问题是什么。但在此之前,你需要了解浏览器和客户端路由是如何工作的。

早些年前,如果你想获取/dashboard的内容,浏览器会向服务器发送一个GET请求,服务器通过检查URL的path得知用户请求的是/dashboard页面,于是把/dashboard页面响应给浏览器。但自从客户端路由(client-side routers,CSR )出现,事情就不一样了。CSR(比如React Router)的不同在于:你每次改变路由不会直接向服务器发送请求,路由变化由你的CSR管理。怎么管理?当你进入路由/dashboard,CSR会调用浏览器的API——history.pushState改变URL,向服务端发送请求,加载当前路由对应的组件并渲染视图——完全不涉及页面刷新。

让我们再深入一点分析这个过程。

假设用户面前打开了一个新的谷歌标签页,在用户第一次访问你的网站之前,客户端没有加载过任何JavaScript文件,意味着客户端不存在React、React Router,所以用户的第一个请求会被发送给你的服务器。假设第一次请求成功了,所有的JS文件在客户端顺利加载、执行完毕,React Router随之生成,并且接管从此之后所有的路由变化。

发现关键问题了吗?只有当用户的第一次GET请求成功了,React Router才能被创建。我们碰到Cannot GET /* 是因为,如果你已经在/dashboard,再去刷新页面,浏览器会向服务器请求到/dashboard,但由于服务端没有处理这个请求的逻辑(请求应该由React Router完成),浏览器请求失败。

举个例子,假如你对你正在开发的一个app相当自豪满意,想分享给你妈妈。你的app叫Tic Tac Toe,有三个路由分别是://play/leaderboard。因为你想和妈妈一起玩,所以把https://tictactyler.com/play的链接发给了她。那当她点开这个网址时会发生什么呢?此时,她(准确来说是浏览器)没有JavaScript,没有React,也没有React Router。浏览器会发送一个GET请求到/play,但由于你的代码是依赖React Router处理路由逻辑的(而她还没有React Router),这个GET请求是得不到想要的页面的,浏览器收到Cannot GET /play :(

好呗,事已至此。

怎么解决问题呢?

问题的根源是你把客户端路由完全交给React Router管理,没写任何处理服务端路由的逻辑。解决思路有二:第一,客户端和服务端路由都由你部署好。第二,把所有的请求都重定向到/index.html,并且保证index会加载所有的JS资源,让React Router可以从此接手。第二种思路更简单,我们接下来要提的大多数解决方案都属于第二种思路。

Hash History

你见过含有#的URL吗?这种URL使用了Hash History。其原理是在你的根URL末尾加一个#,任何#之后的部分都不会被发送给服务器。所以假设用户输入的URL是https://tm.io/#/courses,浏览器会发送请求到https://tm.io,顺利获取所有的JS资源,React Router会被加载,看到了当前路由/courses,再去加载正确的视图。你可以用React Router提供的HashRouter组件,但是老实说,除非你蒸的需要它,其实还有其他更好的办法。

Catch-all

如果你已经有服务器了,这种方法应该最适合你。办法就是你对所有的服务器请求都返回index页面(然后会加载所有需要的JS资源)。实际代码取决于你用的哪种服务器,举几个常见的例子。

Express

app.get('/*', function(req, res) {
  res.sendFile(path.join(__dirname, 'path/to/your/index.html'), function(err) {
    if (err) {
      res.status(500).send(err)
    }
  })
})

Appache .htaccess

RewriteBase /
RewriteRule ^index\.html$ - [L]
 RewriteCond %{REQUEST_FILENAME} !-f
 RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]

Nginx .conf

location / {
  if (!-e $request_filename){
    rewrite ^(.*)$ /index.html break;
  }
}


没有自己的服务器

没有自己写服务器的小伙伴们需要找到支持客户端路由的托管服务。假设你用了Firebase的托管服务,它会询问你下面这个问题:

是否配置单页面应用(重写所有的urls到/index.html)?

Netlify也支持客户端路由,你只需要按下面的方式创建一个/_redirects文件:

/*  /index.html  200

这会告诉Netlify把所有请求重定向到/index.html

Webpack / Development

这一部分专属使用webpack-dev-server的小伙伴们。我们需要告诉Webpack Dev Server把所有请求重定向到/index.html,为此你只需要设置webpack config里的两个选项:publicPath 和 historyApiFallback.

publicPath指定获取应用内所有资源的基础路径(base path);

historyAPIFallback重定向所有的404到/index.html

下面是一个基础的webpack config文件,两个选项都配置好了,转需

var path = require('path');
var HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './app/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index_bundle.js',
    publicPath: '/'                                     //配置1
  },
  module: {
    rules: [
      { test: /\.(js)$/, use: 'babel-loader' },
      { test: /\.css$/, use: [ 'style-loader', 'css-loader' ]}
    ]
  },
  devServer: {
    historyApiFallback: true,                           //配置2
  }, 
  plugins: [
    new HtmlWebpackPlugin({
      template: 'app/index.html'
    })
  ]
};