React 企业级实践指南(二)
六、编写本地状态、发送 HTTP 请求和 ApexCharts
在前一章中,我们用react-router
解决了导航问题。现在,我们将编写本地状态,使用 Axios 发送 HTTP 请求,并安装 ApexCharts 来帮助我们在应用中创建交互式图表。
HTTP 请求是任何与后端服务器通信的 web 应用的基本部分;与此同时,顾名思义,React 中的本地状态是在本地处理的,或者是在一个组件中独立处理的。必须设置状态中的任何更改,否则将使用 setState 函数重新呈现状态。
Axios 和 Fetch API 是我们如何使用 REST APIs 的两种最流行的方法。Axios 是基于 promise 的 HTTP 客户端,而 Fetch API 是 JavaScript 原生的,是现代浏览器内置的 HTTP 客户端。
在这一章中,我们将尽最大努力使用 Axios 进行 API 请求,以保持条理和干爽。编程中的 DRY 概念是抽象的过程,以避免代码的重复。由于从技术上来说,我们没有后端服务器,我们还将使用 JSON 服务器创建一个假的 Rest API。
ApexCharts 是一个开源图表库,我们将使用它来为我们的网页创建现代外观和交互式可视化。
但是在我们开始编写本地状态和发送 HTTP 请求之前,我们将在 TypeScript 的编译器选项中进行一些重构。
这更多是为了我们在应用中使用 TypeScript 时的个人喜好。是的,我们喜欢 TypeScript 给我们的类型安全特性,而 JavaScript 不能;然而,我们也不想失去 JavaScript 在开发应用时提供的灵活性。简而言之,我们希望两全其美。
您可以决定是否退出 TypeScript 中默认的严格类型检查选项。有两种方法可以解决这件事。
TypeScript 中的严格类型检查选项
如前所述,在我们的应用中,有两种方式可以让我们个人使用或设置 TypeScript 中的类型检查选项。
第一个是将值设置或更改为“false”为此,在您的项目应用中,打开 tsconfig.json 文件并在“compilerOptions”下查看。将严格值标志设置为 false。
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noFallthroughCasesInSwitch": true,
"jsx": "react",
"baseUrl": "./src"
},
"include": ["src"]
}
Listing 6-1tsconfig.json File
默认情况下,tsconfig.json 中 compilerOptions 的 strict 模式设置为true
。这意味着应用中的所有代码或文件都将使用严格模式进行验证或类型检查。
更多 tsconfig 参考可以查看 www.typescriptlang.org/tsconfig
。
但是图 6-1 是类型检查标志的简要描述。
图 6-1
类型检查选项标志
第二种方法是有时选择退出 TypeScript 中的类型检查选项,并允许默认值 strict,这是正确的,但是要在compilerOptions
:
中添加以下内容
"noImplicitAny": false,
"strictNullChecks": false,
...
"strict": true
TypeScript Tips
使用 json-server 添加虚假数据
使用我们自己的假 REST API 的主要好处是前端和后端开发工作的解耦。如果不同的团队在前端和后端工作,那么团队只需要讨论并同意 JSON 结构,然后就可以独立工作了。
在前端,使用假数据可以加快开发进程;不需要等待后端完成构建真正的 API。
设置呢?它允许我们通过本地服务器从文件系统中提供 JSON。
让我们转到我们的package.json
文件来检查我们的json-server
作为一个 devDependency:
"devDependencies":{
"concurrently": "5.3.0",
"cypress": "6.0.0",
"json-server": "0.16.3",
"json-server-auth": "2.0.2" } }
Json-server
是一个假的 Node.js 服务器,仅仅通过运行 npmjs.com
, json-server
中的json-server.
就被搭建了起来,被描述为“在不到 30 秒的时间内获得零编码的完整假 REST API”是的,根据经验,它做得很好。
让我们运行它来测试。将以下代码添加到您的脚本中:
"backend": "json-server --watch db.json --port 5000 --delay=1000"
在终端中,运行$ npm run backend。
您将看到以下资源端点。
图 6-2
默认端点
让我们检查一下http://localhost:5000/posts
。这是因为安装了 JSONView Chrome 扩展。它在浏览器中重新格式化 JSON 响应,而不是一长串字符串,就像我们编辑器中更漂亮的一样。Firefox 浏览器中也有这个扩展。
图 6-3
渲染本地主机:5000/post
现在,让我们回到我们的项目;您会注意到在根目录中已经为我们自动生成了一个名为db.json
的新文件。打开它,您将看到示例端点。
现在我们已经看到它正在工作,让我们用我们自己的假数据替换自动生成的端点。复制以下数据。
{
"sales": [
{
"id": "sgacus86fov",
"name": "This week",
"data": [30, 40, 25, 50, 49, 21, 70, 51]
},
{
"id": "saftyaf56",
"name": "Last week",
"data": [23, 12, 54, 61, 32, 56, 81, 19]
}
]
}
Listing 6-2db.json Server Fake Data
清单 6-2 有端点“销售”,它有两个对象,分别是"This week"
和"Last week"
。它有唯一的数据让我们从数组中获取特定的对象。让我们试试 localhost:5000/sales 是否可行。在我们的本地浏览器中,您应该会看到如图 6-4 所示的响应。
图 6-4
渲染数据
使用 Axios 发送 HTTP 请求
在构建复杂或大型应用时,创建 axios 实例非常有用,尤其是当我们需要集成多个 API 时。实现默认的 Axios 实例有时会很麻烦,或者意味着在整个应用中重复代码。
您何时可以实现实例的一些示例:
-
从路径构建完整的 URL
-
分配默认标题
-
贮藏
-
网络错误的全局处理
-
自动设置请求的授权头
-
规范化和管理错误响应
-
转换请求和响应正文
-
刷新访问令牌并重试请求
-
设置自定义实例
现在我们已经有了,让我们创建我们的 axios 配置。创建一个名为axios.ts
的新文件。文件路径为src/api/axios.ts
。
import axios from 'axios';
/*create an instance of axios with a default base URI when sending HTTP requests*/
/*JSON Server has CORS Policy by default*/
const api = axios.create({ baseURL: 'http://localhost:5000/', });
export default api;
export const EndPoints = { sales: 'sales', };
Listing 6-3axios.ts
我们从 axios 导入 axios,并且我们正在使用 axios 的 create 函数,该函数将创建 axios 的一个实例,并放入名为 api 的变量。
在实例 api 中,有一个选项可以传递我们配置的对象。
目前,我们的默认配置是localhost:5000``/.``localhost:5000
,因为baseURL
是 axios 中任何 HTTP 请求的默认 URL。基本上,它是网址的一致或永久的部分。
在一个位置创建一个baseURL
让我们能够根据需要轻松地编辑它,并在我们的应用开发中保持干燥。我们不需要在每次创建 HTTP 请求时重复输入 API 的baseURL
。
我们只需要通过 api 的端点(即销售)。见图 6-5 中的样品。这也将有助于我们避免在输入端点时出现输入错误。
图 6-5
如何使用 api 端点
在我们的 axios 对象中有很多选项可以使用。我们获得了这种内置的智能感知,因为 axios 是使用 TypeScript 构建的。
图 6-6
axios 对象的选项配置
好了,我们暂时结束了。随着我们创建更多端点,我们将不断更新 axios 文件。
塑造物体
在 TypeScript 中,我们通过对象类型传递数据,而在 JavaScript 中,我们通过对象分组和表示数据。简单来说,类型让我们知道一个条目是什么样子:字符串、数字、布尔、数组、函数等。
回想一下在 TypeScript 中塑造我们的对象的两种方法:接口和类型别名。他们或多或少是一样的;你选择什么只是个人喜好的问题。
因为我们使用 TypeScript,所以我们需要定义对象的形状或类型。首先,创建一个新文件,并将其命名为sale-type.ts.
src/models/sale-type.ts
Listing 6-4Creating the Shape or Model of Our Object
我们销售对象的形状或模型具有字符串类型和数字数组类型的数据:
//type alias
type SaleType = {
name: string;
data: number[];
}
使用 Axios 发出请求
在我们继续之前,让我们回顾一下 axios 是什么。Axios 是一个基于 promise 的 HTTP 客户端,用于浏览器和 Node.js。它允许我们拦截和取消请求,并提供一个内置的功能,称为客户端保护,防止跨站点请求伪造。
让我们创建一个新文件,并将其命名为 saleService.ts:
src/services/saleService.ts
我们将导入我们最近创建的文件 api/axios、来自 TypeScript 配置的端点以及模型 sale-type。
import api, { EndPoints } from 'api/axios';
import { SaleType } from 'models/sale-type';
export async function getSalesAxios() {
return await api.get<SaleType[]>(EndPoints.sales);
}
/* Other commonly-used api methods:
api.post
api.put
api.delete
api.patch
*/
/* The < > bracket here is a Generic type that Typescript adapted from OOP.
It means that the return value of the getSalesAxios is an array of SaleType.
This would likewise help us with the TypeScript intell-sense when using it.
*/
Listing 6-5Sending Http Requests
async-await 仅仅意味着 axios 函数是基于承诺的。
现在,让我们测试我们的api.get
请求。
转到src/app/views/dashboard/dashboard-default-content.tsx
。
我们将在这里使用额外的魔法,为此,我们将使用来自React
的生命周期钩子useEffect
。
从 React 导入命名组件。
同样,useEffect
是一个生命周期挂钩,它在 React 组件呈现后运行。在这个钩子中,我们将调用getSalesAxios().
确保从services/saleService.
导入组件
import React, {useEffect} from 'react';
import { getSalesAxios } from 'services/saleService';
const DashboardDefaultContent = () => {
useEffect(() => {
//code to run after render goes here
getSalesAxios();
}, []); //ß empty array means to ‘run once’
return (
<div>
<h1>Dashboard Default Content</h1>
</div>
);
};
export default DashboardDefaultContent;
Listing 6-6Calling the getSalesAxios in DashboardDefaultContent
Tips
在 useEffect 中传递一个空数组[ ]作为第二个参数,以限制其运行或在第一次渲染后仅运行一次 useEffect。
接下来,打开两个终端并执行以下操作:
$ npm run start
$ npm run backend
在localhost:3000/
刷新我们的浏览器,打开 Chrome DevTools,确保你在网络选项卡和 XHR。我们正在做所有这些所谓的我如何构建一个应用的一小步一小步的过程。
尝试先执行低层次的悬挂果实,也许写很多概念证明,或者在 DevTools 上检查是否一切都按预期方式运行。
无论如何,在你的浏览器中,点击菜单选项卡➤仪表板。
观察你的 Chrome DevTools 您应该看到响应方法:Get 和状态代码:200 OK.
点击响应选项卡,查看来自json-server.
的销售对象数据
所以现在我们确信 UI 正在接收来自json-server
的数据。我们发送了 HTTP 请求,并收到了来自 json-server 的 JSON 响应。
但是由于我们可能需要将 getSalesAxios 包装在一个 async-await 中,我们将在useEffect.
之外调用它
我们一会儿会处理useEffect()
的。
图 6-7
在 EffectCallback 中使用 async 时显示错误
因此,让我们在 useEffect 之外创建一个可用的函数,并将其命名为fetchSales.
const fetchSales = async () => {
const data = await getSalesAxios();
console.log(data);
}
Listing 6-7Creating the fetchSales Function
我们解构了响应,只得到数据属性,并将其命名为“数据”
接下来,确保在useEffect.
中调用新创建的函数,这就是我说我们稍后将使用useEffect()
的意思。
const DashboardDefaultContent = () => {
useEffect(() => {
fetchSales();
}, []);
Listing 6-8Calling fetchSales in useEffect
刷新浏览器并确保再次打开 Chrome DevTools,您应该会成功地从 json-server 获得响应。
既然我们知道可以从 json-server 中检索数据,接下来要做的就是使用 DashboardDefaultContent 中的useState
创建一个本地状态。
添加 React 中的组件useState
。
不要忘记从文件夹services/saleService.
中导入getSalesAxios
import { SaleType } from 'models/sale-type';
import React, { useEffect, useState } from 'react';
import { getSalesAxios } from 'services/saleService';
const DashboardDefaultContent = () => {
const [sales, setSales] = useState<SaleType[]>([]);
Listing 6-9Creating a Local State
我们如何更新setSales
的值或数据?
在我们的fetchSales
组件中,让我们调用setSales
并用我们的useState.
中相同类型的对象传递数据
const fetchSales = async () => {
const { data } = await getSalesAxios();
console.log(data); // ← to check in the console if we are successfully getting the data
setSales(data);
};
Listing 6-10Updating the Data of setSales
好的,就像我之前说的,在我继续之前,我会经常做这个小小的概念证明。
让我们检查一下我们是否真的将数据传递给了 HTML 中的setSales.
,键入 h2 头并呈现销售数组的长度。
return (
<div>
<h1>Dashboard Default Content</h1>
<h2>{sales.length}</h2>
</div>
);
};
Listing 6-11Rendering the setSales
当您检查浏览器时,您应该看到 2 ,因为我们的销售数组包含两个对象。去Chrome DevTools
➤ Network
看实物。
现在我们知道它正在工作,下一个计划是改进仪表板的默认内容。
在 components 文件夹中,让我们为网页创建一个模板。模板页面将包含应用于我们应用中每个页面的填充、间距和其他样式。
文件路径为 src ➤ app ➤组件➤页面. tsx
导入以下组件:
import React,{ forwardRef, HTMLProps, ReactNode }from 'react'
import { Helmet } from 'react-helmet-async';
让我们在这里回顾一些组件。
转发引用:这是添加一个对 HTML 的引用。在 reactjs.org
中,说的是通过一个组件自动传递一个 ref 给它的一个子组件的技术,比如转发 refs 给 DOM 组件或者转发 refs 给高阶组件(hoc)。
头盔:添加一些标签,并允许我们建立一个 SEO 友好的应用。
接下来,我们将在清单 6-12 中定义我们的类型或类型定义。
type Props = {
children?: ReactNode;
title?: string;
} & HTMLProps<HTMLDivElement>;
Listing 6-12Defining the Type Alias
我们正在创建 ReactNode 类型的 prop children 和 string 类型的 prop title,两者都可以通过追加?
来空化。我们还使用了HTMLDivElement.
类型的HTMLProps
这是我们的页面组件,它将成为我们所有网页的可重用模板。
const Page = forwardRef<HTMLDivElement, Props>(
({ children, title = '', ...rest }, ref) => {
return (
<div ref={ref as any} {...rest}>
<Helmet>
<title>{title}</title>
</Helmet>
{children}
</div>
);
},
);
export default Page;
Listing 6-13Creating a Reusable Page Component
暂时就这样了。
让我们回到我们的DashboardDefaultContent
组件,给仪表板添加更多样式。
将以下样式组件添加到DashboardDefaultContent.
样式包括图表样式主题、背景颜色、数据表、图例、描边工具提示、x 轴和 y 轴等。你可以在 Material-UI 网站上了解更多相关信息。这有点长,所以请耐心听我说:
const useStyles = makeStyles(() => ({
root: {
minHeight: '100%',
},
}));
const getChartStyling = (theme: Theme) => ({
chart: {
background: theme.palette.background.paper,
toolbar: {
show: false,
},
},
colors: ['#13affe', '#fbab49'],
dataLabels: {
enabled: false,
},
grid: {
borderColor: theme.palette.divider,
yaxis: {
lines: {
show: false,
},
},
},
legend: {
show: true,
labels: {
colors: theme.palette.text.secondary,
},
},
plotOptions: {
bar: {
columnWidth: '40%',
},
},
stroke: {
show: true,
width: 2,
colors: ['transparent'],
},
theme: {
mode: theme.palette.type,
},
tooltip: {
theme: theme.palette.type,
},
xaxis: {
axisBorder: {
show: true,
color: theme.palette.divider,
},
axisTicks: {
show: true,
color: theme.palette.divider,
},
categories: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
labels: {
style: {
colors: theme.palette.text.secondary,
},
},
},
yaxis: {
axisBorder: {
show: true,
color: theme.palette.divider,
},
axisTicks: {
show: true,
color: theme.palette.divider,
},
labels: {
style: {
colors: theme.palette.text.secondary,
},
},
},
});
为了在 React 组件中使用它,我们需要从 Material-UI 导入主题和makeStyles
:
import { Theme } from '@material-ui/core/styles';
import { makeStyles } from '@material-ui/styles';
然后,让我们在 DashboardDefaultContent 中创建两个变量——类和主题——并分别分配useStyles
和useTheme
样式。
const DashboardDefaultContent = () => {
const classes = useStyles();
const theme = useTheme();
const [sales, setSales] = useState<SaleType[]>([]);
useEffect(() => {
fetchSales();
}, []);
const fetchSales = async () => {
const { data } = await getSalesAxios();
setSales(data);
};
Listing 6-14Adding useStyles and useTheme
设置好所有这些之后,我们需要做的下一件事是安装 ApexCharts。
安装 ApexCharts
ApexCharts 是一个开源的现代 JS 图表库,用于使用 API 构建交互式图表和可视化。各种现代浏览器都支持它。让我们安装:
npm install react-apexcharts apexcharts
回到 DashboardDefaultContent,从 Material-UI 核心库中导入 ApexCharts 和其他一些样式组件。
import chart from 'react-apexcharts';
import {
Box,
Card,
CardContent,
Container,
Grid,
Typography,
useTheme,
} from '@material-ui/core';
Listing 6-15Importing Additional Material-UI Components
至此,我们将添加之前创建的页面组件:
import Page from 'app/components/page';
使用页面组件和我们创建的图表。用下面的代码替换页面组件中的返回标题。
<Page className={classes.root} title="Dashboard">
<Container maxWidth={'sm'}>
<Typography variant="h4" color="textPrimary">
Dashboard
</Typography>
<Box my={5}>
<Grid container spacing={3}>
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h5" color="textPrimary">
Sales
</Typography>
<Chart
options={getChartStyling(theme)}
series={sales}
type="bar"
height={'100%'}
/>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
</Container>
Listing 6-16Updating the Page Component
所以我们重用了页面组件,并将其命名为“仪表板”容器和其他样式组件一起设置了DashboardDefaultContent
页面的外观。
运行应用,您应该会在浏览器中看到这些变化。
图 6-8
呈现更新的页面组件
虽然还有很长的路要走,但我们正在努力完成我们的仪表板页面。
在此之前,转到src
➤ index.ts,
并删除<React.StrictMode>
,这样我们就不会在开发应用时收到不必要的警告和错误:
const ConnectedApp = ({ Component }: Props) => (
<Provider store={store}>
<HelmetProvider>
<Component />
</HelmetProvider>
</Provider>
);
接下来,打开文件src/app/index.tsx
并删除<NavigationBar/>
,因为我们将用一个新的布局组件替换它:
export function App() {
return (
<BrowserRouter>
<Helmet
titleTemplate="%s - React Boilerplate"
defaultTitle="React Boilerplate"
>
<meta name="description" content="A React Boilerplate application" />
</Helmet>
<NavigationBar />
<Container>
<Routes />
</Container>
<GlobalStyle />
</BrowserRouter>
);
}
创建主布局
在 layouts 目录中,让我们在 main-layout 目录下创建一个新文件夹并命名为main-layout.
,创建它的 index.tsx 文件。以下是文件路径。
src ➤ app ➤ layouts ➤ main-layout ➤ index.tsx
Listing 6-17Creating a New Main-Layout Component Page
将下面的代码复制到index.tsx
:
import React, { ReactNode } from 'react';
import { makeStyles } from '@material-ui/core';
import NavigationBar from './navigation-bar';
type Props = {
children?: ReactNode;
};
const MainLayout = ({ children }: Props) => {
const classes = useStyles();
return (
<NavigationBar />
<div className={classes.root}>
<div className={classes.wrapper}>
<div className={classes.contentContainer}>
<div className={classes.content}>{children}</div>
</div>
</div>
</div>
);
};
const useStyles = makeStyles(theme => ({
root: {
backgroundColor: theme.palette.background.default,
display: 'flex',
height: '100%',
overflow: 'hidden',
width: '100%',
},
wrapper: {
display: 'flex',
flex: '1 1 auto',
overflow: 'hidden',
paddingTop: 64,
},
contentContainer: {
display: 'flex',
flex: '1 1 auto',
overflow: 'hidden',
},
content: {
flex: '1 1 auto',
height: '100%',
overflow: 'auto',
},
}));
export default MainLayout;
在我们再次运行或检查浏览器是否一切正常之前,我们将做一个小的文件夹重组。
将组件文件夹中的导航栏. tsx 放在新创建的 main-layout 文件夹下。
图 6-9
将 navigation-bar.tsx 移动到主布局文件夹中
完成后,转到应用根文件夹的 index.tsx:
Src ➤ app ➤ index.tsx
我们将用刚刚创建的<MainLayout>
替换<Container>
。不要忘记导入命名的组件。
...
import MainLayout from './layouts/main-layout';
...
<MainLayout>
<Routes />
</MainLayout>
Listing 6-18Using the MainLayout in the index.tsx of app
在http://localhost:3000/dashboard,
刷新你的浏览器,你会注意到样式和间距的一些变化。
图 6-10
呈现更新的主布局
接下来,让我们回到仪表板布局文件夹的index.tsx
文件。我们将添加一些样式,包括间距和布局。让我们从 Material-UI 核心样式中导入makeStyles
:
import {makeStyles} from '@material-ui/core/styles';
然后,为样式添加以下代码。
const useStyles = makeStyles(theme => ({
root: {
display: 'flex',
height: '100%',
overflow: 'hidden',
width: '100%',
},
wrapper: {
display: 'flex',
flex: '1 1 auto',
overflow: 'hidden',
paddingTop: 64,
[theme.breakpoints.up('lg')]: {
paddingLeft: 256,
},
},
contentContainer: {
display: 'flex',
flex: '1 1 auto',
overflow: 'hidden',
},
content: {
flex: '1 1 auto',
height: '100%',
overflow: 'auto',
},
}));
Listing 6-19Adding Styling to the index.tsx of dashboard-layout
接下来,添加useStyles
并将{children}
包裹在<div>.
中
const classes = useStyles()
...
<DashboardSidebarNavigation />{' '}
<div className={classes.wrapper}>
<div className={classes.contentContainer}>
<div className={classes.content}>{children}</div>
</div>
</div>
Listing 6-20Adding the useStyles Component to the index.tsx of the dashboard-layout
检查仪表板,您应该会看到一个更好的、响应更快的布局。
使用 React 羽化图标
最后但同样重要的是,我们将在侧边栏仪表板中添加一个菜单。我们将使用 React 羽毛图标。
安装 React 羽毛库:
npm i react-feather
安装完成后,打开dashboard-sidebar-navigation.tsx
,从 react-feather 导入 PieChart 组件。我们只是将它重命名为PieChartIcon.
另一个我们需要导入的东西是来自 Material-UI 核心的Divider
和ListSubheader
:
import { PieChart as PieChartIcon } from 'react-feather';
import { Divider, ListSubheader } from '@material-ui/core';
在Logo
之后和settings-and-privacy
之前,添加以下代码。
<Link to={`${url}`} className={classes.logoWithLink}>
Logo
</Link>
</Toolbar>
<div className={classes.drawerContainer}>
<List>
...
<ListSubheader>Reports</ListSubheader>
<Link className={classes.link} to={`${url}`}>
<ListItem button>
<ListItemIcon>
<PieChartIcon />
</ListItemIcon>
<ListItemText primary={'Dashboard'} />
</ListItem>
</Link>
...
<Link className={classes.link} to={`${url}/settings-and-privacy`}>
Listing 6-21Updating the dashboard-sidebar-navigation.tsx
刷新浏览器以查看更改,应如图 6-11 所示。点击按钮,查看一切是否正常。
图 6-11
更新仪表板后的用户界面-侧栏-导航
摘要
我们学习了如何使用本地状态,在考虑 DRY(不要重复自己)原则的同时发送 HTTP 请求,以及使用名为 ApexCharts 的开源图表库为我们的应用创建交互式可视化。
在下一章中,我们将更深入地研究我们的应用,因为我们开始在 Material-UI 的数据表上使用 Formik 和 Yup 验证来编写输入表单。希望我们能把所有这些组件放在一起,并在浏览器上呈现出来,看看一切是如何工作的。
七、编写数据表、表单和验证
在前一章中,我们学习了如何写本地状态和发送 HTTP 请求。我们还安装了 ApexCharts 来创建我们的可视化图表。
我们现在将继续构建我们的应用,添加新的组件为我们的数据表打下基础,并开始使用 Formik 编写表单,使用 Yup 进行输入验证。这一章是两部分系列的第一部分,因为它是一个相当长的主题。第一部分是创建数据表和其他样式组件,这将是第二部分(下一章)的基础,重点是编写表单和输入验证。
本章的最终存储库在这里:
来源: https://github.com/webmasterdevlin/practical-enterprise-react/tree/master/chapter-7
组件概述
在我们继续我们的项目应用之前,让我们回顾一下我们将在本章中使用的一些库或组件。
表单处理
表单允许应用用户通过我们的组件直接输入和提交数据,从个人资料页面或登录屏幕到购物结账页面等。这也是为什么它是任何 web 应用的关键部分的主要原因。
根据我自己的经验和许多其他 React 开发人员的经验,在 React 应用中创建表单可能会非常乏味。更重要的是,我们从头开始创建的表单可能容易出错,因为我们需要自己处理所有的 React。
这就是我选择使用 Formik 构建表单的原因。您可以使用许多其他优秀的表单库,包括 Redux Form、Formsy 和 React Forms。
很好
这为我们提供了表单、字段和错误消息组件来创建表单、添加表单字段和显示错误消息。福米克给了我们三个支柱:
-
initialValue
:表格字段的初始值。 -
validate
:用于表单字段中的验证规则。 -
当我们点击提交的时候,这个函数就会起作用。
当我们开始构建表单时会有更多的介绍。我觉得还是用代码展示比较好。
是的
Yup 是 JavaScript 中的对象模式验证器(清单 7-1 )。有了 Yep,我们可以
-
定义对象模式及其验证。
-
使用所需的模式和验证创建验证器对象。
-
使用 Yup 实用函数验证对象是否有效(满足模式和验证)。如果它不满足验证,将返回一条错误消息。
//define the object schema and its validation
const book = {
published: 1951,
author: "JD Salinger",
title: "The Catcher in the Rye",
pages: 234
};
//create a validator object with the required schema and validation
const yup = require("yup");
const yupObject = yup.object().shape({
published: yup.number.required(),
author: yup.string.required(),
title: yup.string.required(),
pages: yup.number()
});
Listing 7-1An Example of Creating the Validations with Yup
为了演示我们如何将所有这些结合在一起使用,我们将构建一个产品仪表板,列出所有产品并将新产品添加到我们的应用中。
首先,我们将使用 Material-UI 中的数据表组件来显示数据集。
数据表
产品创建视图
转到views ➤ dashboard,
新建一个文件夹,命名为product
。在产品文件夹下,创建另一个文件夹,命名为ProductCreateView.
在ProductCreateView
文件夹中,创建一个新文件并命名为Header.tsx.
以下是文件路径:
views ➤ dashboard ➤ product ➤ ProductCreateView ➤ Header.tsx
打开Header.tsx,
,输入 VS 代码的片段rafce
或 WebStorm 的片段rsc
后,暂时添加头<h1>Header - CreativeView Works!</h1>
。
参见清单 7-2 关于创建 ProductCreateView 的 Header 组件。
import React from 'react'.
const Header = () => {
return (
<div>
<h1>Header - CreativeView Works!</h1>
</div>
)
}
export default Header;
Listing 7-2Creating the Header Component of ProductCreateView
仍然在ProductCreateView
文件夹中,我们将添加另一个文件,并将其命名为ProductCreateForm.tsx.
产品创建表单
以下是文件路径:
views ➤ dashboard ➤ product ➤ ProductCreateView ➤ ProductCreateForm.tsx
给ProductCreateForm.tsx
添加一个
标签。参见清单 7-3 关于创建 ProductCreateForm.tsx.
import React from 'react'
const ProductCreateForm = () => {
return (
<div>
<h1>ProductCreateForm Works! h1>
</div>
)
}
export default ProductCreateForm;
Listing 7-3Creating the ProductCreateForm.tsx
接下来,在ProductCreateView
目录下,添加一个index.tsx
文件,该文件将导入我们刚刚创建的两个组件:Header.tsx
和ProductCreateForm.tsx.
清单 7-4 创建index.tsx
import React from 'react';
import { Container, makeStyles } from '@material-ui/core';
import Header from './Header';
import ProductCreateForm from './ProductCreateForm';
const ProductCreateView = () => {
const classes = useStyles();
return (
<Container>
<Header />
<ProductCreateForm />
</Container>
);
};
const useStyles = makeStyles(theme => ({}));
export default ProductCreateView;
Listing 7-4Creating the index.tsx of ProductCreateView
所以我们现在已经完成了。我们稍后将回头讨论这些组件。我们要做的下一件事是创建产品列表视图。
产品列表视图
我们将在产品中创建另一个文件夹,并将其命名为ProductListView
,并在该文件夹下添加两个新文件,分别命名为Header.tsx
和Results.tsx,
:
views ➤ dashboard ➤ product ➤ ProductListView ➤ Header.tsx
Views ➤ dashboard ➤ product ➤ ProductListView ➤ Results.tsx
打开Header.tsx
并复制如下代码。
import React from 'react';
import { makeStyles } from '@material-ui/core';
const Header = () => {
const classes = useStyles();
return (
<div>
<h1>Header - ListView - Works!</h1>
</div>
);
};
const useStyles = makeStyles(theme => ({
root: {},
action: {
marginBottom: theme.spacing(1),
'& + &': {
marginLeft: theme.spacing(1),
},
},
}));
export default Header;
Listing 7-5Creating the Header.tsx of ProductListView
您可以在Results.tsx.
上自己做同样的事情,但是,将
标题改为
"Results - Works!"
完成Results.tsx,
之后,我们将为ProductListView
添加index.tsx
。
import React from 'react';
import {Container, makeStyles} from '@material-ui/core';
import Header from './Header';
import Results from './Results';
const ProductListView = () => {
const classes = useStyles();
return (
<Container>
<Header/>
<Results/>
</Container>
);
};
const useStyles = makeStyles(theme =>
createStyles({
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
root: {
minHeight: '100%',
paddingTop: theme.spacing(3),
paddingBottom: 100,
},
}),
);
export default ProductListView;
Listing 7-6Creating the index.tsx of ProductListView
当我们需要做一些更改时,我们将在后面讨论所有这些组件。
更新路线
现在,我们需要更新我们的routes
——为每个新创建的组件更新一个路由路径:ProductCreateView
和ProductListView.
我们将在路径中注册这两个索引。打开文件
src/app/routes.tsx
在routes.tsx
文件中,定位Dashboard
和settings and privacy. W
e 将在它们之间添加新的路由路径,如清单 7-7 所示。
export const Routes = () => {
return (
<Suspense fallback={<LinearProgress style={{ margin: '10rem' }} />}>
<Switch>
{/*eager loading*/}
<Route path={'/'} component={HomePage} exact />
{/*lazy loadings*/}
<Route
path={'/about'}
component={lazy(() => import('./views/pages/AboutPage'))}
exact
/>
<Route
path={'/dashboard'}
render={({ match: { path } }) => (
<Dashboard>
<Switch>
<Route
path={path + '/'}
component={lazy(
() => import('./views/dashboard/dashboard-default-content'),
)}
exact
/>
<Route
path={path + '/list-products'}
component={lazy(
() => import('./views/dashboard/product/ProductListView'),
)}
exact
/>
<Route
path={path + '/create-product'}
component={lazy(
() => import('./views/dashboard/product/ProductCreateView'),
)}
exact
/>
</Switch>
</Dashboard>
Listing 7-7Registering the Route Paths of ProductCreateView and ProductListView
注册路线后,我们将更新侧栏仪表板。
更新侧栏仪表板
我们将在侧栏仪表板中创建两个新菜单,即列出产品和创建产品。
转到dashboard-sidebar-navigation.tsx
:
app ➤ layouts ➤ dashboard-layout ➤ dashboard-sidebar-navigation.tsx
我们将在上述文件中从 Feather 导入一些图标。
import {PieChart as PieChartIcon,
ShoppingCart as ShoppingCartIcon,
ChevronUp as ChevronUpIcon,
ChevronDown as ChevronDownIcon,
List as ListIcon,
FilePlus as FilePlusIcon,
LogOut as LogOutIcon,} from 'react-feather';
Listing 7-8Updating the Named Imports for the dashboard-sidebar-navigation
请注意,我们已经对导入的图标进行了重命名,以便它们更具可读性,或者团队中的其他开发人员一眼就能理解它们的用途。
接下来,我们将添加一个本地状态(useState)
并创建一个事件处理程序handleClick
来更新本地状态。但是首先,不要忘记从React.
导入useState
组件
import React, { useEffect, useState } from 'react';
...
const [open, setOpen] = useState(false)
useEffect(() => {}, []);
const handleClick =() => {
setOpen(!open)
};
Listing 7-9Adding useState and an Event Handler to dashboard-sidebar-navigation
之后,我们将在浏览器中呈现一个可折叠的菜单。
创建可折叠的侧边栏菜单
让我们在仪表板和设置和隐私之间添加一个可折叠的菜单。
首先,让我们从 Material-UI Core 导入组件塌陷:
import { Collapse, Divider, ListSubheader } from '@material-ui/core';
然后,让我们将下面的代码添加到可折叠菜单中。我们将使用本地状态open
和事件处理器handleClick
以及 Material-UI 核心的样式图标组件。
<List>
<ListSubheader>Reports</ListSubheader>
<Link className={classes.link} to={`${url}`}>
<ListItem button>
<ListItemIcon>
<PieChartIcon />
</ListItemIcon>
<ListItemText primary={'Dashboard'} />
</ListItem>
</Link>
<ListSubheader>Management</ListSubheader>
<ListItem button onClick={handleClick}>
<ListItemIcon>
<ShoppingCartIcon />
</ListItemIcon>
<ListItemText primary="Products" />
{open ? <ChevronUpIcon /> : <ChevronDownIcon />}
</ListItem>
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
<Link className={classes.link} to={`${url}/list-products`}>
<ListItem button className={classes.nested}>
<ListItemIcon>
<ListIcon />
</ListItemIcon>
<ListItemText primary="List Products" />
</ListItem>
</Link>
<Link className={classes.link} to={`${url}/create-product`}>
<ListItem button className={classes.nested}>
<ListItemIcon>
<FilePlusIcon />
</ListItemIcon>
<ListItemText primary="Create Product" />
</ListItem>
</Link>
</List>
</Collapse>
<a className={classes.link} href={'/'}>
<ListItem button>
<ListItemIcon>
<LogOutIcon />
</ListItemIcon>
<ListItemText primary={'logout'} />
</ListItem>
</a>
</List>
</div>
</Drawer>
</div>
Listing 7-10Creating a Collapsible Menu (Material-UI) for dashboard-sidebar-navigation
那么我们的可折叠菜单是怎么回事呢? 我们添加了管理作为列表子标题,在它下面,我们使用可折叠产品菜单的<ShoppingCartIcon />
来显示菜单List Products
和Create Product.
当用户点击时,<ChevronUpIcon />
和<ChevronDownIcon />
将打开和折叠菜单。
在您的编辑器中,您可能会也可能不会注意到{classes.nested}.
上的一条红色曲线
无论如何,我们需要在这里做更多的事情。这是因为我们需要将它添加到我们的useStyle
组件中。加在最下面就行了。
nested: {
paddingLeft: theme.spacing(4),
},
Listing 7-11Updating the useStyle Component of dashboard-sidebar-navigation
现在运行应用,检查一切是否仍然正常。您应该会看到更新后的侧边栏导航,如下所示。
图 7-1
更新了仪表板的用户界面-侧栏-导航
单击“列出产品”和“创建产品”来检查您是否可以在页面之间成功导航。您应该能够看到我们编写的 h1 头:
(Shown when clicking the List Products tab)
Header - ListView Works!
Results - Works!
(Showing when clicking the Create Product tab)
Header - CreativeView Works!
ProductCreateForm Works!
既然我们已经完成了可以导航到新页面的概念验证,我认为是时候做一些清理并删除设置和隐私选项卡了。我们不再需要它了;稍后我们会添加更多的菜单。
清理一下…
从routes.tsx
中删除settings-and-privacy
。
删除文件settings-and-privacy.tsx
。
接下来,进行dashboard-sidebar-navigation.tsx
。
我们将在这里进行两处编辑:
1.删除设置和隐私。
2.然后用我们自己的<LogoutIcon />
替换默认的<ExitToAppIcon />
。
<a className={classes.link} href={'/'}>
<ListItem button>
<ListItemIcon>
<LogOutIcon/>
</ListItemIcon>
<ListItemText primary={'logout'} />
</ListItem>
</a>
Listing 7-12Logout Icon in dashboard-sidebar-navigation
我可能忘记使用 Material-UI 中的<Divider />
了,所以我们现在就把它放上去。把它放在</Toolbar>.
之后
<<Toolbar
style={{ width: '6rem', height: 'auto' }}
className={classes.toolbar}
>
<Link to={`${url}`} className={classes.logoWithLink}>
Logo
</Link>
</Toolbar>
<Divider />
Listing 7-13Adding the Divider Component in dashboard-sidebar-navigation
现在运行或刷新浏览器,如果设置和隐私已被删除,看看是否一切仍然工作。
定义产品类型的类型别名
之后,我们将继续在数据表中实现产品。由于我们使用 TypeScript,我们将首先开始构建我们的模型类型或接口。在这种情况下,我更喜欢使用类型。
在 models 目录中,创建一个新文件,并将其命名为product-type.ts.
。我们的ProductType
对象的形状如下所示。
这就像一个枚举字符串。这里的管道|基本上是一个联合,允许我们选择三个选项中的任何一个。*/
export type InventoryType = 'in_stock' | 'limited' | 'out_of_stock';
export type ProductType = {
id: string;
attributes: string[];
category: string;
//union means can be string or number
createdAt: string | number;
currency: string;
// the ? means nullable
image?: string;
inventoryType: InventoryType;
isAvailable: boolean;
isShippable: boolean;
name: string;
price: number;
quantity: number;
updatedAt: string | number;
variants: number;
description: string;
images: string[];
includesTaxes: boolean;
isTaxable: boolean;
productCode: string;
productSku: string;
salePrice: string;
};
Listing 7-14Creating the Shape of the ProductType Object
形状或类型在这里是不言自明的。为了代码的可维护性和在编辑器中获得智能感知,我们现在需要付出额外的努力。从长远来看,现在这样做可以省去我们很多痛苦。
创建产品端点
在我们进入服务之前,让我们更新 axios 配置中的端点。打开axios.ts
并添加产品端点。
export const EndPoints = {
sales: 'sales',
products: 'products'
};
Listing 7-15Adding the Products Endpoint in axios.ts
现在我们已经为我们的销售和产品设置了端点,是时候设置他们的 HTTP 服务了。
创建产品服务
我们将在名为productService.ts
的新文件中使用该端点,我们将在 services 目录下创建该文件:
services ➤ productService.ts
打开新文件并添加函数来创建产品服务,如清单 7-16 所示。
import api, {EndPoints} from '../api/axios';
import {ProductType} from '../models/product-type';
export async function getProductAxios() {
return await api.get<ProductType[]>(EndPoints.products);
}
export async function postProductAxios(product: ProductType) {
return await api.post<ProductType>(EndPoints.products, product);
}
Listing 7-16Creating productService.ts
在清单 7-16 中,我们创建了两个函数:
getProductAxios
和postProductAxios
两者都使用 Axios 向 JSON 服务器发送请求,返回类型分别是 ProductType: <ProductType[ ]>
和<ProductType>,
的数组。
两个函数都是异步等待类型。
在这之后,让我们用一个产品样本或四个对象的数组来更新我们的db.json
。
更新 db.json 数据
转到 db.json 文件并添加以下数据,如清单 7-17 所示。
"products": [
{
"id": "5ece2c077e39da27658aa8a9",
"attributes": ["Cotton"],
"category": "dress",
"currency": "$",
"createdAt": "2021-01-01T12:00:27.87+00:20",
"image": null,
"inventoryType": "in_stock",
"isAvailable": true,
"isShippable": false,
"name": "Charlie Tulip Dress",
"price": 23.99,
"quantity": 85,
"updatedAt": "2021-01-01T12:00:27.87+00:20",
"variants": 2
},
{
"id": "5ece2c0d16f70bff2cf86cd8",
"attributes": ["Cotton"],
"category": "dress",
"currency": "$",
"createdAt": "2021-01-01T12:00:27.87+00:20",
"image": null,
"inventoryType": "out_of_stock",
"isAvailable": false,
"isShippable": true,
"name": "Kate Leopard Dress",
"price": 95,
"quantity": 0,
"updatedAt": "2021-01-01T12:00:27.87+00:20",
"variants": 1
},
{
"id": "5ece2c123fad30cbbff8d060",
"attributes": ["Variety of styles"],
"category": "jewelry",
"currency": "$",
"createdAt": 345354345,
"image": null,
"inventoryType": "in_stock",
"isAvailable": true,
"isShippable": false,
"name": "Layering Bracelets Collection",
"price": 155,
"quantity": 48,
"updatedAt": "2021-01-01T12:00:27.87+00:20",
"variants": 5
},
{
"id": "5ece2c1be7996d1549d94e34",
"attributes": ["Polyester and Spandex"],
"category": "blouse",
"currency": "$",
"createdAt": "2021-01-01T12:00:27.87+00:20",
"image": null,
"inventoryType": "limited",
"isAvailable": false,
"isShippable": true,
"name": "Flared Sleeve Floral Blouse",
"price": 17.99,
"quantity": 5,
"updatedAt": "2021-01-01T12:00:27.87+00:20",
"variants": 1
}
]
Listing 7-17Adding the db.json Data with Product Objects
您会注意到我们已经创建了四个产品对象。这里为了简单起见,对象的名称如下:
"name": "Charlie Tulip Dress",
"name": "Kate Leopard Dress",
"name": "Layering Bracelets Collection",
"name": "Flared Sleeve Floral Blouse",
现在我们已经在 axios 中添加了productService
并更新了 db.json,让我们通过发送一个 HTTP 请求来测试它。
发送 HTTP 请求
前往ProductListView
的index.tsx
文件。
我们需要 React 的useEffect
。在useEffect,
里面我们称之为从services/productService
?? 进口的getProductAxios,
...
import { getProductsAxios } from 'services/productService';
const ProductListView = () => {
const classes = useStyles();
useEffect(() => {
getProductAxios();
}, []);
Listing 7-18Using the getProductAxios in ProductListView.tsx
进入 Chrome DevTools,点击网络选项卡,选择 XHR。确保您的 JSON 服务器在localhost:5000/products.
运行
单击浏览器中的 List Products,在标题中,您应该会看到 Status Code: 200 OK 来表示来自 JSON 服务器的一个成功的 get 响应。
接下来,单击 Response 选项卡检查 JSON 对象。您应该能够看到我们在db.json.
中添加的一系列产品
重构产品列表视图
好了,现在我们知道它正在工作,我们将在 ProductListView 中进行一些代码重构,以反映最佳实践。
转到ProductListView
的index.tsx
并执行以下操作:
-
创建一个本地状态
(useState)
来更新产品数据的数组。 -
添加一个名为
fetchProducts
的 async-await 函数,在这里我们可以调用getProductAxios().
-
最佳实践是将
fetchProducts()
放在一个 try-catch 块中。 -
从 Material-UI 中添加一个背景组件,其工作方式很像加载器微调器。
/局部状态使用泛型类型的类型数组,所以我们一眼就能知道它的形状。将鼠标悬停在上,您将看到它的型号。如果你去掉这里的泛型,你将失去在悬停时看到物体模型形状的能力。你会得到类型‘any’/
const [products, setProducts] = useState<ProductType[]>([])
/*不需要在这里声明类型 boolean,因为我们已经可以看到它的类型。
通常是原语——不需要显式声明类型。TS 可以推断出来。*/
const [open, setOpen] = useState(false);
useEffect(() => {
fetchProduct()
}, []);
const fetchProduct = async () => {
handleToggle();
try {
const { data } = await getProductAxios();
setProducts(data);
} catch (e) {
alert('Something is wrong.');
}
handleClose();
};
const handleClose = () => {
setOpen(false);
};
const handleToggle = () => {
setOpen(!open);
}
Listing 7-19Updating the index.tsx of ProductListView.tsx
我们将使用当地的州作为背景。我们会用一点,但我们需要首先创建一些额外的 UI 样式。
创建附加的用户界面样式
首先,让我们在 UI 中呈现表格,为此,我们将在components
文件夹下创建一个新文件,并将其命名为label.tsx
。
列表 7-20 为桌子的美学设计或造型创建label.tsx
。
import React, { ReactNode } from 'react';
import clsx from 'clsx';
import { fade, makeStyles } from '@material-ui/core';
//defining the shape or type of our label model
type Props = {
className?: string;
color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success';
children?: ReactNode;
style?: {};
};
const Label = ({
className = '',
color = 'secondary',
children,
style,
...rest
}: Props) => {
const classes = useStyles();
return (
<span
className={clsx(
classes.root,
{
[classes[color]]: color,
},
className,
)}
{...rest}
>
{children}
</span>
);
};
const useStyles = makeStyles(theme => ({
root: {
fontFamily: theme.typography.fontFamily,
alignItems: 'center',
borderRadius: 2,
display: 'inline-flex',
flexGrow: 0,
whiteSpace: 'nowrap',
cursor: 'default',
flexShrink: 0,
fontSize: theme.typography.pxToRem(12),
fontWeight: theme.typography.fontWeightMedium,
height: 20,
justifyContent: 'center',
letterSpacing: 0.5,
minWidth: 20,
padding: theme.spacing(0.5, 1),
textTransform: 'uppercase',
},
primary: {
color: theme.palette.primary.main,
backgroundColor: fade(theme.palette.primary.main, 0.08),
},
secondary: {
color: theme.palette.secondary.main,
backgroundColor: fade(theme.palette.secondary.main, 0.08),
},
error: {
color: theme.palette.error.main,
backgroundColor: fade(theme.palette.error.main, 0.08),
},
success: {
color: theme.palette.success.main,
backgroundColor: fade(theme.palette.success.main, 0.08),
},
warning: {
color: theme.palette.warning.main,
backgroundColor: fade(theme.palette.warning.main, 0.08),
},
}));
export default Label;
Listing 7-20 Creating the label.tsx
接下来,我们需要另一个组件来帮助我们呈现数据表。转到文件夹ProductListView
,创建一个新文件,命名为TableResultsHelpers.tsx
。
让我们导入命名组件并定义对象的类型别名,如清单 7-21 所示。
import React from 'react';
import { InventoryType, ProductType } from 'models/product-type';
import Label from 'app/components/label';
export type TableResultsHelpers = {
availability?: 'available' | 'unavailable';
category?: string;
inStock?: boolean;
isShippable?: boolean;
};
Listing 7-21Importing the component and adding the type alias for TableResultsHelpers
接下来,让我们将产品渲染的过滤条件应用于用户;参见清单 7-22 。
export const applyFilters = (
products: ProductType[],
query: string,
filters: TableResultsHelpers,
): ProductType[] => {
return products.filter(product => {
let matches = true;
/* the product here comes from the parent component. */
if (query && !product.name.toLowerCase().includes(query.toLowerCase())) {
matches = false;
}
if (filters.category && product.category !== filters.category) {
matches = false;
}
if (filters.availability) {
if (filters.availability === 'available' && !product.isAvailable) {
matches = false;
}
if (filters.availability === 'unavailable' && product.isAvailable) {
matches = false;
}}
if (
filters.inStock &&
!['in_stock', 'limited'].includes(product.inventoryType)
) {
matches = false;
}
if (filters.isShippable && !product.isShippable) {
matches = false;
}
return matches;
});
};
/* to limit the products or the number of search results shown*/
export const applyPagination = (
products: ProductType[],
page: number,
limit: number,
): ProductType[] => {
return products.slice(page * limit, page * limit + limit);
};
export const getInventoryLabel = (
inventoryType: InventoryType,
): JSX.Element => {
const map = {
in_stock: {
text: 'In Stock',
color: 'success',
},
limited: {
text: 'Limited',
color: 'warning',
},
out_of_stock: {
text: 'Out of Stock',
color: 'error',
},
};
const { text, color }: any = map[inventoryType];
return <Label color={color}>{text}</Label>;
};
Listing 7-22Creating the TableResultsHelpers
TableResultsHelpers
正在使用我们刚刚创建的标签组件。
我们还从models/product-type
进口InventoryType
和ProductType
。
表助手是用于 UI 的,所以我们可以在过滤器框中查询或输入,并查看结果列表。
之后,在src
下新建一个文件夹,命名为helpers
。在 helpers 文件夹下,添加一个新文件,命名为inputProductOptions.ts.
这个文件只是用来标记表格的,最好把它放在一个单独的文件里,而不是和组件本身堆在一起。
export const categoryOptions = [
{
id: 'all',
name: 'All',
},
{
id: 'dress',
name: 'Dress',
},
{
id: 'jewelry',
name: 'Jewelry',
},
{
id: 'blouse',
name: 'Blouse',
},
{
id: 'beauty',
name: 'Beauty',
},
];
export const availabilityOptions = [
{
id: 'all',
name: 'All',
},
{
id: 'available',
name: 'Available',
},
{
id: 'unavailable',
name: 'Unavailable',
},
];
export const sortOptions = [
{
value: 'updatedAt|desc',
label: 'Last update (newest first)',
},
{
value: 'updatedAt|asc',
label: 'Last update (oldest first)',
},
{
value: 'createdAt|desc',
label: 'Creation date (newest first)',
},
{
value: 'createdAt|asc',
label: 'Creation date (oldest first)',
},
];
Listing 7-23Creating the Helpers for inputProductOptions
暂时就这样了。现在,我们将安装三个 NPM 库:
-
numeral.js
:一个用于格式化和操作数字的 JavaScript 库。 -
@types/numeral
:numeric . js 是用 JavaScript 构建的,所以我们需要为这个库添加类型。 -
这让我们可以很容易地为数据表制作滚动条。
$ npm i numeral
$ npm i @types/numeral
$ npm i react-perfect-scrollbar
成功安装库后,打开文件results.tsx
进行一些编辑。我前面提到过,我们将回到这个文件来构建它。
让我们添加以下命名的导入组件,如清单 7-24 所示。除了我们将要安装的来自 Material-UI 核心的几个样式组件之外,我们正在从inputProductOptions
、TableResultsHelpers
和models
文件夹中的product-type
导入组件。
import React, { useState, ChangeEvent } from 'react';
import clsx from 'clsx';
import numeral from 'numeral';
import PerfectScrollbar from 'react-perfect-scrollbar';
import {
Image as ImageIcon,
Edit as EditIcon,
ArrowRight as ArrowRightIcon,
Search as SearchIcon,
} from 'react-feather';
import {
Box,
Button,
Card,
Checkbox,
InputAdornment,
FormControlLabel,
IconButton,
SvgIcon,
Table,
TableBody,
TableCell,
TableHead,
TablePagination,
TableRow,
TextField,
makeStyles,
} from '@material-ui/core';
import {
availabilityOptions,
categoryOptions,
sortOptions,
} from 'helpers/inputProductOptions';
import {
applyFilters,
applyPagination,
TableResultsHelpers,
getInventoryLabel,
} from './tableResultsHelpers';
import { ProductType } from 'models/product-type';
Listing 7-24Adding the Named Import Components to results.tsx
接下来,我们将定义清单 7-25 中对象的类型或形状。
type Props = {
className?: string;
products?: ProductType[];
};
Listing 7-25Creating the Shape or Type of the Object in results.tsx
根据类型的定义,我们将创建一些本地状态,如清单 7-26 所示。
const Results = ({ className, products, ...rest }: Props) => {
const classes = useStyles();
//Explicitly stating that selectedProducts is an array of type string
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
const [page, setPage] = useState(0);
const [limit, setLimit] = useState(10);
const [query, setQuery] = useState('');
/* Explicitly stating that sort is an array of type string so we'll know on mouser hover that value is of type string. */
const [sort, setSort] = useState<string>(sortOptions[0].value);
const [filters, setFilters] = useState<TableResultsHelpers | any>({
category: null,
availability: null,
inStock: null,
isShippable: null,
});
Listing 7-26Creating the results.tsx Component
接下来,我们将创建以下事件处理程序,如清单 7-27 所示。
/*Updates the query every time the user types on the keyboard */
const handleQueryChange = (event: ChangeEvent<HTMLInputElement>): void => {
event.persist();
setQuery(event.target.value);
};
const handleCategoryChange = (event: ChangeEvent<HTMLInputElement>): void => {
event.persist();
let value: any = null;
if (event.target.value !== 'all') {
value = event.target.value;
}
setFilters(prevFilters => ({
...prevFilters,
category: value,
}));
};
const handleAvailabilityChange = (
event: ChangeEvent<HTMLInputElement>,
): void => {
event.persist();
let value: any = null;
if (event.target.value !== 'all') {
value = event.target.value;
}
setFilters(prevFilters => ({
...prevFilters,
availability: value,
}));
};
const handleStockChange = (event: ChangeEvent<HTMLInputElement>): void => {
event.persist();
let value: any = null;
if (event.target.checked) {
value = true;
}
setFilters(prevFilters => ({
...prevFilters,
inStock: value,
}));
};
const handleShippableChange = (
event: ChangeEvent<HTMLInputElement>,
): void => {
event.persist();
let value: any = null;
if (event.target.checked) {
value = true;
}
setFilters(prevFilters => ({
...prevFilters,
isShippable: value,
}));
};
const handleSortChange = (event: ChangeEvent<HTMLInputElement>): void => {
event.persist();
setSort(event.target.value);
};
/*Updating all selected products */
const handleSelectAllProducts = (
event: ChangeEvent<HTMLInputElement>,
): void => {
setSelectedProducts(
event.target.checked ? products.map(product => product.id) : [],
);
};
/*Updating one selected product */
const handleSelectOneProduct = (
event: ChangeEvent<HTMLInputElement>,
productId: string,
): void => {
if (!selectedProducts.includes(productId)) {
setSelectedProducts(prevSelected => [...prevSelected, productId]);
} else {
setSelectedProducts(prevSelected =>
prevSelected.filter(id => id !== productId),
);
}
};
/*This is for the pagination*/
const handlePageChange = (event: any, newPage: number): void => {
setPage(newPage);
};
const handleLimitChange = (event: ChangeEvent<HTMLInputElement>): void => {
setLimit(parseInt(event.target.value));
};
/* Usually query is done on the backend with indexing solutions, but we're doing it here just to simulate it */
const filteredProducts = applyFilters(products, query, filters);
const paginatedProducts = applyPagination(filteredProducts, page, limit);
const enableBulkOperations = selectedProducts.length > 0;
const selectedSomeProducts =
selectedProducts.length > 0 && selectedProducts.length < products.length;
const selectedAllProducts = selectedProducts.length === products.length;
Listing 7-27Creating Event Handlers in results.tsx
继续 HTML,我们把 Material-UI 核心中的所有东西都包装在卡片中。我们还添加了Box, TextField, Checkbox
和各种表格样式,如清单 7-28 所示。
请记住,所有这些风格都不是你需要从头开始创造的。比方说,你只需进入 Material-UI 网站,搜索“表格”,你就可以根据应用的要求使用那里的任何东西。我们在这里使用的所有 API 都可以在 Material-UI 中找到。
我只是再次向您展示了使用编写良好且受支持的库来使您的编码开发变得更加容易的可能性。当然,正如我之前提到的,有很多 UI 组件库可以使用,Material-UI 只是其中之一。
如果您正在编写代码,从 Material UI 复制粘贴卡片组件,如清单 7-28 所示。必要的时候我们会重构或者做一些改变。
return (
<Card className={clsx(classes.root, className)} {...rest}>
<Box p={2}>
<Box display="flex" alignItems="center">
<TextField
className={classes.queryField}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SvgIcon fontSize="small" color="action">
<SearchIcon />
</SvgIcon>
</InputAdornment>
),
}}
onChange={handleQueryChange}
placeholder="Search products"
value={query}
variant="outlined"
/>
<Box flexGrow={1} />
<TextField
label="Sort By"
name="sort"
onChange={handleSortChange}
select
SelectProps={{ native: true }}
value={sort}
variant="outlined"
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</TextField>
</Box>
<Box mt={3} display="flex" alignItems="center">
<TextField
className={classes.categoryField}
label="Category"
name="category"
onChange={handleCategoryChange}
select
SelectProps={{ native: true }}
value={filters.category || 'all'}
variant="outlined"
>
{categoryOptions.map(categoryOption => (
<option key={categoryOption.id} value={categoryOption.id}>
{categoryOption.name}
</option>
))}
</TextField>
<TextField
className={classes.availabilityField}
label="Availability"
name="availability"
onChange={handleAvailabilityChange}
select
SelectProps={{ native: true }}
value={filters.availability || 'all'}
variant="outlined"
>
{availabilityOptions.map(avalabilityOption => (
<option key={avalabilityOption.id} value={avalabilityOption.id}>
{avalabilityOption.name}
</option>
))}
</TextField>
<FormControlLabel
className={classes.stockField}
control={
<Checkbox
checked={!!filters.inStock}
onChange={handleStockChange}
name="inStock"
/>
}
label="In Stock"
/>
<FormControlLabel
className={classes.shippableField}
control={
<Checkbox
checked={!!filters.isShippable}
onChange={handleShippableChange}
name="Shippable"
/>
}
label="Shippable"
/>
</Box>
</Box>
{enableBulkOperations && (
<div className={classes.bulkOperations}>
<div className={classes.bulkActions}>
<Checkbox
checked={selectedAllProducts}
indeterminate={selectedSomeProducts}
onChange={handleSelectAllProducts}
/>
<Button variant="outlined" className={classes.bulkAction}>
Delete
</Button>
<Button variant="outlined" className={classes.bulkAction}>
Edit
</Button>
</div>
</div>
)}
<PerfectScrollbar>
<Box minWidth={1200}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
checked={selectedAllProducts}
indeterminate={selectedSomeProducts}
onChange={handleSelectAllProducts}
/>
</TableCell>
<TableCell />
<TableCell>Name</TableCell>
<TableCell>Inventory</TableCell>
<TableCell>Details</TableCell>
<TableCell>Attributes</TableCell>
<TableCell>Price</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedProducts.map(product => {
const isProductSelected = selectedProducts.includes(product.id);
return (
<TableRow hover key={product.id} selected={isProductSelected}>
<TableCell padding="checkbox">
<Checkbox
checked={isProductSelected}
onChange={event =>
handleSelectOneProduct(event, product.id)
}
value={isProductSelected}
/>
</TableCell>
<TableCell className={classes.imageCell}>
{product.image ? (
<img
alt="Product"
src={product.image}
className={classes.image}
/>
) : (
<Box p={2} bgcolor="background.dark">
<SvgIcon>
<ImageIcon />
</SvgIcon>
</Box>
)}
</TableCell>
<TableCell>{product.name}</TableCell>
<TableCell>
{getInventoryLabel(product.inventoryType)}
</TableCell>
<TableCell>
{product.quantity} in stock
{product.variants > 1 &&
` in ${product.variants} variants`}
</TableCell>
<TableCell>
{product.attributes.map(attr => attr)}
</TableCell>
<TableCell>
{numeral(product.price).format(
`${product.currency}0,0.00`,
)}
</TableCell>
<TableCell align="right">
<IconButton>
<SvgIcon fontSize="small">
<EditIcon />
</SvgIcon>
</IconButton>
<IconButton>
<SvgIcon fontSize="small">
<ArrowRightIcon />
</SvgIcon>
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<TablePagination
component="div"
count={filteredProducts.length}
onChangePage={handlePageChange}
onChangeRowsPerPage={handleLimitChange}
page={page}
rowsPerPage={limit}
rowsPerPageOptions={[5, 10, 25]}
/>
</Box>
</PerfectScrollbar>
</Card>
);
};
Listing 7-28Creating Event Handlers in results.tsx
之后,我们只需要将makeStyles
中的useStyles
放到results.tsx
中。
const useStyles = makeStyles(theme => ({
availabilityField: {
marginLeft: theme.spacing(2),
flexBasis: 200,
},
bulkOperations: {
position: 'relative',
},
bulkActions: {
paddingLeft: 4,
paddingRight: 4,
marginTop: 6,
position: 'absolute',
width: '100%',
zIndex: 2,
backgroundColor: theme.palette.background.default,
},
bulkAction: {
marginLeft: theme.spacing(2),
},
categoryField: {
flexBasis: 200,
},
imageCell: {
fontSize: 0,
width: 68,
flexBasis: 68,
flexGrow: 0,
flexShrink: 0,
},
image: {
height: 68,
width: 68,
},
root: {},
queryField: {
width: 500,
},
stockField: {
marginLeft: theme.spacing(2),
},
shippableField: {
marginLeft: theme.spacing(2),
},
}));
export default Results;
Listing 7-29Adding the useStyles to results.tsx
我们现在完成了 results.tsx。让我们对ProductListView.
的index.tsx
做一些更新
我们将从 Material-UI 核心导入一些组件,包括页面模板组件,如清单 7-30 所示。
import {
Backdrop,
Box,
CircularProgress,
Container,
makeStyles,
} from '@material-ui/core';
import Page from 'app/components/page';
Listing 7-30Adding Named Components to the index.tsx of ProductListView
然后让我们从makeStyles
组件中添加useStyles
,如清单 7-31 所示。
import { createStyles } from '@material-ui/core/styles';
...
const useStyles = makeStyles(theme =>
createStyles({
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
root: {
minHeight: '100%',
paddingTop: theme.spacing(3),
paddingBottom: 100,
},
}),
);
Listing 7-31Adding useStyles to the index.tsx of ProductListView
好了,现在我们已经在 ProductListView 上设置了这些,我们将在 JSX 中使用页面模板、容器和背景,如清单 7-32 所示。
return (
<Page className={classes.root} title="Product List">
<Container maxWidth={false}>
<Header />
{products && (
<Box mt={3}>
<Results products={products} />
</Box>
)}
<Backdrop
className={classes.backdrop}
open={open}
onClick={handleClose}
>
<CircularProgress color="inherit" />
</Backdrop>
</Container>
</Page>
);
};
Listing 7-32Adding Material-UI Components to the index.tsx of ProductListView
确保您的 JSON 服务器在localhost:5000/product
s 运行,然后通过单击侧边栏仪表板中的 List Products 来刷新您的 UI。
图 7-2
呈现列表产品的用户界面
摆弄搜索框**(搜索产品)、类别、和可用性**,检查是否可以成功搜索,并根据您键入的关键字获得正确的结果。单击刷新按钮也可以检查带有微调器的背景是否工作。
摘要
我们看到产品菜单到目前为止是有效的,至少有一半——列出产品——但是我们还有很长的路要走,以完成产品侧边栏菜单。你可以说,在我们能够触及事物的本质之前,我们已经奠定了框架基础。
在下一章的第二部分,我们将对 ProductListView 做一些收尾工作,然后直接跳到使用 Formik 和 Yup 验证表单。