React 和 TypeScript3 学习手册(三)

原文:zh.annas-archive.org/md5/9ec979022a994e15697a4059ac32f487

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:使用 React Router 进行路由

如果我们的应用程序有多个页面,我们需要管理不同页面之间的导航。React Router 是一个很棒的库,可以帮助我们做到这一点!

在本章中,我们将构建一个网上商店,我们可以在其中购买一些用于 React 的工具。我们的简单商店将有多个页面,我们将使用 React Router 来管理这些页面。当我们完成时,商店将如下截图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在本章中,我们将学习以下主题:

  • 使用路由类型安装 React Router

  • 声明路由

  • 创建导航

  • 路由参数

  • 处理未找到的路由

  • 实现页面重定向

  • 查询参数

  • 路由提示

  • 嵌套路由

  • 动画过渡

  • 延迟加载路由

技术要求

在本章中,我们将使用以下技术:

  • Node.js 和 npm:TypeScript 和 React 依赖于这些。我们可以从nodejs.org/en/download/安装这些。如果已经安装了这些,请确保npm至少是 5.2 版本。

  • Visual Studio Code:我们需要一个编辑器来编写我们的 React 和 TypeScript 代码,可以从code.visualstudio.com/安装。我们还需要在 Visual Studio Code 中安装 TSLint(由 egamma 提供)和 Prettier(由 Estben Petersen 提供)扩展。

本章中的所有代码片段都可以在github.com/carlrip/LearnReact17WithTypeScript/tree/master/04-ReactRouter上找到。

使用路由安装 React Router

React Router及其类型在npm中,因此我们可以从那里安装它们。

在安装 React Router 之前,我们需要创建我们的 React 商店项目。让我们通过选择一个空文件夹并打开 Visual Studio Code 来做好准备。要做到这一点,请按照以下步骤进行:

  1. 现在让我们打开一个终端并输入以下命令来创建一个新的 React 和 TypeScript 项目:
npx create-react-app reactshop --typescript

请注意,我们使用的 React 版本至少需要是16.7.0-alpha.0。我们可以在package.json文件中检查这一点。如果package.json中的 React 版本小于16.7.0-alpha.0,那么我们可以使用以下命令安装此版本:

npm install react@16.7.0-alpha.0
npm install react-dom@16.7.0-alpha.0
  1. 项目创建后,让我们将 TSLint 作为开发依赖项添加到我们的项目中,并添加一些与 React 和 Prettier 兼容的规则:
cd reactshop
npm install tslint tslint-react tslint-config-prettier --save-dev
  1. 现在让我们添加一个包含一些规则的tslint.json文件:
{
  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
  "rules": {
    "ordered-imports": false,
    "object-literal-sort-keys": false,
    "no-debugger": false,
    "no-console": false,
  },
  "linterOptions": {
    "exclude": [
      "config/**/*.js",
      "node_modules/**/*.ts",
      "coverage/lcov-report/*.js"
    ]
  }
}
  1. 现在,让我们输入以下命令将 React Router 安装到我们的项目中:
npm install react-router-dom
  1. 让我们还安装 React Router 的 TypeScript 类型,并将它们保存为开发依赖项:
npm install @types/react-router-dom --save-dev

在进入下一节之前,我们将删除一些我们不需要的create-react-app创建的文件:

  1. 首先,让我们删除App组件。因此,让我们删除App.cssApp.test.tsxApp.tsx文件。让我们还在index.tsx中删除对"./App"的导入引用。

  2. 让我们还通过删除serviceWorker.ts文件并在index.tsx中删除对它的引用来删除服务工作者。

  3. index.tsx中,让我们将根组件从<App/>更改为<div/>。我们的index.tsx文件现在应该包含以下内容:

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import './index.css';

ReactDOM.render(
  <div />,
  document.getElementById('root') as HTMLElement
);

声明路由

我们使用BrowserRouterRoute组件在应用程序中声明页面。BrowserRouter是顶层组件,它查找其下方的Route组件以确定所有不同的页面路径。

我们将在本节的后面使用BrowserRouterRoute声明一些页面,但在此之前,我们需要创建我们的前两个页面。这第一个页面将包含我们在商店中要出售的 React 工具列表。我们使用以下步骤来创建我们的页面:

  1. 因此,让我们首先通过创建一个ProductsData.ts文件并包含以下内容来为我们的工具列表创建数据:
export interface IProduct {
  id: number;
  name: string;
  description: string;
  price: number;
}

export const products: IProduct[] = [
  {
    description:
      "A collection of navigational components that compose  
       declaratively with your app",
    id: 1,
    name: "React Router",
    price: 8
  },
  {
    description: "A library that helps manage state across your app",
    id: 2,
    name: "React Redux",
    price: 12
  },
  {
    description: "A library that helps you interact with a GraphQL backend",
    id: 3,
    name: "React Apollo",
    price: 12
  }
];
  1. 让我们创建另一个名为ProductsPage.tsx的文件,其中包含以下内容来导入 React 以及我们的数据:
import * as React from "react";
import { IProduct, products } from "./ProductsData";
  1. 我们将在组件状态中引用数据,因此让我们为此创建一个接口:
interface IState {
  products: IProduct[];
}
  1. 让我们继续创建名为ProductsPage的类组件,将状态初始化为空数组:
class ProductsPage extends React.Component<{}, IState> {
  public constructor(props: {}) {
    super(props);
    this.state = {
      products: []
    };
  }
}

export default ProductsPage;
  1. 现在让我们实现componentDidMount生命周期方法,并从ProductData.ts将数据设置为products数组:
public componentDidMount() {
  this.setState({ products });
}
  1. 继续实现render方法,让我们欢迎我们的用户并在列表中列出产品:
public render() {
  return (
    <div className="page-container">
      <p>
        Welcome to React Shop where you can get all your tools for ReactJS!
      </p>
      <ul className="product-list">
        {this.state.products.map(product => (
          <li key={product.id} className="product-list-item">
           {product.name}
          </li>
        ))}
      </ul>
    </div>
  );
}

我们已经在products数组中使用了map函数来迭代元素并为每个产品生成一个列表项标签li。我们需要为每个li赋予一个唯一的key属性,以帮助 React 管理列表项的任何更改,而在我们的情况下是产品的id

  1. 我们已经引用了一些 CSS 类,因此让我们将它们添加到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;
}
  1. 现在让我们实现我们的第二个页面,即管理面板。因此,让我们创建一个名为AdminPage.tsx的文件,并在其中添加以下功能组件:
import * as React from "react";

const AdminPage: React.SFC = () => {
  return (
    <div className="page-container">
      <h1>Admin Panel</h1>
      <p>You should only be here if you have logged in</p>
    </div>
  );
};

export default AdminPage;
  1. 现在我们的商店中有两个页面,我们可以为它们声明两个路由。让我们创建一个名为Routes.tsx的文件,其中包含以下内容,以从 React Router 中导入ReactBrowserRouterRoute组件,以及我们的两个页面:
import * as React from "react";
import { BrowserRouter as Router, Route } from "react-router-dom";

import AdminPage from "./AdminPage";
import ProductsPage from "./ProductsPage";

我们已经在导入语句中将BrowserRouter重命名为Router,以节省一些按键次数。

  1. 接下来,让我们实现一个包含我们两个路由的功能组件:
const Routes: React.SFC = () => {
  return (
    <Router>
      <div>
        <Route path="/products" component={ProductsPage} />
        <Route path="/admin" component={AdminPage} />
      </div>
    </Router>
  );
};

export default Routes;

在渲染过程中,如果Route组件中的path与当前路径匹配,那么该组件将被渲染,如果不匹配,则将渲染null。在我们的例子中,如果路径是"/products",则将渲染ProductPage,如果路径是"/admin",则将渲染AdminPage

  1. 以下是将我们的Routes作为根组件在index.tsx中渲染的最后一步:
import * as React from "react";
import * as ReactDOM from "react-dom";
import "./index.css";
import Routes from "./Routes";

ReactDOM.render(<Routes />, document.getElementById("root") as HTMLElement);
  1. 现在我们应该能够运行我们的应用程序了:
npm start

应用可能会从根页面开始,因为该路径没有指向任何内容,所以页面会是空白的。

  1. 如果我们将路径更改为"/products",我们的产品列表应该呈现如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 如果我们将路径更改为"/admin",我们的管理面板应该呈现如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在我们已经成功创建了一些路由,我们真的需要一个导航组件来使我们的页面更加可发现。我们将在下一节中做到这一点。

创建导航

React Router 提供了一些很好的组件来提供导航。我们将使用这些组件来实现应用程序标题中的导航选项。

使用 Link 组件

我们将使用 React Router 中的Link组件来创建我们的导航选项,具体步骤如下:

  1. 让我们从创建一个名为Header.tsx的新文件开始,其中包含以下导入:
import * as React from "react";
import { Link } from "react-router-dom";

import logo from "./logo.svg";
  1. 让我们在Header功能组件中使用Link组件创建两个链接:
const Header: React.SFC = () => {
  return (
    <header className="header">
      <img src={logo} className="header-logo" alt="logo" />
      <h1 className="header-title">React Shop</h1>
      <nav>
        <Link to="/products" className="header-
         link">Products</Link>
        <Link to="/admin" className="header-link">Admin</Link>
      </nav>
    </header>
  );
};

export default Header;

Link组件允许我们定义链接导航到的路径以及要显示的文本。

  1. 我们已经引用了一些 CSS 类,所以让我们把它们添加到index.css中:
.header {
  text-align: center;
  background-color: #222;
  height: 160px;
  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;
}
  1. 现在我们的Header组件就位了,让我们在Routes.tsximport它:
import Header from "./Header";
  1. 然后我们可以在 JSX 中使用它如下:
<Router>
  <div>
    <Header />
    <Route path="/products" component={ProductsPage} />
    <Route path="/admin" component={AdminPage} />
  </div>
</Router>
  1. 如果我们检查正在运行的应用程序,它应该看起来像以下截图,有一个漂亮的标题和两个导航选项,可以转到我们的产品和管理页面:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 尝试点击导航选项-它们有效!如果我们使用浏览器开发者工具检查产品和管理元素,我们会看到 React Router 已将它们呈现为锚标签:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如果我们在点击导航选项时查看开发者工具中的网络选项卡,我们会看到没有网络请求正在被发出来为我们的 React 应用程序提供页面。这表明 React Router 正在处理我们的导航。

使用 NavLink 组件

React Router 还提供了另一个用于链接页面的组件,称为NavLink。实际上,这更适合我们的需求。以下步骤解释了我们如何重构我们的Header组件以使用NavLink

  1. 所以,让我们在我们的Header组件中用NavLink替换Link并进行一些改进:
import * as React from "react";
import { NavLink } from "react-router-dom";

import logo from "./logo.svg";

const Header: React.SFC = () => {
  return (
    <header className="header">
      <img src={logo} className="header-logo" alt="logo" />
      <h1 className="header-title">React Shop</h1>
      <nav>
        <NavLink to="/products" className="header-
         link">Products</NavLink>
        <NavLink to="/admin" className="header-
         link">Admin</NavLink>
      </nav>
    </header>
  );
};

export default Header;

此时,我们的应用程序看起来和行为都完全一样。

  1. NavLink公开了一个activeClassName属性,我们可以用它来设置活动链接的样式。所以,让我们使用它:
<NavLink to="/products" className="header-link" activeClassName="header-link-active">
  Products
</NavLink>
<NavLink to="/admin" className="header-link" activeClassName="header-link-active">
  Admin
</NavLink>
  1. 让我们将header-link-active的 CSS 添加到我们的index.css中:
.header-link-active {
  border-bottom: #ebebeb solid 2px;
}
  1. 如果我们现在切换到正在运行的应用程序,活动链接将被下划线划掉:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,NavLink非常适合主应用程序导航,我们希望突出显示活动链接,而Link非常适合应用程序中的所有其他链接。

路由参数

路由参数是路径的可变部分,在目标组件中可以使用它们来有条件地渲染某些内容。

我们需要向我们的商店添加另一个页面,以显示每个产品的描述和价格,以及将其添加到购物篮的选项。我们希望能够使用"/products/{id}"路径导航到此页面,其中id是产品的 ID。例如,到达 React Redux 的路径将是"products/2"。因此,路径的id部分是一个路由参数。我们可以通过以下步骤来完成所有这些:

  1. 让我们在两个现有路由之间的Routes.tsx中添加此路由。路由的id部分将是一个路由参数,我们在其前面用冒号定义它:
<Route path="/products" component={ProductsPage} />
<Route path="/products/:id" component={ProductPage} />
<Route path="/admin" component={AdminPage} />
  1. 当然,ProductPage组件还不存在,所以,让我们首先创建一个名为ProductPage.tsx的新文件,其中包含以下导入:
import * as React from "react";
import { RouteComponentProps } from "react-router-dom";
import { IProduct, products } from "./ProductsData";
  1. 关键部分在于我们将使用RouteComponentProps类型来访问路径中的id参数。让我们使用RouteComponentProps通用类型来定义我们的ProductPage组件的 props 类型别名,并传入一个具有id属性的类型:
type Props = RouteComponentProps<{id: string}>;

如果您不理解type表达式中的尖括号,不要担心。这表示一个通用类型,我们将在第五章中探讨高级类型

理想情况下,我们应该将id属性指定为数字,以匹配产品数据中的类型。但是,RouteComponentProps只允许我们拥有类型为字符串或未定义的路由参数。

  1. ProductPage组件将具有状态来保存正在呈现的产品以及它是否已添加到购物篮中,因此让我们为我们的状态定义一个接口:
interface IState {
  product?: IProduct;
  added: boolean;
}
  1. 产品最初将是undefined,这就是为什么它被定义为可选的。让我们创建我们的ProductPage类并初始化状态,以便产品不在购物篮中:
class ProductPage extends React.Component<Props, IState> {
  public constructor(props: Props) {
    super(props);
    this.state = {
      added: false
    };
  }
}

export default ProductPage;
  1. 当组件加载到 DOM 中时,我们需要使用Route参数中的id属性从产品数据中找到我们的产品。RouteComponentProps给我们一个包含params对象的match对象,其中包含我们的id路由参数。所以,让我们实现这个:
public componentDidMount() {
  if (this.props.match.params.id) {
    const id: number = parseInt(this.props.match.params.id, 10);
    const product = products.filter(p => p.id === id)[0];

    this.setState({ product });
  }
}

请记住,id路由参数是一个字符串,这就是为什么我们在将其与filter数组中的产品数据进行比较之前,将其转换为数字使用parseInt

  1. 现在我们已经在组件状态中有了我们的产品,让我们继续进行render函数:
public render() {
  const product = this.state.product;
  return (
    <div className="page-container">
      {product ? (
        <React.Fragment>
          <h1>{product.name}</h1>
          <p>{product.description}</p>
          <p className="product-price">
            {new Intl.NumberFormat("en-US", {
              currency: "USD",
              style: "currency"
            }).format(product.price)}
          </p>
          {!this.state.added && (
            <button onClick={this.handleAddClick}>Add to 
              basket</button>
          )}
        </React.Fragment>
      ) : (
        <p>Product not found!</p>
      )}
    </div>
  );
}

在这个 JSX 中有一些有趣的地方:

  • 在函数内的第一行,我们将product变量设置为产品状态,以节省一些按键,因为我们在 JSX 中引用产品很多。

  • div内的三元运算符在有产品时呈现产品。否则,它会通知用户找不到产品。

  • 我们在三元运算符的真部分中使用React.Fragment,因为三元运算符的每个部分只能有一个父级,React.Fragment是一种实现这一点的机制,而不需要渲染像div这样的不是真正需要的标记。

  • 我们使用Intl.NumberFormat将产品价格格式化为带有货币符号的货币。

  1. 当单击“添加到购物篮”按钮时,我们还将调用handleAddClick方法。我们还没有实现这一点,所以现在让我们这样做,并将added状态设置为true
private handleAddClick = () => {
  this.setState({ added: true });
};
  1. 现在我们已经实现了ProductPage组件,让我们回到Routes.tsx并导入它:
import ProductPage from "./ProductPage";
  1. 让我们打开我们的运行中的应用,输入"/products/2"作为路径:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

不太符合我们的要求!ProductsPageProductPage都被渲染了,因为"/products/2"同时匹配"/products""/products/:id"

  1. 为了解决这个问题,我们可以告诉"/products"路由只在有精确匹配时才进行渲染:
<Route exact={true} path="/products" component={ProductsPage} />
  1. 在我们进行这些更改并保存Routes.tsx之后,我们的产品页面看起来好多了:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 我们不打算让用户输入特定的路径来访问产品!因此,我们将更改ProductsPage,使用Link组件为每个产品链接到ProductPage。首先,让我们从 React Router 中导入LinkProductsPage中:
import { Link } from "react-router-dom";
  1. 现在,我们不再在每个列表项中渲染产品名称,而是要渲染一个Link组件,用于跳转到我们的产品页面:
public render() {
  return (
    <div className="page-container">
      <p>
        Welcome to React Shop where you can get all your tools 
         for ReactJS!
      </p>
      <ul className="product-list">
        {this.state.products.map(product => (
          <li key={product.id} className="product-list-item">
            <Link to={`/products/${product.id}`}>{product.name}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}
  1. 在我们查看运行中的应用之前,让我们在index.css中添加以下 CSS 类:
.product-list-item a {
  text-decoration: none;
}

现在,如果我们在应用中的产品列表中点击一个列表项,它会带我们到相关的产品页面。

处理未找到的路由

如果用户输入了我们应用中不存在的路径会怎么样?例如,如果我们尝试导航到"/tools",我们在标题下面什么都看不到。这是有道理的,因为 React Router 没有找到匹配的路由,所以什么都没有渲染。然而,如果用户导航到无效的路径,我们希望通知他们该路径不存在。以下步骤可以实现这一点:

  1. 因此,让我们创建一个名为NotFoundPage.tsx的新文件,其中包含以下组件:
import * as React from "react";

const NotFoundPage: React.SFC = () => {
  return (
    <div className="page-container">
      <h1>Sorry, this page cannot be found</h1>
    </div>
  );
};

export default NotFoundPage;
  1. 让我们在Routes.tsx中导入这个:
import NotFoundPage from "./NotFoundPage";
  1. 然后让我们在其他路由中添加一个Route组件:
<Router>
  <div>
    <Header />
    <Route exact={true} path="/products" component={ProductsPage} 
       />
    <Route path="/products/:id" component={ProductPage} />
    <Route path="/admin" component={AdminPage} />
    <Route component={NotFoundPage} />
  </div>
</Router>

然而,这将对每个路径进行渲染:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

当没有找到其他路由时,我们如何只渲染NotFoundPage?答案是在 React Router 中用Switch组件包裹路由。

  1. 首先在Routes.tsx中导入Switch
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
  1. 现在让我们将Route组件包裹在Switch组件中:
<Switch>
  <Route exact={true} path="/products" component={ProductsPage} />
  <Route path="/products/:id" component={ProductPage} />
  <Route path="/admin" component={AdminPage} />
  <Route component={NotFoundPage} />
</Switch>

Switch组件只渲染第一个匹配的Route组件。如果我们查看运行中的应用,我们会发现我们的问题已经解决了。如果输入一个不存在的路径,我们会得到一个友好的未找到消息:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

实现页面重定向

React Router 有一个名为Redirect的组件,我们可以用它来重定向到页面。我们在接下来的几节中使用这个组件来改进我们的商店。

简单重定向

如果我们访问/路由路径,我们会注意到我们得到了“抱歉,找不到此页面”的消息。让我们把它改成在路径为/时重定向到"/products"

  1. 首先,我们需要在Routes.tsx中导入Redirect组件:
import { BrowserRouter as Router, Redirect,Route, Switch } from "react-router-dom";
  1. 现在我们可以使用Redirect组件在路径为/时重定向到"/products"
<Switch>
 <Redirect exact={true} from="/" to="/products" />
  <Route exact={true} path="/products" component={ProductsPage} 
   />
  <Route path="/products/:id" component={ProductPage} />
  <Route path="/admin" component={AdminPage} />
  <Route component={NotFoundPage} />
</Switch>
  1. 我们在Redirect上使用了exact属性,以便它只匹配/而不匹配"/products/1""/admin"。如果我们尝试在运行的应用程序中输入/作为路径,它将立即重定向到"/products"

条件重定向

我们可以使用Redirect组件来保护未经授权的用户访问页面。在我们的商店中,我们可以使用这个来确保只有已登录的用户可以访问我们的 Admin 页面。我们通过以下步骤来实现这一点:

  1. 让我们首先在Routes.tsx中的 Admin 页面路由之后添加一个到LoginPage的路由:
<Route path="/login" component={LoginPage} />
  1. 当然,LoginPage目前不存在,所以让我们创建一个名为LoginPage.tsx的文件并输入以下内容:
import * as React from "react";

const LoginPage: React.SFC = () => {
  return (
    <div className="page-container">
      <h1>Login</h1>
      <p>You need to login ...</p>
    </div>
  );
};

export default LoginPage;
  1. 然后我们可以回到Routes.tsx并导入LoginPage
import LoginPage from "./LoginPage";
  1. 如果我们去运行的应用程序并导航到"/login",我们会看到我们的登录页面:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

我们不打算完全实现我们的登录页面;我们已经实现的页面足以演示条件重定向。

  1. 在我们在Routes.tsx中实现"admin"路径的条件重定向之前,我们需要在Routes.tsx中添加一个关于用户是否已登录的状态:
const Routes: React.SFC = () => {
  const [loggedIn, setLoggedIn] = React.useState(false);
  return (
    <Router>
      ...
    </Router>
   );
};

因此,我们使用了useState钩子来添加一个名为loggedIn的状态变量和一个名为setLoggedIn的函数。

  1. 最后一步是在"/admin"路径的Route组件内添加以下内容:
<Route path="/admin">
 {loggedIn ? <AdminPage /> : <Redirect to="/login" 
 />}
</Route>

如果用户已登录,我们有条件地渲染AdminPage,否则,我们重定向到"/login"路径。如果我们现在在运行的应用程序中点击admin链接,我们将被重定向到登录页面。

  1. 如果我们将loggedIn状态更改为 true,我们就能再次访问我们的 Admin 页面:
const [loggedIn, setLoggedIn] = React.useState(true);

查询参数

查询参数是 URL 的一部分,允许将附加参数传递到路径中。例如,"/products?search=redux"有一个名为search的查询参数,值为redux

让我们实现这个例子,让商店的用户可以搜索产品:

  1. 让我们首先在ProductsPage.tsx中的状态中添加一个名为search的变量,它将保存搜索条件:
interface IState {
  products: IProduct[];
  search: string;
}
  1. 鉴于我们需要访问 URL,我们需要在ProductsPage中使用RouteComponentProps作为props类型。让我们首先导入这个:
import { RouteComponentProps } from "react-router-dom";
  1. 然后我们可以将其用作props类型:
class ProductsPage extends React.Component<RouteComponentProps, IState> {
  1. 我们可以在constructor中将search状态初始化为空字符串:
public constructor(props: RouteComponentProps) {
  super(props);
  this.state = {
    products: [],
    search: ""
  };
}
  1. 然后我们需要在componentDidMount中将search状态设置为搜索查询参数。React Router 通过location.searchprops参数中给我们访问所有查询参数。然后我们需要解析该字符串以获取我们的搜索查询字符串参数。我们可以使用URLSearchParamsJavaScript 函数来做到这一点。我们将使用静态的getDerivedStateFromProps生命周期方法来做到这一点,当组件加载时以及其props参数发生变化时会调用该方法:
public static getDerivedStateFromProps(
  props: RouteComponentProps,
  state: IState
) {
  const searchParams = new URLSearchParams(props.location.search);
  const search = searchParams.get("search") || "";
  return {
    products: state.products,
    search
  };
}
  1. 不幸的是,URLSearchParams在所有浏览器中尚未实现,因此我们可以使用一个名为url-search-params-polyfill的 polyfill。让我们安装这个:
npm install url-search-params-polyfill
  1. 让我们将其导入到ProductPages.tsx中:
import "url-search-params-polyfill";
  1. 然后我们可以在render方法中使用search状态,通过在返回的列表项周围包装一个if语句,只有在产品名称中包含search的值时才返回结果:
<ul className="product-list">
  {this.state.products.map(product => {
    if (
 !this.state.search ||
 (this.state.search &&
 product.name
 .toLowerCase()
 .indexOf(this.state.search.toLowerCase()) > -1)
 ) {
      return (
        <li key={product.id} className="product-list-item">
          <Link to={`/products/${product.id}`}>{product.name}
           </Link>
        </li>
      );
    } else {
 return null;
 }
  })}
</ul>
  1. 如果我们在运行的应用程序中输入"/products?search=redux"作为路径,我们将看到我们的产品列表仅包含 React Redux:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 我们将通过在应用程序标题中添加一个搜索输入来完成实现此功能,该输入将设置搜索查询参数。让我们首先在Header.tsx中的Header组件中创建一些状态来存储搜索值:
 const [search, setSearch] = React.useState("");
  1. 我们还需要通过 React Router 和URLSearchParams访问查询字符串,所以让我们导入RouteComponentPropswithRouterURLSearchParams polyfill:
import { NavLink, RouteComponentProps, withRouter} from "react-router-dom";
import "url-search-params-polyfill";
  1. 让我们向Header组件添加一个props参数:
const Header: React.SFC<RouteComponentProps> = props => { ... }
  1. 现在我们可以从路径查询字符串中获取搜索值,并在组件首次渲染时将search状态设置为该值:
const [search, setSearch] = React.useState("");
React.useEffect(() => {
  const searchParams = new URLSearchParams(props.location.search);
 setSearch(searchParams.get("search") || "");
}, []);
  1. 现在让我们在render方法中添加一个search输入,让用户输入他们的搜索条件:
public render() {
  return (
    <header className="header">
      <div className="search-container">
 <input
 type="search"
 placeholder="search"
 value={search}
 onChange={handleSearchChange}
 onKeyDown={handleSearchKeydown}
 />
 </div>
      <img src={logo} className="header-logo" alt="logo" />
      <h1 className="header-title">React Shop</h1>
      <nav>
        ...
      </nav>
    </header>
  );
}
  1. 让我们将刚刚引用的search-container CSS 类添加到index.css中:
.search-container {
  text-align: right;
  margin-bottom: -25px;
}
  1. 回到Header.tsx,让我们添加handleSearchChange方法,该方法在render方法中被引用,并将保持我们的search状态与输入的值保持同步:
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setSearch(e.currentTarget.value);
};
  1. 现在我们可以实现handleSearchKeydown方法,该方法在render方法中被引用。当按下Enter键时,这需要将search状态值添加到路径查询字符串中。我们可以利用RouteComponentProps给我们的history属性中的push方法:
const handleSearchKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === "Enter") {
    props.history.push(`/products?search=${search}`);
  }
};
  1. 我们需要导出使用withRouter高阶组件包装的Header组件,以便引用this.props.history能够工作。所以,让我们这样做并调整我们的export表达式:
export default withRouter(Header);
  1. 让我们在运行的应用程序中尝试一下。如果我们在搜索输入中输入redux并按下Enter键,应用程序应该导航到产品页面并将产品过滤为 React Redux:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

路由提示

有时,我们可能希望要求用户确认他们是否要离开页面。如果用户在页面上进行数据输入并在保存数据之前按导航链接转到另一页,这将很有用。React Router 中的Prompt组件允许我们执行此操作,如下所述:

  1. 在我们的应用程序中,如果用户尚未将产品添加到其购物篮中,我们将提示用户确认是否要离开产品页面。首先,在ProductPage.tsx中,让我们从 React Router 中导入Prompt组件:
import { Prompt, RouteComponentProps } from "react-router-dom";
  1. Prompt组件在满足某些条件时在导航期间调用确认对话框。我们可以在我们的 JSX 中使用Prompt组件如下:
<div className="page-container">
  <Prompt when={!this.state.added} message={this.navAwayMessage}
  />
  ...
</div>

when属性允许我们指定对话框何时出现的表达式。在我们的情况下,这是当产品尚未添加到购物篮时。

message属性允许我们指定一个返回要在对话框中显示的消息的函数。

  1. 在我们的情况下,我们调用一个navAwayMessage方法,接下来我们将实现:
private navAwayMessage = () =>
    "Are you sure you leave without buying this product?";
  1. 让我们尝试一下,通过导航到 React Router 产品,然后在不点击添加到购物篮按钮的情况下离开:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

我们被要求确认是否要离开。

嵌套路由

嵌套路由是指 URL 超过一个级别,并且呈现多个组件。在本节中,我们将实现一些嵌套路由在我们的管理页面中。我们完成的管理页面将如下截图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

前面截图中的 URL 有 3 个级别,会显示如下内容:

  • 包含用户和产品链接的顶级菜单。

  • 包含所有用户的菜单。在我们的示例中只有 Fred、Bob 和 Jane。

  • 所选用户的信息。

  1. 让我们开始打开AdminPage.tsx并从react-router-dom中为以下内容添加import语句:
import { NavLink, Route, RouteComponentProps } from "react-router-dom";
  • 我们将使用NavLink组件来呈现菜单。

  • Route组件将用于渲染嵌套路由

  • RouteComponentProps类型将用于从 URL 获取用户的id

  1. 我们将用无序列表替换p标签,其中包含菜单选项 Users 和 Products:
<div className="page-container">
  <h1>Admin Panel</h1>
  <ul className="admin-sections>
 <li key="users">
 <NavLink to={`/admin/users`} activeClassName="admin-link-
        active">
 Users
 </NavLink>
 </li>
 <li key="products">
 <NavLink to={`/admin/products`} activeClassName="admin-link-
       active">
 Products
 </NavLink>
 </li>
 </ul>
</div>

我们使用NavLink组件导航到两个选项的嵌套路由。

  1. 让我们在index.css中添加我们刚刚引用的 CSS 类:
.admin-sections {
  list-style: none;
  margin: 0px 0px 20px 0px;
  padding: 0;
}

.admin-sections li {
  display: inline-block;
  margin-right: 10px;
}

.admin-sections li a {
  color: #222;
  text-decoration: none;
}

.admin-link-active {
  border-bottom: #6f6e6e solid 2px;
}
  1. 回到AdminPage.tsx,让我们在我们刚刚添加的菜单下面添加两个Route组件。这些将处理我们在菜单中引用的/admin/users/admin/products路径:
<div className="page-container">
  <h1>Admin Panel</h1>
  <ul className="admin-sections">
    ...
  </ul>
 <Route path="/admin/users" component={AdminUsers} />
 <Route path="/admin/products" component={AdminProducts} />
</div>
  1. 我们刚刚引用了尚不存在的AdminUsersAdminProducts组件。让我们首先在AdminPage.tsx中的AdminPage组件下面输入以下内容来实现AdminProducts组件:
const AdminProducts: React.SFC = () => {
  return <div>Some options to administer products</div>;
};

因此,此组件只在屏幕上呈现一些文本。

  1. 现在让我们继续处理AdminUsers组件,这更加复杂。我们将从在AdminPage.tsx中的AdminProducts组件下面定义用户接口以及一些用户数据开始:
interface IUser {
  id: number;
  name: string;
  isAdmin: boolean;
}
const adminUsersData: IUser[] = [
  { id: 1, name: "Fred", isAdmin: true },
  { id: 2, name: "Bob", isAdmin: false },
  { id: 3, name: "Jane", isAdmin: true }
];

所以,在我们的示例中有 3 个用户。

  1. 让我们开始在AdminPage.tsx中实现AdminUsers组件:
const AdminUsers: React.SFC = () => {
  return (
    <div>
      <ul className="admin-sections">
        {adminUsersData.map(user => (
          <li>
            <NavLink
              to={`/admin/users/${user.id}`}
              activeClassName="admin-link-active"
            >
              {user.name}
            </NavLink>
          </li>
        ))}
      </ul>
    </div>
  );
};

该组件呈现一个包含每个用户名称的链接。该链接是到一个嵌套路径,最终将显示有关用户的详细信息。

  1. 因此,我们需要定义另一个路由,调用一个组件来渲染有关用户的详细信息。我们可以通过使用另一个Route组件来实现这一点:
<div>
  <ul className="admin-sections">
    ...
  </ul>
 <Route path="/admin/users/:id" component={AdminUser} />
</div>
  1. 我们刚刚定义的路径路由到一个我们还没有定义的AdminUser组件。所以,让我们从AdminUsers组件下面开始:
const AdminUser: React.SFC<RouteComponentProps<{ id: string }>> = props => {
  return null;
};

我们使用RouteComponentProps从 URL 路径中获取id并在 props 中使其可用。

  1. 现在,我们可以使用路径中的id来从我们的adminUsersData数组中获取用户:
const AdminUser: React.SFC<RouteComponentProps<{ id: string }>> = props => {
  let user: IUser;
 if (props.match.params.id) {
 const id: number = parseInt(props.match.params.id, 10);
 user = adminUsersData.filter(u => u.id === id)[0];
 } else {
 return null;
 }
  return null;
};
  1. 现在我们有了user对象,我们可以呈现其中的信息。
const AdminUser: React.SFC<RouteComponentProps<{ id: string }>> = props => {
  let user: IUser;
  if (props.match.params.id) {
    const id: number = parseInt(props.match.params.id, 10);
    user = adminUsersData.filter(u => u.id === id)[0];
  } else {
    return null;
  }
  return (
 <div>
 <div>
 <b>Id: </b>
 <span>{user.id.toString()}</span>
 </div>
 <div>
 <b>Is Admin: </b>
 <span>{user.isAdmin.toString()}</span>
 </div>
 </div>
 );
};
  1. 如果我们转到运行的应用程序,转到管理页面并单击产品菜单项,它将如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 如果我们单击用户菜单项,我们将看到我们可以单击以获取有关用户的更多信息的 3 个用户。这将看起来像本节中的第一个截图。

因此,为了实现嵌套路由,我们使用NavLinkLink组件创建必要的链接,并使用Route组件将这些链接路由到要使用Route组件呈现内容的组件。在本节之前,我们已经了解了这些组件,所以我们只需要学习如何在嵌套路由的上下文中使用它们。

动画过渡

在本节中,当用户导航到不同页面时,我们将添加一些动画。我们使用react-transition-group npm包中的TransitionGroupCSSTransition组件来实现这一点,如下所示:

  1. 因此,让我们首先安装此包及其 TypeScript 类型:
npm install react-transition-group
npm install @types/react-transition-group --save-dev

TransitionGroup跟踪其所有子元素并计算子元素何时进入或退出。CSSTransitionTransitionGroup获取子元素是离开还是退出,并根据该状态对子元素应用 CSS 类。因此,TransitionGroupCSSTransition可以包装我们的路由并调用我们可以创建的 CSS 类,以实现页面的进出动画。

  1. 因此,让我们将这些组件导入Routes.tsx
import { CSSTransition, TransitionGroup } from "react-transition-group";
  1. 我们还需要从 React Router 中导入RouteComponentProps
import { Redirect, Route, RouteComponentProps, Switch} from "react-router-dom";
  1. 让我们将RouteComponentProps用作Route组件的 props 类型:
const Routes: React.SFC<RouteComponentProps> = props => {
  ...
}
  1. 让我们将CSSTransitionTransitionGroup组件添加到Switch组件周围的 JSX 中:
<TransitionGroup>
  <CSSTransition
    key={props.location.key}
    timeout={500}
    classNames="animate"
    >
    <Switch>
      ...
    </Switch>
  </CSSTransition>
</TransitionGroup>

TransitionGroup要求子元素具有唯一的key,以确定何时退出和进入。因此,我们已经指定了CSSTransition上的key属性为RouteComponentPropslocation.key属性。我们已经指定了过渡将在半秒内运行的timeout属性。我们还指定了将使用animate前缀调用的 CSS 类,通过classNames属性。

  1. 因此,让我们在index.css中添加这些 CSS 类:
.animate-enter {
  opacity: 0;
  z-index: 1;
}
.animate-enter-active {
  opacity: 1;
  transition: opacity 450ms ease-in;
}
.animate-exit {
  display: none;
}

CSSTransition将在其键更改时调用这些 CSS 类。这些 CSS 类最初隐藏了正在过渡的元素,并逐渐缓解了元素的不透明度,以便显示出来。

  1. 如果我们转到index.tsx,我们会得到一个编译错误,因为它期望我们传递来自路由器的history等 props 给Routes组件:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

不幸的是,我们无法使用withRouter高阶组件,因为这将位于Router组件之外。为了解决这个问题,我们可以添加一个名为RoutesWrap的新组件,它不接受任何 props,并包装我们现有的Routes组件。Router将移动到RoutesWrap,并包含一个始终渲染我们的Routes组件的Route组件。

  1. 因此,让我们将RoutesWrap组件添加到Routes.tsx中,并导出RoutesWrap而不是Routes
const RoutesWrap: React.SFC = () => {
 return (
 <Router>
 <Route component={Routes} />
 </Router>
 );
};

class Routes extends React.Component<RouteComponentProps, IState> { 
  ...
}

export default RoutesWrap;

编译错误消失了,这太棒了。

  1. 现在让我们从我们的Routes组件中删除Router,将div标签作为其根:
public render() {
  return (
    <div>
      <Header />
      <TransitionGroup>
        ...
      </TransitionGroup>
    </div>
  );
}

如果我们转到运行的应用程序并导航到不同的页面,您将看到一个很好的淡入淡出动画,当页面进入视图时。

延迟加载路由

目前,当应用程序首次加载时,将加载我们应用程序的所有 JavaScript。这包括用户不经常使用的管理页面。如果AdminPage组件在应用程序加载时不加载,而是按需加载,那将是很好的。这正是我们将在本节中要做的。这称为“延迟加载”组件。以下步骤允许我们按需加载内容:

  1. 首先,我们将从 React 中导入Suspense组件,稍后我们将使用它:
import { Suspense } from "react";
  1. 现在,我们将以不同的方式导入AdminPage组件:
const AdminPage = React.lazy(() => import("./AdminPage"));

我们使用一个名为lazy的 React 函数,它接受一个返回动态导入的函数,然后将其分配给我们的AdminPage组件变量。

  1. 在我们这样做之后,我们可能会遇到一个 linting 错误:在 ES5/ES3 中进行动态导入调用需要’Promise’构造函数。确保您有’Promise’构造函数的声明,或在--lib选项中包含’ES2015’。因此,在tsconfig.json中,让我们添加lib编译器选项:
"compilerOptions": { 
  "lib": ["es6", "dom"],
  ...
}
  1. 接下来的部分是在AdminPage组件周围包装Suspense组件:
<Route path="/admin">
  {loggedIn ? (
    <Suspense fallback={<div className="page-container">Loading...</div>}>
      <AdminPage />
    </Suspense>
  ) : (
    <Redirect to="/login" />
  )}
</Route>

Suspense组件显示一个包含 Loading…的div标签,同时加载AdminPage

  1. 让我们在运行的应用程序中尝试这个。让我们打开浏览器开发者工具,转到网络选项卡。在我们的应用程序中,让我们转到产品页面并刷新浏览器。然后清除开发者工具中网络选项卡中的内容。如果我们然后转到应用程序中的管理页面并查看网络选项卡中的内容,我们将看到动态加载AdminPage组件的 JavaScript

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. AdminPage组件加载非常快,所以我们从来没有真正看到 Loading … div标签。所以,让我们在浏览器开发者工具中减慢连接速度:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 如果我们然后刷新浏览器,再次转到管理页面,我们将看到 Loading …:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这个例子中,AdminPage组件并不是很大,所以这种方法并没有真正对性能产生积极影响。然而,按需加载更大的组件确实可以帮助提高性能,特别是在慢速连接上。

总结

React Router 为我们提供了一套全面的组件,用于管理应用程序中页面之间的导航。我们了解到顶层组件是Router,它在其下寻找Route组件,我们在其中定义了应该为特定路径呈现哪些组件。

Link组件允许我们链接到应用程序中的不同页面。我们了解到NavLink组件类似于Link,但它包括根据是否为活动路径来设置样式的能力。因此,NavLink非常适合应用程序中的主导航元素,而Link非常适合出现在页面上的其他链接。

RouteComponentProps是一种类型,它使我们能够访问路由参数和查询参数。我们发现 React Router 不会为我们解析查询参数,但可以使用原生 JavaScript URLSearchParams接口来为我们做这个。

Redirect组件在特定条件下重定向到路径。我们发现这非常适合保护只有特权用户可以访问的页面。

Prompt组件允许我们在特定条件下要求用户确认他们是否要离开页面。我们在产品页面上使用它来再次确认用户是否要购买产品。这个组件的另一个常见用例是在输入的数据没有保存时,确认离开数据输入页面的导航。

我们了解到嵌套路由如何为用户提供进入应用程序特定部分的深链接。我们只需使用LinkNavLinkRoute组件来定义相关链接以处理这些链接。

我们使用react-transition-group npm包中的TransitionGroupCSSTransition组件改进了我们的应用体验。我们将这些组件包裹在定义应用路径的Route组件周围,并添加了 CSS 类来实现我们希望页面退出和进入视图时的动画效果。

我们了解到,React 的lazy函数以及其Suspense组件可以用于按需加载用户很少使用的大型组件。这有助于提高应用程序的启动时间性能。

问题

让我们通过以下问题来测试我们对 React Router 的了解:

  1. 我们有以下显示客户列表的Route组件:
<Route path="/customers" component={CustomersPage} />

当页面是"/customers"时,CustomersPage组件会渲染吗?

  1. 当页面是"/customers/24322"时,CustomersPage组件会渲染吗?

  2. 我们只希望在路径为"/customers"时,CustomersPage组件才会渲染。我们如何更改Route上的属性来实现这一点?

  3. 什么样的Route组件可以处理"/customers/24322"路径?它应该将"24322"放在名为customerId的路由参数中。

  4. 我们如何捕获不存在的路径,以便通知用户?

  5. 我们如何在CustomersPage中实现search查询参数?因此,"/customers/?search=Cool Company"将显示名称为"Cool Company"的客户。

  6. 过了一会儿,我们决定将"customer"路径更改为"clients"。我们如何实现这一点,以便用户仍然可以使用现有的"customer"路径,但路径会自动重定向到新的"client"路径?

进一步阅读

第五章:高级类型

我们已经学习了相当多的 TypeScript 类型系统知识。在本章中,我们将继续这个旅程,这次深入一些更高级的类型和概念,这将帮助我们在本书后面创建可重用的强类型 React 组件。

我们将学习如何将现有类型组合成联合类型。我们将在第八章React Redux中发现,这些类型对于创建强类型的 React Redux 代码至关重要。

我们在第二章中简要介绍了类型守卫,TypeScript 3 有什么新特性,当时我们学习了unknown类型。在本章中,我们将更详细地了解这些内容。

泛型是 TypeScript 的一个特性,许多库使用它允许消费者使用其库创建强类型应用程序。React 本身在类组件中使用它,允许我们在组件中创建强类型的 props 和 states。我们将在本章中详细介绍泛型。

重载签名是一个很好的功能,允许我们的单个函数接受不同组合的参数。我们将在本章中学习如何使用这些内容。

查找和映射类型允许我们从现有类型动态创建新类型。我们将在本章末尾详细了解这些内容。

在本章中,我们将学习以下主题:

  • 联合类型

  • 类型守卫

  • 泛型

  • 重载签名

  • 查找和映射类型

技术要求

在本章中,我们将使用以下技术:

  • TypeScript playground:这是一个网站,网址为www.typescriptlang.org/play,允许我们在不安装 TypeScript 的情况下进行实验和了解其特性。在本章中,我们将大部分时间使用这个网站。

  • Visual Studio Code:我们需要一个编辑器来编写我们的 React 和 TypeScript 代码,可以从code.visualstudio.com/网站安装。我们还需要在 Visual Studio Code 中安装TSLint(由 egamma 提供)和Prettier(由 Esben Petersen 提供)扩展。

本章中的所有代码片段都可以在以下网址找到:github.com/carlrip/LearnReact17WithTypeScript/tree/master/05-AdvancedTypes.

联合类型

顾名思义,联合类型是我们可以组合在一起形成新类型的类型。联合类型通常与字符串文字类型一起使用,我们将在第一部分中介绍。联合类型可以用于一种称为辨识联合的模式,我们可以在创建通用和可重用的 React 组件时使用它。

字符串文字类型

字符串文字类型的变量只能被赋予字符串文字类型中指定的确切字符串值。

在 TypeScript playground 中,让我们通过一个例子来看一下:

  1. 让我们创建一个名为Control的字符串文字类型,它只能设置为"Textbox"字符串:
type Control = "Textbox";
  1. 现在让我们创建一个名为notes的变量,使用我们的Control类型,并将其设置为"Textbox"
let notes: Control;
notes = "Textbox";

正如我们所期望的,TypeScript 编译器对此非常满意。

  1. 现在让我们将变量设置为不同的值:
notes = "DropDown";

我们得到了编译错误,类型"DropDown"不能赋值给类型"Textbox"

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 与 TypeScript 中的所有其他类型一样,nullundefined也是有效的值:
notes = null;
notes = undefined;

字符串文字类型本身并不那么有用。然而,当它们用于联合类型时,它们变得非常有用,我们将在下一部分中看到。

字符串文字联合类型

字符串文字联合类型是指我们将多个字符串文字类型组合在一起。

让我们从上一个例子继续,并通过这个例子来看一下。

  1. 让我们增强我们的Control类型,使其成为字符串文字的联合类型:
type Control = "Textbox" | "DropDown"

我们使用|在联合类型中组合类型。

  1. 现在将我们的notes变量设置为"Textbox""DropDown"现在是完全有效的:
let notes: Control;
notes = "Textbox";
notes = "DropDown";
  1. 让我们扩展我们的Control类型,以包含更多的字符串文字:
type Control = "Textbox" | "DropDown" | "DatePicker" | "NumberSlider";
  1. 现在我们可以将我们的notes变量设置为这些值中的任何一个:
notes = "DatePicker";
notes = "NumberSlider";

如果我们仔细想一想,这真的很有用。我们本来可以将notes变量声明为string,但是用包含的特定字符串文字来声明它可以包含的内容,这样就可以使它成为超级类型安全。

辨识联合模式

辨识联合模式允许我们处理不同联合类型的逻辑。

让我们通过一个例子来看一下:

  1. 让我们首先创建三个接口来表示文本框、日期选择器和数字滑块:
interface ITextbox {
  control: "Textbox";
  value: string;
  multiline: boolean;
}

interface IDatePicker {
  control: "DatePicker";
  value: Date;
}

interface INumberSlider {
  control: "NumberSlider";
  value: number;
}

它们都有一个名为control的属性,这将是模式中的辨识者。

  1. 让我们继续将这些接口组合成一个名为Field的联合类型:
type Field = ITextbox | IDatePicker | INumberSlider;

因此,我们可以从任何类型创建联合类型,而不仅仅是字符串文字。在这种情况下,我们已经从三个接口创建了一个联合类型。

  1. 现在让我们创建一个函数来初始化Field类型中的值:
function intializeValue(field: Field) {
  switch (field.control) {
    case "Textbox":
      field.value = "";
      break;
    case "DatePicker":
      field.value = new Date();
      break;
    case "NumberSlider":
      field.value = 0;
      break;
    default:
      const shouldNotReach: never = field;
  }
}

我们需要设置的值取决于辨别属性control。因此,我们使用了switch语句来根据这个属性进行分支。

switch语句中的default分支是让事情变得有趣的地方。这个分支永远不应该被执行,所以我们在那个分支中放置了一个带有never类型的语句。在接下来的步骤之后,我们将看到这样做的价值。

  1. 假设时间已经过去,我们对复选框字段有了新的要求。让我们为此实现一个接口:
interface ICheckbox {
  control: "Checkbox";
  value: boolean;
}
  1. 让我们也将这个加入到联合Field类型中:
type Field = ITextbox | IDatePicker | INumberSlider | ICheckbox;

我们会立即看到我们的initializeValue函数在never声明上抛出编译错误:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这非常有价值,因为never语句确保我们不会忘记为新的复选框要求添加代码分支。

  1. 所以,让我们去实现这个额外的分支,针对"Checkbox"字段:
function intializeValue(field: Field) {
  switch (field.control) {
    case "Textbox":
      field.value = "";
      break;
    case "DatePicker":
      field.value = new Date();
      break;
    case "NumberSlider":
      field.value = 0;
      break;
    case "Checkbox":
 field.value = false;
 break;
    default:
      const shouldNotReach: never = field;
  }
}

因此,联合类型允许我们将任何类型组合在一起形成另一个类型。这使我们能够创建更严格的类型,特别是在处理字符串时。辨别联合模式允许我们为联合中的不同类型有逻辑分支,而never类型帮助我们捕捉添加新类型到联合类型时需要发生的所有变化。

类型守卫

类型守卫允许我们在代码的条件分支中缩小对象的特定类型。当我们需要实现处理联合类型中特定类型的代码分支时,它们非常有用。

在上一节中,当我们实现intializeValue函数时,我们已经使用了类型守卫。在辨别属性control上的switch语句允许我们在联合中的每种类型上设置值。

我们可以实现类型守卫的其他方法。以下部分介绍了不同的方法。

使用typeof关键字

typeof关键字是 JavaScript 中返回表示类型的字符串的关键字。因此,我们可以在条件中使用它来缩小类型。

让我们通过一个例子来说明:

  1. 我们有一个可以是字符串或字符串数组的联合类型:
type StringOrStringArray = string | string[];
  1. 我们需要实现一个名为first的函数,它接受一个StringOrStringArray类型的参数并返回一个string
function first(stringOrArray: StringOrStringArray): string {

}
  1. 如果stringOrArray是一个string,那么函数需要返回第一个字符;否则,它应该返回第一个数组元素:
function first(stringOrArray: StringOrStringArray): string {
  if (typeof stringOrArray === "string") {
    return stringOrArray.substr(0, 1);
  } else {
    return stringOrArray[0];
  }
}

在第一个分支中,如果我们悬停在stringOrArray上,我们会看到类型已成功缩小为string

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在第二个分支中,如果我们悬停在stringOrArray上,我们会看到类型已成功缩小为string[]

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 为了检查我们的函数是否有效,我们可以添加以下内容:
console.log(first("The"));
console.log(first(["The", "cat"]));

如果我们运行程序,TThe将被输出到控制台。

typeof关键字只能与 JavaScript 类型一起使用。为了说明这一点,让我们创建一个增强版本的函数:

  1. 我们将我们的函数称为firstEnhanced。我们希望第二个分支专门处理string[]类型,并将第三个分支标记为永远不会到达的地方。让我们试试看:
function firstEnhanced(stringOrArray: StringOrStringArray): string {
  if (typeof stringOrArray === "string") {
    return stringOrArray.substr(0, 1);
  } else if (typeof stringOrArray === "string[]") { 
    return stringOrArray[0];
  } else {
    const shouldNotReach: never = stringOrArray;
  }
}

TypeScript 编译器对第二个分支不满意:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

消息给了我们一些线索。JavaScript 的typeof关键字适用于 JavaScript 类型,这些类型是stringnumberbooleansymbolundefinedobjectfunction;因此错误消息中结合了这些类型的联合类型。因此,我们的第二个分支中的typeof实际上会返回"object"

  1. 让我们正确地实现这个:
function firstEnhanced(stringOrArray: StringOrStringArray): string {
  if (typeof stringOrArray === "string") {
    return stringOrArray.substr(0, 1);
  } else if (typeof stringOrArray === "object") { 
    return stringOrArray[0];
  } else {
    const shouldNotReach: never = stringOrArray;
  }
}

TypeScript 编译器现在又高兴了。

因此,typeof非常适合根据 JavaScript 类型进行分支,但不太适合于 TypeScript 特定类型。让我们在接下来的部分中找出如何弥合这一差距。

使用 instanceof 关键字

instanceof关键字是另一个 JavaScript 关键字。它检查对象是否具有特定的构造函数。通常用于确定对象是否是类的实例。

让我们通过一个例子来看一下:

  1. 我们有两个表示PersonCompany的类:
class Person {
  id: number;
  firstName: string;
  surname: string;
}

class Company {
  id: number;
  name: string;
}
  1. 我们还有一个结合这两个类的联合类型:
type PersonOrCompany = Person | Company;
  1. 现在我们需要编写一个函数,该函数接受PersonCompany并将它们的名称输出到控制台:
function logName(personOrCompany: PersonOrCompany) {
  if (personOrCompany instanceof Person) {
    console.log(`${personOrCompany.firstName} ${personOrCompany.surname}`);
  } else {
    console.log(personOrCompany.name);
  }
}

在使用instanceof时,我们在它之前有要检查的变量,之后是构造函数名称(类名)。

在第一个分支中,如果我们悬停在personOrCompany上,我们会得到Person类型:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在第二个分支中,如果我们悬停在personOrCompany上,我们会得到Company类型:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,instanceof在处理类时非常适用于缩小类型。然而,我们使用许多不是 JavaScript 类型或基于类的 TypeScript 类型。那么,在这些情况下我们该怎么办呢?让我们在接下来的部分中找出答案。

使用in关键字

in关键字是另一个 JavaScript 关键字,可用于检查属性是否在对象中。

让我们使用in关键字来实现上一节的示例:

  1. 这次,我们使用接口而不是PersonCompany结构的类:
interface IPerson {
  id: number;
  firstName: string;
  surname: string;
}

interface ICompany {
  id: number;
  name: string;
}
  1. 我们再次从PersonCompany结构创建一个联合类型:
type PersonOrCompany = IPerson | ICompany;
  1. 最后,让我们使用in关键字来实现我们的函数:
function logName(personOrCompany: PersonOrCompany) {
 if ("firstName" in personOrCompany) {
  console.log(`${personOrCompany.firstName} ${personOrCompany.surname}`);
 } else {
  console.log(personOrCompany.name);
 }
}

in关键字之前,我们用双引号将属性名称放在一起,然后是要检查的对象。

如果我们在第一个分支上悬停在personOrCompany上,我们会得到IPerson类型。如果我们在第二个分支上悬停在personOrCompany上,我们会得到ICompany类型。

因此,in关键字非常灵活。它可以与任何对象一起使用,通过检查属性是否存在来缩小其类型。

在下一节中,我们将介绍最后一个类型保护。

使用用户定义的类型保护

在无法使用其他类型保护的情况下,我们可以创建自己的类型保护。我们可以通过创建一个返回类型为类型断言的函数来实现这一点。在本书之前,我们实际上在讨论unknown类型时使用了用户定义的类型保护。

让我们使用我们自己的类型保护函数来实现上两节的示例:

  1. 我们有相同的接口和联合类型:
interface IPerson {
  id: number;
  firstName: string;
  surname: string;
}

interface ICompany {
  id: number;
  name: string;
}

type PersonOrCompany = IPerson | ICompany;
  1. 因此,让我们实现返回对象是否为IPerson类型的类型保护函数:
function isPerson(personOrCompany: PersonOrCompany): personOrCompany is IPerson {
  return "firstName" in personOrCompany;
}

类型断言personOrCompanyIPerson有助于 TypeScript 编译器缩小类型。要确认这一点,在第一个分支上悬停在personOrCompany上应该给出IPerson类型。然后,如果我们在第二个分支上悬停在personOrCompany上,我们应该得到ICompany类型。

创建用户定义的类型保护比其他方法更费力,但它为我们提供了处理其他方法无法解决的情况的灵活性。

泛型

泛型可以应用于函数或整个类。这是一种允许消费者使用自己的类型与泛型函数或类一起使用的机制。接下来的部分将介绍这两种情况的示例。

泛型函数

让我们通过一个通用函数的示例来进行讲解。我们将创建一个包装函数,用于调用fetch JavaScript 函数从 web 服务获取数据:

  1. 让我们从创建function签名开始:
function getData<T>(url: string): Promise<T> {

}

我们在函数名后的尖括号中放置一个T来表示它是一个通用函数。实际上我们可以使用任何字母,但T是常用的。然后我们在类型是通用的地方使用T。在我们的示例中,通用部分是返回类型,所以我们返回Promise<T>

如果我们想要使用箭头函数,这将是:

const getData = <T>(url: string): Promise<T> => {

};
  1. 现在让我们实现我们的函数:
function getData<T>(url: string): Promise<T> {
  return fetch(url).then(response => {
    if (!response.ok) {
      throw new Error(response.statusText);
    }
    return response.json();
  });
}
  1. 最后,让我们消费这个函数:
interface IPerson {
  id: number;
  name: string;
}

getData<IPerson>("/people/1").then(person => console.log(person));

我们在函数名后的尖括号中传递我们想要在函数中使用的类型。在我们的例子中,它是IPerson

如果我们在then回调中悬停在person上,我们会看到person被正确地类型化为IPerson

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,顾名思义,通用函数是与通用类型一起工作的函数。先前示例的另一种实现方式是将any作为返回类型,但那不是类型安全的。

通用类

我们可以使整个类成为通用的。让我们深入了解一个将数据存储在列表中的通用类的示例:

  1. 首先让我们定义我们的类,不包含任何内容:
class List<T> {

}

我们通过在类名后面加上<T>来标记类为通用的。

  1. 在类内部,让我们为列表中的数据创建一个private属性:
private data: T[] = [];

我们使用T来引用通用类型。在我们的示例中,我们的data属性是一个根据类声明的任何类型的数组。

  1. 现在让我们添加一个public方法来获取列表中的所有数据:
public getList(): T[] {
  return this.data;
}

我们使用T[]来引用通用数组作为返回类型。

  1. 让我们实现一个向列表中添加项目的方法:
public add(item: T) {
  this.data.push(item);
}

我们使用通用类型T来引用传入的数据项。该实现简单地使用数组的push方法将项目添加到我们的private数组中。

  1. 让我们也实现一个从列表中移除项目的方法:
public remove(item: T) {
  this.data = this.data.filter((dataItem: T) => {
    return !this.equals(item, dataItem);
  });
}
private equals(obj1: T, obj2: T) {
  return Object.keys(obj1).every(key => {
    return obj1[key] === obj2[key];
  });
}

我们再次使用通用类型T来引用传入的数据项。该实现使用数组的filter方法来过滤私有数组中的项目。过滤谓词使用一个检查两个对象是否相等的private方法。

  1. 因此,现在我们已经实现了我们的通用列表类,让我们创建一个类型和一些数据,以便消费它:
interface IPerson {
  id: number;
  name: string;
}
const billy: IPerson = { id: 1, name: "Billy" };
  1. 现在让我们创建一个通用类的实例:
const people = new List<IPerson>();

我们在类名后面使用尖括号中的类型来与类交互。

  1. 现在我们可以通过添加和删除billy来与类交互:
people.add(billy);
people.remove(billy);
  1. 让我们尝试在我们的列表实例中使用不同的类型:
people.add({name: "Sally"});

我们得到了编译错误,正如我们所预期的那样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 让我们将列表实例中的所有项目保存到一个变量中:
const items = people.getList();

如果我们悬停在items变量上,我们会看到类型已经被正确推断为IPerson[]

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,泛型类允许我们使用不同类型的类,但仍然保持强类型。

我们在本书的早些时候使用了泛型类,我们用它来实现了带有 props 和 state 的 React 类组件:

interface IProps { ... }
interface IState { ... }
class App extends React.Component<IProps, IState> {
  ...
}

在这里,React.Component类有两个用于 props 和 state 的泛型参数。

因此,泛型在这本书中是一个非常重要的概念,我们将大量使用它来创建强类型的 React 组件。

重载签名

重载签名允许使用不同的签名调用函数。这个特性可以很好地用于简化库向消费者提供的一组函数。如果一个库包含condenseString公共函数和condenseArray,那么将它们简化为只包含一个公共condense函数会很好,不是吗?我们将在本节中做到这一点:

  1. 我们有一个从字符串中移除空格的函数:
function condenseString(string: string): string {
  return string.split(" ").join("");
}
  1. 我们有另一个从数组项中移除空格的函数:
function condenseArray(array: string[]): string[] {
  return array.map(item => item.split(" ").join(""));
}
  1. 现在我们想将这两个函数合并为一个单一的函数。我们可以使用联合类型来实现:
function condense(stringOrArray: string | string[]): string | string[] {
  return typeof stringOrArray === "string"
    ? stringOrArray.split(" ").join("")
    : stringOrArray.map(item => item.split(" ").join(""));
}
  1. 让我们使用我们的统一函数:
const condensedText = condense("the cat sat on the mat");

当我们输入函数参数时,智能感知提醒我们需要输入一个字符串或字符串数组:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如果我们悬停在condensedText变量上,我们会看到推断类型是联合类型:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 现在是时候添加两个签名重载来改进我们函数的使用了:
function condense(string: string): string;
function condense(array: string[]): string[];
function condense(stringOrArray: string | string[]): string | string[] { ... }

我们在主函数签名之前添加了函数重载签名。我们为处理字符串时添加了一个重载,为处理字符串数组时添加了第二个重载。

  1. 让我们使用我们的重载函数:
const moreCondensedText = condense("The cat sat on the mat");

现在,当我们输入参数时,我们得到了改进的智能感知。我们还可以使用上下箭头来滚动两个不同的签名:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如果我们悬停在moreCondensedText变量上,我们会看到我们获得了更好的类型推断:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,重载签名可以改善开发人员使用我们函数的体验。它们可以提供改进的智能感知和类型推断。

查找和映射类型

keyof是 TypeScript 中的一个关键字,它创建了对象中所有属性的联合类型。创建的类型称为查找类型。这允许我们根据现有类型的属性动态创建类型。这是一个有用的功能,我们可以用它来针对不同的数据创建通用但强类型的代码。

让我们通过一个例子来说明:

  1. 我们有以下IPerson接口:
interface IPerson {
  id: number;
  name: string;
}
  1. 让我们在这个接口上使用keyof创建一个查找类型:
type PersonProps = keyof IPerson;

如果我们悬停在PersonProps类型上,我们会看到创建了一个包含"id""name"的联合类型:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 让我们向IPerson添加一个新属性:
interface IPerson {
  id: number;
  name: string;
  age: number
}

如果我们再次悬停在PersonProps类型上,我们会看到该类型已自动扩展以包含"age"

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,PersonProps类型是一个查找类型,因为它查找它需要包含的文字。

现在让我们用查找类型创建一些有用的东西:

  1. 我们将创建一个Field类,其中包含字段名称、标签和默认值:
class Field {
  name: string;
  label: string;
  defaultValue: any;
}
  1. 这只是一个开始,但我们可以通过使我们的类通用来使name更加强类型化:
class Field<T, K extends keyof T> {
  name: K;
  label: string;
  defaultValue: any;
}

我们在类上创建了两个通用参数。第一个是包含字段的对象类型,第二个是对象内的属性名称。

  1. 如果我们创建类的实例,可能会更有意义。让我们使用上一个示例中的IPerson,并将"id"作为字段名称传递进去:
const idField: Field<IPerson, "id"> = new Field();
  1. 让我们尝试引用在IPerson中不存在的属性:
const addressField: Field<IPerson, "address"> = new Field();

我们得到了编译错误,正如我们所期望的那样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

捕捉这样的问题是查找类型的好处,而不是使用string类型。

  1. 现在让我们把注意力转向Field类中的defaultValue属性。目前这不是类型安全的。例如,我们可以将idField设置为一个字符串:
idField.defaultValue = "2";
  1. 让我们解决这个问题,使defaultValue具有类型安全性:
class Field<T, K extends keyof T> {
  name: K;
  label: string;
  defaultValue: T[K];
}

我们使用T[K]查找类型。对于idField,这将解析为IPersonid属性的类型,即number

现在设置idField.defaultValue的代码行会引发编译错误,正如我们所期望的那样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 让我们将"2"更改为2
idField.defaultValue = 2;

编译错误消失了。

因此,在创建可变数据类型的通用组件时,查找类型可能会很有用。

现在让我们转到映射类型。同样,这些让我们可以从现有类型的属性中创建新类型。但是,映射类型允许我们通过从现有属性中映射它们来明确定义新类型中的属性。

让我们通过一个示例来看一下:

  1. 首先,让我们创建一个类型,我们将在下一步中进行映射:
interface IPerson {
  id: number;
  name: string;
}
  1. 现在让我们创建一个新版本的interface,其中所有属性都是使用映射类型readonly的:
type ReadonlyPerson = { readonly [P in keyof IPerson]: IPerson[P] };

创建映射的重要部分是[P in keyof IPerson]。这会遍历IPerson中的所有属性,并将每个属性分配给P以创建类型。因此,在上一个示例中生成的类型如下:

type ReadonlyPerson = { 
  readonly id: number
  readonly name: string 
};
  1. 让我们尝试一下,看看我们的类型是否真的是readonly
let billy: ReadonlyPerson = {
  id: 1,
  name: "Billy"
};
billy.name = "Sally";

正如我们所期望的,当我们尝试将readonly属性设置为新值时,会引发编译错误:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

所以我们的映射类型起作用了!这种映射类型的更通用版本实际上是 TypeScript 中的标准类型,即Readonly<T>

  1. 现在让我们使用标准的readonly类型:
let sally: Readonly<IPerson> = {
  id: 1,
  name: "sally"
};
  1. 让我们尝试更改我们的readonly中的值:
Sally.name = "Billy";

引发编译错误,正如我们所期望的那样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如果我们在 Visual Studio Code 中使用“转到定义”选项来查看Readonly类型,我们会得到以下结果:

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

这与我们的ReadonlyPerson类型非常相似,但是IPerson已被替换为通用类型T

让我们尝试创建我们自己的通用映射类型:

  1. 我们将创建一个映射类型,使现有类型的所有属性都是string类型:
type Stringify<T> = { [P in keyof T]: string };
  1. 让我们尝试使用我们的映射类型:
let tim: Stringify<IPerson> = {
 id: "1",
 name: "Tim"
};
  1. 让我们尝试将id设置为一个数字:
tim.id = 1

预期的编译错误被引发:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,在需要基于现有类型创建新类型的情况下,映射类型非常有用。除了Readonly<T>之外,在 TypeScript 中还有许多标准映射类型,例如Partial<T>,它创建一个映射类型,使所有属性都是可选的。

总结

在本章中,我们学习了 TypeScript 中一些更高级的类型,从联合类型开始。联合类型非常有用,允许我们通过将现有类型联合在一起来创建新类型。我们发现,将字符串字面量联合在一起可以创建比普通string更具体和类型安全的类型。

我们探讨了各种实现类型守卫的方式。类型守卫在逻辑分支中帮助编译器缩小联合类型的范围时非常有用。它们在使用unknown类型时,在逻辑分支中告诉编译器类型是什么也非常有用。

泛型,顾名思义,允许我们创建通用类型。在详细讨论了这个主题之后,React 组件中的 props 和 state 的类型安全现在更加有意义了。我们将在本书的其余部分大量使用通用类和函数。

我们了解到重载签名允许我们拥有具有不同参数和返回类型的函数。现在我们可以有效地使用这个特性来简化我们在库中公开的公共函数。

我们学习了如何可以使用查找和映射类型从现有类型属性动态创建新类型。我们现在知道,有许多有用的标准 TypeScript 映射类型,如Readonly<T>Partial<T>

学习所有这些特性是对下一章的很好准备,我们将深入探讨在使用 React 组件时的一些常见模式。

问题

让我们来试试一些关于高级类型的问题:

  1. 我们有一个代表课程结果的interface,如下:
interface ICourseMark {
  courseName: string;
  grade: string;
}

我们可以像这样使用这个interface

const geography: ICourseMark = {
  courseName: "Geography",
  grade: "B"
} 

成绩只能是 A、B、C 或 D。我们如何创建这个接口中grade属性的更强类型版本?

  1. 我们有以下函数,用于验证数字和字符串是否有值:
function isNumberPopulated(field: number): boolean {
  return field !== null && field !== undefined;
}

function isStringPopulated(field: string): boolean {
  return field !== null && field !== undefined && field !== "";
}

我们如何将这些组合成一个名为isPopulated的单一函数,带有签名重载?

  1. 我们如何可以使用泛型实现一个更灵活的isPopulated函数?

  2. 我们有一个代表阶段的type别名:

type Stages = {
  pending: 'Pending',
  started: 'Started',
  completed: 'Completed',
};
  1. 我们如何可以编程地将这个转换成'Pending' | 'Started' | 'Completed'联合类型?

  2. 我们有以下联合类型:

type Grade = 'gold' | 'silver' | 'bronze';

我们如何可以编程地创建以下类型:

type GradeMap = {
  gold: string;
  silver: string;
  bronze: string
};

进一步阅读

TypeScript 文档中有一个关于高级类型的很棒的部分,值得一看:

www.typescriptlang.org/docs/handbook/advanced-types.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值