React 和 TypeScript3 学习手册(三)
原文:
zh.annas-archive.org/md5/9ec979022a994e15697a4059ac32f487译者:飞龙
第四章:使用 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 来做好准备。要做到这一点,请按照以下步骤进行:
- 现在让我们打开一个终端并输入以下命令来创建一个新的 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
- 项目创建后,让我们将 TSLint 作为开发依赖项添加到我们的项目中,并添加一些与 React 和 Prettier 兼容的规则:
cd reactshop
npm install tslint tslint-react tslint-config-prettier --save-dev
- 现在让我们添加一个包含一些规则的
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"
]
}
}
- 现在,让我们输入以下命令将 React Router 安装到我们的项目中:
npm install react-router-dom
- 让我们还安装 React Router 的 TypeScript 类型,并将它们保存为开发依赖项:
npm install @types/react-router-dom --save-dev
在进入下一节之前,我们将删除一些我们不需要的create-react-app创建的文件:
-
首先,让我们删除
App组件。因此,让我们删除App.css,App.test.tsx和App.tsx文件。让我们还在index.tsx中删除对"./App"的导入引用。 -
让我们还通过删除
serviceWorker.ts文件并在index.tsx中删除对它的引用来删除服务工作者。 -
在
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
);
声明路由
我们使用BrowserRouter和Route组件在应用程序中声明页面。BrowserRouter是顶层组件,它查找其下方的Route组件以确定所有不同的页面路径。
我们将在本节的后面使用BrowserRouter和Route声明一些页面,但在此之前,我们需要创建我们的前两个页面。这第一个页面将包含我们在商店中要出售的 React 工具列表。我们使用以下步骤来创建我们的页面:
- 因此,让我们首先通过创建一个
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
}
];
- 让我们创建另一个名为
ProductsPage.tsx的文件,其中包含以下内容来导入 React 以及我们的数据:
import * as React from "react";
import { IProduct, products } from "./ProductsData";
- 我们将在组件状态中引用数据,因此让我们为此创建一个接口:
interface IState {
products: IProduct[];
}
- 让我们继续创建名为
ProductsPage的类组件,将状态初始化为空数组:
class ProductsPage extends React.Component<{}, IState> {
public constructor(props: {}) {
super(props);
this.state = {
products: []
};
}
}
export default ProductsPage;
- 现在让我们实现
componentDidMount生命周期方法,并从ProductData.ts将数据设置为products数组:
public componentDidMount() {
this.setState({ products });
}
- 继续实现
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。
- 我们已经引用了一些 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;
}
- 现在让我们实现我们的第二个页面,即管理面板。因此,让我们创建一个名为
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;
- 现在我们的商店中有两个页面,我们可以为它们声明两个路由。让我们创建一个名为
Routes.tsx的文件,其中包含以下内容,以从 React Router 中导入React、BrowserRouter和Route组件,以及我们的两个页面:
import * as React from "react";
import { BrowserRouter as Router, Route } from "react-router-dom";
import AdminPage from "./AdminPage";
import ProductsPage from "./ProductsPage";
我们已经在导入语句中将BrowserRouter重命名为Router,以节省一些按键次数。
- 接下来,让我们实现一个包含我们两个路由的功能组件:
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。
- 以下是将我们的
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);
- 现在我们应该能够运行我们的应用程序了:
npm start
应用可能会从根页面开始,因为该路径没有指向任何内容,所以页面会是空白的。
- 如果我们将路径更改为
"/products",我们的产品列表应该呈现如下:
- 如果我们将路径更改为
"/admin",我们的管理面板应该呈现如下:
现在我们已经成功创建了一些路由,我们真的需要一个导航组件来使我们的页面更加可发现。我们将在下一节中做到这一点。
创建导航
React Router 提供了一些很好的组件来提供导航。我们将使用这些组件来实现应用程序标题中的导航选项。
使用 Link 组件
我们将使用 React Router 中的Link组件来创建我们的导航选项,具体步骤如下:
- 让我们从创建一个名为
Header.tsx的新文件开始,其中包含以下导入:
import * as React from "react";
import { Link } from "react-router-dom";
import logo from "./logo.svg";
- 让我们在
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组件允许我们定义链接导航到的路径以及要显示的文本。
- 我们已经引用了一些 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;
}
- 现在我们的
Header组件就位了,让我们在Routes.tsx中import它:
import Header from "./Header";
- 然后我们可以在 JSX 中使用它如下:
<Router>
<div>
<Header />
<Route path="/products" component={ProductsPage} />
<Route path="/admin" component={AdminPage} />
</div>
</Router>
- 如果我们检查正在运行的应用程序,它应该看起来像以下截图,有一个漂亮的标题和两个导航选项,可以转到我们的产品和管理页面:
- 尝试点击导航选项-它们有效!如果我们使用浏览器开发者工具检查产品和管理元素,我们会看到 React Router 已将它们呈现为锚标签:
如果我们在点击导航选项时查看开发者工具中的网络选项卡,我们会看到没有网络请求正在被发出来为我们的 React 应用程序提供页面。这表明 React Router 正在处理我们的导航。
使用 NavLink 组件
React Router 还提供了另一个用于链接页面的组件,称为NavLink。实际上,这更适合我们的需求。以下步骤解释了我们如何重构我们的Header组件以使用NavLink:
- 所以,让我们在我们的
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;
此时,我们的应用程序看起来和行为都完全一样。
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>
- 让我们将
header-link-active的 CSS 添加到我们的index.css中:
.header-link-active {
border-bottom: #ebebeb solid 2px;
}
- 如果我们现在切换到正在运行的应用程序,活动链接将被下划线划掉:
因此,NavLink非常适合主应用程序导航,我们希望突出显示活动链接,而Link非常适合应用程序中的所有其他链接。
路由参数
路由参数是路径的可变部分,在目标组件中可以使用它们来有条件地渲染某些内容。
我们需要向我们的商店添加另一个页面,以显示每个产品的描述和价格,以及将其添加到购物篮的选项。我们希望能够使用"/products/{id}"路径导航到此页面,其中id是产品的 ID。例如,到达 React Redux 的路径将是"products/2"。因此,路径的id部分是一个路由参数。我们可以通过以下步骤来完成所有这些:
- 让我们在两个现有路由之间的
Routes.tsx中添加此路由。路由的id部分将是一个路由参数,我们在其前面用冒号定义它:
<Route path="/products" component={ProductsPage} />
<Route path="/products/:id" component={ProductPage} />
<Route path="/admin" component={AdminPage} />
- 当然,
ProductPage组件还不存在,所以,让我们首先创建一个名为ProductPage.tsx的新文件,其中包含以下导入:
import * as React from "react";
import { RouteComponentProps } from "react-router-dom";
import { IProduct, products } from "./ProductsData";
- 关键部分在于我们将使用
RouteComponentProps类型来访问路径中的id参数。让我们使用RouteComponentProps通用类型来定义我们的ProductPage组件的 props 类型别名,并传入一个具有id属性的类型:
type Props = RouteComponentProps<{id: string}>;
如果您不理解type表达式中的尖括号,不要担心。这表示一个通用类型,我们将在第五章中探讨高级类型。
理想情况下,我们应该将id属性指定为数字,以匹配产品数据中的类型。但是,RouteComponentProps只允许我们拥有类型为字符串或未定义的路由参数。
ProductPage组件将具有状态来保存正在呈现的产品以及它是否已添加到购物篮中,因此让我们为我们的状态定义一个接口:
interface IState {
product?: IProduct;
added: boolean;
}
- 产品最初将是
undefined,这就是为什么它被定义为可选的。让我们创建我们的ProductPage类并初始化状态,以便产品不在购物篮中:
class ProductPage extends React.Component<Props, IState> {
public constructor(props: Props) {
super(props);
this.state = {
added: false
};
}
}
export default ProductPage;
- 当组件加载到 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。
- 现在我们已经在组件状态中有了我们的产品,让我们继续进行
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将产品价格格式化为带有货币符号的货币。
- 当单击“添加到购物篮”按钮时,我们还将调用
handleAddClick方法。我们还没有实现这一点,所以现在让我们这样做,并将added状态设置为true:
private handleAddClick = () => {
this.setState({ added: true });
};
- 现在我们已经实现了
ProductPage组件,让我们回到Routes.tsx并导入它:
import ProductPage from "./ProductPage";
- 让我们打开我们的运行中的应用,输入
"/products/2"作为路径:
不太符合我们的要求!ProductsPage和ProductPage都被渲染了,因为"/products/2"同时匹配"/products"和"/products/:id"。
- 为了解决这个问题,我们可以告诉
"/products"路由只在有精确匹配时才进行渲染:
<Route exact={true} path="/products" component={ProductsPage} />
- 在我们进行这些更改并保存
Routes.tsx之后,我们的产品页面看起来好多了:
- 我们不打算让用户输入特定的路径来访问产品!因此,我们将更改
ProductsPage,使用Link组件为每个产品链接到ProductPage。首先,让我们从 React Router 中导入Link到ProductsPage中:
import { Link } from "react-router-dom";
- 现在,我们不再在每个列表项中渲染产品名称,而是要渲染一个
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>
);
}
- 在我们查看运行中的应用之前,让我们在
index.css中添加以下 CSS 类:
.product-list-item a {
text-decoration: none;
}
现在,如果我们在应用中的产品列表中点击一个列表项,它会带我们到相关的产品页面。
处理未找到的路由
如果用户输入了我们应用中不存在的路径会怎么样?例如,如果我们尝试导航到"/tools",我们在标题下面什么都看不到。这是有道理的,因为 React Router 没有找到匹配的路由,所以什么都没有渲染。然而,如果用户导航到无效的路径,我们希望通知他们该路径不存在。以下步骤可以实现这一点:
- 因此,让我们创建一个名为
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;
- 让我们在
Routes.tsx中导入这个:
import NotFoundPage from "./NotFoundPage";
- 然后让我们在其他路由中添加一个
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组件包裹路由。
- 首先在
Routes.tsx中导入Switch:
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
- 现在让我们将
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"。
- 首先,我们需要在
Routes.tsx中导入Redirect组件:
import { BrowserRouter as Router, Redirect,Route, Switch } from "react-router-dom";
- 现在我们可以使用
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>
- 我们在
Redirect上使用了exact属性,以便它只匹配/而不匹配"/products/1"和"/admin"。如果我们尝试在运行的应用程序中输入/作为路径,它将立即重定向到"/products"。
条件重定向
我们可以使用Redirect组件来保护未经授权的用户访问页面。在我们的商店中,我们可以使用这个来确保只有已登录的用户可以访问我们的 Admin 页面。我们通过以下步骤来实现这一点:
- 让我们首先在
Routes.tsx中的 Admin 页面路由之后添加一个到LoginPage的路由:
<Route path="/login" component={LoginPage} />
- 当然,
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;
- 然后我们可以回到
Routes.tsx并导入LoginPage:
import LoginPage from "./LoginPage";
- 如果我们去运行的应用程序并导航到
"/login",我们会看到我们的登录页面:
我们不打算完全实现我们的登录页面;我们已经实现的页面足以演示条件重定向。
- 在我们在
Routes.tsx中实现"admin"路径的条件重定向之前,我们需要在Routes.tsx中添加一个关于用户是否已登录的状态:
const Routes: React.SFC = () => {
const [loggedIn, setLoggedIn] = React.useState(false);
return (
<Router>
...
</Router>
);
};
因此,我们使用了useState钩子来添加一个名为loggedIn的状态变量和一个名为setLoggedIn的函数。
- 最后一步是在
"/admin"路径的Route组件内添加以下内容:
<Route path="/admin">
{loggedIn ? <AdminPage /> : <Redirect to="/login"
/>}
</Route>
如果用户已登录,我们有条件地渲染AdminPage,否则,我们重定向到"/login"路径。如果我们现在在运行的应用程序中点击admin链接,我们将被重定向到登录页面。
- 如果我们将
loggedIn状态更改为 true,我们就能再次访问我们的 Admin 页面:
const [loggedIn, setLoggedIn] = React.useState(true);
查询参数
查询参数是 URL 的一部分,允许将附加参数传递到路径中。例如,"/products?search=redux"有一个名为search的查询参数,值为redux。
让我们实现这个例子,让商店的用户可以搜索产品:
- 让我们首先在
ProductsPage.tsx中的状态中添加一个名为search的变量,它将保存搜索条件:
interface IState {
products: IProduct[];
search: string;
}
- 鉴于我们需要访问 URL,我们需要在
ProductsPage中使用RouteComponentProps作为props类型。让我们首先导入这个:
import { RouteComponentProps } from "react-router-dom";
- 然后我们可以将其用作
props类型:
class ProductsPage extends React.Component<RouteComponentProps, IState> {
- 我们可以在
constructor中将search状态初始化为空字符串:
public constructor(props: RouteComponentProps) {
super(props);
this.state = {
products: [],
search: ""
};
}
- 然后我们需要在
componentDidMount中将search状态设置为搜索查询参数。React Router 通过location.search在props参数中给我们访问所有查询参数。然后我们需要解析该字符串以获取我们的搜索查询字符串参数。我们可以使用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
};
}
- 不幸的是,
URLSearchParams在所有浏览器中尚未实现,因此我们可以使用一个名为url-search-params-polyfill的 polyfill。让我们安装这个:
npm install url-search-params-polyfill
- 让我们将其导入到
ProductPages.tsx中:
import "url-search-params-polyfill";
- 然后我们可以在
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>
- 如果我们在运行的应用程序中输入
"/products?search=redux"作为路径,我们将看到我们的产品列表仅包含 React Redux:
- 我们将通过在应用程序标题中添加一个搜索输入来完成实现此功能,该输入将设置搜索查询参数。让我们首先在
Header.tsx中的Header组件中创建一些状态来存储搜索值:
const [search, setSearch] = React.useState("");
- 我们还需要通过 React Router 和
URLSearchParams访问查询字符串,所以让我们导入RouteComponentProps,withRouter和URLSearchParamspolyfill:
import { NavLink, RouteComponentProps, withRouter} from "react-router-dom";
import "url-search-params-polyfill";
- 让我们向
Header组件添加一个props参数:
const Header: React.SFC<RouteComponentProps> = props => { ... }
- 现在我们可以从路径查询字符串中获取搜索值,并在组件首次渲染时将
search状态设置为该值:
const [search, setSearch] = React.useState("");
React.useEffect(() => {
const searchParams = new URLSearchParams(props.location.search);
setSearch(searchParams.get("search") || "");
}, []);
- 现在让我们在
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>
);
}
- 让我们将刚刚引用的
search-containerCSS 类添加到index.css中:
.search-container {
text-align: right;
margin-bottom: -25px;
}
- 回到
Header.tsx,让我们添加handleSearchChange方法,该方法在render方法中被引用,并将保持我们的search状态与输入的值保持同步:
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.currentTarget.value);
};
- 现在我们可以实现
handleSearchKeydown方法,该方法在render方法中被引用。当按下Enter键时,这需要将search状态值添加到路径查询字符串中。我们可以利用RouteComponentProps给我们的history属性中的push方法:
const handleSearchKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
props.history.push(`/products?search=${search}`);
}
};
- 我们需要导出使用
withRouter高阶组件包装的Header组件,以便引用this.props.history能够工作。所以,让我们这样做并调整我们的export表达式:
export default withRouter(Header);
- 让我们在运行的应用程序中尝试一下。如果我们在搜索输入中输入
redux并按下Enter键,应用程序应该导航到产品页面并将产品过滤为 React Redux:
路由提示
有时,我们可能希望要求用户确认他们是否要离开页面。如果用户在页面上进行数据输入并在保存数据之前按导航链接转到另一页,这将很有用。React Router 中的Prompt组件允许我们执行此操作,如下所述:
- 在我们的应用程序中,如果用户尚未将产品添加到其购物篮中,我们将提示用户确认是否要离开产品页面。首先,在
ProductPage.tsx中,让我们从 React Router 中导入Prompt组件:
import { Prompt, RouteComponentProps } from "react-router-dom";
Prompt组件在满足某些条件时在导航期间调用确认对话框。我们可以在我们的 JSX 中使用Prompt组件如下:
<div className="page-container">
<Prompt when={!this.state.added} message={this.navAwayMessage}
/>
...
</div>
when属性允许我们指定对话框何时出现的表达式。在我们的情况下,这是当产品尚未添加到购物篮时。
message属性允许我们指定一个返回要在对话框中显示的消息的函数。
- 在我们的情况下,我们调用一个
navAwayMessage方法,接下来我们将实现:
private navAwayMessage = () =>
"Are you sure you leave without buying this product?";
- 让我们尝试一下,通过导航到 React Router 产品,然后在不点击添加到购物篮按钮的情况下离开:
我们被要求确认是否要离开。
嵌套路由
嵌套路由是指 URL 超过一个级别,并且呈现多个组件。在本节中,我们将实现一些嵌套路由在我们的管理页面中。我们完成的管理页面将如下截图所示:
前面截图中的 URL 有 3 个级别,会显示如下内容:
-
包含用户和产品链接的顶级菜单。
-
包含所有用户的菜单。在我们的示例中只有 Fred、Bob 和 Jane。
-
所选用户的信息。
- 让我们开始打开
AdminPage.tsx并从react-router-dom中为以下内容添加import语句:
import { NavLink, Route, RouteComponentProps } from "react-router-dom";
-
我们将使用
NavLink组件来呈现菜单。 -
Route组件将用于渲染嵌套路由 -
RouteComponentProps类型将用于从 URL 获取用户的id
- 我们将用无序列表替换
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组件导航到两个选项的嵌套路由。
- 让我们在
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;
}
- 回到
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>
- 我们刚刚引用了尚不存在的
AdminUsers和AdminProducts组件。让我们首先在AdminPage.tsx中的AdminPage组件下面输入以下内容来实现AdminProducts组件:
const AdminProducts: React.SFC = () => {
return <div>Some options to administer products</div>;
};
因此,此组件只在屏幕上呈现一些文本。
- 现在让我们继续处理
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 个用户。
- 让我们开始在
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>
);
};
该组件呈现一个包含每个用户名称的链接。该链接是到一个嵌套路径,最终将显示有关用户的详细信息。
- 因此,我们需要定义另一个路由,调用一个组件来渲染有关用户的详细信息。我们可以通过使用另一个
Route组件来实现这一点:
<div>
<ul className="admin-sections">
...
</ul>
<Route path="/admin/users/:id" component={AdminUser} />
</div>
- 我们刚刚定义的路径路由到一个我们还没有定义的
AdminUser组件。所以,让我们从AdminUsers组件下面开始:
const AdminUser: React.SFC<RouteComponentProps<{ id: string }>> = props => {
return null;
};
我们使用RouteComponentProps从 URL 路径中获取id并在 props 中使其可用。
- 现在,我们可以使用路径中的
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;
};
- 现在我们有了
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>
);
};
- 如果我们转到运行的应用程序,转到管理页面并单击产品菜单项,它将如下所示:
- 如果我们单击
用户菜单项,我们将看到我们可以单击以获取有关用户的更多信息的 3 个用户。这将看起来像本节中的第一个截图。
因此,为了实现嵌套路由,我们使用NavLink或Link组件创建必要的链接,并使用Route组件将这些链接路由到要使用Route组件呈现内容的组件。在本节之前,我们已经了解了这些组件,所以我们只需要学习如何在嵌套路由的上下文中使用它们。
动画过渡
在本节中,当用户导航到不同页面时,我们将添加一些动画。我们使用react-transition-group npm包中的TransitionGroup和CSSTransition组件来实现这一点,如下所示:
- 因此,让我们首先安装此包及其 TypeScript 类型:
npm install react-transition-group
npm install @types/react-transition-group --save-dev
TransitionGroup跟踪其所有子元素并计算子元素何时进入或退出。CSSTransition从TransitionGroup获取子元素是离开还是退出,并根据该状态对子元素应用 CSS 类。因此,TransitionGroup和CSSTransition可以包装我们的路由并调用我们可以创建的 CSS 类,以实现页面的进出动画。
- 因此,让我们将这些组件导入
Routes.tsx:
import { CSSTransition, TransitionGroup } from "react-transition-group";
- 我们还需要从 React Router 中导入
RouteComponentProps:
import { Redirect, Route, RouteComponentProps, Switch} from "react-router-dom";
- 让我们将
RouteComponentProps用作Route组件的 props 类型:
const Routes: React.SFC<RouteComponentProps> = props => {
...
}
- 让我们将
CSSTransition和TransitionGroup组件添加到Switch组件周围的 JSX 中:
<TransitionGroup>
<CSSTransition
key={props.location.key}
timeout={500}
classNames="animate"
>
<Switch>
...
</Switch>
</CSSTransition>
</TransitionGroup>
TransitionGroup要求子元素具有唯一的key,以确定何时退出和进入。因此,我们已经指定了CSSTransition上的key属性为RouteComponentProps的location.key属性。我们已经指定了过渡将在半秒内运行的timeout属性。我们还指定了将使用animate前缀调用的 CSS 类,通过classNames属性。
- 因此,让我们在
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 类最初隐藏了正在过渡的元素,并逐渐缓解了元素的不透明度,以便显示出来。
- 如果我们转到
index.tsx,我们会得到一个编译错误,因为它期望我们传递来自路由器的history等 props 给Routes组件:
不幸的是,我们无法使用withRouter高阶组件,因为这将位于Router组件之外。为了解决这个问题,我们可以添加一个名为RoutesWrap的新组件,它不接受任何 props,并包装我们现有的Routes组件。Router将移动到RoutesWrap,并包含一个始终渲染我们的Routes组件的Route组件。
- 因此,让我们将
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;
编译错误消失了,这太棒了。
- 现在让我们从我们的
Routes组件中删除Router,将div标签作为其根:
public render() {
return (
<div>
<Header />
<TransitionGroup>
...
</TransitionGroup>
</div>
);
}
如果我们转到运行的应用程序并导航到不同的页面,您将看到一个很好的淡入淡出动画,当页面进入视图时。
延迟加载路由
目前,当应用程序首次加载时,将加载我们应用程序的所有 JavaScript。这包括用户不经常使用的管理页面。如果AdminPage组件在应用程序加载时不加载,而是按需加载,那将是很好的。这正是我们将在本节中要做的。这称为“延迟加载”组件。以下步骤允许我们按需加载内容:
- 首先,我们将从 React 中导入
Suspense组件,稍后我们将使用它:
import { Suspense } from "react";
- 现在,我们将以不同的方式导入
AdminPage组件:
const AdminPage = React.lazy(() => import("./AdminPage"));
我们使用一个名为lazy的 React 函数,它接受一个返回动态导入的函数,然后将其分配给我们的AdminPage组件变量。
- 在我们这样做之后,我们可能会遇到一个 linting 错误:在 ES5/ES3 中进行动态导入调用需要'Promise'构造函数。确保您有'Promise'构造函数的声明,或在
--lib选项中包含'ES2015'。因此,在tsconfig.json中,让我们添加lib编译器选项:
"compilerOptions": {
"lib": ["es6", "dom"],
...
}
- 接下来的部分是在
AdminPage组件周围包装Suspense组件:
<Route path="/admin">
{loggedIn ? (
<Suspense fallback={<div className="page-container">Loading...</div>}>
<AdminPage />
</Suspense>
) : (
<Redirect to="/login" />
)}
</Route>
Suspense组件显示一个包含 Loading...的div标签,同时加载AdminPage。
- 让我们在运行的应用程序中尝试这个。让我们打开浏览器开发者工具,转到网络选项卡。在我们的应用程序中,让我们转到产品页面并刷新浏览器。然后清除开发者工具中网络选项卡中的内容。如果我们然后转到应用程序中的管理页面并查看网络选项卡中的内容,我们将看到动态加载
AdminPage组件的 JavaScript 块:
AdminPage组件加载非常快,所以我们从来没有真正看到 Loading ...div标签。所以,让我们在浏览器开发者工具中减慢连接速度:
- 如果我们然后刷新浏览器,再次转到管理页面,我们将看到 Loading ...:
在这个例子中,AdminPage组件并不是很大,所以这种方法并没有真正对性能产生积极影响。然而,按需加载更大的组件确实可以帮助提高性能,特别是在慢速连接上。
总结
React Router 为我们提供了一套全面的组件,用于管理应用程序中页面之间的导航。我们了解到顶层组件是Router,它在其下寻找Route组件,我们在其中定义了应该为特定路径呈现哪些组件。
Link组件允许我们链接到应用程序中的不同页面。我们了解到NavLink组件类似于Link,但它包括根据是否为活动路径来设置样式的能力。因此,NavLink非常适合应用程序中的主导航元素,而Link非常适合出现在页面上的其他链接。
RouteComponentProps是一种类型,它使我们能够访问路由参数和查询参数。我们发现 React Router 不会为我们解析查询参数,但可以使用原生 JavaScript URLSearchParams接口来为我们做这个。
Redirect组件在特定条件下重定向到路径。我们发现这非常适合保护只有特权用户可以访问的页面。
Prompt组件允许我们在特定条件下要求用户确认他们是否要离开页面。我们在产品页面上使用它来再次确认用户是否要购买产品。这个组件的另一个常见用例是在输入的数据没有保存时,确认离开数据输入页面的导航。
我们了解到嵌套路由如何为用户提供进入应用程序特定部分的深链接。我们只需使用Link或NavLink和Route组件来定义相关链接以处理这些链接。
我们使用react-transition-group npm包中的TransitionGroup和CSSTransition组件改进了我们的应用体验。我们将这些组件包裹在定义应用路径的Route组件周围,并添加了 CSS 类来实现我们希望页面退出和进入视图时的动画效果。
我们了解到,React 的lazy函数以及其Suspense组件可以用于按需加载用户很少使用的大型组件。这有助于提高应用程序的启动时间性能。
问题
让我们通过以下问题来测试我们对 React Router 的了解:
- 我们有以下显示客户列表的
Route组件:
<Route path="/customers" component={CustomersPage} />
当页面是"/customers"时,CustomersPage组件会渲染吗?
-
当页面是
"/customers/24322"时,CustomersPage组件会渲染吗? -
我们只希望在路径为
"/customers"时,CustomersPage组件才会渲染。我们如何更改Route上的属性来实现这一点? -
什么样的
Route组件可以处理"/customers/24322"路径?它应该将"24322"放在名为customerId的路由参数中。 -
我们如何捕获不存在的路径,以便通知用户?
-
我们如何在
CustomersPage中实现search查询参数?因此,"/customers/?search=Cool Company"将显示名称为"Cool Company"的客户。 -
过了一会儿,我们决定将
"customer"路径更改为"clients"。我们如何实现这一点,以便用户仍然可以使用现有的"customer"路径,但路径会自动重定向到新的"client"路径?
进一步阅读
-
值得一读的 React Router 文档链接如下:
reacttraining.com/react-router -
也值得查看
react-transition-group文档,以进一步了解过渡组件:reactcommunity.org/react-transition-group/
第五章:高级类型
我们已经学习了相当多的 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 中,让我们通过一个例子来看一下:
- 让我们创建一个名为
Control的字符串文字类型,它只能设置为"Textbox"字符串:
type Control = "Textbox";
- 现在让我们创建一个名为
notes的变量,使用我们的Control类型,并将其设置为"Textbox":
let notes: Control;
notes = "Textbox";
正如我们所期望的,TypeScript 编译器对此非常满意。
- 现在让我们将变量设置为不同的值:
notes = "DropDown";
我们得到了编译错误,类型"DropDown"不能赋值给类型"Textbox":
- 与 TypeScript 中的所有其他类型一样,
null和undefined也是有效的值:
notes = null;
notes = undefined;
字符串文字类型本身并不那么有用。然而,当它们用于联合类型时,它们变得非常有用,我们将在下一部分中看到。
字符串文字联合类型
字符串文字联合类型是指我们将多个字符串文字类型组合在一起。
让我们从上一个例子继续,并通过这个例子来看一下。
- 让我们增强我们的
Control类型,使其成为字符串文字的联合类型:
type Control = "Textbox" | "DropDown"
我们使用|在联合类型中组合类型。
- 现在将我们的
notes变量设置为"Textbox"或"DropDown"现在是完全有效的:
let notes: Control;
notes = "Textbox";
notes = "DropDown";
- 让我们扩展我们的
Control类型,以包含更多的字符串文字:
type Control = "Textbox" | "DropDown" | "DatePicker" | "NumberSlider";
- 现在我们可以将我们的
notes变量设置为这些值中的任何一个:
notes = "DatePicker";
notes = "NumberSlider";
如果我们仔细想一想,这真的很有用。我们本来可以将notes变量声明为string,但是用包含的特定字符串文字来声明它可以包含的内容,这样就可以使它成为超级类型安全。
辨识联合模式
辨识联合模式允许我们处理不同联合类型的逻辑。
让我们通过一个例子来看一下:
- 让我们首先创建三个接口来表示文本框、日期选择器和数字滑块:
interface ITextbox {
control: "Textbox";
value: string;
multiline: boolean;
}
interface IDatePicker {
control: "DatePicker";
value: Date;
}
interface INumberSlider {
control: "NumberSlider";
value: number;
}
它们都有一个名为control的属性,这将是模式中的辨识者。
- 让我们继续将这些接口组合成一个名为
Field的联合类型:
type Field = ITextbox | IDatePicker | INumberSlider;
因此,我们可以从任何类型创建联合类型,而不仅仅是字符串文字。在这种情况下,我们已经从三个接口创建了一个联合类型。
- 现在让我们创建一个函数来初始化
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类型的语句。在接下来的步骤之后,我们将看到这样做的价值。
- 假设时间已经过去,我们对复选框字段有了新的要求。让我们为此实现一个接口:
interface ICheckbox {
control: "Checkbox";
value: boolean;
}
- 让我们也将这个加入到联合
Field类型中:
type Field = ITextbox | IDatePicker | INumberSlider | ICheckbox;
我们会立即看到我们的initializeValue函数在never声明上抛出编译错误:
这非常有价值,因为never语句确保我们不会忘记为新的复选框要求添加代码分支。
- 所以,让我们去实现这个额外的分支,针对
"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 中返回表示类型的字符串的关键字。因此,我们可以在条件中使用它来缩小类型。
让我们通过一个例子来说明:
- 我们有一个可以是字符串或字符串数组的联合类型:
type StringOrStringArray = string | string[];
- 我们需要实现一个名为
first的函数,它接受一个StringOrStringArray类型的参数并返回一个string:
function first(stringOrArray: StringOrStringArray): string {
}
- 如果
stringOrArray是一个string,那么函数需要返回第一个字符;否则,它应该返回第一个数组元素:
function first(stringOrArray: StringOrStringArray): string {
if (typeof stringOrArray === "string") {
return stringOrArray.substr(0, 1);
} else {
return stringOrArray[0];
}
}
在第一个分支中,如果我们悬停在stringOrArray上,我们会看到类型已成功缩小为string:
在第二个分支中,如果我们悬停在stringOrArray上,我们会看到类型已成功缩小为string[]:
- 为了检查我们的函数是否有效,我们可以添加以下内容:
console.log(first("The"));
console.log(first(["The", "cat"]));
如果我们运行程序,T和The将被输出到控制台。
typeof关键字只能与 JavaScript 类型一起使用。为了说明这一点,让我们创建一个增强版本的函数:
- 我们将我们的函数称为
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 类型,这些类型是string、number、boolean、symbol、undefined、object和function;因此错误消息中结合了这些类型的联合类型。因此,我们的第二个分支中的typeof实际上会返回"object"。
- 让我们正确地实现这个:
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 关键字。它检查对象是否具有特定的构造函数。通常用于确定对象是否是类的实例。
让我们通过一个例子来看一下:
- 我们有两个表示
Person和Company的类:
class Person {
id: number;
firstName: string;
surname: string;
}
class Company {
id: number;
name: string;
}
- 我们还有一个结合这两个类的联合类型:
type PersonOrCompany = Person | Company;
- 现在我们需要编写一个函数,该函数接受
Person或Company并将它们的名称输出到控制台:
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关键字来实现上一节的示例:
- 这次,我们使用接口而不是
Person和Company结构的类:
interface IPerson {
id: number;
firstName: string;
surname: string;
}
interface ICompany {
id: number;
name: string;
}
- 我们再次从
Person和Company结构创建一个联合类型:
type PersonOrCompany = IPerson | ICompany;
- 最后,让我们使用
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类型时使用了用户定义的类型保护。
让我们使用我们自己的类型保护函数来实现上两节的示例:
- 我们有相同的接口和联合类型:
interface IPerson {
id: number;
firstName: string;
surname: string;
}
interface ICompany {
id: number;
name: string;
}
type PersonOrCompany = IPerson | ICompany;
- 因此,让我们实现返回对象是否为
IPerson类型的类型保护函数:
function isPerson(personOrCompany: PersonOrCompany): personOrCompany is IPerson {
return "firstName" in personOrCompany;
}
类型断言personOrCompany是IPerson有助于 TypeScript 编译器缩小类型。要确认这一点,在第一个分支上悬停在personOrCompany上应该给出IPerson类型。然后,如果我们在第二个分支上悬停在personOrCompany上,我们应该得到ICompany类型。
创建用户定义的类型保护比其他方法更费力,但它为我们提供了处理其他方法无法解决的情况的灵活性。
泛型
泛型可以应用于函数或整个类。这是一种允许消费者使用自己的类型与泛型函数或类一起使用的机制。接下来的部分将介绍这两种情况的示例。
泛型函数
让我们通过一个通用函数的示例来进行讲解。我们将创建一个包装函数,用于调用fetch JavaScript 函数从 web 服务获取数据:
- 让我们从创建
function签名开始:
function getData<T>(url: string): Promise<T> {
}
我们在函数名后的尖括号中放置一个T来表示它是一个通用函数。实际上我们可以使用任何字母,但T是常用的。然后我们在类型是通用的地方使用T。在我们的示例中,通用部分是返回类型,所以我们返回Promise<T>。
如果我们想要使用箭头函数,这将是:
const getData = <T>(url: string): Promise<T> => {
};
- 现在让我们实现我们的函数:
function getData<T>(url: string): Promise<T> {
return fetch(url).then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
});
}
- 最后,让我们消费这个函数:
interface IPerson {
id: number;
name: string;
}
getData<IPerson>("/people/1").then(person => console.log(person));
我们在函数名后的尖括号中传递我们想要在函数中使用的类型。在我们的例子中,它是IPerson。
如果我们在then回调中悬停在person上,我们会看到person被正确地类型化为IPerson:
因此,顾名思义,通用函数是与通用类型一起工作的函数。先前示例的另一种实现方式是将any作为返回类型,但那不是类型安全的。
通用类
我们可以使整个类成为通用的。让我们深入了解一个将数据存储在列表中的通用类的示例:
- 首先让我们定义我们的类,不包含任何内容:
class List<T> {
}
我们通过在类名后面加上<T>来标记类为通用的。
- 在类内部,让我们为列表中的数据创建一个
private属性:
private data: T[] = [];
我们使用T来引用通用类型。在我们的示例中,我们的data属性是一个根据类声明的任何类型的数组。
- 现在让我们添加一个
public方法来获取列表中的所有数据:
public getList(): T[] {
return this.data;
}
我们使用T[]来引用通用数组作为返回类型。
- 让我们实现一个向列表中添加项目的方法:
public add(item: T) {
this.data.push(item);
}
我们使用通用类型T来引用传入的数据项。该实现简单地使用数组的push方法将项目添加到我们的private数组中。
- 让我们也实现一个从列表中移除项目的方法:
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方法。
- 因此,现在我们已经实现了我们的通用列表类,让我们创建一个类型和一些数据,以便消费它:
interface IPerson {
id: number;
name: string;
}
const billy: IPerson = { id: 1, name: "Billy" };
- 现在让我们创建一个通用类的实例:
const people = new List<IPerson>();
我们在类名后面使用尖括号中的类型来与类交互。
- 现在我们可以通过添加和删除
billy来与类交互:
people.add(billy);
people.remove(billy);
- 让我们尝试在我们的列表实例中使用不同的类型:
people.add({name: "Sally"});
我们得到了编译错误,正如我们所预期的那样:
- 让我们将列表实例中的所有项目保存到一个变量中:
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函数会很好,不是吗?我们将在本节中做到这一点:
- 我们有一个从字符串中移除空格的函数:
function condenseString(string: string): string {
return string.split(" ").join("");
}
- 我们有另一个从数组项中移除空格的函数:
function condenseArray(array: string[]): string[] {
return array.map(item => item.split(" ").join(""));
}
- 现在我们想将这两个函数合并为一个单一的函数。我们可以使用联合类型来实现:
function condense(stringOrArray: string | string[]): string | string[] {
return typeof stringOrArray === "string"
? stringOrArray.split(" ").join("")
: stringOrArray.map(item => item.split(" ").join(""));
}
- 让我们使用我们的统一函数:
const condensedText = condense("the cat sat on the mat");
当我们输入函数参数时,智能感知提醒我们需要输入一个字符串或字符串数组:
如果我们悬停在condensedText变量上,我们会看到推断类型是联合类型:
- 现在是时候添加两个签名重载来改进我们函数的使用了:
function condense(string: string): string;
function condense(array: string[]): string[];
function condense(stringOrArray: string | string[]): string | string[] { ... }
我们在主函数签名之前添加了函数重载签名。我们为处理字符串时添加了一个重载,为处理字符串数组时添加了第二个重载。
- 让我们使用我们的重载函数:
const moreCondensedText = condense("The cat sat on the mat");
现在,当我们输入参数时,我们得到了改进的智能感知。我们还可以使用上下箭头来滚动两个不同的签名:
如果我们悬停在moreCondensedText变量上,我们会看到我们获得了更好的类型推断:
因此,重载签名可以改善开发人员使用我们函数的体验。它们可以提供改进的智能感知和类型推断。
查找和映射类型
keyof是 TypeScript 中的一个关键字,它创建了对象中所有属性的联合类型。创建的类型称为查找类型。这允许我们根据现有类型的属性动态创建类型。这是一个有用的功能,我们可以用它来针对不同的数据创建通用但强类型的代码。
让我们通过一个例子来说明:
- 我们有以下
IPerson接口:
interface IPerson {
id: number;
name: string;
}
- 让我们在这个接口上使用
keyof创建一个查找类型:
type PersonProps = keyof IPerson;
如果我们悬停在PersonProps类型上,我们会看到创建了一个包含"id"和"name"的联合类型:
- 让我们向
IPerson添加一个新属性:
interface IPerson {
id: number;
name: string;
age: number
}
如果我们再次悬停在PersonProps类型上,我们会看到该类型已自动扩展以包含"age":
因此,PersonProps类型是一个查找类型,因为它查找它需要包含的文字。
现在让我们用查找类型创建一些有用的东西:
- 我们将创建一个
Field类,其中包含字段名称、标签和默认值:
class Field {
name: string;
label: string;
defaultValue: any;
}
- 这只是一个开始,但我们可以通过使我们的类通用来使
name更加强类型化:
class Field<T, K extends keyof T> {
name: K;
label: string;
defaultValue: any;
}
我们在类上创建了两个通用参数。第一个是包含字段的对象类型,第二个是对象内的属性名称。
- 如果我们创建类的实例,可能会更有意义。让我们使用上一个示例中的
IPerson,并将"id"作为字段名称传递进去:
const idField: Field<IPerson, "id"> = new Field();
- 让我们尝试引用在
IPerson中不存在的属性:
const addressField: Field<IPerson, "address"> = new Field();
我们得到了编译错误,正如我们所期望的那样:
捕捉这样的问题是查找类型的好处,而不是使用string类型。
- 现在让我们把注意力转向
Field类中的defaultValue属性。目前这不是类型安全的。例如,我们可以将idField设置为一个字符串:
idField.defaultValue = "2";
- 让我们解决这个问题,使
defaultValue具有类型安全性:
class Field<T, K extends keyof T> {
name: K;
label: string;
defaultValue: T[K];
}
我们使用T[K]查找类型。对于idField,这将解析为IPerson中id属性的类型,即number。
现在设置idField.defaultValue的代码行会引发编译错误,正如我们所期望的那样:
- 让我们将
"2"更改为2:
idField.defaultValue = 2;
编译错误消失了。
因此,在创建可变数据类型的通用组件时,查找类型可能会很有用。
现在让我们转到映射类型。同样,这些让我们可以从现有类型的属性中创建新类型。但是,映射类型允许我们通过从现有属性中映射它们来明确定义新类型中的属性。
让我们通过一个示例来看一下:
- 首先,让我们创建一个类型,我们将在下一步中进行映射:
interface IPerson {
id: number;
name: string;
}
- 现在让我们创建一个新版本的
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
};
- 让我们尝试一下,看看我们的类型是否真的是
readonly:
let billy: ReadonlyPerson = {
id: 1,
name: "Billy"
};
billy.name = "Sally";
正如我们所期望的,当我们尝试将readonly属性设置为新值时,会引发编译错误:
所以我们的映射类型起作用了!这种映射类型的更通用版本实际上是 TypeScript 中的标准类型,即Readonly<T>。
- 现在让我们使用标准的
readonly类型:
let sally: Readonly<IPerson> = {
id: 1,
name: "sally"
};
- 让我们尝试更改我们的
readonly中的值:
Sally.name = "Billy";
引发编译错误,正如我们所期望的那样:
如果我们在 Visual Studio Code 中使用“转到定义”选项来查看Readonly类型,我们会得到以下结果:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
这与我们的ReadonlyPerson类型非常相似,但是IPerson已被替换为通用类型T。
让我们尝试创建我们自己的通用映射类型:
- 我们将创建一个映射类型,使现有类型的所有属性都是
string类型:
type Stringify<T> = { [P in keyof T]: string };
- 让我们尝试使用我们的映射类型:
let tim: Stringify<IPerson> = {
id: "1",
name: "Tim"
};
- 让我们尝试将
id设置为一个数字:
tim.id = 1
预期的编译错误被引发:
因此,在需要基于现有类型创建新类型的情况下,映射类型非常有用。除了Readonly<T>之外,在 TypeScript 中还有许多标准映射类型,例如Partial<T>,它创建一个映射类型,使所有属性都是可选的。
总结
在本章中,我们学习了 TypeScript 中一些更高级的类型,从联合类型开始。联合类型非常有用,允许我们通过将现有类型联合在一起来创建新类型。我们发现,将字符串字面量联合在一起可以创建比普通string更具体和类型安全的类型。
我们探讨了各种实现类型守卫的方式。类型守卫在逻辑分支中帮助编译器缩小联合类型的范围时非常有用。它们在使用unknown类型时,在逻辑分支中告诉编译器类型是什么也非常有用。
泛型,顾名思义,允许我们创建通用类型。在详细讨论了这个主题之后,React 组件中的 props 和 state 的类型安全现在更加有意义了。我们将在本书的其余部分大量使用通用类和函数。
我们了解到重载签名允许我们拥有具有不同参数和返回类型的函数。现在我们可以有效地使用这个特性来简化我们在库中公开的公共函数。
我们学习了如何可以使用查找和映射类型从现有类型属性动态创建新类型。我们现在知道,有许多有用的标准 TypeScript 映射类型,如Readonly<T>和Partial<T>。
学习所有这些特性是对下一章的很好准备,我们将深入探讨在使用 React 组件时的一些常见模式。
问题
让我们来试试一些关于高级类型的问题:
- 我们有一个代表课程结果的
interface,如下:
interface ICourseMark {
courseName: string;
grade: string;
}
我们可以像这样使用这个interface:
const geography: ICourseMark = {
courseName: "Geography",
grade: "B"
}
成绩只能是 A、B、C 或 D。我们如何创建这个接口中grade属性的更强类型版本?
- 我们有以下函数,用于验证数字和字符串是否有值:
function isNumberPopulated(field: number): boolean {
return field !== null && field !== undefined;
}
function isStringPopulated(field: string): boolean {
return field !== null && field !== undefined && field !== "";
}
我们如何将这些组合成一个名为isPopulated的单一函数,带有签名重载?
-
我们如何可以使用泛型实现一个更灵活的
isPopulated函数? -
我们有一个代表阶段的
type别名:
type Stages = {
pending: 'Pending',
started: 'Started',
completed: 'Completed',
};
-
我们如何可以编程地将这个转换成
'Pending' | 'Started' | 'Completed'联合类型? -
我们有以下联合类型:
type Grade = 'gold' | 'silver' | 'bronze';
我们如何可以编程地创建以下类型:
type GradeMap = {
gold: string;
silver: string;
bronze: string
};
进一步阅读
TypeScript 文档中有一个关于高级类型的很棒的部分,值得一看:
www.typescriptlang.org/docs/handbook/advanced-types.html
第六章:组件模式
在本章中,我们将继续构建之前的 React 商店。我们将构建一个可重用的选项卡组件,以及一个可重用的加载指示器组件,两者都将在商店的产品页面上使用。本章将首先将产品页面分割为容器和展示组件,然后再处理选项卡组件,利用复合组件和渲染属性模式。然后,我们将继续实现一个使用高阶组件模式的加载指示器组件。
在这一章中,我们将学习以下主题:
-
容器和展示组件
-
复合组件
-
渲染属性模式
-
高阶组件
技术要求
在本章中,我们将使用以下技术:
-
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 提供)扩展。 -
React 商店:我们将从我们在查看 React Router 的章节中开始的项目开始。这可以在 GitHub 上找到:
github.com/carlrip/LearnReact17WithTypeScript/tree/master/04-ReactRouter。
本章中的所有代码片段都可以在以下网址找到:github.com/carlrip/LearnReact17WithTypeScript/tree/master/06-ComponentPatterns。
容器和展示组件
将页面分割为容器和展示组件可以使展示组件更容易重用。容器组件负责事物的运作,从 Web API 获取数据并管理状态。展示组件负责外观。展示组件通过属性接收数据,同时具有属性事件处理程序,以便其容器可以管理用户交互。
我们将在我们的 React 商店中使用这种模式,将产品页面分成容器和展示组件。ProductPage组件将是容器,我们将引入一个名为Product的新组件,它将是展示组件:
- 让我们首先在 Visual Studio Code 中打开我们的商店项目,并在终端中输入以下命令来启动应用程序:
npm start
- 如果我们导航到一个产品,让我们回顾一下产品页面是什么样子的:
- 让我们创建一个名为
Product.tsx的新文件,其中包含我们的展示组件,内容如下:
import * as React from "react";
const Product: React.SFC<{}> = props => {
return <React.Fragment>TODO</React.Fragment>;
};
export default Product;
我们的展示组件是一个函数组件。
- 展示组件通过 props 接收数据,也通过 props 委托事件处理。因此,让我们为产品数据项、是否已添加到购物篮以及添加到购物篮的处理程序创建 props:
import * as React from "react";
import { IProduct } from "./ProductsData";
interface IProps {
product: IProduct;
inBasket: boolean;
onAddToBasket: () => void;
}
const Product: React.SFC<IProps> = props => {
return <React.Fragment>TODO</React.Fragment>;
};
export default Product;
- 如果我们查看
ProductsPage.tsx,我们将复制React.Fragment部分中有产品时的 JSX。然后我们将其粘贴到Product组件的 return 语句中:
const Product: React.SFC<IProps> = props => {
return (
<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>
);
};
我们现在有一些参考问题需要解决。
- 让我们在 return 语句之前定义一个产品变量,以解决 JSX 中产品引用的问题:
const product = props.product;
return (
...
)
- 现在通过
inBasketprop 传递产品是否在购物篮中。因此,让我们改变添加到购物篮按钮周围的条件以使用这个 prop:
{!props.inBasket && (
<button onClick={this.handleAddClick}>Add to basket</button>
)}
- 我们需要解决的最后一个参考问题是点击“添加到购物篮”按钮的处理程序。让我们首先创建一个简单调用
onAddToBasketprop 的处理程序:
const product = props.product;
const handleAddClick = () => {
props.onAddToBasket();
};
return (
...
);
- 我们可以在 JSX 中删除对此处理程序的引用
this。
{!props.inBasket && (
<button onClick={handleAddClick}>Add to basket</button>
)}
这就是我们目前的Product展示组件完成了。因此,让我们在ProductPage组件中引用我们的Product组件。
- 首先,让我们将我们的
Product组件导入到ProductPage.tsx中:
import Product from "./Product";
- 现在,让我们用我们的
Product组件替换在 JSX 中复制的部分:
return (
<div className="page-container">
<Prompt when={!this.state.added} message={this.navAwayMessage} />
{product ? (
<Product
product={product}
inBasket={this.state.added}
onAddToBasket={this.handleAddClick}
/>
) : (<p>Product not found!</p>)}
</div>
);
我们将产品、产品是否已添加到购物篮以及添加到购物篮的处理程序一起作为 props 传递给Product组件。
如果我们再次查看商店并转到产品页面,它看起来完全一样。
因此,我们刚刚实现了我们的第一个容器和展示组件。容器组件非常适合作为页面中的顶层组件,从 Web API 获取数据,并管理页面内的所有状态。展示组件只关注需要呈现在屏幕上的内容。这种模式的好处是展示组件可以更容易地在应用程序的其他地方使用。例如,我们的Product组件可以相当容易地在商店中创建的其他页面上使用。这种模式的另一个好处是,展示组件通常更容易进行单元测试。在我们的示例中,我们的Product组件是一个纯函数,因此对其进行单元测试只是检查不同输入的输出是否正确,因为没有副作用。我们将在本书的后面详细介绍单元测试。
在下一节中,我们将继续增强我们的产品页面,通过向其添加评论并添加选项卡来将产品描述与评论分开。
复合组件
复合组件是一组共同工作的组件。我们将使用这种模式在产品页面上创建一个可重用的选项卡组件,以分隔产品描述和评论。
为产品添加评论
在创建我们的Tabs复合组件之前,让我们在产品页面上添加评论:
- 首先,我们需要在
ProductsData.ts中为评论数据结构添加一个接口:
export interface IReview {
comment: string;
reviewer: string;
}
- 我们现在可以将评论添加到我们的产品接口中:
export interface IProduct {
...
reviews: IReview[];
}
- 我们现在可以将评论添加到我们的产品数据数组中:
const products: IProduct[] = [
{
id: 1,
...
reviews: [
{
comment: "Excellent! This does everything I want",
reviewer: "Billy"
},
{ comment: "The best router I've ever worked with", reviewer:
"Sally" }
]
},
{
id: 2,
..
reviews: [
{
comment: "I've found this really useful in a large app I'm
working on",
reviewer: "Billy"
},
{
comment: "A bit confusing at first but simple when you get
used to it",
reviewer: "Sally"
}
]
},
{
id: 3,
..
reviews: [
{
comment: "I'll never work with a REST API again!",
reviewer: "Billy"
},
{
comment: "It makes working with GraphQL backends a breeze",
reviewer: "Sally"
}
]
}
];
因此,我们为每个产品添加了一个reviews属性,它是一个评论数组。每个评论都是一个包含comment和reviewer属性的对象,由IReview接口定义。
- 有了我们的数据,让我们在描述之后将评论添加到我们的
Product组件中:
<p>{product.description}</p>
<div>
<ul className="product-reviews">
{product.reviews.map(review => (
<li key={review.reviewer} className="product-reviews-item">
<i>"{review.comment}"</i> - {review.reviewer}
</li>
))}
</ul>
</div>
<p className="product-price">
...
</p>
因此,我们正在使用map函数在reviews数组上显示comment和reviewer。
- 我们引用了一些新的 CSS 类,因此让我们将它们添加到
index.css中:
.product-reviews {
list-style: none;
padding: 0px;
}
.product-reviews .product-reviews-item {
display: block;
padding: 8px 0px;
}
如果我们查看正在运行的应用程序并转到产品,我们现在将看到评论:
现在我们已经添加了评论,我们可以在下一节中处理我们的Tabs组件。
创建一个基本的选项卡组件
我们的工作现在是使用我们将要构建的选项卡组件将描述与评论分开。我们将首先创建一个简单的选项卡组件,然后在下一节将其重构为复合组件模式。
是时候开始我们的选项卡组件了:
- 首先,让我们创建一个名为
Tabs.tsx的文件,用以下内容作为骨架类组件:
import * as React from "react";
interface IProps {}
interface IState {}
class Tabs extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {};
}
public render() {
return;
}
}
export default Tabs;
我们选择创建基于类的组件,因为我们的组件将需要跟踪活动的选项卡标题的状态。
- 因此,让我们通过添加一个属性来完成我们状态的接口,该属性将给出活动的标题名称:
interface IState {
activeHeading: string;
}
- 我们的组件将接受选项卡标题并将它们显示为属性。因此,让我们完成这个接口:
interface IProps {
headings: string[];
}
因此,我们的组件可以接受一个headings属性中的标题名称数组。
- 让我们现在在构造函数中为
activeHeading状态创建初始值:
public constructor(props: IProps) {
super(props);
this.state = {
activeHeading:
this.props.headings && this.props.headings.length > 0
? this.props.headings[0]
: ""
};
}
因此,活动标题最初将设置为headings数组中的第一个元素。三元运算符确保我们的组件在消费者未传递任何选项卡时不会产生错误。
- 现在转到渲染方法,让我们通过映射
headings属性在列表中渲染我们的选项卡:
public render() {
return (
<ul className="tabs">
{this.props.headings.map(heading => (
<li className={heading === this.state.activeHeading ?
"active" : ""}
>
{heading}
</li>
))}
</ul>
);
}
我们引用了一些 CSS 类,包括active,它是基于三元运算符设置的,取决于正在呈现的是否是活动选项卡标题。
- 现在让我们将这些 CSS 类添加到
index.css中:
.tabs {
list-style: none;
padding: 0;
}
.tabs li {
display: inline-block;
padding: 5px;
margin: 0px 5px 0px 5px;
cursor: pointer;
}
.tabs li:focus {
outline: none;
}
.tabs li.active {
border-bottom: #222 solid 2px;
}
在我们可以看到我们的选项卡组件是什么样子之前,我们需要使用它。
- 因此,让我们首先导入
Tabs组件,然后将其添加到Product组件中。
import Tabs from "./Tabs";
- 现在我们可以在产品名称和描述之间添加
Tabs组件:
<h1>{product.name}</h1>
<Tabs headings={["Description", "Reviews"]} />
<p>{product.description}</p>
我们将向Tabs组件传递我们要显示的两个选项卡标题,即描述和评论。
让我们看看这是什么样子:
这是一个良好的开始。第一个选项卡下划线是active CSS 样式,正如我们所希望的那样。但是,如果我们点击评论选项卡,什么也不会发生。
- 因此,让我们在
Tabs.tsx中引用点击处理程序来处理每个选项卡:
<li
onClick={this.handleTabClick}
className={heading === this.state.activeHeading ? "active" : ""}
>
{heading}
</li>
- 现在让我们也实现点击处理程序:
private handleTabClick = (e: React.MouseEvent<HTMLLIElement>) => {
const li = e.target as HTMLLIElement;
const heading: string = li.textContent ? li.textContent : "";
this.setState({ activeHeading: heading });
};
我们首先从li的textContent中提取标题。然后将activeHeading状态设置为此标题。这将导致 React 重新渲染组件,显示所点击的选项卡为活动状态。
请注意,我们使用as关键字帮助 TypeScript 编译器将li变量声明为HTMLLIElement。如果不这样做,编译器将不允许我们访问其中的textContent属性。
如果我们再次转到运行的应用程序,现在我们可以单击选项卡并看到活动状态的变化。
目前,我们的选项卡组件只是渲染一些可以单击的选项卡。它还没有与任何内容相关联。直到下一节关于渲染属性模式的部分,我们才会将标题与内容关联起来。但是,现在是时候探索复合组件模式,并在下一节中稍微增强我们的选项卡标题。
利用复合组件模式
目前,我们的选项卡标题只能是字符串。如果我们希望允许组件的使用者在标题中定义更丰富的内容怎么办?例如,使用者可能希望在选项卡标题前放置图标或使标题加粗。因此,使用的 JSX 可能如下所示:
<Tabs>
<Tabs.Tab name="Description" initialActive={true}>
<b>Description</b>
</Tabs.Tab>
<Tabs.Tab name="Reviews">
Reviews
</Tabs.Tab>
</Tabs>
在上一个示例中,Tabs和Tabs.Tab是复合组件:
-
Tabs是渲染其中的Tabs.Tab组件的组件。它还管理活动选项卡的状态。 -
Tabs.Tab渲染单个标题。它以唯一的选项卡名称作为属性,允许管理活动选项卡。它还接受一个名为initialActive的boolean属性,该属性在首次加载时设置该选项卡为活动状态。渲染的标题是Tabs.Tab标记内的内容。因此,第一个选项卡将以粗体呈现描述。
因此,让我们将我们的基本选项卡组件重构为一个复合组件,可以类似于上一个示例中使用:
- 我们的
Tabs组件不再接受任何属性,因此,让我们删除IProps接口。我们可以删除构造函数,因为我们不再需要从属性初始化状态。我们还将状态属性的名称从activeHeading更改为activeName:
interface IState {
activeName: string;
}
class Tabs extends React.Component<{}, IState> {
public render() {
...
}
...
}
- 首先,我们将在
Tabs中工作Tab组件。因此,让我们为其属性创建一个接口:
interface ITabProps {
name: string;
initialActive?: boolean;
}
-
name属性是选项卡的唯一名称。稍后将使用它来帮助我们管理活动选项卡。 -
initialActive属性指定组件首次加载时选项卡是否处于活动状态。
- 现在让我们在我们的
Tabs类组件中添加以下Tab函数组件:
class Tabs extends React.Component<IProps, IState> {
public static Tab: React.SFC<ITabProps> = props => <li>TODO - render the nodes child nodes</li>;
public render() {...}
...
}
这是将渲染每个标签的组件的开始。Tab组件被定义为Tabs组件的静态属性。这意味着Tab存在于实际的Tabs类中,而不是它的实例中。因此,我们必须记住我们无法访问任何Tabs实例成员(例如this)。但是,现在我们可以在 JSX 中使用Tabs.Tab来引用Tab,这是我们的要求之一。
目前,Tab只是渲染带有提醒的li,提醒我们需要以某种方式渲染组件的子节点。请记住,我们希望消费Tabs组件的标记如下:
<Tabs.Tab name="Description" initialActive={true}>
<b>Description</b>
/Tabs.Tab>
- 因此,我们的渲染函数需要以某种方式在我们的
li标签内渲染<b> Description </b>。我们该如何做呢?答案是通过一个叫做children的特殊属性:
public static Tab: React.SFC<ITabProps> = props => <li>{props.children}</li>;
React 组件属性可以是任何类型,包括 React 节点。children属性是 React 给组件的一个特殊属性,其中包含组件的子节点。我们通过在 JSX 中引用children属性来渲染组件的子节点。
我们的Tab组件还没有完成,但我们暂时会保持这样。现在我们需要继续进行Tabs组件。
Tabs类中的render方法现在只是简单地渲染其子节点。让我们用以下内容替换这个方法:
public render() {
return (
<ul className="tabs">{this.props.children}</ul>
);
}
我们再次使用神奇的children属性来渲染Tabs中的子节点。
我们在复合Tabs和Tab组件中取得了进展,但是我们的项目不再编译,因为我们有一个标签点击处理程序handleTabClick,它不再被引用。当点击标签标题时,我们需要以某种方式从Tab组件中引用它,但请记住Tab无法访问Tabs的成员。那么,我们该如何做呢?我们将在下一节中找到这个问题的答案。
使用 React 上下文共享状态
React 上下文允许在组件之间共享状态。它与复合组件非常配合。我们将在Tabs和Tab组件中使用它来共享状态:
- 我们的第一个任务是在文件顶部创建一个用于
Tabs.tsx中使用的上下文接口,就在导入语句的下面:
interface ITabsContext {
activeName?: string;
handleTabClick?: (name: string) => void;
}
因此,我们的上下文将包含活动标签名称以及标签点击处理程序的引用。这些是需要在组件之间共享的两个状态。
- 接下来,让我们在
ITabsContext接口下创建上下文:
const TabsContext = React.createContext<ITabsContext>({});
我们在 React 中使用createContext函数创建了我们的上下文,这是一个通用函数,用于创建一个通用类型的上下文,在我们的情况下是ITabsContext。
我们需要将默认上下文值作为参数值传递给createContext,但在我们的情况下这是没有意义的,所以我们只是传递一个空的{}对象,以使 TypeScript 编译器满意。这就是为什么ITabsContext中的两个属性都是可选的。
- 现在是时候在我们的复合组件中使用这个上下文了。我们需要做的第一件事是在
Tabs的render方法中定义上下文提供程序:
public render() {
return (
<TabsContext.Provider
value={{
activeName: this.state ? this.state.activeName : "",
handleTabClick: this.handleTabClick
}}
>
<ul className="tabs">{this.props.children}</ul>
</TabsContext.Provider>
);
}
这里有一些事情要处理,所以让我们来分解一下:
-
我们之前声明的上下文常量
TabsContext在 JSX 中可以作为<TabsContext />组件使用。 -
上下文提供程序用值填充上下文。鉴于
Tabs管理状态和事件处理,将提供程序引用到那里是有意义的。 -
我们使用
<TabsContext.Provider />引用提供程序。 -
提供程序接受一个名为
value的属性作为上下文值。我们将其设置为一个包含活动选项卡名称和选项卡点击事件处理程序的对象。
- 我们需要稍微调整选项卡点击处理程序,因为点击不再直接在
Tabs中处理。因此,我们只需要将活动选项卡名称作为参数传入,然后在方法中设置活动选项卡名称状态:
private handleTabClick = (name: string) => {
this.setState({ activeName: name });
};
- 现在我们已经向上下文提供了一些数据,是时候在
Tab组件中使用它了:
public static Tab: React.SFC<ITabProps> = props => (
<TabsContext.Consumer>
{(context: ITabsContext) => {
const activeName = context.activeName
? context.activeName
: props.initialActive
? props.name
: "";
const handleTabClick = (e: React.MouseEvent<HTMLLIElement>) =>
{
if (context.handleTabClick) {
context.handleTabClick(props.name);
}
};
return (
<li
onClick={handleTabClick}
className={props.name === activeName ? "active" : ""}
>
{props.children}
</li>
);
}}
</TabsContext.Consumer>
);
这看起来又有点令人生畏,所以让我们来分解一下:
-
我们可以通过上下文组件内的
Consumer组件来消费上下文。所以,在我们的情况下是<TabsContext.Consumer />。 -
Consumer的子代需要是一个具有上下文值参数并返回一些 JSX 的函数。Consumer然后将渲染我们返回的 JSX。
如果这仍然有点令人困惑,不要担心。当我们讨论子代属性和渲染属性时,我们将在以后更详细地介绍这种模式。
-
这个上下文函数为我们提供了渲染选项卡所需的一切。我们可以从
context参数中访问状态,还可以访问Tab组件的props对象。 -
函数的第一行通过使用上下文中的内容来确定活动选项卡名称。如果上下文中的活动选项卡是空字符串,我们将使用当前选项卡名称,如果已经定义为初始活动选项卡。
-
函数的第二行创建了一个标签点击处理程序,如果已经指定了上下文标签点击处理程序,则调用它。
-
返回语句与以前一样,但我们已经能够添加标签点击处理程序的引用和类名。
所以,这就是我们的标签复合组件。React 上下文的语法一开始可能看起来有点奇怪,但当你习惯了它之后,它真的很简单和优雅。
在我们尝试之前,我们需要在我们的Product组件中使用我们的复合组件。让我们用以下突出显示的 JSX 替换我们之前对Tabs组件的使用:
<React.Fragment>
<h1>{product.name}</h1>
<Tabs>
<Tabs.Tab name="Description" initialActive={true}>
<b>Description</b>
</Tabs.Tab>
<Tabs.Tab name="Reviews">Reviews</Tabs.Tab>
</Tabs>
<p>{product.description}</p>
...
</React.Fragment>
这正是我们在开始构建复合标签组件时想要实现的 JSX。如果我们转到运行的应用程序并浏览到产品页面,我们的标签组件将完美地工作,描述标签会以粗体显示:
因此,复合组件非常适合相互依赖的组件。<Tabs.Tab />的语法真的强调了Tab需要与Tabs一起使用。
React 上下文与复合组件非常配合,允许复合中的组件轻松共享状态。状态甚至可以包括诸如事件处理程序之类的函数。
允许消费者指定要在组件的各个部分中呈现的内容,为消费者提供了极大的灵活性。将此自定义内容指定为组件的子级是直观且自然的。在接下来的部分中,我们将继续使用这种方法来完成我们的标签组件。
渲染道具模式
在上一节中,我们使用了渲染道具模式的一种形式,其中我们利用了children道具。我们用它来允许Tab组件的消费者为标签标题呈现自定义内容。这很好,但是如果我们想允许消费者在组件的不同部分呈现自定义内容怎么办?在我们的Tabs组件中,我们还没有允许消费者呈现标签的内容。我们确实希望消费者能够为此指定自定义内容,但是既然我们已经使用了children道具来表示标题,那么现在该怎么做呢?
答案很简单,但一开始并不明显。答案是,因为 props 可以是任何东西,它们可以是一个呈现内容的函数 - 就像特殊的childrenprop 一样。这些类型的 prop 被称为渲染 prop。我们可以拥有尽可能多的渲染 prop,从而灵活地允许消费者呈现组件的多个部分。
在上一节中,当我们使用 React 上下文时,实际上使用了渲染 prop。我们消费上下文的方式是通过渲染 prop。
接下来,我们将利用渲染 prop 模式完成我们的Tabs组件。
使用渲染 prop 完成 Tabs
我们将通过使用渲染 prop 模式来完成我们的 Tabs 组件。在我们实现第一个渲染 prop 之前,让我们考虑一下当Tabs组件完成后,我们希望消费者如何消费它。以下的 JSX 是我们理想情况下从Product组件中消费Tabs组件的方式:
<Tabs>
<Tabs.Tab
name="Description"
initialActive={true}
heading={() => <b>Description</b>}
>
<p>{product.description}</p>
</Tabs.Tab>
<Tabs.Tab
name="Reviews"
heading={() => "Reviews"}
>
<ul className="product-reviews">
{product.reviews.map(review => (
<li key={review.reviewer}>
<i>"{review.comment}"</i> - {review.reviewer}
</li>
))}
</ul>
</Tabs.Tab>
</Tabs>
让我们来看看这些关键部分的步骤:
-
我们仍然在使用复合组件。渲染 prop 与这些组件完全兼容。
-
每个选项卡的标题不再在
Tab组件的子元素中定义。相反,我们使用一个heading渲染 prop,在那里我们仍然可以呈现简单的字符串或更丰富的内容。 -
然后指定选项卡内容为
Tab组件的子元素。
使用渲染 prop 来设置选项卡标题
因此,让我们改变选项卡标题的实现,使用渲染 prop:
- 在
Tabs.tsx中,让我们首先在选项卡 props 接口中添加一个新的属性用于标题:
interface ITabProps {
name: string;
initialActive?: boolean;
heading: () => string | JSX.Element;
}
这个属性是一个没有参数的函数,返回一个string或一些 JSX。这就是我们的渲染 prop 的定义。
- 更改实现非常简单。我们只需在
Tab组件的返回语句中用新的渲染 prop 函数替换对childrenprop 函数的调用:
return (
<li
onClick={handleTabClick}
className={props.name === activeName ? "active" : ""}
>
{props.heading()}
</li>
);
- 让我们将
Product.tsx中Tabs的使用切换为以下内容:
<Tabs>
<Tabs.Tab
name="Description"
initialActive={true}
heading={() => <b>Description</b>}
/>
<Tabs.Tab name="Reviews" heading={() => "Reviews"} />
</Tabs>
我们可能会收到一个 TSLint 警告:由于其渲染性能影响,JSX 属性中禁止使用 lambda。知道 lambda 可能会有问题是有用的,这样我们在遇到性能问题时可以记住这一点。然而,我们将在tslint.json中关闭此规则,指定"jsx-no-lambda"为false:
{
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
"rules": {
...
"jsx-no-lambda": false
},
...
}
如果我们想要非常关注性能,我们可以引用组件内的方法,而不是使用 lambda 函数。
在保存了新的 TSLint 设置之后,编译器的投诉希望会消失。请注意,我们可能需要杀死终端并再次启动应用程序以消除编译器的投诉。
如果我们尝试在我们的应用程序中使用产品页面,它将表现得和以前一样。
因此,实现渲染属性模式非常简单。使用此模式最耗时的事情是理解它可以做什么以及它是如何工作的。一旦我们掌握了它,它就是一个可以为我们组件的消费者提供渲染灵活性的优秀模式。
在我们的Tab组件完成之前,我们还有最后一个部分要完成。
使用“children”属性来呈现选项卡内容。
现在我们的Tab组件已经接近完成了。最后的任务是允许消费者呈现选项卡内容。我们将使用children属性来实现这一点:
- 首先,在
Tabs.tsx中,让我们将上下文接口中的handleTabClick属性更改为包括要呈现的内容:
interface ITabsContext {
activeName: string;
handleTabClick?: (name: string, content: React.ReactNode) => void;
}
- 我们还将在状态接口中保存活动内容以及活动选项卡名称。因此,让我们将其添加到
Tabs的状态接口中:
interface IState {
activeName: string;
activeContent: React.ReactNode;
}
- 现在让我们在
Tabs中更改选项卡点击处理程序,以设置活动内容的状态以及活动选项卡名称:
private handleTabClick = (name: string, content: React.ReactNode) => {
this.setState({ activeName: name, activeContent: content });
};
- 在
Tab组件中,让我们通过传递children属性来调用选项卡点击处理程序,以获取选项卡内容的附加参数:
const handleTabClick = (e: React.MouseEvent<HTMLLIElement>) => {
if (context.handleTabClick) {
context.handleTabClick(props.name, props.children);
}
};
- 现在让我们在
Tabs``render方法中呈现我们状态中的活动内容,就在我们呈现选项卡标题的下面:
<TabsContext.Provider ...
>
<ul className="tabs">{this.props.children}</ul>
<div>{this.state && this.state.activeContent}</div>
</TabsContext.Provider>
- 让我们改变在
Product组件中使用Tabs组件的方式:
<h1>{product.name}</h1>
<Tabs>
<Tabs.Tab
name="Description"
initialActive={true}
heading={() => <b>Description</b>}
>
<p>{product.description}</p>
</Tabs.Tab>
<Tabs.Tab name="Reviews" heading={() => "Reviews"}>
<ul className="product-reviews">
{product.reviews.map(review => (
<li key={review.reviewer}>
<i>"{review.comment}"</i> - {review.reviewer}
</li>
))}
</ul>
</Tabs.Tab>
</Tabs>
<p className="product-price">
...
</p>
现在选项卡内容已经嵌套在每个Tab组件中,正如我们所希望的那样。
让我们试一试。如果我们转到产品页面,我们会注意到一个问题:
在页面首次加载时未呈现内容。如果我们单击“Reviews”选项卡或“Description”选项卡,然后内容就会加载。
- 问题在于当选项卡初始加载时,我们没有任何代码来呈现内容。因此,让我们通过在
Tab组件中添加高亮显示的行来解决这个问题:
public static Tab: React.SFC<ITabProps> = props => (
<TabsContext.Consumer>
{(context: ITabsContext) => {
if (!context.activeName && props.initialActive) {
if (context.handleTabClick) {
context.handleTabClick(props.name, props.children);
return null;
}
}
const activeName = context.activeName
? context.activeName
: props.initialActive
? props.name
: "";
...
}}
</TabsContext.Consumer>
);
高亮显示的行在上下文中没有活动选项卡并且选项卡被标记为初始活动时,调用选项卡点击处理程序。在这种情况下,我们返回 null,因为调用选项卡点击将设置活动选项卡的状态,这将导致另一个渲染周期。
我们的选项卡组件现在应该已经完成了。让我们通过转到产品页面来检查:
内容呈现如我们所期望的那样。 如果我们点击评论选项卡,这也会很好地呈现:
因此,渲染道具和子道具模式非常适合允许消费者渲染自定义内容。 语法一开始可能看起来有点棘手,但当你理解它时,它就变得非常合理和优雅。
在下一节中,我们将看一下本章中的最终模式。
高阶组件
高阶组件(HOC)是一个将组件作为参数并返回增强版本的函数组件。 这可能不太明晰,因此我们将在本节中通过一个示例来说明。 我们的示例创建了一个名为withLoader的 HOC,可以应用于任何组件,以在组件忙碌时添加加载旋转器。 我们将在我们的 React 商店(我们在上一节中工作过的)中使用它在产品页面上获取数据时。 完成后将如下所示:
添加异步数据获取
目前,我们商店中的数据获取是瞬时的,因为所有数据都是本地的。 因此,在着手处理withLoader组件之前,让我们重构数据获取函数,包括延迟和异步。 这将更好地模拟使用 Web API 获取数据的真实数据获取函数:
- 在
ProductData.ts中,让我们添加以下箭头函数,用于获取产品:
export const getProduct = async (id: number): Promise<IProduct | null> => {
await wait(1000);
const foundProducts = products.filter(customer => customer.id === id);
return foundProducts.length === 0 ? null : foundProducts[0];
};
该函数接受产品 ID 并使用products数组中的filter函数找到产品,然后返回它。
该函数以async关键字为前缀,因为它是异步的。
- 该函数还使用
await关键字异步调用名为wait的函数。 因此,让我们创建wait函数:
const wait = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
};
该函数使用标准的 JavaScript setTimeout函数等待我们在函数参数中指定的毫秒数。 该函数返回一个在setTimeout完成时解析的Promise。
如果此刻async和await关键字以及承诺不太明晰,不要担心。 我们将在本书的后面详细讨论这些。
因此,我们现在有一个异步获取产品的函数,至少需要 1 秒。让我们将其插入到我们的产品页面中。ProductPage组件是一个负责获取数据的容器组件,所以让我们在这里插入它。
- 首先,让我们将
getProduct函数导入到ProductPage中:
import { getProduct, IProduct } from "./ProductsData";
- 让我们向
ProductPage的状态添加一个名为loading的属性,以指示数据是否正在加载:
interface IState {
product?: IProduct;
added: boolean;
loading: boolean;
}
- 让我们在构造函数中也将这个状态初始化为
true:
public constructor(props: Props) {
super(props);
this.state = {
added: false,
loading: true
};
}
- 现在,我们可以在
ProductPage组件加载时使用getProduct函数:
public async componentDidMount() {
if (this.props.match.params.id) {
const id: number = parseInt(this.props.match.params.id, 10);
const product = await getProduct(id);
if (product !== null) {
this.setState({ product, loading: false });
}
}
}
我们使用await关键字异步调用getProduct。为了做到这一点,我们需要使用async关键字将componentDidMount生命周期方法标记为异步。在获取产品后,我们将其设置在状态中,并将loading标志重置为false。
- 如果我们的商店没有运行,让我们运行这个:
npm start
如果我们转到产品页面,我们会发现产品加载大约需要 1 秒。您可能会注意到在产品加载时显示“产品未找到!”。这是因为产品在初始渲染时未设置。我们暂时忽略这个问题,因为我们的withLoaderHOC 将解决这个问题。
因此,现在我们正在异步获取数据,大约需要 1 秒,我们准备实现我们的withLoaderHOC 并在产品页面上使用它。我们将在下一节中完成这个操作。
实现withLoader HOC
我们将创建一个名为withLoader的加载器组件,可以与任何组件一起使用,以指示组件正在忙于执行某些操作:
- 让我们首先创建一个名为
withLoader.tsx的新文件,内容如下:
import * as React from "react";
interface IProps {
loading: boolean;
}
const withLoader = <P extends object>(
Component: React.ComponentType<P>
): React.SFC<P & IProps> => ({ loading, ...props }: IProps) =>
// TODO - return a loading spinner if loading is true otherwise return the component passed in
export default withLoader;
这里有一些事情正在发生,让我们来分解一下:
-
withLoader是一个接受类型为P的组件的函数。 -
withLoader调用一个函数组件。 -
函数组件的属性被定义为
P & IProps,这是一个交集类型。
交集类型将多种类型合并为一个。因此,X,Y和Z将X,Y和Z的所有属性和方法合并到一个新类型中。
-
因此,SFC 的属性包括从传入的组件中获取的所有属性,以及我们定义的
loading布尔属性。 -
使用剩余参数,将 props 解构为一个
loading变量和一个包含所有其他属性的props变量。
- 因此,我们剩下要做的工作是,如果
loading为true,则返回我们的加载旋转器,否则我们只需要返回传入的组件。我们可以使用下面代码中窗口中突出显示的三元表达式来实现这一点:
const withLoader = <P extends object>(
Component: React.ComponentType<P>
): React.SFC<P & IProps> => ({ loading, ...props }: IProps) =>
loading ? (
<div className="loader-overlay">
<div className="loader-circle-wrap">
<div className="loader-circle" />
</div>
</div>
) : (
<Component {...props} />
);
传入的组件在第二个三元分支中返回。我们使用扩展语法将props变量中的属性扩展到组件中。
加载旋转器在第一个三元分支中返回。
- 加载旋转器引用了一些 CSS 类,所以让我们把它们添加到
index.css中:
.loader-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: Black;
opacity: 0.3;
z-index: 10004;
}
.loader-circle-wrap {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
height: 100px;
width: 100px;
margin: auto;
}
.loader-circle {
border: 4px solid #ffffff;
border-top: 4px solid #899091;
border-radius: 50%;
width: 100px;
height: 100px;
animation: loader-circle-spin 0.7s linear infinite;
}
loader-overlay类在整个页面上创建一个黑色的透明覆盖层。loader-circle-wrap类在覆盖层的中心创建一个100px乘100px的正方形。loader-circle类创建旋转的圆圈。
我们的withLoader HOC 现在已经完成。
供参考,下面的代码块显示了基于类的withLoader版本:
const withLoader = <P extends object>(Component: React.ComponentType<P>) =>
class WithLoader extends React.Component<P & IProps> {
public render() {
const { loading, ...props } = this.props as IProps;
return loading ? (
<div className="loader-overlay">
<div className="loader-circle-wrap">
<div className="loader-circle" />
</div>
</div>
) : (
<Component {...props} />
);
}
};
我们将坚持使用 SFC 版本,因为它不包含任何状态,也不需要访问任何生命周期方法。
在下一节中,我们将在商店应用程序中的产品页面中使用我们的withLoader组件。
使用 withLoader HOC
使用 HOC 非常简单。我们只需将 HOC 包装在我们想增强的组件周围。这样做的最简单的地方是在导出语句中。
让我们将在上一节中创建的withLoader HOC 添加到我们的产品页面中:
- 因此,我们将使用
withLoader来包装Product组件。首先,让我们将withLoader导入到Product.tsx中:
import withLoader from "./withLoader";
- 现在我们可以在导出语句中将
withLoader包装在Product周围:
export default withLoader(Product);
现在,在ProductPage组件中,我们得到了一个编译错误,因为它期望向Product传递一个 loading 属性。
- 因此,让我们在引用
Product时,从加载状态中传递 loading 属性:
<Product
loading={this.state.loading}
product={product}
inBasket={this.state.added}
onAddToBasket={this.handleAddClick}
/>
- 在
ProductPage.tsx中,我们应该修改渲染Product组件的条件。现在,如果产品仍在加载,我们希望渲染Product。然后将渲染加载旋转器:
{product || this.state.loading ? (
<Product
loading={this.state.loading}
product={product}
inBasket={this.state.added}
onAddToBasket={this.handleAddClick}
/>
) : (
<p>Product not found!</p>
)}
然而,这会导致另一个编译错误,因为Product组件内的product属性不希望是undefined。然而,在加载产品时它将是undefined。
- 因此,让我们在
IProps中将这个属性设为可选的,用于Product组件:
interface IProps {
product?: IProduct;
inBasket: boolean;
onAddToBasket: () => void;
}
这样,在Product组件中引用product属性时,JSX 中会出现进一步的编译错误,因为在加载数据时它现在将是undefined。
- 一个简单的解决方法是,如果我们没有产品,就渲染
null。withLoader高阶组件在这种情况下会渲染一个加载旋转器。所以,我们只是让 TypeScript 编译器在这里很高兴:
const handleAddClick = () => {
props.onAddToBasket();
};
if (!product) {
return null;
}
return (
<React.Fragment>
...
</React.Fragment>
);
现在 TypeScript 编译器很高兴,如果我们去商店的产品页面,它将在渲染产品之前显示我们的加载旋转器:
因此,高阶组件非常适合增强组件,其中增强是可以应用于许多组件的东西。我们的加载旋转器是高阶组件的一个常见用例。另一个非常常见的 HOC 模式的用法是在使用 React Router 时。在本书的前面,我们使用了 React Router 的withRouter高阶组件来访问路径的参数。
总结
在本章中,我们学习了容器组件以及它们如何用于管理状态以及展示组件需要做什么。然后展示组件可以专注于它们需要看起来的样子。这使得展示组件可以更容易地在多个地方重复使用并进行单元测试。
我们学到了复合组件是相互依赖的组件。在父类上将复合子项声明为静态成员,可以清楚地告诉消费者这些组件应该一起使用。React 上下文是复合组件共享状态的便捷方式。
我们学到了特殊的 children 属性,可以用来访问和渲染组件的子项。然后我们学到,我们可以创建自己的渲染属性,为消费者提供对组件的自定义渲染部分的极大灵活性。
在上一节中,我们学习了高阶组件以及它们如何用于实现对组件的常见增强。在本书的前面,我们已经使用了 React Router 高阶组件来获取路径参数。
在下一章中,我们将学习如何在 React 中创建表单。在下一章的末尾,我们将使用本章学到的一些模式来以通用的方式处理表单。
问题
让我们用一些问题来测试一下我们对组件模式的学习成果:
-
React 给我们提供了什么特殊属性来访问组件的子项?
-
有多少组件可以通过 React 上下文共享状态?
-
在使用 React 上下文时,它使用什么模式来允许我们使用上下文渲染我们的内容?
-
一个组件中有多少个渲染 props?
-
一个组件中有多少个 children props?
-
我们只在产品页面上使用了
withLoader。我们在ProductData.ts中使用以下函数来获取所有产品:
export const getProducts = async (): Promise<IProduct[]> => {
await wait(1000);
return products;
};
你能用这个来通过使用withLoader高阶组件在产品页面上实现一个加载器吗?
- 是否可以使用 children props 模式来创建一个加载器?消费的 JSX 可能如下所示:
<Loader loading={this.state.loading}>
<div>
The content for my component ...
</div>
</Loader>
如果可以的话,试着去实现它。
进一步阅读
-
React 上下文在 React 文档中有详细说明,链接如下:
reactjs.org/docs/context.html -
高阶组件在 React 文档中有详细说明,链接如下:
reactjs.org/docs/higher-order-components.html -
渲染 props 模式在 React 文档中有详细说明,链接如下:
reactjs.org/docs/render-props.html