[React + TypeScript 3] React Router

本文综合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,浏览器会打开如下页面:

[React + TypeScript 3] React Router_第1张图片

接下来我们创建另一个页面:管理员页面。在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中展示的页面组件改为,则浏览器会显示:

[React + TypeScript 3] React Router_第2张图片

响应地址栏

现在提出如下需求:

  • 用户访问/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这样的路径时,应用也只会去匹配开头的那部分路径。

[React + TypeScript 3] React Router_第3张图片

我们可以继续打磨SimpleRoute组件,让它支持全文匹配与否的配置,甚至支持路径参数的解析,但这些属于细节,而无法展示更多原理,我们就不自己实现了。

响应链接

接下来我们来考虑这样一个新的要求:

  • 在所有的页面上展示两个链接,分别指向/products/admin路径;当用户点击它们时,地址栏内容相应地改变,显示的页面也随之变化

先来实现第一个。我们可以将这两个链接展示在页面的头部区域,并为这个区域新建一个组件components/Header.tsx:

import React, {FC} from 'react'

import logo from '../logo.svg'

const Header: FC = () => 
logo

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 = () => 

浏览器刷新完成后,页面上确实显示有两个链接,而且都能够把我们导向正确的路径。

[React + TypeScript 3] React Router_第4张图片

不过现在有一个问题,因为我们使用的是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

然后用SimpleLink替换Header中的:

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)
  }
  // ...
}

现在我们的应用就能正确地通过链接在页面之间切换了。

重构

上面的代码里,有很多“反模式”的地方:

  • SimpleRouteSimpleLink直接通过一个“外部对象”(history)来共享状态
  • SimpleRouteSimpleLink都属于“末端组件”,最好都是方法组件,但为了监听外部事件,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 + TypeScript 3] React Router_第5张图片

切换到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-domLink替换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 + TypeScript 3] React Router_第6张图片

路径参数

如果我们想把路径中的某一部分截取出来,在处理逻辑中使用,那就需要用到路径参数。简单来说,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用于定义ProductPagestate的属性。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。最后,根据stateproduct可能取值的三种情况,渲染不同的内容。

切换到浏览器,如果我们用地址栏访问路径/products/1/products/2/products/3,则会看到如下内容:

[React + TypeScript 3] React Router_第7张图片

如果对应ID的产品不存在,则会是:

[React + TypeScript 3] React Router_第8张图片

上面显示结果有个问题:把产品列表的内容也显示出来了。这是因为指向产品列表页面的路径模式/products也会和形如/products/xxx的路径匹配。为了解决这个问题我们可以使用Route组件的exact属性。打开App.tsx文件,将对应的那行代码改为:


这样产品列表就不会显示在单个产品页面上了:

[React + TypeScript 3] React Router_第9张图片

下面在产品列表页面增加链接。打开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 + TypeScript 3] React Router_第10张图片

但如果现在访问之前定义的页面,未找到的信息也会显示在底部:

[React + TypeScript 3] React Router_第11张图片

这是因为我们为了“兜底”所有未显式定义的路径,使用的匹配模式为"/",这样所有的页面都会与之匹配。这也是React Router中的Router组件的特点,它会展示所有与当前路径匹配的规则的组件。

这时候,我们就需要用到React Router提供的Switch。它必须用在RouterBrowserRouter)之下,用来“框住”一组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页面转到登录页面后,看到的是:

[React + TypeScript 3] React Router_第12张图片

点击按钮,浏览器会立即跳转到Admin页面:

[React + TypeScript 3] React Router_第13张图片

路由嵌套

登录后,我们需要在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,还必须传入historylocationmatchRouteComponentProps所要求的属性。React Router为我们提供了一个名叫withRouter的方法,如果一个组件A需要RouteComponentProps所要求的属性,那么我们可以生成一个高阶组件const B = withRouter(A),当B赋值给Route组件的component时,它将自动地获取那些属性。

    所以这里我们不是简单地导出AdminUser,而是:

    // import {withRouter} from 'react-router'
    ...
    export default withRouter(AdminUser)
    

    这样,当一层层地选到某个用户信息时,应用会显示:

    [React + TypeScript 3] React Router_第14张图片

    如果直接在地址栏指向一个不存在的用户,则会显示:

    [React + TypeScript 3] React Router_第15张图片

    总结

    以上就是用TypeScript使用React Router库的一个简单的示例。主要的知识点包括:

    • React Router实现的原理和思路
    • React新的Context API的使用
    • React Router中的BrowerRouter, Route, Link, NavLink, Switch等组件和withRouter方法的使用

    转载于:https://my.oschina.net/qiaotoubao/blog/3066608

    你可能感兴趣的:(javascript,json,ViewUI)