本文综合Learn React with TypeScript 3一书和Fullstack React(第r39版)一书中的相关内容写成,有较大改动
开发环境
- Linux Mint 19
- node 10.6.0
- create-react-app 3.0.1
创建项目
首先新建项目:
$ create-react-app reactshop --typescript
安装TSLint:
$ cd reactshop
$ npm i -D tslint tslint-react tslint-config-prettier
在项目根目录下新建tslint.json
文件,内容如下(这里是我的偏好,读者应自行更改):
{
"extends": [
"tslint:recommended",
"tslint-react",
"tslint-config-prettier"
],
"rules": {
"jsx-boolean-value": [true, "never"],
"jsx-curly-spacing": [true, "never"],
"jsx-equals-spacing": [true, "never"],
"jsx-key": true,
"jsx-self-close": true,
"jsx-space-before-trailing-slash": true,
"curly": [true, "ignore-same-line"],
"member-access": true,
"semicolon": [true, "never"],
"trailing-comma": [true, {
"multiline": "always",
"singleline": "never"
}],
"quotemark": [
true,
"single",
"jsx-double",
"avoid-template",
"avoid-escape"
],
"object-literal-sort-keys": [
true,
"match-declaration-order"
]
},
"linterOptions": {
"exclude": [
"node_modules/**/*.ts"
]
}
}
往根目录下的tsconfig.json
文件中加入严格空值检查选项:
{
"compilerOptions": {
"strictNullChecks": true,
// ...
}
}
安装React Router:
$ npm i -S react-router-dom
$ npm i -D @types/react-router-dom
删去下面这些自动生成的文件:
App.css
App.test.tsx
App.tsx
serviceWorker.ts
移除index.tsx
中对上面文件的引用,并将其内容更改为:
import 'bootstrap/dist/css/bootstrap.min.css'
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import ProductsPage from './pages/ProductsPage'
ReactDOM.render(
,
document.getElementById('root'),
)
到此项目创建完成。
基本页面
我们开发的是一个简单的产品展示页面,所以首先编造一些需要展示的商品数据。在src
文件夹下新建data
文件夹,并在其中新建products.ts
文件,内容为:
export interface IProduct {
id: number
name: string
price: number
description: string
}
export const products: IProduct[] = [
{
id: 1,
name: 'React Router',
price: 8,
description: 'A collection of navigational components that compose declaratively with your app',
},
{
id: 2,
name: 'React Redux',
price: 12,
description: 'A library that helps manage state across your app',
},
{
id: 3,
name: 'React Apollo',
price: 12,
description: 'A library that helps you interact with a GraphQL backend',
},
]
以上就是我们等下会用到的几条简单的数据。
接下来我们来编写显示这些产品的页面。在src
文件夹下新建pages
文件夹,并在其中新建ProductsPage.tsx
文件,写入如下内容:
import React, {Component} from 'react'
import {IProduct, products} from '../data/products-data'
接着我们先为该页面组件的状态声明一个interface
:
interface IState {
products: IProduct[]
}
然后是产品列表页面组件(的骨架):
class ProductsPage extends Component<{}, IState> {
public constructor(props: {}) {
super(props)
this.state = {
products: [],
}
}
}
export default ProductsPage
利用componentDidMount
来“读取”数据到组件状态中:
public componentDidMount() {
this.setState({products})
}
最后是render
方法,一一展示状态中的产品信息:
public render() {
return
Welcome to React Shop where you can get all your tools for ReactJS!
{this.state.products.map(product =>
-
{product.name}
,
)}
}
往index.css
文件中加入如下样式:
.page-container {
text-align: center;
padding: 20px;
font-size: large;
}
.product-list {
list-style: none;
margin: 0;
padding: 0;
}
.product-list-item {
padding: 5px;
}
现在把这个页面展示出来。在src
文件夹下新建App.tsx
文件,内容如下:
import React, {FC} from 'react'
import ProductsPage from './pages/ProductsPage'
const App: FC = () =>
export default App
可以简单地理解为,现在我们的应用只有一个页面,即上面定义的产品列表页面。
修改index.tsx
:将render
调用中的改为
(别忘了导入该组件)。
然后运行npm start
,浏览器会打开如下页面:
接下来我们创建另一个页面:管理员页面。在pages
文件夹下新建文件AdminPage.tsx
,写入如下内容:
import React, {FC} from 'react'
const AdminPage: FC = () =>
Admin Panel
You should only be here if you have logged in
export default AdminPage
这个管理员页面应当只有登录且有相应权限的用户可以访问。我们将在后面实现这一需求。现在它只是显示如上定义的一句话。
如果我们把App.tsx
中展示的页面组件改为
,则浏览器会显示:
响应地址栏
现在提出如下需求:
- 用户访问
/products
时,显示产品列表页面 - 用户访问
/admin
时,显示管理员页面
也就是说我们的应用需要相应浏览器的当前URL。Web API里的window.location
可以用来获取当前URL的path部分。我们可以利用它自己来写一个路由映射组件。
在src
文件夹下新建components
文件夹,在其中新建SimpleRoute.tsx
文件,写入如下内容:
import React, {ComponentType, FC} from 'react'
interface IProps {
path: string
component: ComponentType
}
const SimpleRoute: FC = ({path, component}) => {
const currentPath = window.location.pathname
if (currentPath.match(path)) {
return React.createElement(component)
}
return null
}
export default SimpleRoute
打开App.tsx
,导入SimpleRoute
,并将App
组件改写如下:
const App: FC = () =>
这样,我们就实现了上面提出的两个需求。值得指出的是,现在当我们访问形如/productsXgdjls9g
或者/admin/ggu904
这样的路径时,应用也只会去匹配开头的那部分路径。
我们可以继续打磨SimpleRoute
组件,让它支持全文匹配与否的配置,甚至支持路径参数的解析,但这些属于细节,而无法展示更多原理,我们就不自己实现了。
响应链接
接下来我们来考虑这样一个新的要求:
- 在所有的页面上展示两个链接,分别指向
/products
和/admin
路径;当用户点击它们时,地址栏内容相应地改变,显示的页面也随之变化
先来实现第一个。我们可以将这两个链接展示在页面的头部区域,并为这个区域新建一个组件components/Header.tsx
:
import React, {FC} from 'react'
import logo from '../logo.svg'
const Header: FC = () =>
React Shop
export default Header
index.css
中增加如下样式:
.header {
text-align: center;
background-color: #222;
height: 170px;
padding: 20px;
color: white;
}
.header-logo {
animation: header-logo-spin infinite 20s linear;
height: 80px;
}
@keyframes header-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg)
}
}
.header-title {
font-size: 1.5em;
}
.header-link {
color: #fff;
text-decoration: none;
padding: 5px;
}
在App
里展示这个头部:
const App: FC = () =>
浏览器刷新完成后,页面上确实显示有两个链接,而且都能够把我们导向正确的路径。
不过现在有一个问题,因为我们使用的是HTML的原生元素,所以上面的链接会向服务端发起请求,且浏览器会在得到回复后重新刷新并展示所回复的页面,这不符合“单页面”的要求。所以我们还是得利用Web API提供的接口来阻止这一行为并修改浏览器的地址栏。
window.history
是一个代表当前会话的历史的对象,提供back()
和forward()
这样的方法来在历史页面中进行跳转,以及pushState()
方法将浏览器导航到新的地址。这正是我们需要的。
新建components/SimpleLink.tsx
文件,写入:
import React, {FC} from 'react'
interface IProps {
to: string
className?: string
}
const SimpleLink: FC = (props) => {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
window.history.pushState(null, props.to, props.to)
}
return
{props.children}
}
export default SimpleLink
const Header: FC = () =>
// ...
浏览器刷新后,可以看到,现在的两个链接确实会正确地更新地址栏,也不会再引发页面的重载,但是问题是,点击它们也不会让应用更新页面的显示。这是为什么呢?回顾下SimpleRoute
组件,很显然,从Web API获取当前路径以及检查匹配与否都发生在渲染阶段,而浏览器的路径(或者更准确地来说,是本地会话的历史)的改变,并不会让该组件重新渲染。这就是症结所在:我们需要监听会话的历史的改变,然后触发SimpleRoute
的重新渲染。
由于Web API并没有提供直接监听会话历史改变的接口,所以我们需要使用一个名叫history
的第三方包。简单来理解,它是对window.history
的一层封装,提供了对各大浏览器的兼容和一些接口的改进和功能上的增强。另外,它也是React Router的依赖之一,所以我们这里不用另行安装。
为使用这套接口,打开App.js
,加入如下代码:
import {createBrowserHistory} from 'history'
export const history = createBrowserHistory()
将SimpleRoute
改写如下:
class SimpleRoute extends Component {
private unregister?: () => void
public componentDidMount() {
this.unregister = history.listen(() => this.forceUpdate())
}
public componentWillUnmount(): void {
if (this.unregister) {
this.unregister()
}
}
public render() {
const currentPath = history.location.pathname
const {path, component: Comp} = this.props
if (currentPath.match(path)) {
return
}
return null
}
}
这里我们在componentDidMount()
里注册了一个event handler去监听history
的路径变化事件。然后在componentWillUnmount()
里注销。
检查下应用,现在还是不能正确更新页面,那是因为我们的SimpleLink
还没有使用history
接口。更新其代码:
const SimpleLink: FC = (props) => {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
history.push(props.to)
}
// ...
}
现在我们的应用就能正确地通过链接在页面之间切换了。
重构
上面的代码里,有很多“反模式”的地方:
SimpleRoute
和SimpleLink
直接通过一个“外部对象”(history
)来共享状态SimpleRoute
和SimpleLink
都属于“末端组件”,最好都是方法组件,但为了监听外部事件,SimpleRoute
是以类组件的方式实现的
我们使用React 16.3开始提供的新Context API来消灭这些反模式。
打开App.tsx
文件,将其内容替换为:
import {createBrowserHistory, History} from 'history'
import React, {Component} from 'react'
import Header from './components/Header'
import SimpleRoute from './components/SimpleRoute'
import AdminPage from './pages/AdminPage'
import ProductsPage from './pages/ProductsPage'
export interface IRoutingContext {
history: History
}
export const RoutingContext =
React.createContext({} as IRoutingContext)
const history = createBrowserHistory()
class App extends Component {
public constructor() {
super({})
history.listen(() => this.forceUpdate())
}
public render() {
return
}
}
export default App
上面有了context的提供者为其子元素提供history
对象,下面要做的就是消费这一context。
将SimpleLink
更改为:
const SimpleLink: FC = (props) => {
(ctx: IRoutingContext) => {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
ctx.history.push(props.to)
}
return
{props.children}
}}
注意context的消费者(RoutingContext.Consumer
)接收的是参数为该context数据类型的方法组件。
类似地,SimpleRoute
可以改为:
const SimpleRoute: FC = (props: IProps) => {
(ctx: IRoutingContext) => {
const currentPath = ctx.history.location.pathname
const {path, component: Comp} = props
if (currentPath.match(path)) {
return
}
return null
}
}
现在我们的应用又能够正常运行,但是我们可以进一步把涉及到路由的逻辑从App
组件中剥离,将它们移到新建的components/SimpleRouter.tsx
文件中:
import {createBrowserHistory, History} from 'history'
import React, {Component} from 'react'
export interface IRoutingContext {
history: History
}
export const RoutingContext =
React.createContext({} as IRoutingContext)
const history = createBrowserHistory()
class SimpleRouter extends Component<{}> {
public constructor(props: {}) {
super(props)
history.listen(() => this.forceUpdate())
}
public render() {
return
{this.props.children}
}
}
export default SimpleRouter
这样可以使App
专门负责装配的工作:
const App: FC = () =>
这样,应用还是运行正确,且代码更加清晰易读。
页面跳转
目前我们的应用可以正确地处理开头为/products
和/admin
的路径。下面我们来实现这样一个需求:
- 用户访问Admin页面时,如果没有登录,浏览器会在倒数3秒后跳转到登录页面
首先,在pages
文件夹新建LoginPage.tsx
如下:
import React, {FC} from 'react'
const LoginPage: FC = () =>
You will be able to log in here
export default LoginPage
往App
中加入一条路由:
const App: FC = () =>
// ...
将AdminPage.tsx
整个改写为:
import React, {Component} from 'react'
import SimpleRedirect from '../components/SimpleRedirect'
interface IState {
secondsLeft: number
}
class AdminPage extends Component<{}, IState> {
public constructor(props: {}, private intervalId?: NodeJS.Timeout) {
super(props)
this.state = {
secondsLeft: 3,
}
}
public componentDidMount() {
this.intervalId = setInterval(this.countDown, 1000)
}
public componentWillUnmount() {
if (this.intervalId) {
clearInterval(this.intervalId)
}
}
public render() {
return
Admin Panel
You should only be here if you have logged in
You haven't logged in. Redirecting to the login page in {this.state.secondsLeft} seconds
{this.state.secondsLeft < 1 ? : null}
}
private countDown = () => {
this.setState({
secondsLeft: this.state.secondsLeft - 1,
})
}
}
export default AdminPage
上面,我们展示了马上将要创建的SimpleRedirect
组件的用法。它的to
属性用于指定跳转的目标路径。而从之前的内容我们知道,实现浏览器的路径跳转其实就是调用history.push()
方法,而history
对象可以通过路由context获得。
新建components/SimpleRedirect.tsx
文件,内容为:
import React, {FC} from 'react'
import {IRoutingContext, RoutingContext} from './SimpleRouter'
interface IProps {
to: string
}
const SimpleRedirect: FC = props =>
{(ctx: IRoutingContext) => {
ctx.history.push(props.to)
return null
}}
export default SimpleRedirect
值得注意的是,这个组件本身并不渲染任何HTML元素,仅仅是调用接口来操纵浏览器的URL。
访问/admin
路径,页面会展示一条倒数3秒的信息,并在最后成功跳转到登录页面。
切换到React Router
上面我们自己动手实现了路由相关的若干组件。这一节我们要把它们替换成React Router提供的组件。它们背后的实现原理和思路是差不多的。
首先我们从components
文件夹里删除以下文件:
SimpleLink.tsx
SimpleRedirect.tsx
SimpleRoute.tsx
SimpleRouter.tsx
接着打开App.tsx
文件,删除对上述文件的引用,并从react-router-dom
中导入相对应的组件,加以替换:
// import SimpleRoute from './components/SimpleRoute'
// import SimpleRouter from './components/SimpleRouter'
import {BrowserRouter as Router, Route} from 'react-router-dom'
const App: FC = () =>
同样地,在Header.tsx
里,用react-router-dom
的Link
替换SimpleLink
;在AdminPage.tsx
里,我们用react-router-dom
中的Redirect
来替换SimpleRedirect
。应用依然正常运行。
现在我们来看看React Router还提供了哪些其他好用的功能。
NavLink
例如这样一个新的需求:
- 当浏览器显示产品或者管理员页面时,其在头部区域的对应的链接应该展示不同的样式
这时我们就可以使用React Router提供的NavLink
组件。它和Link
组件非常类似,只是我们可以给它指定额外的类名(通过activeClassName
属性),当它的路径和当前路径匹配时,则其元素会加上这些类名。
打开Header.tsx
,做如下改动:
// import {Link} from 'react-router-dom'
import {NavLink} from 'react-router-dom'
const Header: FC = () =》
// ...
往index.css
中加入:
.header-link-active {
border-bottom: #ebebeb solid 2px;
}
现在应用就会突显“当前链接”以额外的样式:
路径参数
如果我们想把路径中的某一部分截取出来,在处理逻辑中使用,那就需要用到路径参数。简单来说,React Router里带有参数的路径模式都形如/products/:id
,如果用户访问其中:id
比如我们要求:
- 把产品列表页面的每一行产品名称都转化成一个链接,当用户点击时,跳转到单独展示该产品信息的单个产品页面
- 单个产品页面的路径应该形如
/products/:id
,其中:id
代表该产品的ID
显然,应用中,我们需要把:id
这一部分从路径里截取出来。
先为单个产品页面添加一条路由匹配规则。打开App.tsx
,加入下面代码中中间那一行:
// 新加下面这一行
接着在pages
文件夹下新建ProductPage.tsx
文件,先写入下面的代码:
import React, {Component} from 'react'
import {RouteComponentProps} from 'react-router'
import {IProduct} from '../data/products-data'
type Props = RouteComponentProps<{id: string}>;
interface IState {
product?: IProduct | null
}
Props
是接下来要定义的ProductPage
组件的props
的类型,由于在ProductPage
中,我们需要获取有关当前路径的信息,所以将Props
声明为RouteComponentProps
的一个“特例”,类型参数{id: string}
表明我们希望从路径中取得一个名叫id
类型为string
的参数。要指出的是,当我们像这样写定一个组件的props
的类型为一种RouteComponentProps
时,一般来说,这个组件就只能用在Route
匹配规则里面了。
IState
用于定义ProductPage
的state
的属性。product
表示页面当前展示的产品。
下面来定义ProductPage
组件:
class ProductPage extends Component {
public constructor(props: Props) {
super(props)
this.state = {}
}
public componentDidMount() {
const id = parseInt(this.props.match.params.id, 10)
const filtered = products.filter(p => p.id === id)
this.setState({
product: filtered[0] || null,
})
}
public render() {
return {
'product' in this.state ?
this.state.product ?
this.displayProduct(this.state.product) :
Product not found!
:
Loading...
}
}
private displayProduct = (product: IProduct) => {
return
{product.name}
{product.description}
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(product.price)}
}
}
export default ProductPage
当一个ProductPage
对象新创建时,其state
为一个空对象,表示当前没有载入产品信息。在componentDidMount
方法里,我们用this.props.match.params.id
获取所需的路径参数,作为所需载入的产品的ID。上面讲到,this.props
的类型为RouteComponentProps<{id: string}>
,那么可以想见,match
就是定义在这个类型里的一个属性,而{id: string}
则用来设定match.params
。得到产品ID后,我们试着找出对应的产品,如果没有,则设置为null
。最后,根据state
中product
可能取值的三种情况,渲染不同的内容。
切换到浏览器,如果我们用地址栏访问路径/products/1
,/products/2
或/products/3
,则会看到如下内容:
如果对应ID的产品不存在,则会是:
上面显示结果有个问题:把产品列表的内容也显示出来了。这是因为指向产品列表页面的路径模式/products
也会和形如/products/xxx
的路径匹配。为了解决这个问题我们可以使用Route
组件的exact
属性。打开App.tsx
文件,将对应的那行代码改为:
这样产品列表就不会显示在单个产品页面上了:
下面在产品列表页面增加链接。打开ProductsPage.tsx
,修改render()
方法为:
public render() {
return
Welcome to React Shop where you can get all your tools for ReactJS!
{this.state.products.map(product =>
-
{product.name}
,
)}
}
最后往index.css
里加入:
.product-list-item a {
text-decoration: none;
}
如此我们就实现了从产品列表跳转到单个产品页面的需求。
Switch
来看新的需求:
- 当用户访问上面没有定义的页面时,显示一个“未找到”页面
先为这个页面创建组件。新建pages/NotFoundPage.tsx
:
import React, {FC} from 'react'
const NotFoundPage: FC = () =>
Sorry, this page cannot be found
export default NotFoundPage
然后在App.tsx
的最后添加一条路由规则:
const App: FC = () =>
很容易看出,当访问之前未定义的页面时,将会显示:
但如果现在访问之前定义的页面,未找到的信息也会显示在底部:
这是因为我们为了“兜底”所有未显式定义的路径,使用的匹配模式为"/"
,这样所有的页面都会与之匹配。这也是React Router中的Router
组件的特点,它会展示所有与当前路径匹配的规则的组件。
这时候,我们就需要用到React Router提供的Switch
。它必须用在Router
(BrowserRouter
)之下,用来“框住”一组Route
,且只会展示其中第一个与当前路径匹配的规则的组件。
修改App.tsx
如下:
const App: FC = () =>
注意到最后的Route
组件去掉了path="/"
,这两种写法是等价的。
再打开产品列表等页面,就看不到“未找到页面”的信息了。
模拟登录
现在当我们访问Admin页面时,浏览器会最终跳转到登录页面。我们接下来想要在登录页面模拟登录的功能,好让用户可以正常访问Admin页面。
先在App.tsx
里加入一个记录登录与否的状态:
const App: FC = () => {
const [loggedIn, setLoggedIn] = React.useState(false)
const logIn = () => setLoggedIn(true)
return ...
}
打开AdminPage.tsx
,为props
添加接口,并使用到组件的定义里:
interface IProps {
loggedIn: boolean
}
class AdminPage extends Component {
public constructor(props: IProps, private intervalId?: NodeJS.Timeout) {
// ...
修改componentDidMount()
方法,如果用户已经登录,则不用再倒计时:
public componentDidMount() {
if (this.props.loggedIn) return
this.intervalId = setInterval(this.countDown, 1000)
}
修改render()
方法,如果用户已经登录,不再跳转:
public render() {
return
Admin Panel
{this.props.loggedIn ?
Welcome
:
You should only be here if you have logged in
You haven't logged in. Redirecting to the login page in {this.state.secondsLeft} seconds
{this.state.secondsLeft < 1 ? : null}
}
}
AdminPage
目前的实现有一个小问题,有可能会带来bug。现在不论用户登录与否,AdminPage
都保有一个跳转前剩余秒数的状态。这不是最安全的情景:当用户已经登录,这个状态就应该不存在。我们可以把这个状态以及跳转的逻辑剥离成一个单独的组件。
新建components/CountDownRedirectNotice.tsx
文件,内容为:
import React, {Component} from 'react'
import {Redirect} from 'react-router'
interface IProps {
maxSeconds: number
to: string
}
interface IState {
secondsLeft: number
}
class CountDownRedirectNotice extends Component {
public constructor(props: IProps, private intervalId?: NodeJS.Timeout) {
super(props)
this.state = {
secondsLeft: this.props.maxSeconds,
}
}
public componentDidMount() {
this.intervalId = setInterval(this.countDown, 1000)
}
public componentWillUnmount() {
if (this.intervalId) {
clearInterval(this.intervalId)
}
}
public render() {
return
You will be redirected to "{this.props.to}" in {this.state.secondsLeft} seconds
{this.state.secondsLeft < 1 ? : null}
}
private countDown = () => {
this.setState({
secondsLeft: this.state.secondsLeft - 1,
})
}
}
export default CountDownRedirectNotice
回过头来,把AdminPage.tsx
更改为:
import React, {FC} from 'react'
import CountDownRedirectNotice from '../components/CountDownRedirectNotice'
interface IProps {
loggedIn: boolean
}
const AdminPage: FC = ({loggedIn}) =>
Admin Panel
{loggedIn ?
Welcome
:
You should only be here if you have logged it
}
export default AdminPage
接下来把LoginPage.tsx
整个修改为:
import React, {FC} from 'react'
import {Redirect} from 'react-router'
interface IProps {
loggedIn: boolean
onLogin: () => void
}
const LoginPage: FC = (props) =>
{props.loggedIn ?
:
You can login here
}
export default LoginPage
最后就是在App.tsx
里显示这两个页面了。我们会用到前面定义的loggedIn
状态和setLoggedIn
方法。
创建两个高阶组件:
const App: FC = () => {
// ...
const AdminPageEnhanced = () =>
const LoginPageEnhanced = () =>
return ...
}
然后修改下面这两个对应的Route
:
return
// ...
// ...
现在,我们因为未登录被Admin页面转到登录页面后,看到的是:
点击按钮,浏览器会立即跳转到Admin页面:
路由嵌套
登录后,我们需要在Admin页面管理用户和产品。具体的需求如下:
- Admin页面需展示两个链接:Users和Products
- 当用户点击Users,浏览器的地址栏应该显示路径为
/admin/users
,页面应该展示一系列的用户名。每一个用户名也是一个链接。当用户点击任意一个用户名时,浏览器应切换为类似/admin/users/1
的路径,其中1
为该用户的ID,且页面应该显示该用户的信息 - 当用户通过地址栏直接访问形如
admin/user/:id
的路径时,如果未登录,则应按照之前的方式处理;如果对应:id
的用户不存在,则需给用户以合适的反馈;否则,展示对应用户的信息 - 类似地,用户点击Preducts时,路径应为
/admin/produts
。因为已经有专门的产品列表信息,这里就不用再展示产品的信息了。
为了开发方便,我们先在App.tsx
中把默认的登录状态改为true
:
const [loggedIn, setLoggedIn] = React.useState(true)
首先,我们生成一些用户的信息。在data
文件夹下新建文件users.ts
,内容为:
export interface IUser {
id: number
name: string
isAdmin: boolean
}
export const users: IUser[] = [
{
id: 1,
name: 'Fred',
isAdmin: true,
},
{
id: 2,
name: 'Bob',
isAdmin: false,
},
{
id: 3,
name: 'Jane',
isAdmin: true,
},
]
Admin页面需要通过链接修改浏览器路径,需要为不同的路径展示不同的内容,所以我们在AdminPage.tsx
中做相应的导入:
import {NavLink, Route, Switch} from 'react-router-dom'
把AdminPage
的实现重构为:
const AdminPage: FC = (props) => {
const contentsForLoggedIn = () => {
return Welcome
}
const contentsForNotLoggedIn = () =>
You should only be here if you have logged it
return
Admin Panel
{props.loggedIn ?
contentsForLoggedIn() :
contentsForNotLoggedIn()
}
}
现在我们让Admin页面展示Users和Product两个链接:
const contentsForLoggedIn = () => {
return
-
Users
-
Products
{adminSectionBody()}
}
const adminSectionBody = () => {
return Please select a tab
}
加入对应的样式到index.css
:
.admin-section-tabs {
list-style: none;
margin: 0 0 20px 0;
padding: 0;
}
.admin-section-tabs li {
display: inline-block;
margin-right: 10px;
}
.admin-section-tabs li a {
color: #222;
text-decoration: none;
}
.admin-link-active {
border-bottom: #6f6e6e solid 2px;
}
现在点击这两个链接虽然可以改变地址栏,但是不会改变页面的显示。为了让页面随地址栏变化,显然我们应该用到Route
。修改adminSectionBody
如下:
const adminSectionBody = () => {
const DefaultComp = () => Please select a tab
return
}
我们先解决简单的AdminProducts
。新建components/AdminProducts.tsx
文件,写入:
import React, {FC} from 'react'
const AdminProducts: FC = () => {
return Some options to administer products
}
export default AdminProducts
然后是展示用户列表的AdminUsers
。新建components/AdminUsers.tsx
。它需要载入并记录用户信息,所以需要为它的state
定义interface如下:
import {IUser, users as allUsers} from '../data/users'
interface IState {
users?: IUser[]
}
AdminUsers
的实现是:
class AdminUsers extends Component<{}, IState> {
public constructor(props: {}) {
super(props)
this.state = {}
}
public componentDidMount() {
this.setState({
users: allUsers,
})
}
public render() {
return
{
this.state.users ?
this.usersSection(this.state.users) :
Loading users...
}
}
private usersSection = (users: IUser[]) => {
return
{
users.map(user =>
{user.name}
)
}
{/* Display the selected user below */}
}
}
当所有的用户信息都加载后,usersSection
会为每一个用户创建一个链接并动态生成对应的路径。
如果当前路径形如/admin/users/:id
,则这些链接的下方应该显示对应用户的信息。所以我们应该加入下面的代码:
{/* Display the selected user below */}
}
AdminUser
负责展示用户信息的组件,它有一个users
属性,接受现有全部用户的数据。很容易想见,在这个组件里我们需要通过以this.props.match.params.id
的形式来从路径截取id
的值,但是我们这里却没有传入相应的match
值。那么我们要怎么获得这个值呢?
在components
文件夹里新建AdminUser.tsx
文件,先定义其props
的结构:
import React, {FC} from 'react'
import {RouteComponentProps, withRouter} from 'react-router'
import {IUser} from '../data/users'
interface IProps extends RouteComponentProps<{id: string}> {
users: IUser[]
}
如上面分析的,通过继承RouteComponentProps<{id: string}>
,该组件的props
可以访问match.params.id
成员来获取路径中的相应部分。
下面是这个组件的实现:
const AdminUser: FC = (props) => {
const userInfo = (user: IUser) =>
Id:
{user.id.toString()}
Is Admin:
{user.isAdmin.toString()}
const id =parseInt(props.match.params.id, 10)
const userSelected = props.users.filter(u => u.id === id)[0]
return
{userSelected ?
userInfo(userSelected) :
No such user
}
}
逻辑很好理解:当能够通过用户ID找到对应的用户时,则展示用户信息;否则,反馈给用户“未找到”信息。
现在要使用AdminUser
的话,除了users
,还必须传入history
、location
、match
等RouteComponentProps
所要求的属性。React Router为我们提供了一个名叫withRouter
的方法,如果一个组件A
需要RouteComponentProps
所要求的属性,那么我们可以生成一个高阶组件const B = withRouter(A)
,当B
赋值给Route
组件的component
时,它将自动地获取那些属性。
所以这里我们不是简单地导出AdminUser
,而是:
// import {withRouter} from 'react-router'
...
export default withRouter(AdminUser)
这样,当一层层地选到某个用户信息时,应用会显示:
如果直接在地址栏指向一个不存在的用户,则会显示:
总结
以上就是用TypeScript使用React Router库的一个简单的示例。主要的知识点包括:
- React Router实现的原理和思路
- React新的Context API的使用
- React Router中的
BrowerRouter
,Route
,Link
,NavLink
,Switch
等组件和withRouter
方法的使用