愿你坚持不懈,努力进步,进阶成自己的巨人

—— 2017.09, 写给3年后的自己

ReactRouter4学习笔记(一):入门

一、React-router4的理念

react-router4的核心设计理念是“动态路由”

1、静态路由

在Rails、Express、Ember、Angular等库,以及ReactRouter在4.0之前的版本中,使用的都是静态路由。使用静态路由,在应用渲染前的初始化阶段,就需要配置好路由信息。

2、动态路由

动态路由静态路由就不一样了,动态路由是随着应用渲染而起作用的。在动态路由里,无需事先进行路由配置。所以这也就是说,在ReactRouter里一切都是组件,而这也更符合React组件化的思想。

3、嵌套路由

大部分的路由系统都有嵌套路由的概念,在ReactRouter4之前也一样是有嵌套路由这个概念的。那么,ReactRouter4是动态路由,动态路由里又是怎么实现嵌套路由的呢?答案是:ReactRouter4没有“嵌套”的API,<Route>仅仅就是一个组件,它就像<div>一样,我们可以在<Route>里嵌套<Route>,就像<div>里嵌套<div>一样,以此来实现路由的嵌套。如:

const App = () => (
    <BrowserRouter>
        <Route path="/comp1" component={Comp1} /> 
    </BrowserRouter>
)
const Comp1 = ({ match }) => (
    <div>
        <Route path={match.url + '/comp2'} component={Comp2} />
    </div>
)

4、响应式路由

假设有这么一种场景:

有一个后台管理系统,其由左边的导航栏和右边的主面板构成。
由于在小屏幕下空间有限,所以:

  1. 当我们访问/admin时,主面板是隐藏的,由导航栏提供跳转功能
  2. 而在大屏幕下,主面板应该展示,而且这种情况下的路由地址是/admin/dashboard
  3. 此外,如果在大屏幕下访问/admin,那么是不合理的,因为这种情况下右边没有内容可以展示,所以应该自动跳转到/admin/dashboard

假设我们在一部手机上访问/admin,那么竖屏模式下应该是展示导航栏,而当我们旋转手机到横屏模式的时候,则应该自动跳转到/admin/dashboard

ReactRouter4为这种场景提供了支持,针对以上场景,可以实现如:

const App = () => (
    <AppLayout>
        <Route path="/admin" component={Admin} />
    </AppLayout>
)
const Admin = () => (
    <Layout>
        <Nav />
        <Media query={PRETTY_SMALL}>
        {screenIsSmall => screenIsSmall
            ? <Switch>
                  <Route exact path="/admin/dashboard" component={Dashboard}/>
                  <Route path="/admin/other" component={Other} />
              </Switch>
            : <Switch>
                  <Route exact path="/admin/dashboard" component={Dashboard}/>
                  <Route path="/admin/other" component={Other} />
                  <Redirect from="/admin" to="/admin/dashboard" />
              </Switch>
        }
        </Media>
    </Layout>
)


二、ReactRouter使用入门

1、安装

安装:npm install react-router-dom
引用:

import React from 'react'
import { BrowserRouter, Route, Link } from 'react-router-dom'

2、基本用法

使用ReactRoutet4,我们首先需要在应用的最外层声明一个路由组件,注意:

这里要根据运行环境选取合适的组件类型,如在浏览器里用BrowserRouter,而在ReactNative里则采用NativeRouter

示例代码如下:

import { BrowserRouter } from 'react-router-dom'
ReactDOM.render({
    <BrowserRouter>
        <App/>
    </BrowserRouter>
})

然后,我们可以使用<Link />组件来创建导航:

const App = () => (
    <div>
        <Link to="/dashboard">Dashboard</Link>
    </div>
)

此后,还要使用<Route>组件来渲染一些UI。如:响应用户访问/dashboard时展示的内容,其代码编写如下:

const App = () => (
    <div>
        <Link to="/dashboard">Dashboard</Link>
        <Route path="/dashboard" component={DashBoard} />
    </div>
)

这时候,当用户访问的URL是/dashboard时,<Route>组件就会渲染为:

<Dashboard {...props} />

其中,props等同于对象{ match, location, history }。如果用户访问的URL不匹配,那么这种情况下,组件则会渲染为null


三、服务端渲染

服务端渲染的路由和客户端渲染是不太相同的,这是因为服务端渲染都是无状态的。ReactRouter对此的处理主要是采用<StaticRouter>来代替<BrowserRouter><StaticRouter>会根据请求URL和请求上下文进行匹配。

// 客户端
<BrowserRouter>
    <App />
</BrowserRouter>

// 服务端
<StaticRouter location={req.url} context={context}>
    <App />
</StaticRouter>

在客户端中渲染<Redirect>组件时,浏览器的history状态会改变,继而呈现出新一屏的内容,以此实现了状态的变更。然而在静态服务端环境下我们改变不了应用的状态,所以策略是:采用context属性来计算出渲染后的结果,只要当context.url有值时,就表明页面需要进行跳转:

const context = {}
const markup = ReactDOMServer.renderToString(
    <StaticRouter location={req.url} context={context}>
        <App />
    </StaticRouter>
)
if (context.url) {
    // 表明`<Redirect>`组件被渲染了
    redirect(301, context.url)
} else {
    // 响应请求给浏览器
}

1、为应用添加特定的上下文信息

路由只添加了context.url信息,但有时候我们希望当一些特定的UI被渲染的时候,能够进行带301/302、404、401等状态的跳转。那么,我们可以直接为context属性添加信息,如:

const RedirectWithStatus = ({from, to, status}) => (
    <Route render={({ staticContext }) => {
        // 由于客户端里没有staticContext,所以要做一些处理
        if (staticContext) {
            staticContext.status = status
        }
        return <Redirect from={from} to={to} />
    }}/>
)

// 在应用里
const App = () => (
    <Switch>
        <RedirectWithStatus status={301} from="/user" to="/profiles" />
        <RedirectWithStatus status={302} from="/courses" to="/dashboard" />
    </Switch>
)

// 在服务端
const context = {}
const markup = ReactDOMServer.renderToString(
    <StaticRouter context={context}>
        <App />
    </StaticRouter>
)
if (context.url) {
    redirect(context.status, context.url)
}

2、404、401或其他状态

我们可以做和以上类似的事情:创建一个组件,然后为context添加特定的信息,然后根据context里的信息做出特定的响应,如:

const Status = ({ code, children }) => (
    <Route render={({staticContext}) => {
        if (staticContext) {
            staticContext.status = code
        }
        return children
    }}
)

const NotFound = () => {
    <Status code={404}>
        <div>
            <h1>抱歉,页面消失了</h1>
        </div>
    </Status>
}

<Switch>
    <Route path="/about" component={About} />
    <Route path="/dashboard" component={Dashboard} />
    <Route component={NotFound} />
</Switch>

3、完整的例子

以上的实例还不是一个完整的应用,不过都是组成应用的要点部分。以下则是一个完整的实例:

import { createServer } from 'http'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router'
import App from './App'

createServer((req, res) => {
    const context = {}
    const html = ReactDOMServer.renderToString(
        <StaticRouter location={req.url} context={context}>
            <App />
        </StaticRouter>
    )
    if (context.url) {
        res.writeHead(301, {
            Location: context.url
        })
        res.end()
    } else {
        res.write(`
            <!DOCTYPE html>
            <div id="app">${html}</div>
        `)
        res.end()
    }
}).listen(3000)

4、数据加载

服务端渲染的一个限制是我们需要在组件渲染之前加载好所需的数据,所以ReactRouter提供了matchPath这一静态函数来匹配路由,我们可以在服务端使用这个函数来确定渲染前的数据依赖。这种技术的要点是:定义一个静态的路由配置,配置里包含了数据依赖信息,然后根据这个配置信息进行处理,如:

const routes = [
    {
        path: '/',
        component: Root,
        loadData: () => getSomeData()
    },
    // ...
]

然后在应用里,渲染路由的时候就可以用到这个配置了:

import { routes } from './routes'
const App = () => (
    <Switch>
        {routes.map(route => (
            <Route {...route} />
        ))}
    </Switch>
)

然后,服务端里可以这么处理:

import { matchPath } from 'react-router-dom'
const promises = []
// 使用`some`方法是为了只匹配第一个第一个匹配项
routes.some(route => {
    const match = matchPath(req.url, route)
    if (match) {
        promises.push(route.loadData(match))
    }
    return match
})
Promise.all(promises).then(data => {
    // 做一些处理,使得在渲染应用时数据是可用的
})

要注意的是:ReactRouter并没有规定数据加载的方式,但是以上示例展示了在实现服务端渲染时针对数据加载要实现的关键点。


四、代码分离(Code splitting)

Web应用的一大好处就是用户在使用之前不必下载整个APP,也就是说Web应用是按需加载的。我们可以结合使用webpackbundle loader来实现代码分离。
实际上,实现代码分离,路由是不需要做什么事情的,当我们处于一个路由中时,就仅仅意味着我们在渲染某一组件。所以我们可以编写一个<Bundle>组件,来响应路由的变化,并动态地导入组件。如:

import loadSomething from 'bundle-loader?lazy!./Something'
<Bundle load={loadSomething}>
    {(mod) => (
        // 对模块进行一些处理
    )}
</Bundle>

如果模块是一个组件的话,那么我们也可以这么处理:

<Bundle load={loadSomething}>
    {(Comp) => (Comp
        ? <Comp/>
        : <Loading/>
    )}
</Bundle>

<Bundle>组件中,我们定义了属性load,用来接收从webpack的bundle-loader里获取到的内容。因此当组件挂载或者读取一个新的load属性值的时候,就会调用load方法,然后用得到返回值来更新状态。如:

import React, { Component } from 'react'
export default class Bundle extends Component {
    state = {
        mod: null
    }
    componentWillMount() {
        this.load(this.props)
    }
    componentWillReceiveProps(nextProps) {
        if (nextProps.load !== this.props.load) {
            this.load(nextProps)
        }
    }
    load(props) {
        this.setState({
            mod: null
        })
        props.load((mod) => {
            this.setState({
                mod: mod.default ? mod.default : mod
            })
        })
    }
    render() {
        return this.state.mod
            ? this.props.children(this.state.mod)
            : null
    }
}

当模块加载未完毕的时候,render()函数返回的是null,从而能够渲染<Loading/>组件,从而能够给用户提供一些提示,表明我们正在等待模块加载。

1、为什么使用bundle loader,而不是import()

这是因为bundle loader已经使用多年了,只不过现在TC39起草了动态导入的官方提案。最新的提案是import(),而使用import(),我们也只需要简单地修改<Bundle>组件即可,如:

<Bundle load={() => require('./something')}>
    {(mod) => ()}
</Bundle>

而另一个使用bundle loader的巨大好处是,在第二次调用时,回调是同步的,这就防止了每次访问动态加载的内容时,会闪现一屏的Loading。不过不管导入的方式是什么,理念都是一致的:一旦一个组件需要在渲染时加载代码,就可以用<Bundle>组件来处理这种动态的代码加载。

2、在渲染完成后进行加载

<Bundle>组件可以很棒地在访问新一屏内容时进行组件的加载,但它更棒的是可以在后台进行app剩余部分的预加载。如:

import loadAbout from 'bundle-loader?lazy!./loadAbout'
import loadDashboard from 'bundle-loader?lazy!./loadDashboard'
// 初次访问时组件加载各自的模块
const About = (props) => (
    <Bundle load={loadAbout}>
        {(About) => <About {...props} />}
    </Bundle>
)
const Dashboard = (props) => (
    <Bundle load={loadDashboard}>
        {(Dashboard) => <Dashboard {...props}/>}
    </Bundle>
)
const App extends React.Component {
    componentDidMount() {
        loadAbout(() => {})
        loadDashboard(() => {})
    }
    render() {
        return (
            <div>
                <h1>Welcome!</h1>
                <Route path="/about" component={About}></Route>
                <Route path="/dashboard" component={Dashboard}></Route>
            </div>
        )
    }
}

ReactDOM.render(<App/>, preloadTheRestOfTheApp)

而何时加载、加载多少都是取决于我们自己的。这种加载方式无需和特定的路由绑在一起,所以,你可以在用户可交互时加载,也可以在访问特定路由时加载,或者在初始渲染后加载,都是可以的。

3、代码分离与服务端渲染

在多次失败的尝试过后,我们总结出了:
1、在服务端需要进行同步的模块解析,以便在初始渲染时就能获得这些文件。
2、为了保证服务端渲染和客户端渲染的同步,需要在客户端渲染前就加载和服务端渲染一致的所有文件(这是最难搞的地方)
3、在客户端运行的过程中,需要进行异步解析在初始渲染时没有解析的部分
不过,对于代码分离与服务端渲染,ReactRouter官方文档里的解释是:

我们相信Google能够很好地对我们的站点进行检索,而无需我们做服务端渲染。所以我们放弃了服务端渲染,采用更好的代码分离与Service Worker缓存的方式。


五、滚动恢复(scroll restoration)

ReactRouter的早期版本中提供了人们一直以来就强烈呼吁的、支持开箱即用的滚动恢复功能。

1、滚到头部

大部分时候,我们需要的是滚到头部,这是因为当处于在一个长内容的页面里时,如果导航到其他页面,滚动条会停留在底部。为了解决这个问题,可以直接地采用<ScrollToTop>组件来处理,这样一来每次跳转的时候就都会滚到窗口的顶部。不过需要将它包裹在withRouter里,从而确保它能够访问到路由的属性。如:

class ScrollToTop extends Component {
    componentDidUpdate(prevProps) {
        if (this.props.location !== prevProps.location) {
            window.scrollTo(0, 0)
        }
    }
    render() {
        return this.props.children
    }
}
export default withRouter(ScrollToTop)

然后将其放在<App/>组件的父级,<Router>的子级里:

const App = () => (
    <Router>
        <ScrollToTop>
            <App/>
        </ScrollToTop>
    </Router>
)
// 也可以单独放在任何地方,不过只能放一个
<ScrollToTop/>

有时候我们想要的不是每个跳转都要将滚动条恢复到顶部。如在切换Tab时,就不期望每次都滚到头部。这种情况下,可以编写成<ScrollToTopOnMount>组件,然后在需要滚到头部的情况下才使用,如:

class ScrollToTopOnMount extends Component {
    componentDidMount(prevProps) {
        window.scrollToTop(0, 0)
    }
    render() {
        return null
    }
}

class LongContent extends Component {
    render() {
        <div>
            <ScrollToTopOnMount/>
            <h1>这里是长内容页面</h1>
        </div>
    }
}

<Route path="/long-content" component={LongContent} />

2、通用解决方案

在寻求通用解决方案的时候,我们主要关心两件事:

  • 在导航跳转时滚到顶部,避免开启新一屏内容时滚动条还在底部
  • 在点击浏览器的“前进”或“回退”按钮(非点击链接)时,恢复窗口或者设置了overflow的元素此前的滚动位置

为了达成以上的方案,官方做法是提供一种通过的API,处理如下:

<Router>
    <ScrollRestoration>
        <div>
            <h1>App</h1>
            <RestoredScroll id="bunny">
                <div style={{height: '200px', overflow: 'auto'}}>
                    I will overflow
                </div>
            </RestoredScroll>
        </div>
    </ScrollRestoration>
</Router>

首先,ScrollRestoration能进行导航跳转后滚到窗口顶部的处理。其次,它会使用location.key来保存窗口滚动的位置或者<RestoredScroll>组件的位置,并将其保存在sessionStorage中。然后,当ScrollRestoration或者RestoredScroll组件挂载时,就会从sessionStorage里找出他们的定位。


六、集成redux

Redux是React生态圈里很重要的一部分,所以我们希望能够尽可能地无缝集成React和ReactRouter

1、更新阻碍

总的来说,ReactRouter和Redux一起结合得刚刚好。不过有时候,在location改变时,应用会有一些组件不会随之更新(子路由或者当前导航链接没有更新)
这种情况会发生在:
a. 组件通过connect()(Comp)来连接到Redux
b. 组件不是一个路由组件,意味着它不是这么渲染的:

<Route component={SomeConnectedThing} />

问题在于,Redux实现了shouldComponentUpdate(),然而如果从路由里没有接收到任何属性的话,是没有迹象可以表明发生了什么变化的。修复这个问题也很简单,做法是找出连接组件的地方,然后用withRouter进行包装。如:

// 之前
export default connect(mapStateToProps)(Something)

// 之后
import { withRouter } from 'react-router-dom'
export default withRouter(connect(mapStateToProps)(Something))

2、深度集成

有些人想要:

  • 同步路由数据和store中的数据
  • 能够通过分发actions来进行导航跳转
  • 能够通过Redux的开发者工具实现路由变更的时间旅行调试

以上的需求都需要更深程度的集成。然而请注意,我们可以不需要这种深度集成,因为:

  • 路由数据已经作为一个属性提供给了需要这个数据的大部分组件,而这个数据不管是来自于store或者来自于router,组件的编写方式都没有什么不同
  • 可以传递提供给路由组件的history对象到actions里,然后用这个对象来做跳转
  • 对于时间旅行调试而言,路由变更无关紧要

然而,还是有很多人强烈需要,所以我们也想要尽可能地提供最佳的深度集成方案。随着ReactRouter版本4的发布,React Router Redux包也成了项目的一部分(参照:https://github.com/reacttraining/react-router/tree/master/packages/react-router-redux