ZhangYang's Blog

React Router

安装

前置

首先需要安装 Node.js 环境 和包管理器 npm

Clone例子

git clone https://github.com/BerlinChan/react-router-tutorial-cn.git

cd react-router-tutorial-cn

cd lessons/01-setting-up

npm install

npm start

测试

在浏览器中打开网址 http://localhost:8080

在浏览器中应该看到 “Hello React Router”

修改modules/App.js,修改后浏览器会自动重载更新

初看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
// index.js
import React from 'react'
import { render } from 'react-dom'
import App from './modules/App'
render(<App/>, document.getElementById('app'))
// App.js
import React from 'react'
export default React.createClass({
render() {
return <div>Hello, React Router!</div>
}
})

渲染路由

添加hashHistory

React Router 实质上就是一个组件

1
render(<Router/>, document.getElementById('app'))

配置一个路由,打开index.js

1
2
3
4
5
6
7
import { Router, Route, hashHistory } from 'react-router'
render((
<Router history={hashHistory}>
<Route path="/" component={App}/>
</Router>
), document.getElementById('app'))

命令行中输入 npm start 运行服务后, 在浏览器中访问 http://localhost:8080

URL 中多了一串乱七八糟的字符。 这是因为我们使用了 hashHistory, 它的作用是: 在 URL 中添加 hash 值来管理路由历史

添加更多屏幕显示

创建 2 个新组件

  • modules/About.js
  • modules/Repos.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// modules/About.js
import React from 'react'
export default React.createClass({
render() {
return <div>About</div>
}
})
// modules/Repos.js
import React from 'react'
export default React.createClass({
render() {
return <div>Repos</div>
}
})

修改index.js

1
2
3
4
5
6
7
8
9
10
11
12
// insert into index.js
import About from './modules/About'
import Repos from './modules/Repos'
render((
<Router history={hashHistory}>
<Route path="/" component={App}/>
{/* 在这里添加路由 */}
<Route path="/repos" component={Repos}/>
<Route path="/about" component={About}/>
</Router>
), document.getElementById('app'))

现在访问 http://localhost:8080/#/abouthttp://localhost:8080/#/repos

使用链接导航

最常用的组件也许就是 Link

使用 来导航非常相似,只是它链接的内容是由 Router 渲染到页面上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// modules/App.js
import React from 'react'
import { Link } from 'react-router'
export default React.createClass({
render() {
return (
<div>
<h1>React Router Tutorial</h1>
<ul role="nav">
<li><Link to="/about">About</Link></li>
<li><Link to="/repos">Repos</Link></li>
</ul>
</div>
)
}
})

现在访问 http://localhost:8080,点击上面的链接,然后点浏览器的前进、后退

嵌套的路由

React Router 的路由嵌套提供了另外一种方法来实现 UI 复用

复用导航栏

将 About 和 Repos 组件嵌套进 App 中, 这样我们就能将导航栏复用到本应用程序所有的界面显示中

首先,移动其他的路由到 App Route 其下,成为 App 的子组件

1
2
3
4
5
6
7
8
9
10
11
// index.js
// ...
render((
<Router history={hashHistory}>
<Route path="/" component={App}>
{/* 使如下路由成为 `App` 的子元素 */}
<Route path="/repos" component={Repos}/>
<Route path="/about" component={About}/>
</Route>
</Router>
), document.getElementById('app'))

然后,在 App 中渲染子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// modules/App.js
// ...
render() {
return (
<div>
<h1>Ghettohub Issues</h1>
<ul role="nav">
<li><Link to="/about">About</Link></li>
<li><Link to="/repos">Repos</Link></li>
</ul>
{/* 记得加下面这行 */}
{this.props.children}
</div>
)
}
// ...

现在点击链接并观察,当路由改变的时候, App 组件通过 this.props.children 将对应的子组件渲染到界面上

激活项 Link

Link 与 a 的一个不同之处就是,它知道它自身是否为当前激活项, 并将自己的样式与非激活项区分开

激活项样式

在 Link 中添加 activeStyle 属性

1
2
3
4
// 现在当你点击链接的时候,活动项显示为红色
// modules/App.js
<li><Link to="/about" activeStyle={{ color: 'red' }}>About</Link></li>
<li><Link to="/repos" activeStyle={{ color: 'red' }}>Repos</Link></li>

活动项类名

使用一个活动项类名 activeClassName 代替内联样式

1
2
3
// modules/App.js
<li><Link to="/about" activeClassName="active">About</Link></li>
<li><Link to="/repos" activeClassName="active">Repos</Link></li>

从外部文件中引用,若 Webpack 没有编译 index.html,你需要手动刷新浏览器来呈现上面的代码修改

1
2
3
4
5
6
7
// index.html
<link rel="stylesheet" href="index.css" />
// css 文件中的内容
.active {
color: green;
}

将 activeClassName 或 activeStyle 包裹到组件中

用扩展运算符,它的写法是 …

扩展运算符传递父组件所有props到组件中,与activeClassName一同成为该组件的props

创建 modules/NavLink.js

1
2
3
4
5
6
7
8
9
// modules/NavLink.js
import React from 'react'
import { Link } from 'react-router'
export default React.createClass({
render() {
return <Link {...this.props} activeClassName="active"/>
}
})

替换 App 中的 Link 为 NavLink

1
2
3
4
5
6
7
// modules/App.js
import NavLink from './NavLink'
// ...
<li><NavLink to="/about">About</NavLink></li>
<li><NavLink to="/repos">Repos</NavLink></li>

URL 参数

以 : 开头的部分就是 URL 参数名,参数值会被解析成 URL 中匹配的字符, 这个值可通过路由组件中的 this.props.params[参数名] 来访问

1
2
3
4
5
6
// 如下的 URL
/repos/reactjs/react-router
/repos/facebook/react
// URL 按照下面的模式来匹配
/repos/:userName/:repoName

添加一个带参数的路由组件

首先需要有个组件,当进入这个路由的时候显示相应内容, 新建文件 modules/Repo.js

1
2
3
4
5
6
7
8
9
10
11
12
// modules/Repo.js
import React from 'react'
export default React.createClass({
render() {
return (
<div>
<h2>{this.props.params.repoName}</h2>
</div>
)
}
})

到 index.js 中添加新路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ...
// import Repo
import Repo from './modules/Repo'
render((
<Router history={hashHistory}>
<Route path="/" component={App}>
<Route path="/repos" component={Repos}/>
{/* 添加新路由 */}
<Route path="/repos/:userName/:repoName" component={Repo}/>
<Route path="/about" component={About}/>
</Route>
</Router>
), document.getElementById('app'))

在 Repos.js 中添加一些链接地址并指向新添加的路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Repos.js
import { Link } from 'react-router'
// ...
export default React.createClass({
render() {
return (
<div>
<h2>Repos</h2>
{/* 添加一些连接 */}
<ul>
<li><Link to="/repos/reactjs/react-router">React Router</Link></li>
<li><Link to="/repos/facebook/react">React</Link></li>
</ul>
</div>
)
}
})

路由中 path 的值成为子组件中的 props。 repoName 和 userName 都可以在组件中通过 this.props.params 访问到

更多嵌套

点击 Repos 下的二级导航后,二级导航链接列表会消失

让二级导航链接一直显示,就像全局导航栏

首先,嵌套 Repo 到 Repos 路由下。 然后在 Repos 中渲染 this.props.children

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// index.js
// ...
<Route path="/repos" component={Repos}>
<Route path="/repos/:userName/:repoName" component={Repo}/>
</Route>
// Repos.js
// ...
<div>
<h2>Repos</h2>
<ul>
<li><Link to="/repos/reactjs/react-router">React Router</Link></li>
<li><Link to="/repos/facebook/react">React</Link></li>
</ul>
{/* 当 URL 匹配 /repos/:userName/:repoName 时渲染 `Repo.js` */}
{this.props.children}
</div>

把之前添加的 NavLink 组件用上,用于为活动项链接添加类名 active

1
2
3
4
5
6
7
8
// modules/Repos.js
// import it
import NavLink from './NavLink'
// ...
<li><NavLink to="/repos/reactjs/react-router">React Router</NavLink></li>
<li><NavLink to="/repos/facebook/react">React</NavLink></li>
// ...

页面最上面全局导航的 /repos 链接和其下的二级导航 repo 都被标示为活动项,当子元素为活动项时,其父元素当然也是活动项

首页路由

当访问这个应用的 / 地址时,渲染 Home 首页界面

先创建 Home 组件,然后讨论如何将其指定为 / 时渲染

1
2
3
4
5
6
7
8
// modules/Home.js
import React from 'react'
export default React.createClass({
render() {
return <div>Home</div>
}
})

一种做法是检查 App 中是否有子元素, 若没有的话就渲染 Home 组件

1
2
3
4
5
6
7
8
9
// modules/App.js
import Home from './Home'
// ...
<div>
{/* ... */}
{this.props.children || <Home/>}
</div>
//...

访问到首页的时候, 是像访问 About 和 Repos 时候那样被路由指定进入的

更加利于将 Home 从 App 中解耦,让路由配置决定应该渲染什么子元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// index.js
// new imports:
// add `IndexRoute` to 'react-router' imports
import { Router, Route, hashHistory, IndexRoute } from 'react-router'
// and the Home component
import Home from './modules/Home'
// ...
render((
<Router history={hashHistory}>
<Route path="/" component={App}>
{/* 在这里添加 `/` 时渲染的内容 */}
<IndexRoute component={Home}/>
<Route path="/repos" component={Repos}>
<Route path="/repos/:userName/:repoName" component={Repo}/>
</Route>
<Route path="/about" component={About}/>
</Route>
</Router>
), document.getElementById('app'))

现在打开 http://localhost:8080,新添加的 Home 组件被渲染了

IndexRoute 中没有指定 path 属性

当没有其他的子元素匹配路由的时候,它指向的组件会成为父元素的 this.props.children 被渲染, 这其实也是路由精确匹配所得到的结果

用类比的方式来帮助理解: 当你访问 / 的时候, web 服务器会去找 index.html 页面,React Router 是一样的, 当路由路径匹配到 / 的时候, 去找首页路由

首页链接

添加链接可以链接返回到Home组件

Home 的链接总是显示为激活项

1
2
3
4
// in App.js
// ...
<li><NavLink to="/">Home</NavLink></li>
// ...

使用 IndexLink 链接到首页路由

当我们访问到首页路由的时候, 这个链接才会被标示为激活项

1
2
3
4
5
// App.js
import { IndexLink } from 'react-router'
// ...
<li><IndexLink to="/" activeClassName="active">Home</IndexLink></li>

onlyActiveOnIndex 属性

为 Link 添加 onlyActiveOnIndex 属性也可以达到一样的效果

1
<li><NavLink to="/" activeClassName="active" onlyActiveOnIndex={true}>Home</NavLink></li>

在 NavLink 组件中, 我们使用 {…spread} 扩展运算符, 传递父组件所有的 props 给其中的 Link

这样当渲染一个 NavLink 组件的时候, 其中的所有 props 都能传递到 Link 组件中

1
<li><NavLink to="/" onlyActiveOnIndex={true}>Home</NavLink></li>

清理浏览器历史的 URL

现代浏览器允许 JavaScript 脚本操作 URL 而不发起新的 http 请求, 因此不必再依赖 hash (#) 作为 URL 的一部分来实现路由

配置浏览器历史

打开 index.js 将导入的 hashHistory 替换成 browserHistory

1
2
3
4
5
6
7
8
9
10
// index.js
// ...
// 替换 `hashHistory` 为 `browserHistory`
import { Router, Route, browserHistory, IndexRoute } from 'react-router'
render((
<Router history={browserHistory}>
{/* ... */}
</Router>
), document.getElementById('app'))

配置你的服务器

不论访问什么 URL, 服务器都应该响应返回你的应用程序, 因为应用程序是在浏览器中解析 URL 并操作路由

当前我们的服务器不知该如何处理不同的 URL, 于是返回了Cannot GET /repos

Webpack Dev Server 有一个选项, 用来使服务器总是响应返回应用程序

打开 package.json 然后添加 –history-api-fallback

1
"start": "webpack-dev-server --inline --content-base . --history-api-fallback"

index.html 中的相对路径改成绝对路径

1
2
3
4
5
6
<!-- index.html -->
<!-- index.css -> /index.css -->
<link rel="stylesheet" href="/index.css">
<!-- bundle.js -> /bundle.js -->
<script src="/bundle.js"></script>

配置生产环境服务器

Webpack dev server 不是用于生产环境的服务器,建立一个生产环境服务器, 然后写一个用来识别不同环境, 启动相应服务器的脚本

安装一些模块

1
npm install express if-env compression --save

运行 npm start 的时候, 脚本会检查 NODE_ENV 的值是否为 production

如果是的, 脚本会运行 npm run start:prod, 反之则是运行 npm run start:dev

1
2
3
4
5
6
// package.json
"scripts": {
"start": "if-env NODE_ENV=production && npm run start:prod || npm run start:dev",
"start:dev": "webpack-dev-server --inline --content-base . --history-api-fallback",
"start:prod": "webpack && node server.js"
}

使用 Express 创建一个生产服务器, 建立配置文件 server.js, 放根目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// server.js
var express = require('express')
var path = require('path')
var compression = require('compression')
var app = express()
// 伺服像 index.css 这类静态文件
app.use(express.static(__dirname))
// 响应所有的请求并返回 index.html 页面, 这样 React Router 中的 browserHistory 就能工作了
app.get('*', function (req, res) {
res.sendFile(path.join(__dirname, 'index.html'))
})
var PORT = process.env.PORT || 8080
app.listen(PORT, function() {
console.log('生产 Express 服务器运行在 localhost:' + PORT)
})

运行

1
2
3
NODE_ENV=production npm start
# Windows 用户请输入:
# SET NODE_ENV=production npm start

导航到 http://localhost:8080/package.json,直接暴露配置文件信息

移动一些文件, 然后修改静态文件路径配置

  • 建立一个 public 目录
  • 移动 index.html 和 index.css 到该目录下

修改 server.js, 将静态文件目录指定到正确的位置

1
2
3
4
5
6
7
8
9
10
// server.js
// ...
// add path.join here
app.use(express.static(path.join(__dirname, 'public')))
// ...
app.get('*', function (req, res) {
// and drop 'public' in the middle of here
res.sendFile(path.join(__dirname, 'public', 'index.html'))
})

修改 webpack 的配置, 告诉它构建的目标路径

1
2
3
4
5
6
// webpack.config.js
// ...
output: {
path: 'public',
// ...
}

修改package.json,在 npm run start:dev 后面添加 –content-base 参数

1
"start:dev": "webpack-dev-server --inline --content-base public --history-api-fallback",

现的服务器不会伺服 public 目录之外的文件了, 最后再为 webpack 加上代码压缩的配置, 并给 express 配置上 gzip 压缩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack.config.js
// make sure to import this
var webpack = require('webpack')
module.exports = {
// ...
// add this handful of plugins that optimize the build
// when we're in production
plugins: process.env.NODE_ENV === 'production' ? [
new webpack.optimize.DedupePlugin(),
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.optimize.UglifyJsPlugin()
] : [],
// ...
}

express 服务器配置压缩

1
2
3
4
5
6
7
// server.js
// ...
var compression = require('compression')
var app = express()
// must be first!
app.use(compression())

以生产环境模式来启动服务

1
NODE_ENV=production npm start

编程实现导航

大部分的导航链接使用 Link 实现, 但你也可以编程实现应用程序中的导航, 比如响应表单的提交、按钮的点击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Repos 中添加表单, 并编程实现导航
// modules/Repos.js
import React from 'react'
import NavLink from './NavLink'
export default React.createClass({
// 添加如下方法
handleSubmit(event) {
event.preventDefault()
const userName = event.target.elements[0].value
const repo = event.target.elements[1].value
const path = `/repos/${userName}/${repo}`
console.log(path)
},
render() {
return (
<div>
<h2>Repos</h2>
<ul>
<li><NavLink to="/repos/reactjs/react-router">React Router</NavLink></li>
<li><NavLink to="/repos/facebook/react">React</NavLink></li>
{/* 添加表单 */}
<li>
<form onSubmit={this.handleSubmit}>
<input type="text" placeholder="userName"/> / {' '}
<input type="text" placeholder="repo"/>{' '}
<button type="submit">Go</button>
</form>
</li>
</ul>
{this.props.children}
</div>
)
}
})

将 browserHistory 传递到文件 index.js 内的 Router 当中, 然后推送一个新的 URL 到浏览历史中从而渲染新的内容

1
2
3
4
5
6
7
8
9
10
// modules/Repos.js
import { browserHistory } from 'react-router'
// ...
handleSubmit(event) {
// ...
const path = `/repos/${userName}/${repo}`
browserHistory.push(path)
},
// ...

利用 React 的”上下文特性” 来直接使用父组件 index.js 中 Router 提供的 router

首先在组件中关联上下文, 然后使用它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default React.createClass({
// 从上下文中获取 `router`
contextTypes: {
router: React.PropTypes.object
},
// ...
handleSubmit(event) {
// ...
this.context.router.push(path)
},
// ..
})

服务器渲染

在 React 中, 服务器渲染是一个简单的核心概念

1
2
3
render(<App/>, domNode)
// 下面可以调用服务器渲染
const markup = renderToString(<App/>)

webpack 来打包生成一个运行在服务器端的应用包,创建名为 webpack.server.config.js 的配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var fs = require('fs')
var path = require('path')
module.exports = {
entry: path.resolve(__dirname, 'server.js'),
output: {
filename: 'server.bundle.js'
},
target: 'node',
// 将 node_module 目录排除到应用包之外
externals: fs.readdirSync(path.resolve(__dirname, 'node_modules')).concat([
'react-dom/server', 'react/addons',
]).reduce(function (ext, mod) {
ext[mod] = 'commonjs ' + mod
return ext
}, {}),
node: {
__filename: true,
__dirname: true
},
module: {
loaders: [
{ test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader?presets[]=es2015&presets[]=react' }
]
}
}

更新 package.json 配置

1
2
3
4
5
6
7
8
"scripts": {
"start": "if-env NODE_ENV=production && npm run start:prod || npm run start:dev",
"start:dev": "webpack-dev-server --inline --content-base public/ --history-api-fallback",
"start:prod": "npm run build && node server.bundle.js",
"build:client": "webpack",
"build:server": "webpack --config webpack.server.config.js",
"build": "npm run build:client && npm run build:server"
},

运行命令行,服务器端也能运行应用并渲染页面

1
NODE_ENV=production npm start

为了让路由在服务器端与客户端都可以运行, 我们需要将它放到一个单独的模块中

创建文件 modules/routes, 然后将路由配置移动到其中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// modules/routes.js
import React from 'react'
import { Route, IndexRoute } from 'react-router'
import App from './App'
import About from './About'
import Repos from './Repos'
import Repo from './Repo'
import Home from './Home'
module.exports = (
<Route path="/" component={App}>
<IndexRoute component={Home}/>
<Route path="/repos" component={Repos}>
<Route path="/repos/:userName/:repoName" component={Repo}/>
</Route>
<Route path="/about" component={About}/>
</Route>
)

修改index.js

1
2
3
4
5
6
7
8
9
10
11
// index.js
import React from 'react'
import { render } from 'react-dom'
import { Router, browserHistory } from 'react-router'
// import routes and pass them into <Router/>
import routes from './modules/routes'
render(
<Router routes={routes} history={browserHistory}/>,
document.getElementById('app')
)

打开文件 server.js,从 React Router 中导入 match 和 RouterContext, 这样路由就会先根据 URL 完成匹配, 然后完成渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// ...
// import some new stuff
import React from 'react'
// 下面这个是用于做服务器端渲染的
import { renderToString } from 'react-dom/server'
// 下面的用于使路由匹配 URL, 然后做渲染
import { match, RouterContext } from 'react-router'
import routes from './modules/routes'
// ...
// 发送所有请求到 index.html 让 browserHistory 正常工作
app.get('*', (req, res) => {
//匹配路由到 URL
match({ routes: routes, location: req.url }, (err, redirect, props) => {
// `Router` 渲染的就是 `RouterContext` 对象。
// `Router` 会侦听 `browserHistroy`, 并将历史保存在 `props` 中。
// 但是在服务器端, 应用程序是无状态的,
// 所以我们需要用 ` match` 获取
// `RouterContext` is the what `Router` renders. `Router` keeps these
// `props` in its state as it listens to `browserHistory`. But on the
// server our app is stateless, so we need to use `match` to
// get these props before rendering.
const appHtml = renderToString(<RouterContext {...props}/>)
// dump the HTML into a template, lots of ways to do this, but none are
// really influenced by React Router, so we're just using a little
// function, `renderPage`
res.send(renderPage(appHtml))
})
})
function renderPage(appHtml) {
return `
<!doctype html public="storage">
<html>
<meta charset=utf-8/>
<title>My First React Router App</title>
<link rel=stylesheet href=/index.css>
<div id=app>${appHtml}</div>
<script src="/bundle.js"></script>
`
}
var PORT = process.env.PORT || 8080
app.listen(PORT, function() {
console.log('Production Express server running at localhost:' + PORT)
})

运行命令行

1
NODE_ENV=production npm start

实际生产环境的回调如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.get('*', (req, res) => {
match({ routes: routes, location: req.url }, (err, redirect, props) => {
// in here we can make some decisions all at once
if (err) {
// there was an error somewhere during route matching
res.status(500).send(err.message)
} else if (redirect) {
// we haven't talked about `onEnter` hooks on routes, but before a
// route is entered, it can redirect. Here we handle on the server.
res.redirect(redirect.pathname + redirect.search)
} else if (props) {
// if we got props then we matched a route and can render
const appHtml = renderToString(<RouterContext {...props}/>)
res.send(renderPage(appHtml))
} else {
// no errors, no redirect, we just didn't match anything
res.status(404).send('Not Found')
}
})
})