开始一个React项目(三)路由基础(v4)

前言

react-router针对不同的使用场景衍生了不同的路由包,RN项目用react-router-native,web项目用react-router-dom。并且,不需要再重复引入react-router了。我搭建的是web项目环境,所以用的是react-router-dom

本节代码是基于开始一个React项目(一)一个最简单的webpack配置开始一个React项目(二) 彻底弄懂webpack-dev-server的热更新搭建的环境开始的。

前端路由

开始今天的话题之前,让我们先来了解一下前端路由,Ajax诞生以后,解决了每次用户操作都要向服务器端发起请求重刷整个页面的问题,但随之而来的问题是无法保存Ajax操作状态,浏览器的前进后退功能也不可用,当下流行的两种解决方法是:

  1. hash, hash原本的作用是为一个很长的文档页添加锚点信息,它自带不改变url刷新页面的功能,所以自然而然被用在记录Ajax操作状态中了。
  2. history, 应该说history是主流的解决方案,浏览器的前进后退用的就是这个,它是window对象下的,以前的history提供的方法只能做页面之间的前进后退,如下:
  • history.go(number|URL) 可加载历史列表中的某个具体的页面
  • history.forward() 可加载历史列表中的下一个 URL
  • history.back() 可加载历史列表中的前一个 URL

为了让history不仅仅能回退到上一个页面,还可以回到上一个操作状态。HTML5新增了三个方法,其中两个是在history对象里的:

  • history.pushState(state, title, url)
    添加一条历史记录, state用于传递参数,可以为空。title是设置历史记录的标题,可以为空。url是历史记录的URL,不可以为空。
  • history.replaceState(state, title, url)
    将history堆栈中当前的记录替换成这里的url,参数同上。

还有一个事件在window对象下:

window.onpopstate() 监听url的变化,会忽略hash的变化(hash变化有一个onhashchange事件),但是前面的两个事件不会触发它。

好了,到这里你大概猜到了单页面应用或者Ajax操作记录状态用的就是hash和h5增加的history API,这就是react-router-dom 扩展的路由实现,也是web应用最常用的两种路由。

静态路由和动态路由

react-router v4是一个非常大的版本改动,具体体现在从“静态路由”到“动态路由”的转变上。一般将“静态路由”看作一种配置,当启动react项目时,会先生成好一个路由表,发生页面跳转时,react会根据地址到路由表中找到对应的处理页面或处理方法。而动态路由不是作为一个项目运行的配置文件存储在外部,它在项目render的时候才开始定义,router的作者认为route应当和其它普通组件一样,它的作用不是提供路由配置,而是一个普通的UI组件。而这也符合react的开发思想——一切皆组件。
由于我自己对之前版本的路由了解不多,这里就不做比较了,有兴趣的小伙伴可以自己去了解一下。这里引一段router作者为什么要做这样大的改动的解释:

To be candid, we were pretty frustrated with the direction we’d taken React Router by v2. We (Michael and Ryan) felt limited by the API, recognized we were reimplementing parts of React (lifecycles, and more), and it just didn’t match the mental model React has given us for composing UI.
We ended up with API that wasn’t “outside” of React, an API that composed, or naturally fell into place, with the rest of React.
坦率地说,我们对于之前版本的Route感到十分沮丧,我和我的小伙伴意识到我们在重新实现react的部分功能,比如生命周期和其它更多的,但是这一点都不符合react的模型设计(UI组件)。我们真正想要开发出的不是脱离了react的API ,而是一个本身就属于react一部分的API.这才是我们想要的route(英语功底太差,大家将就着看吧)
——引自react-router的作者

安装

正如我前面所说,对于web应用,我们只需要安装react-router-dom

yarn add react-router-dom

不过在node_modules下你依然会看到react-router的身影,这是react-router-dom依赖的包,另外还有一个history包,这个下面会提到。

<Router>

<Router>是实现路由最外层的容器,一般情况下我们不再需要直接使用它,而是使用在它基础之上封装的几个适用于不同环境的组件,react-router-dom的Router有四种:

Router 适用情况
BrowserRouter react-router-dom扩展,利用HTML5 新增的history API (pushState, replaceState),是web应用最常用的路由组件
HashRouter react-router-dom扩展,利用window.location.hash,适用于低版本浏览器或者一些特殊情境
MemoryRouter 继承自react-router ,用户在地址栏看不到任何路径变化,一般用在测试或者非浏览器环境开发中
StaticRouter 继承自react-router,某些页面从渲染出来以后没有多的交互,所以没有状态的变化需要存储,就可以使用静态路由,静态路由适用于服务器端

备注一:<withRouter>有别于上面四个组件,这里没有列出来。

备注二:一般我们很少会用到<MemoryRouter><StaticRouter>,在web应用中更多的是用react-router-dom扩展出来的<BrowserRouter><HashRouter>,这两个就是我前面提到的前端路由的两种解决办法的各自实现。

为了不被后面的一些配置弄迷糊,我们从<Router>的实现源码来看看路由到底传了些什么东西。

Router.js

class Router extends React.Component {
  //检测接收的参数
  static propTypes = {
    history: PropTypes.object.isRequired, //必须传入
    children: PropTypes.node
  }

  //设置传递给子组件的属性
  getChildContext() {
    return {
      router: {
        ...this.context.router, 
        history: this.props.history, //核心对象
        route: {
          location: this.props.history.location, //history里的location对象
          match: this.state.match //当路由路径和当前路径成功匹配,一些有关的路径信息会存放在这里,嵌套路由会用到它。
        }
      }
    }
  }
    state = {
      match: this.computeMatch(this.props.history.location.pathname)
    }

  computeMatch(pathname) {
    return {
      path: '/',
      url: '/', 
      params: {}, //页面间传递参数
      isExact: pathname === '/'
    }
  }
}

这里面最重要的就是需要我们传入的history对象,我前面提到过我们一般不会直接使用<Router>组件,因为这个组件要求我们手动传入history对象,但这个对象又非常重要,而且不同的开发环境需要不同的history,所以针对这种情况react-router才衍生了两个插件react-router-domreact-router-native(我认为这是比较重要的原因,浏览器有一个history对象,所以web应用的路由都是在此对象基础上扩展的)。
接着让我们来看一下react-router-dom用到的来自history的两个方法:

  • createBrowserHistory 适用于现代浏览器(支持h5 history API)
  • createHashHistory 适用于需要兼容老版本浏览器的情况

这两个方法就分别对应了两个组件:<BrowserRouter><HashRouter>,它俩返回的history对象拥有的属性是一样的,但是各自的实现不同。

//createHashHistory.js
var HashChangeEvent = 'hashchange'; //hash值改变时会触发该事件
var createHashHistory = function createHashHistory() {
  var globalHistory = window.history; //全局的history对象
  var handleHashChange = function handleHashChange() {} //hash值变化时操作的方法
}
//createBrowserHistory.js
var PopStateEvent = 'popstate'; //监听url的变化事件
var HashChangeEvent = 'hashchange'; //依然监听了hash改变的事件,但是多加了一个判断是是否需要监听hash改变,如果不需要就不绑定该事件。
var createBrowserHistory = function createBrowserHistory() {
  var globalHistory = window.history; //全局的history对象
  var handlePop = function handlePop(location) {} //出栈操作
}

//createHashHistory.js,createBrowserHistory.js导出的history对象
const history = {
    length: globalHistory.length, //globalHistory就是window.history
    action: "POP", //操作历史状态都属于出栈操作
    location: initialLocation, //最重要的!!前面的Router.js源码向子组件单独传递了这个对象,因为路由匹配会用到它。
    createHref, //生成的url地址样式,如果是hash则加一个'#'
    push, //扩展history.pushState()方法
    replace, //扩展history.replaceState()方法
    go, //history.go()方法
    goBack, //history.back()方法
    goForward, //history.forward()方法
    block,
    listen
}

我们从控制台打印一下看看这个history

image.png

所以,我们直接用<BrowserRouter>与使用<Router>搭配createBrowserHistory()方法是一样的效果。

import {
    Router,
} from 'react-router-dom'
import createBrowserHistory from 'history/createBrowserHistory';

const history = createBrowserHistory();

const App = () => (
    <Router history={history}>
        <div>{/*其它*/}</div>
    </Router>
)

就等于:

import {
    BrowserRouter,
} from 'react-router-dom'

const App = () => (
    <BrowserRouter>
        <div>{/*其它*/}</div>
    </BrowserRouter>
)

<BrowserRouter>和<HashRouter>使用注意点
<HashRouter>生成的url路径看起来是这样的:

http://localhost:8080/#/user

我们知道hash值是不会传到服务器端的,所以使用hash记录状态不需要服务器端配合,但是<BrowserRouter>生成的路径是这样的:

http://localhost:8080/user

这时候在此目录下刷新浏览器会重新向服务器发起请求,服务器端没有配置这个路径,所以会出现can't GET /user这种错误,而解决方法就是,修改devServer的配置(前面我们配置了热替换,其实就是用webpack-dev-server搭了一个本地服务器):
webpack.config.js

    devServer: {
        publicPath: publicPath,
        contentBase: path.resolve(__dirname, 'build'),
        inline: true,
        hot: true,  
        historyApiFallback: true, //增加
    },

还有一点需要注意的是<Router>只能有一个子孩子,这也符合React的规则。

小结:这里讲了这么多还扯到了源码估计你会觉得烦了,但是请相信,这些东西很有用,我自己在学习router的时候,一开始的状态就是好像我知道怎么用,咦?path是什么?match是什么?exact在不同的地方效果怎么不一样?match.url和match.path看起来一模一样为什么用法不一样?这么多东西都是从哪里来的?等我把router到底用的什么在操作历史状态搞清楚了,接下来要学的知识就完全清晰了,到这里为止我其实已经断断续续花了一周多时间了,但这非常值得。

<Route>

<Route>是路由配置的具体实现,它指定当路径匹配的时候渲染哪一个UI,一个基本的路由配置如下:

    <Router>
        <div>
            <Route exact path="/" component={Home}/>
            <Route strict path="/login" render={() => <h1>Login</h1>} />
            <Route path="/user" children={() => <h1>User</h1>}/>
        </div>
    </Router>

path,exact,strict
path是用于指定路径名的,exactstrict是匹配路径名时指定更为严格的匹配规则,其匹配原则用的是path-to-regexp

  1. 如果<Route>不写path则总是能被匹配。
  2. 当exact为true时只有path等于location.pathname时才会匹配成功。location就是前面Router提到的location对象,我也在图中框出来了。
  3. 当strict为true时会严格验证尾随线,path和location.pathname都有或者都没有才会匹配成功。

让我们看几个例子理解一下,注意以下例子exactstrict都是写在<Route>里的,<NavLink>也有这两个值,写在这两个地方效果是不一样的,后面会讲<NavLink>.

path location.pathname exact match?
/ /user false yes
/ /user true no
/user /user/:name false yes
/user /user/:name true no

注:第三、四行是带参数路由的写法,后面会讲。

总结:从表中可以看出,当一个路径包含某一个路径,暂且称它们为子路径和父路径,如果exact为false(默认),那么“子路径”会渲染出“父路径”的UI(所有的路径都是'/'的子路径)如果不想子路径渲染出父路径的UI,那么就给父路径添加exact属性。所以表中一二行的exact是加在‘/’的<Route>里,三四行是加在'/user'的<Route>里。

path location.pathname strict match?
/user/ /user false yes
/user/ /user true no
/user /user/ true yes
/user/ /user/logout true or false yes

注意:表中第二三行的区别,即多余的尾随线加在location.pathname里,那么依然会匹配成功。

从第四行可以看出,path有尾随线,location.pathname有二级路由,会被认为也是有尾随线的,所以会匹配成功,不过只需要再添加exact,那么就无法匹配成功了。

component, render, children
component, render, children是渲染UI方法,它们的区别如下:

  • component (最常用)当路径匹配时渲染UI,内部实现用的是React.createElement()方法,即每一次都会触发卸载和创建组件,如果渲染的UI没有多余的内容,推荐使用render。
  • render 当路径匹配时渲染UI,与component不同的是,它只调用render()方法去渲染组件,不会去重新创建元素,所以速度更快,只适用于行内渲染。
  • children 与render类似,唯一的区别是不管路径是否匹配都会渲染,所以它最适合用于做转场动画

这三个方法在渲染组件的同时还传递了几个参数过去,这些参数也不是它的,是从前面传下来的:

const props = { match, location, history, staticContext }

除了最后一个其它三个我们已经见过了,match来自Router.js,前面我也贴过源码,historyloaction来自history插件的createBrowserHistory(或createHashHistory)方法,最后一个我暂时还不清楚怎么用。现在,这几个UI组件都可以访问到这几个对象了:

//component
<Route path="/user" component={User} />
//User.js
class User extends Component {
        let { match, location, history } = this.props;
    render() {
        return(
            <div className="user"></div>    
        ) 
    }
}
//render, children
<Route path="/user" render={(match, location, history) => <div></div>} />

当然,有个最简单的方式就是直接传一个props属性过去,这几个对象可以直接通过props属性访问:

//render, children
<Route path="/user" render={(props) => <User {...props}/>} />

它们有啥用?后面就知道了。

<NavLink>和<Link>

前面的<Route>提供了路由配置,<NavLink><Link>就是可以访问这些路由的组件,也就是:

Route path => path
//to可以是对象也可以是字符串
NavLink(Link) to => location or location.pathname

总结:整个路由栈匹配就是在围绕pathlocation.pathname这两个东西,其中,<Route>组件负责path, <NavLink>(<Link>)组件负责location.pathname

一个简单的<NavLink>示例:

<ul>
    <li><NavLink exact to="/">Home</NavLink></li>
    <li><NavLink strict
        to={{
            pathname: "/user/",
            state: {isLogin: true},
            search: 'name=melody',
            hash: 'tab1'
        }}>
        User</NavLink></li>
    <li><NavLink to="/login">Login</NavLink></li>
</ul>

<NavLink>和 <Link>的区别
它俩都是react-router-dom提供的组件,<NavLink>是在<Link>上面扩展了当路由匹配时添加样式属性,而这更常用,所以建议直接使用<NavLink>.

<Link>提供的属性及方法:

  • to [string]: 路径名
  • to [object]: location对象,值如下:
 {
    pathname: '/', //路径名,
    search: '', //参数,会添加到url里面,形如"?name=melody&age=20"
    hash: '', //参数,会添加到url里面,形如"#tab1"
    state: {},//参数,不会添加到url里面
  }
  • replace[bool]: false, //是否替换当前路由,正常情况下是往路由栈里新增一条数据,如果将此参数设置为true,则会替换当前路由。

<NavLink>扩展的属性及方法:

  • activeClassName[string]:'active', //路由匹配时添加的class,默认是active
  • activeStyle[object]: {}, //路由匹配时的样式
  • exact[bool]: 是否开启严格模式
  • strict[bool]:是否严格验证尾随线

exact和strict
我对于<NavLink>设置这两个参数非常困惑,比如我遇到的一个坑:

image.png

我已经设置了<Route exact path="/" component={Home}/>,并且在login页也不会渲染出home页的UI,但是我却非常惊讶的发现当我使用了<NavLink>的选中样式属性时,在二级路由(图中的User和Login)里却始终显示着Home页的选中样式。后来我发现需要给匹配Home页的<NavLink>也添加exact。而strict参数我也并没有验证出加与不加有何区别。

然后我又去源码里面找答案了:
NavLink.js

return (
    <Route
      path={escapedPath}
      exact={exact}
      strict={strict}
      location={location}
      children={({ location, match }) => {
        const isActive = !!(getIsActive ? getIsActive(match, location) : match)

        return (
          <Link
            to={to}
            className={isActive ? [ className, activeClassName ].filter(i => i).join(' ') : className}
            style={isActive ? { ...style, ...activeStyle } : style}
          />
        )
      }}
    />
  )

我发现这俩参数依然是添加在了<Route>组件上,那为什么和之前<Route>exactstrict参数表现会不一样呢?这里有一个关键属性就是isActive,源码中可以看到,某一个路由是否匹配完全取决于这个属性。

前面我没有提到<NavLink>还可以传入一个方法:isActive(),源码中的getIsActive对应的就是我们传入的isActive方法,源码中的isActive仅仅是一个布尔值。官网对isActive()方法的解释是:

isActive[func]:添加额外逻辑以确定路由是否处于被匹配状态。 如果你想要做的不仅仅是验证链接的路径名是否与当前URL的路径名相匹配,那么应该使用它。

从源码中可以看到:当不传入isActive()方法时,isActive的取值就是match,match就比较好玩了,我在最前面提到过它最常用在嵌套路由中,当路由不匹配的时候,它的值为null。当路由匹配时,它会长这样:

match = {
  isExact: true, //没研究过,不知道干啥用的
  params: {}, //参数
  path: "/", //值就是<Route>的path值
  url: "/" //值就是location.pathname
}

也就是说,假如你当前在Login页下面,那么Login页的match对象有值,而别的页面Login页的match是null,但是这个别的页面不包括首页,如下:

image.png

解决办法就是给<NavLink to="/"> 添加exact参数。

注:exact和strict都是对正则匹配添加了别的验证条件,react-router的路由匹配使用的是path-to-regexp插件,我的正则一向很烂,这个东西我也没去研究,所以这里就不误导大家了。

<Switch>

顾名思义<Switch>就是一个“开关”,它会在多个路由配置都可以匹配成功的时候只选择第一个匹配上的渲染其UI,有的时候它也需要和exact配合使用,否则会有永远匹配不上某个路由的情况发生。比如:

<Route path="/dataList" component={List}/>
<Route path="/dataList/:id" component={ListDetails}/>
<Route path="/error" component={Error} />
<Route path="*" render={() => <Redirect to="/error"/> }/>


<li><NavLink to="/dataList/4">Go to ListDetails</NavLink></li>
<li><NavLink to="/dataList">Go to List</NavLink></li>

List<Route>配置没有加exact参数,所以在ListDetails页也会渲染出List页面,添加了<Switch>以后,根据<Switch>的工作原则,它只渲染第一个匹配成功的UI,这就会导致ListDetails永远不会被渲染,而正确做法是给List添加exact:

<Switch>
        <Route exact path="/dataList" component={List}/>
        <Route path="/dataList/:id" component={ListDetails}/>
        <Route path="/error" component={Error} />
        <Route path="*" render={() => <Redirect to="/error"/> }/>
</Switch>

我觉得<Switch>最大的作用就是可以实现当所有路由都匹配不上的时候,可以显示一个404页面,也就是代码中的Error页。

注意:使用<Switch>路由配置的顺序非常重要,因为它会渲染第一个匹配上的,所以应该将最详细的路由写在前面,容易被配上的路由写在后面。

<Redirect>

重定向组件,它会从路由栈里将当前路由替换为它的路径名,这也是它和<NavLink>的最大区别。

 <Route path="/error" component={Error} />
 <Route path="*" render={() => <Redirect to="/error"/> }/>

to和push
to属性和<NavLink>to一样,可以为string,也可以为object,为string时就是location.pathname,为object时就是location对象。

push属性对应<NavLink>replace,<Redirect>默认行为是替换路由,而<NavLink>默认行为是新增一个路由,pushreplace就是改变它们的默认行为的参数。

from
指定一个路由名,当匹配到该路由时重定向到另一个路由上:

<Switch>
  <Redirect from="/hello" to="/user" />
  <Route path="/login" component={Login}/>
  <Route exact path="/user" render={() => <div>hello user</div>}/>
</Switch>

注:官网说指定的路由必须在<Switch>里面才有效,但我测试了发现它的意思不是说from或者to的值都必须是<Switch>里面的<Route>的path指定过的(比如代码中的/hello),而是如果你要使用from属性则必须将其包含在<Switch>里面,否则页面会报警告。并且要注意<Redirect>要写在它想要替换的路由配置前面,否则不会生效。

小结

大致上路由的知识点就这些了,还有一些不常用到的没有提到,路由看着简单但妙用很多,我自己也没有研究得特别深入,只不过可以将自己开发过程中的一些经验分享出来,本身也是和大家一起交流学习的嘛~
下一篇我会分享一些实用的例子,路由的代码也会等下一篇文章完结后上传到github上面的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容