前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >使用 TypeScript 编写 React.js 应用 | 笔记

使用 TypeScript 编写 React.js 应用 | 笔记

作者头像
yiyun
发布2023-07-20 14:38:53
7410
发布2023-07-20 14:38:53
举报
文章被收录于专栏:yiyun 的专栏yiyun 的专栏

引言

image-preview

本地开发环境

代码语言:javascript
复制
🦄  node --version
v18.14.0
代码语言:javascript
复制
🦄  yarn --version
3.6.0

1. 创建一个新项目

代码语言:javascript
复制
yarn create react-app keeptrack --template typescript
代码语言:javascript
复制
cd keeptrack

code .

查看默认的项目结构

  • package.json
  • public/index.html 页面模版
  • src/index.tsx JavaScript 入口点

2. 运行项目

代码语言:javascript
复制
yarn start

做出一些改变并查看更新

src\App.tsx

代码语言:javascript
复制
function App() {
return (
    <div className="App">
    <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
        Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
        className="App-link"
        href="https://reactjs.org"
        target="_blank"
        rel="noopener noreferrer"
        >
-        Learn React
+        Learn React!!!
        </a>
    </header>
    </div>
);
}

保存后即可立即查看浏览器页面更新

停止项目运行: Ctrl + C

3. 使用 CSS 预处理器 Sass

参考:

注意: node-sass 已被遗弃

较新的版本不需要配置 sass-loader 等一系列插件,安装即用。

Sass 有两种语法! SCSS 语法 (.scss) 最常使用。 它是 CSS 的超集,这意味着所有有效的 CSS 也是有效的 SCSS。 缩进语法 ( .sass ) 更不寻常:它使用缩进而不是大括号来嵌套语句,并使用换行符而不是分号来分隔它们。

代码语言:javascript
复制
yarn add sass

安装 mini.css 包, Mini.css is a 最小的, 响应式的, 与样式无关的 CSS 框架. Mini.css 类似于 Bootstrap, 但更轻, and 需要的 CSS 类更少 因此你可以 专注于 学习 React 但仍然可以获得 专业的外观.

代码语言:javascript
复制
yarn add mini.css@3.0.1

应用 CSS

打开并删除 App.css 的内容

更新 App.tsx

src\App.tsx

代码语言:javascript
复制
import React from 'react';
- import logo from './logo.svg';
import './App.css';

function App() {
  return (
-     <div className="App">
-      <header className="App-header">
-        <img src={logo} className="App-logo" alt="logo" />
-        <p>
-          Edit <code>src/App.tsx</code> and save to reload.
-        </p>
-        <a
-          className="App-link"
-          href="https://reactjs.org"
-          target="_blank"
-          rel="noopener noreferrer"
-        >
-          Learn React!!
-        </a>
-      </header>
-    </div>

+    <blockquote cite="Benjamin Franklin">
+      Tell me and I forget, teach me and I may remember, involve me and I learn.
+    </blockquote>
  );
}

export default App;

打开 src\index.css 并删除其中内容

更新 src\index.css

代码语言:javascript
复制
/* 导入安装的包中 CSS */
@import '../node_modules/mini.css/dist/mini-default.min.css';

image-20230621142849274

4. 你的首个组件

  1. 创建文件夹: src\projects
  2. 创建文件: src\projects\ProjectsPage.tsx
  3. 打开文件, 更新文件内容

在 VSCode 中,可以使用扩展 VS Code ES7 React/Redux/React-Native/JS snippets , 安装启用后可以使用快捷键 rfce 然后 tab

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
import React from 'react';

function ProjectsPage() {
  return <h1>Projects</h1>;
}

export default ProjectsPage;

import React from 'react'; 在最新版本的 React 中不是必需的,因为它使用了一个新的 JSX Transform

  • React 17 RC and higher supports the new JSX Transform, and they’ve also released React 16.14.0, React 15.7.0, and React 0.14.10 for people who are still on the older major versions).
  • With the new JSX Transform, the import statement is only needed at the entry point of the application which is src\index.js in a Create React App. Note that the code still works if you include the import in other files but it is no longer required.
  • The import is included throughout the labs so the code continues to work on older versions of React

src\App.tsx

代码语言:javascript
复制
- import React from 'react';
- import './App.css';
+ import ProjectsPage from './projects/ProjectsPage';

function App() {
-   return (
-      <blockquote cite="Benjamin Franklin">
-         Tell me and I forget, teach me and I may remember, involve me and I learn.
-      </blockquote>
-   );
+   return (
+   <div className="container">
+      <ProjectsPage />
+   </div>
+   );
}

image-20230621144335843

5. Data

  1. 下载此仓库.zip: craigmckeachie/react-starter-files
  2. 解压, 复制 assets 其中文件到 keeptrack/public 文件夹
  3. 创建两个文件: src\projects\Project.ts , src\projects\MockProjects.ts

src\projects\Project.ts

代码语言:javascript
复制
export class Project {
  id: number | undefined;
  name: string = '';
  description: string = '';
  imageUrl: string = '';
  contractTypeId: number | undefined;
  contractSignedOn: Date = new Date();
  budget: number = 0;
  isActive: boolean = false;
  get isNew(): boolean {
    return this.id === undefined;
  }

  constructor(initializer?: any) {
    if (!initializer) return;
    if (initializer.id) this.id = initializer.id;
    if (initializer.name) this.name = initializer.name;
    if (initializer.description) this.description = initializer.description;
    if (initializer.imageUrl) this.imageUrl = initializer.imageUrl;
    if (initializer.contractTypeId)
      this.contractTypeId = initializer.contractTypeId;
    if (initializer.contractSignedOn)
      this.contractSignedOn = new Date(initializer.contractSignedOn);
    if (initializer.budget) this.budget = initializer.budget;
    if (initializer.isActive) this.isActive = initializer.isActive;
  }
}

src\projects\MockProjects.ts

代码语言:javascript
复制
import { Project } from './Project';

export const MOCK_PROJECTS = [
  new Project({
    id: 1,
    name: 'Johnson - Kutch',
    description:
      'Fully-configurable intermediate framework. Ullam occaecati libero laudantium nihil voluptas omnis.',
    imageUrl: '/assets/placeimg_500_300_arch4.jpg',
    contractTypeId: 3,
    contractSignedOn: '2013-08-04T22:39:41.473Z',
    budget: 54637,
    isActive: false,
  }),
  new Project({
    id: 2,
    name: 'Wisozk Group',
    description:
      'Centralized interactive application. Exercitationem nulla ut ipsam vero quasi enim quos doloribus voluptatibus.',
    imageUrl: '/assets/placeimg_500_300_arch1.jpg',
    contractTypeId: 4,
    contractSignedOn: '2012-08-06T21:21:31.419Z',
    budget: 91638,
    isActive: true,
  }),
  new Project({
    id: 3,
    name: 'Denesik LLC',
    description:
      'Re-contextualized dynamic moratorium. Aut nulla soluta numquam qui dolor architecto et facere dolores.',
    imageUrl: '/assets/placeimg_500_300_arch12.jpg',
    contractTypeId: 6,
    contractSignedOn: '2016-06-26T18:24:01.706Z',
    budget: 29729,
    isActive: true,
  }),
  new Project({
    id: 4,
    name: 'Purdy, Keeling and Smitham',
    description:
      'Innovative 6th generation model. Perferendis libero qui iusto et ullam cum sint molestias vel.',
    imageUrl: '/assets/placeimg_500_300_arch5.jpg',
    contractTypeId: 4,
    contractSignedOn: '2013-05-26T01:10:42.344Z',
    budget: 45660,
    isActive: true,
  }),
  new Project({
    id: 5,
    name: 'Kreiger - Waelchi',
    description:
      'Managed logistical migration. Qui quod praesentium accusamus eos hic non error modi et.',
    imageUrl: '/assets/placeimg_500_300_arch12.jpg',
    contractTypeId: 2,
    contractSignedOn: '2009-12-18T21:46:47.944Z',
    budget: 81188,
    isActive: true,
  }),
  new Project({
    id: 6,
    name: 'Lesch - Waelchi',
    description:
      'Profound mobile project. Rem consequatur laborum explicabo sint odit et illo voluptas expedita.',
    imageUrl: '/assets/placeimg_500_300_arch1.jpg',
    contractTypeId: 3,
    contractSignedOn: '2016-09-23T21:27:25.035Z',
    budget: 53407,
    isActive: false,
  }),
];

展示数据

使用 JSON.stringify() 输出来自 MockProjects.tsMOCK_PROJECTS 数组

  • React 组件只能返回一个根元素,因此你将需要使用 React 片段 <></> 包装 <h1><pre> 标签
  • 在 JSX 中使用 JavaScript,使用 {}
  • JSON.stringify(MOCK_PROJECTS, null, ' ')
    • 第三个参数被用于插入空格到输出 JSON 字符串出于可读性目的
    • 第二个参数是一个替换函数,因此我们可以传递 null,因为我们不需要替换任何东西。

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
+ import { MOCK_PROJECTS } from './MockProjects';

function ProjectsPage() {
- return  <h1>Projects</h1>
+  return (
+   <>
+     <h1>Projects</h1>
+    <pre>{JSON.stringify(MOCK_PROJECTS, null, ' ')}</pre>
+   </>
+  );
}

export default ProjectsPage;

image-20230621165409156

6. 传递数据到组件

创建一个可重用的列表组件

src\projects\ProjectList.tsx

代码语言:javascript
复制
import React from 'react';
import { Project } from './Project';

interface ProjectListProps {
  projects: Project[];
}

function ProjectList({ projects }: ProjectListProps) {
  return <pre>{JSON.stringify(projects, null, ' ')}</pre>;
}

export default ProjectList;

传递数据到组件属性

  1. 修改 src\projects\ProjectsPage.tsx 以便渲染 ProjectList 组件,并且传递 MOCK_PROJECTS

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
import React from 'react';
import { MOCK_PROJECTS } from './MockProjects';
+ import ProjectList from './ProjectList';

function ProjectsPage() {
  return (
    <>
      <h1>Projects</h1>
-     <pre>{JSON.stringify(MOCK_PROJECTS, null, ' ')}</pre>
+     <ProjectList projects={MOCK_PROJECTS} />
    </>
  );
}

export default ProjectsPage;

验证界面

image-20230621171106650

7. 展示列表数据

格式化列表数据中每一项

src\projects\ProjectList.tsx

代码语言:javascript
复制
...

function ProjectList({ projects }: ProjectListProps) {
-  return <pre>{JSON.stringify(projects, null, ' ')}</pre>;
+  return (
+    <ul className="row">
+      {projects.map((project) => (
+        <li key={project.id}>{project.name}</li>
+      ))}
+    </ul>
+  );
}

export default ProjectList;

image-20230621172627483

格式化列表数据作为卡片

Visual Studio Code 扩展: HTML to JSX

src\projects\ProjectList.tsx

代码语言:javascript
复制
...

function ProjectList({ projects }: ProjectListProps) {
-  return (
-    <ul className="row">
-      {projects.map((project) => (
-        <li key={project.id}>{project.name}</li>
-      ))}
-    </ul>
-  );
}

export default ProjectList;
代码语言:javascript
复制
...
function ProjectList({ projects }: ProjectListProps) {
+  return (
+    <div className="row">
+      {projects.map((project) => (
+        <div key={project.id} className="cols-sm">
+          <div className="card">
+            <img src={project.imageUrl} alt={project.name} />
+            <section className="section dark">
+              <h5 className="strong">
+                <strong>{project.name}</strong>
+              </h5>
+              <p>{project.description}</p>
+              <p>Budget : {project.budget.toLocaleString()}</p>
+            </section>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
}

export default ProjectList;

image-20230621173900661

8. 更多的可重用组件

创建另一个可重用组件

src\projects\ProjectCard.tsx

代码语言:javascript
复制
import { Project } from './Project';
import React from 'react';

function formatDescription(description: string): string {
  return description.substring(0, 60) + '...';
}

interface ProjectCardProps {
  project: Project;
}

function ProjectCard(props: ProjectCardProps) {
  const { project } = props;
  return (
    <div className="card">
      <img src={project.imageUrl} alt={project.name} />
      <section className="section dark">
        <h5 className="strong">
          <strong>{project.name}</strong>
        </h5>
        <p>{formatDescription(project.description)}</p>
        <p>Budget : {project.budget.toLocaleString()}</p>
      </section>
    </div>
  );
}

export default ProjectCard;

渲染可重用组件

src\projects\ProjectList.tsx

代码语言:javascript
复制
import React from 'react';
import { Project } from './Project';
+ import ProjectCard from './ProjectCard';

interface ProjectListProps {
  projects: Project[];
}

function ProjectList ({ projects }: ProjectListProps) {
    const items = projects.map(project => (
      <div key={project.id} className="cols-sm">
-      <div className="card">
-      <img src={project.imageUrl} alt={project.name} />
-       <section className="section dark">
-         <h5 className="strong">
-           <strong>{project.name}</strong>
-         </h5>
-         <p>{project.description}</p>
-        <p>Budget : {project.budget.toLocaleString()}</p>
-       </section>
-     </div>
+      <ProjectCard project={project}></ProjectCard>
      </div>
    ));
    return <div className="row">{items}</div>;

}

export default ProjectList;

image-20230621175326049

9. 响应事件

添加按钮

src\projects\ProjectCard.tsx

代码语言:javascript
复制
...
<p>Budget...</p>
<button className=" bordered">
  <span className="icon-edit "></span>
  Edit
</button>

image-20230621175803310

处理点击事件

  1. handleEditClick 事件处理程序添加到 ProjectCard ,该处理程序将 project 作为参数并将其记录到 console

src\projects\ProjectCard.tsx

代码语言:javascript
复制
function ProjectCard(props: ProjectCardProps) {
  const { project } = props;
+  const handleEditClick = (projectBeingEdited: Project) => {
+    console.log(projectBeingEdited);
+  };
  return (
    <div className="card">
      <img src={project.imageUrl} alt={project.name} />
      <section className="section dark">
        <h5 className="strong">
          <strong>{project.name}</strong>
        </h5>
        <p>{project.description}</p>
        <p>Budget : {project.budget.toLocaleString()}</p>
        <button className=" bordered">
          <span className="icon-edit "></span>
          Edit
        </button>
      </section>
    </div>
  );
}
  1. 将编辑按钮的单击事件连接到 handleEditClick 事件处理程序。

src\projects\ProjectCard.tsx

代码语言:javascript
复制
function ProjectCard(props: ProjectCardProps) {
  const { project } = props;
  const handleEditClick = (projectBeingEdited: Project) => {
    console.log(projectBeingEdited);
  };
  return (
    <div className="card">
      <img src={project.imageUrl} alt={project.name} />
      <section className="section dark">
        <h5 className="strong">
          <strong>{project.name}</strong>
        </h5>
        <p>{project.description}</p>
        <p>Budget : {project.budget.toLocaleString()}</p>
        <button
          className=" bordered"
+          onClick={() => {
+            handleEditClick(project);
+          }}
        >
          <span className="icon-edit "></span>
          Edit
        </button>
      </section>
    </div>
  );
}

image-20230621180700051

10. 创建表单以编辑数据

创建表单组件

添加以下 CSS 样式以设置表单的宽度。

将所有 css 改为 scss, 相关 import 路径也要更新

src\index.scss

代码语言:javascript
复制
form {
  min-width: 300px;
}

DOM Element vs JSX Element Differences

src\projects\ProjectForm.tsx

代码语言:javascript
复制
import React from "react";

function ProjectForm() {
  return (
    <form className="input-group vertical">
      <label htmlFor="name">Project Name</label>
      <input type="text" name="name" placeholder="enter name" />
      <label htmlFor="description">Project Description</label>
      <textarea name="description" placeholder="enter description" />
      <label htmlFor="budget">Project Budget</label>
      <input type="number" name="budget" placeholder="enter budget" />
      <label htmlFor="isActive">Active?</label>
      <input type="checkbox" name="isActive" />
      <div className="input-group">
        <button className="primary bordered medium">Save</button>
        <span />
        <button type="button" className="bordered medium">
          cancel
        </button>
      </div>
    </form>
  );
}

export default ProjectForm;

渲染表单组件

src\projects\ProjectList.tsx

代码语言:javascript
复制
...
+ import ProjectForm from './ProjectForm';
...
function ProjectList ({ projects }: ProjectListProps) {
    const items = projects.map(project => (
      <div key={project.id} className="cols-sm">
        <ProjectCard project={project}></ProjectCard>
+       <ProjectForm />
      </div>
    ));
    return <div className="row">{items}</div>;
}
...

image-20230621185516296

11. 从子组件到父组件通信

在子组件中,函数接收 props

src\projects\ProjectCard.tsx

代码语言:javascript
复制
...
interface ProjectCardProps {
  project: Project;
+  onEdit: (project: Project) => void;
}
...

更新 handleEditClick 事件将调用传递到 onEdit props 中的函数并删除 console.log 语句。

src\projects\ProjectCard.tsx

代码语言:javascript
复制
 function ProjectCard(props: ProjectCardProps) {
   const { project,
+         onEdit
    } = props;

   const handleEditClick = (projectBeingEdited: Project) => {
+    onEdit(projectBeingEdited);
-    console.log(projectBeingEdited);
   };

   ...
 }

在父组件中,实现一个函数并将其作为 prop 传递给子组件

在 VS Code 中,代码段 nfn 可以帮助创建 handleEdit 事件处理程序。

src\projects\ProjectList.tsx

代码语言:javascript
复制
 function ProjectList ({ projects }: ProjectListProps) {
+   const handleEdit = (project: Project) => {
+     console.log(project);
+   };

     const items = projects.map(project => (
       <div key={project.id} className="cols-sm">
         <ProjectCard
           project={project}
+          onEdit={handleEdit}
         ></ProjectCard>
         <ProjectForm></ProjectForm>
       </div>
     ));
     return <div className="row">{items}</div>;
 }

测试, 点击 Edit

image-20230621192532206

12. 隐藏和显示组件

向组件添加状态

添加状态变量 projectBeingEdited 以保存当前正在编辑的项目。 并更新 handleEdit 以设置 projectBeingEdited 变量。

src\projects\ProjectList.tsx

代码语言:javascript
复制
- import React from 'react';
+ import React, { useState } from 'react';
import { Project } from './Project';
import ProjectCard from './ProjectCard';
import ProjectForm from './ProjectForm';

interface ProjectListProps {
  projects: Project[];
}

function ProjectList({ projects }: ProjectListProps) {
+ const [projectBeingEdited, setProjectBeingEdited] = useState({});

  const handleEdit = (project: Project) => {
-    console.log(project);
+    setProjectBeingEdited(project);
  };

  return (
    ...
  );
}

export default ProjectList;

隐藏和显示组件

src\projects\ProjectList.tsx

代码语言:javascript
复制
...

function ProjectList({ projects }: ProjectListProps) {
  const [projectBeingEdited, setProjectBeingEdited] = useState({});
  const handleEdit = (project: Project) => {
    setProjectBeingEdited(project);
  };

  return (
    <div className="row">
      {projects.map((project) => (
        <div key={project.id} className="cols-sm">
-          <ProjectCard project={project} onEdit={handleEdit} />
-          <ProjectForm />
+          {project === projectBeingEdited ? (
+            <ProjectForm />
+          ) : (
+            <ProjectCard project={project} onEdit={handleEdit} />
+          )}
        </div>
      ))}
    </div>
  );
}

export default ProjectList;

测试

image-12-preview

13. 更多组件通信

在子组件中,在 props 中接收函数并调用它

src\projects\ProjectForm.tsx

代码语言:javascript
复制
import React from 'react';

+ interface ProjectFormProps {
+   onCancel: () => void;
+ }

- function ProjectForm() {
+ function ProjectForm({ onCancel }: ProjectFormProps) {
  return (
    <form className="input-group vertical">
      <label htmlFor="name">Project Name</label>
      <input type="text" name="name" placeholder="enter name" />
      <label htmlFor="description">Project Description</label>
      <textarea name="description" placeholder="enter description" />
      <label htmlFor="budget">Project Budget</label>
      <input type="number" name="budget" placeholder="enter budget" />
      <label htmlFor="isActive">Active?</label>
      <input type="checkbox" name="isActive" />
      <div className="input-group">
        <button className="primary bordered medium">Save</button>
        <span />
        <button type="button" className="bordered medium"
+        onClick={onCancel}
        >
          cancel
        </button>
      </div>
    </form>
  );
}

export default ProjectForm;

在父组件中,实现一个函数并将其作为 prop 传递给子组件

src\projects\ProjectList.tsx

代码语言:javascript
复制
...
function ProjectList({ projects }: ProjectListProps) {
  const [projectBeingEdited, setProjectBeingEdited] = useState({});

  const handleEdit = (project: Project) => {
    setProjectBeingEdited(project);
  };

+  const cancelEditing = () => {
+    setProjectBeingEdited({});
+  };

  return (
    <div className="row">
      {projects.map((project) => (
        <div key={project.id} className="cols-sm">
          {project === projectBeingEdited ? (
            <ProjectForm
+              onCancel={cancelEditing}
            />
          ) : (
            <ProjectCard project={project} onEdit={handleEdit} />
          )}
        </div>
      ))}
    </div>
  );
}

export default ProjectList;

测试

image-13-preview

14. 通过多级进行组件通信

在子组件中,在 props 中接收函数并调用它并传递参数

src\projects\ProjectForm.tsx

代码语言:javascript
复制
...
+ import { Project } from './Project';

interface ProjectFormProps {
+  onSave: (project: Project) => void;
  onCancel: () => void;
}
...

创建一个事件处理程序函数 handleSubmit 来处理表单的提交。

该函数应防止浏览器的默认行为发布到后端,然后调用传递到 onSave prop 中的函数, 并传递当前创建的新 Project

src\projects\ProjectForm.tsx

代码语言:javascript
复制
+ import React, { SyntheticEvent } from 'react';
...

- function ProjectForm({ onCancel }: ProjectFormProps) {
+ function ProjectForm({ onSave, onCancel }: ProjectFormProps) {

+  const handleSubmit = (event: SyntheticEvent) => {
+    event.preventDefault();
+    onSave(new Project({ name: 'Updated Project' }));
+  };

return (

   <form className="input-group vertical"
+    onSubmit={handleSubmit}
   >
      <label htmlFor="name">Project Name</label>
      <input type="text" name="name" placeholder="enter name" />
      <label htmlFor="description">Project Description</label>
      <textarea name="description" placeholder="enter description" />
      <label htmlFor="budget">Project Budget</label>
      <input type="number" name="budget" placeholder="enter budget" />
      <label htmlFor="isActive">Active?</label>
      <input type="checkbox" name="isActive" />
      <div className="input-group">
      <button className="primary bordered medium">Save</button>
      <span />
      <button type="button" className="bordered medium" onClick={onCancel}>
         cancel
      </button>
      </div>
   </form>
);
}

export default ProjectForm;

在组件层次结构的下一级,在 props 中接收函数并调用它

src\projects\ProjectList.tsx

代码语言:javascript
复制
interface ProjectListProps {
  projects: Project[];
+ onSave: (project: Project) => void;
}

src\projects\ProjectList.tsx

代码语言:javascript
复制
interface ProjectListProps {
projects: Project[];
onSave: (project: Project) => void;
}

- function ProjectList({ projects }: ProjectListProps) {
+ function ProjectList({ projects, onSave }: ProjectListProps) {
const [projectBeingEdited, setProjectBeingEdited] = useState({});

const handleEdit = (project: Project) => {
   setProjectBeingEdited(project);
};

const cancelEditing = () => {
   setProjectBeingEdited({});
};

return (
   <div className="row">
      {projects.map((project) => (
      <div key={project.id} className="cols-sm">
         {project === projectBeingEdited ? (
            <ProjectForm
+            onSave={onSave}
            onCancel={cancelEditing} />
         ) : (
            <ProjectCard project={project} onEdit={handleEdit} />
         )}
      </div>
      ))}
   </div>
);
}

export default ProjectList;

在父组件中,实现一个函数并将其作为 prop 传递给子组件

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
import React, { Fragment } from 'react';
import { MOCK_PROJECTS } from './MockProjects';
import ProjectList from './ProjectList';
+ import { Project } from './Project';

function ProjectsPage() {
+  const saveProject = (project: Project) => {
+    console.log('Saving project: ', project);
+  };

  return (
     <Fragment>
        <h1>Projects</h1>
        <ProjectList
+         onSave={saveProject}
          projects={MOCK_PROJECTS} />
     </Fragment>
  );
}

export default ProjectsPage;

测试

image-20230621221459636

15. 表单值存到状态 state

将表单数据添加到组件状态

src\projects\ProjectForm.tsx

代码语言:javascript
复制
interface ProjectFormProps {
+ project: Project;
  onSave: (project: Project) => void;
  onCancel: () => void;
}

src\projects\ProjectForm.tsx

代码语言:javascript
复制
- import React, { SyntheticEvent } from 'react';
+ import React, { SyntheticEvent, useState } from 'react';

function ProjectForm({
+ project: initialProject,
  onSave,
  onCancel,
}: ProjectFormProps) {
+  const [project, setProject] = useState(initialProject);

const handleSubmit = (event: SyntheticEvent) => {
   event.preventDefault();
   onSave(new Project({ name: 'Updated Project' }));
};

...
}

使表单域(字段)控制组件

src\projects\ProjectForm.tsx

代码语言:javascript
复制
...
function ProjectForm({
  project: initialProject,
  onSave,
  onCancel,
}: ProjectFormProps) {
  const [project, setProject] = useState(initialProject);
  const handleSubmit = (event: SyntheticEvent) => {
    event.preventDefault();
    onSave(new Project({ name: 'Updated Project' }));
  };

+  const handleChange = (event: any) => {
+    const { type, name, value, checked } = event.target;
+    // if input type is checkbox use checked
+    // otherwise it's type is text, number etc. so use value
+    let updatedValue = type === 'checkbox' ? checked : value;
+
+    //if input type is number convert the updatedValue string to a +number
+    if (type === 'number') {
+      updatedValue = Number(updatedValue);
+    }
+    const change = {
+      [name]: updatedValue,
+    };
+
+    let updatedProject: Project;
+    // need to do functional update b/c
+    // the new project state is based on the previous project state
+    // so we can keep the project properties that aren't being edited +like project.id
+    // the spread operator (...) is used to
+    // spread the previous project properties and the new change
+    setProject((p) => {
+      updatedProject = new Project({ ...p, ...change });
+      return updatedProject;
+    });
+  };

  return (
    <form className="input-group vertical" onSubmit={handleSubmit}>
      <label htmlFor="name">Project Name</label>
      <input
        type="text"
        name="name"
        placeholder="enter name"
+       value={project.name}
+       onChange={handleChange}
      />
      <label htmlFor="description">Project Description</label>
      <textarea
        name="description"
        placeholder="enter description"
+       value={project.description}
+       onChange={handleChange}
      />
      <label htmlFor="budget">Project Budget</label>
      <input
        type="number"
        name="budget"
        placeholder="enter budget"
+       value={project.budget}
+       onChange={handleChange}
      />
      <label htmlFor="isActive">Active?</label>
      <input
        type="checkbox"
        name="isActive"
+       checked={project.isActive}
+       onChange={handleChange}
      />
      <div className="input-group">
        <button className="primary bordered medium">Save</button>
        <span />
        <button type="button" className="bordered medium" onClick={onCancel}>
          cancel
        </button>
      </div>
    </form>
  );
}

export default ProjectForm;

处理表单的提交

src\projects\ProjectForm.tsx

代码语言:javascript
复制
...
function ProjectForm({
  project: initialProject,
  onSave,
  onCancel,
}: ProjectFormProps) {
  const [project, setProject] = useState(initialProject);

  const handleSubmit = (event: SyntheticEvent) => {
    event.preventDefault();
-   onSave(new Project({ name: 'Updated Project' }));
+   onSave(project);
  };
  ...
}

export default ProjectForm;

src\projects\ProjecList.tsx

代码语言:javascript
复制
...
function ProjectList({ projects, onSave }: ProjectListProps) {
const [projectBeingEdited, setProjectBeingEdited] = useState({});

const handleEdit = (project: Project) => {
   setProjectBeingEdited(project);
};

const cancelEditing = () => {
   setProjectBeingEdited({});
};

return (
   <div className="row">
      {projects.map((project) => (
      <div key={project.id} className="cols-sm">
         {project === projectBeingEdited ? (
            <ProjectForm
+              project={project}
            onSave={onSave}
            onCancel={cancelEditing}
            />
         ) : (
            <ProjectCard project={project} onEdit={handleEdit} />
         )}
      </div>
      ))}
   </div>
);
}

export default ProjectList;

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
import React, { Fragment,
+ useState } from 'react';
import { MOCK_PROJECTS } from './MockProjects';
import ProjectList from './ProjectList';
+ import { Project } from './Project';

function ProjectsPage() {
+  const [projects, setProjects] = useState<Project[]>(MOCK_PROJECTS);

const saveProject = (project: Project) => {
-   console.log('Saving project: ', project);
+    let updatedProjects = projects.map((p: Project) => {
+      return p.id === project.id ? project : p;
+    });
+    setProjects(updatedProjects);
};

return (
   <Fragment>
      <h1>Projects</h1>
-      <ProjectList onSave={saveProject} projects={MOCK_PROJECTS} />
+      <ProjectList onSave={saveProject} projects={projects} />
   </Fragment>
);
}

export default ProjectsPage;

测试

image-15-preview

16. 添加表单验证

  1. 打开文件 src\project\ProjectForm.tsx
  2. errors 对象初始化为状态变量为 {name: '', description: '', budget: ''} ,以便我们可以在组件的 state 中保存表单错误。

src\projects\ProjectForm.tsx

代码语言:javascript
复制
...

function ProjectForm({
  project: initialProject,
  onSave,
  onCancel,
}: ProjectFormProps) {
  const [project, setProject] = useState(initialProject);
+ const [errors, setErrors] = useState({
+   name: '',
+   description: '',
+   budget: '',
+ });

...

}
export default ProjectForm;
  1. 在满足这些要求的 ProjectForm 组件中实现 validate 函数。
    • 此外,在 ProjectForm 组件中实现一个 isValid 函数,用于检查是否存在任何验证错误。

src\projects\ProjectForm.tsx

代码语言:javascript
复制
...
function ProjectForm({
  project: initialProject,
  onSave,
  onCancel,
}: ProjectFormProps) {
  const [project, setProject] = useState(initialProject);
  const [errors, setErrors] = useState({
    name: '',
    description: '',
    budget: '',
  });

  const handleChange = (event: any) => {
    ...
  };

+  function validate(project: Project) {
+    let errors: any = { name: '', description: '', budget: '' };
+    if (project.name.length === 0) {
+      errors.name = 'Name is required';
+    }
+    if (project.name.length > 0 && project.name.length < 3) {
+      errors.name = 'Name needs to be at least 3 characters.';
+    }
+    if (project.description.length === 0) {
+      errors.description = 'Description is required.';
+    }
+    if (project.budget === 0) {
+      errors.budget = 'Budget must be more than $0.';
+    }
+    return errors;
+  }

+  function isValid() {
+    return (
+      errors.name.length === 0 &&
+      errors.description.length === 0 &&
+      errors.budget.length === 0
+    );
+  }

  return (
    ...
  );
}

export default ProjectForm;
  1. 调用 handleChange 中的 validate 函数以确定是否存在任何错误,然后将它们设置为 errors 状态变量。
代码语言:javascript
复制
...
function ProjectForm({
  project: initialProject,
  onSave,
  onCancel,
}: ProjectFormProps) {
...

const handleChange = (event: any) => {
  const { type, name, value, checked } = event.target;
  // if input type is checkbox use checked
  // otherwise it's type is text, number etc. so use value
  let updatedValue = type === 'checkbox' ? checked : value;

  //if input type is number convert the updatedValue string to a number
  if (type === 'number') {
    updatedValue = Number(updatedValue);
  }
  const change = {
    [name]: updatedValue,
  };

  let updatedProject: Project;
  // need to do functional update b/c
  // the new project state is based on the previous project state
  // so we can keep the project properties that aren't being edited like project.id
  // the spread operator (...) is used to
  // spread the previous project properties and the new change
  setProject((p) => {
    updatedProject = new Project({ ...p, ...change });
    return updatedProject;
  });
+ setErrors(() => validate(updatedProject));
};

return (
  ...
);
}

export default ProjectForm;
  1. 在表单提交时调用 isValid 函数,如果表单无效,则在保存更改之前返回该函数。

src\projects\ProjectForm.tsx

代码语言:javascript
复制
...
function ProjectForm({
  project: initialProject,
  onSave,
  onCancel,
}: ProjectFormProps) {
...

  const handleSubmit = (event: SyntheticEvent) => {
    event.preventDefault();
+    if (!isValid()) return;
    onSave(project);
  };

...

  return (
    ...
  );
}

export default ProjectForm;
  1. 在组件返回的 JSX 中:使用以下 HTML 模板显示验证消息。

src\projects\ProjectForm.tsx

代码语言:javascript
复制
...
function ProjectForm({
  project: initialProject,
  onSave,
  onCancel,
}: ProjectFormProps) {
  const [project, setProject] = useState(initialProject);
  const [errors, setErrors] = useState({
    name: '',
    description: '',
    budget: '',
  });
  ...

  return (
    <form className="input-group vertical" onSubmit={handleSubmit}>
      <label htmlFor="name">Project Name</label>
      <input
        type="text"
        name="name"
        placeholder="enter name"
        value={project.name}
        onChange={handleChange}
      />
+      {errors.name.length > 0 && (
+        <div className="card error">
+          <p>{errors.name}</p>
+        </div>
+      )}

      <label htmlFor="description">Project Description</label>
      <textarea
        name="description"
        placeholder="enter description"
        value={project.description}
        onChange={handleChange}
      />
+      {errors.description.length > 0 && (
+        <div className="card error">
+          <p>{errors.description}</p>
+        </div>
+      )}

      <label htmlFor="budget">Project Budget</label>
      <input
        type="number"
        name="budget"
        placeholder="enter budget"
        value={project.budget}
        onChange={handleChange}
      />
+      {errors.budget.length > 0 && (
+        <div className="card error">
+          <p>{errors.budget}</p>
+        </div>
+      )}

      <label htmlFor="isActive">Active?</label>
      <input
        type="checkbox"
        name="isActive"
        checked={project.isActive}
        onChange={handleChange}
      />
      <div className="input-group">
        <button className="primary bordered medium">Save</button>
        <span />
        <button type="button" className="bordered medium" onClick={onCancel}>
          cancel
        </button>
      </div>
    </form>
  );
}

export default ProjectForm;

测试

image-16-preview

17. REST API 后端

安装后端: REST API 服务

代码语言:javascript
复制
yarn add json-server@0.16.2

创建自定义 script 用于运行 REST API 服务

package.json

代码语言:javascript
复制
{
 "name": "keeptrack",
 ...
 "scripts": {
     "start": "react-scripts start",
     "build": "react-scripts build",
     "test": "react-scripts test",
     "eject": "react-scripts eject",
+    "api": "json-server api/db.json --port 4000"
 },
}

将之前下载的 react-starter-files-mainapi 文件夹复制到 keeptrack 根目录

image-20230622210701329

启动 REST API 服务

代码语言:javascript
复制
yarn api

若使用 npm ,则为下方

代码语言:javascript
复制
npm run api

image-20230622210940884

预览 http://localhost:4000/ , 发现空白页,正常, 只有标题 React App

预览 http://localhost:4000/projects

image-20230622211313365

18. HTTP GET

创建一个 API 对象用于从 REST API 加载数据

src\projects\projectAPI.ts

代码语言:javascript
复制
import { Project } from './Project';
const baseUrl = 'http://localhost:4000';
const url = `${baseUrl}/projects`;

function translateStatusToErrorMessage(status: number) {
  switch (status) {
    case 401:
      return 'Please login again.';
    case 403:
      return 'You do not have permission to view the project(s).';
    default:
      return 'There was an error retrieving the project(s). Please try again.';
  }
}

function checkStatus(response: any) {
  if (response.ok) {
    return response;
  } else {
    const httpErrorInfo = {
      status: response.status,
      statusText: response.statusText,
      url: response.url,
    };
    console.log(`log server http error: ${JSON.stringify(httpErrorInfo)}`);

    let errorMessage = translateStatusToErrorMessage(httpErrorInfo.status);
    throw new Error(errorMessage);
  }
}

function parseJSON(response: Response) {
  return response.json();
}

// eslint-disable-next-line
function delay(ms: number) {
  return function (x: any): Promise<any> {
    return new Promise((resolve) => setTimeout(() => resolve(x), ms));
  };
}

function convertToProjectModels(data: any[]): Project[] {
  let projects: Project[] = data.map(convertToProjectModel);
  return projects;
}

function convertToProjectModel(item: any): Project {
  return new Project(item);
}

const projectAPI = {
  get(page = 1, limit = 20) {
    return fetch(`${url}?_page=${page}&_limit=${limit}&_sort=name`)
      .then(delay(600))
      .then(checkStatus)
      .then(parseJSON)
      .then(convertToProjectModels)
      .catch((error: TypeError) => {
        console.log('log client error ' + error);
        throw new Error(
          'There was an error retrieving the projects. Please try again.'
        );
      });
  },
};

export { projectAPI };

更新组件到使用 API 对象

使用 useState 函数创建两个附加状态变量 loadingerror

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
...
 function ProjectsPage() {
   const [projects, setProjects] = useState<Project[]>(MOCK_PROJECTS);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | undefined>(undefined);
...
}

projects 状态更改为空数组 [] (请务必删除模拟数据)。

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
- import { MOCK_PROJECTS } from './MockProjects';
...
 function ProjectsPage() {
-  const [projects, setProjects] = useState<Project[]>(MOCK_PROJECTS);
+  const [projects, setProjects] = useState<Project[]>([]);
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | undefined>(undefined);
...
}

在初始组件呈现在 useEffect 钩子中后实现从 API 加载数据。 请遵循这些规范。

  • 将状态 loading 设置为 true
  • 调用 API: projectAPI.get(1)
  • 如果成功,将返回的 data 设置为组件 projects 状态变量,并将 loading 状态变量设置为 false
  • 如果发生错误,请将返回的错误消息 error.message 设置为组件 error 状态,将 loading 设置为 false

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
import React, { Fragment, useState,
+ useEffect } from 'react';
+ import { projectAPI } from './projectAPI';

function ProjectsPage() {
  const [projects, setProjects] = useState<Project[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | undefined>(undefined);

//#region 方法1: 使用 promise then
//  useEffect(() => {
//    setLoading(true);
//    projectAPI
//      .get(1)
//      .then((data) => {
//        setError('');
//        setLoading(false);
//        setProjects(data);
//      })
//      .catch((e) => {
//        setLoading(false);
//        setError(e.message);
//        if (e instanceof Error) {
//           setError(e.message);
//        }
//      });
//  }, []);
//#endregion

+  //#region 方法2: 使用 async/await
+  useEffect(() => {
+    async function loadProjects() {
+      setLoading(true);
+      try {
+        const data = await projectAPI.get(1);
+        setError('');
+        setProjects(data);
+      }
+       catch (e) {
+        if (e instanceof Error) {
+          setError(e.message);
+        }
+        } finally {
+        setLoading(false);
+      }
+    }
+    loadProjects();
+  }, []);
+  //#endregion

...
}

<ProjectList /> 下方显示加载指示器。仅在 loading=true .

<></> 等价于 <Fragment></Fragment>

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
function ProjectsPage() {
  const [projects, setProjects] = useState<Project[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | undefined>(undefined);

  ...

  return (
    <Fragment>
      <h1>Projects</h1>

      <ProjectList onSave={saveProject} projects={projects} />

+      {loading && (
+        <div className="center-page">
+          <span className="spinner primary"></span>
+          <p>Loading...</p>
+        </div>
+      )}
    </Fragment>
  );
}

export default ProjectsPage;

添加这 CSS 个样式以使加载指示器在页面上居中。

src\index.scss

代码语言:javascript
复制
... //add below existing styles

html,
body,
#root,
.container,
.center-page {
  height: 100%;
}

.center-page {
  display: flex;
  justify-content: center;
  align-items: center;
}

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
function ProjectsPage() {
  const [projects, setProjects] = useState<Project[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | undefined>(undefined);

  ...

  return (
  <>
      <h1>Projects</h1>

+      {error && (
+        <div className="row">
+          <div className="card large error">
+            <section>
+              <p>
+                <span className="icon-alert inverse "></span>
+                {error}
+              </p>
+            </section>
+          </div>
+        </div>
+      )}

      <ProjectList onSave={saveProject} projects={projects} />

      {loading && (
        <div className="center-page">
          <span className="spinner primary"></span>
          <p>Loading...</p>
        </div>
      )}
    </>
  );
}

export default ProjectsPage;

测试 http://localhost:3000/

刷新页面, 短时间内会出现一个加载器

image-20230622221019935

会发现一个 API 请求

image-20230622221122184

我们在 projectAPI.get() 中使用 delay 函数来延迟数据的返回,以便更容易看到加载指示器。 此时可以删除 delay

src\projects\projectAPI.ts

代码语言:javascript
复制
return fetch(`${url}?_page=${page}&_limit=${limit}&_sort=name`)
- .then(delay(600))
  .then(checkStatus)
  .then(parseJSON);

更改 URL,以便无法访问 API 终结点。

src\projects\projectAPI.ts

代码语言:javascript
复制
const baseUrl = 'http://localhost:4000';
- const url = `${baseUrl}/projects`;
+ const url = `${baseUrl}/fail`;
...

此时再次测试, 会发现提示错误

image-20230622221437057

修复后端 API 的 URL

src\projects\projectAPI.tsx

代码语言:javascript
复制
...
const baseUrl = 'http://localhost:4000';
+ const url = `${baseUrl}/projects`;
- const url = `${baseUrl}/fail`;
...

添加分页

使用 useState 函数创建附加状态变量 currentPage

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
...
function ProjectsPage() {
  const [projects, setProjects] = useState<Project[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | undefined>(undefined);
+ const [currentPage, setCurrentPage] = useState(1);
  ...
}

更新 useEffect 方法以使 currentPage 成为依赖项,并在获取数据时使用它

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
...
function ProjectsPage() {
  const [projects, setProjects] = useState<Project[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | undefined>(undefined);
  const [currentPage, setCurrentPage] = useState(1);

  useEffect(() => {
    async function loadProjects() {
      setLoading(true);
      try {
-        const data = await projectAPI.get(1);
+        const data = await projectAPI.get(currentPage);
-        setProjects(data);
+      if (currentPage === 1) {
+          setProjects(data);
+        } else {
+          setProjects((projects) => [...projects, ...data]);
+        }
      } catch (e) {
        if (e instanceof Error) {
          setError(e.message);
        }
      } finally {
        setLoading(false);
      }
    }
    loadProjects();
-  }, []);
+  }, [currentPage]);
...
}

实现 handleMoreClick 事件处理程序,并通过递增页面然后调用 loadProjects 来实现它

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
...
function ProjectsPage() {
  ...
  const [currentPage, setCurrentPage] = useState(1);
  ...

+  const handleMoreClick = () => {
+    setCurrentPage((currentPage) => currentPage + 1);
+  };
  ...
}

<ProjectList /> 下方添加一个 More... 按钮。 仅当不是 loading 且没有 error 时才显示 More... 按钮, 并处理 More... 按钮的 click 事件并调用 handleMoreClick

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
...
function ProjectsPage() {
...

  return (
    <Fragment>
      <h1>Projects</h1>
      {error && (
        <div className="row">
          <div className="card large error">
            <section>
              <p>
                <span className="icon-alert inverse "></span>
                {error}
              </p>
            </section>
          </div>
        </div>
      )}
      <ProjectList onSave={saveProject} projects={projects} />

+      {!loading && !error && (
+        <div className="row">
+          <div className="col-sm-12">
+            <div className="button-group fluid">
+              <button className="button default" onClick={handleMoreClick}>
+                More...
+              </button>
+            </div>
+          </div>
+        </div>
+      )}

      {loading && (
        <div className="center-page">
          <span className="spinner primary"></span>
          <p>Loading...</p>
        </div>
      )}
    </Fragment>
  );
}

export default ProjectsPage;

测试

  1. 刷新页面
  2. 应显示项目列表
  3. 点击 More... 按钮
  4. 验证是否将另外 20 个项目追加到列表末尾
  5. 再次单击 More... 按钮
  6. 验证是否将另外 20 个项目追加到列表末尾

image-18-preview

19. HTTP PUT

在 API 对象中实现一个方法来执行 PUT(更新)

src\projects\projectAPI.ts

代码语言:javascript
复制

const projectAPI = {
...

+  put(project: Project) {
+    return fetch(`${url}/${project.id}`, {
+      method: 'PUT',
+      body: JSON.stringify(project),
+      headers: {
+        'Content-Type': 'application/json'
+      }
+    })
+      .then(checkStatus)
+      .then(parseJSON)
+      .catch((error: TypeError) => {
+        console.log('log client error ' + error);
+        throw new Error(
+          'There was an error updating the project. Please try again.'
+        );
+      });
+  },

};

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
import { Project } from './Project';
...
function ProjectsPage() {
  ...

  const saveProject = (project: Project) => {
-    let updatedProjects = projects.map((p: Project) => {
-      return p.id === project.id ? project : p;
-    });
-    setProjects(updatedProjects);

+   projectAPI
+     .put(project)
+     .then((updatedProject) => {
+       let updatedProjects = projects.map((p: Project) => {
+         return p.id === project.id ? new Project(updatedProject) : p;
+       });
+       setProjects(updatedProjects);
+     })
+     .catch((e) => {
+        if (e instanceof Error) {
+         setError(e.message);
+        }
+     });
  };

  return (
    ...
  );
}

export default ProjectsPage;

测试

  1. 单击项目的编辑按钮
  2. 更改窗体中的项目名称
  3. 单击表单上的保存按钮
  4. 验证卡片是否显示更新的数据
  5. 刷新浏览器
  6. 验证项目是否仍处于更新状态
    • 注意: 更新后卡片会被排到最后, 目前没有在代码中排序
      • 错误推断, 发现并不对, db.json 保存更新并不会重新排序: 于是按照 db.json 中顺序, 而保存更新后, 会保存到 db.json 最后 (×)

更新后, 对应保存更新到 keeptrack/api/db.json 文件

20. 路由器 Router

创建另一个页面(容器组件)

创建 HomePage 组件。

src\home\HomePage.tsx

代码语言:javascript
复制
import React from 'react';

function HomePage() {
  return <h2>Home</h2>;
}

export default HomePage;

添加基本路由(安装、配置)

运行以下命令以安装 React Router

代码语言:javascript
复制
yarn add react-router-dom@6.3

PS:

代码语言:javascript
复制
npm install react-router-dom@6.3

配置路由

src/App.tsx

代码语言:javascript
复制
import React from 'react';
import './App.css';
import ProjectsPage from './projects/ProjectsPage';

+ import { BrowserRouter as Router, Routes, Route, NavLink} from 'react-router-dom';
+ import HomePage from './home/HomePage';

  function App() {
-  return (
-    <div className="container">
-      <ProjectsPage />
-    </div>
- );

+  return (
+    <Router>
+      <div className="container">
+        <Routes>
+          <Route path="/" element={<HomePage />} />
+          <Route path="/projects" element={<ProjectsPage />} />
+        </Routes>
+      </div>
+    </Router>
+  );
};

export default App;

创建导航菜单

修改 CSS 样式以包含导航菜单的一些自定义项。

src/App.scss

代码语言:javascript
复制
header {
  height: 5.1875rem;
}

a.button.active {
  border: 1px solid var(--fore-color);
}

...

添加两个 <NavLink> 组件(由 React 路由器提供)并将它们设置为访问配置的路由。

src/App.tsx

代码语言:javascript
复制
function App() {
  return (
    <Router>
+      <header className="sticky">
+        <span className="logo">
+          <img src="/assets/logo-3.svg" alt="logo" width="49" height="99" />
+        </span>
+        <NavLink to="/"  className="button rounded">
+          <span className="icon-home"></span>
+          Home
+        </NavLink>
+        <NavLink to="/projects" className="button rounded">
+          Projects
+        </NavLink>
+      </header>
       <div className="container">
         ...
       </div>
    </Router>
  );
};
...

可以将任何 <a> 标记改为 <NavLink> ,并添加 to 属性以设置 href

测试 通过以下步骤验证路由是否正常工作:

  1. 访问站点的根目录: http://localhost:3000/ 并在浏览器中刷新页面
  2. 单击导航中的 Projects
  3. 验证你是否被带到 /projects 路由和 ProjectsPage 显示
  4. 单击导航中的 Home
  5. 验证你是否被带到 / 路由和 HomePage 显示

image-20230623104923625

image-20230623104954202

21. 路由参数

导航到带有参数的路由

find 方法添加到 projectAPI 以返回单个 Project x id

src\projects\projectAPI.ts

代码语言:javascript
复制
const projectAPI = {
...

+  find(id: number) {
+    return fetch(`${url}/${id}`)
+      .then(checkStatus)
+      .then(parseJSON)
+      .then(convertToProjectModel);
+  },
+
...
};

创建下面的文件,并为我们将在本实验中使用的这些预构建组件添加代码。 花点时间查看其中的代码。

src\projects\ProjectDetail.tsx

代码语言:javascript
复制
import React from 'react';
import { Project } from './Project';

interface ProjectDetailProps {
  project: Project;
}
export default function ProjectDetail({ project }: ProjectDetailProps) {
  return (
    <div className="row">
      <div className="col-sm-6">
        <div className="card large">
          <img
            className="rounded"
            src={project.imageUrl}
            alt={project.name}
          />
          <section className="section dark">
            <h3 className="strong">
              <strong>{project.name}</strong>
            </h3>
            <p>{project.description}</p>
            <p>Budget : {project.budget}</p>

            <p>Signed: {project.contractSignedOn.toLocaleDateString()}</p>
            <p>
              <mark className="active">
                {' '}
                {project.isActive ? 'active' : 'inactive'}
              </mark>
            </p>
          </section>
        </div>
      </div>
    </div>
  );
}

注意: 是 ProjectPage ,少了一个 s,是新文件, 代表单个项目 Project

src\projects\ProjectPage.tsx

代码语言:javascript
复制
import React, { useEffect, useState } from 'react';
import { projectAPI } from './projectAPI';
import ProjectDetail from './ProjectDetail';
import { Project } from './Project';
import { useParams } from 'react-router-dom';

function ProjectPage(props: any) {
  const [loading, setLoading] = useState(false);
  const [project, setProject] = useState<Project | null>(null);
  const [error, setError] = useState<string | null>(null);
  const params = useParams();
  const id = Number(params.id);

  useEffect(() => {
    setLoading(true);
    projectAPI
      .find(id)
      .then((data) => {
        setProject(data);
        setLoading(false);
      })
      .catch((e) => {
        setError(e);
        setLoading(false);
      });
  }, [id]);

  return (
    <div>
      <>
        <h1>Project Detail</h1>

        {loading && (
          <div className="center-page">
            <span className="spinner primary"></span>
            <p>Loading...</p>
          </div>
        )}

        {error && (
          <div className="row">
            <div className="card large error">
              <section>
                <p>
                  <span className="icon-alert inverse "></span> {error}
                </p>
              </section>
            </div>
          </div>
        )}

        {project && <ProjectDetail project={project} />}
      </>
    </div>
  );
}

export default ProjectPage;

添加一个路由以显示 ProjectPage (请注意,我们现在有一个 ProjectPage 和一个 ProjectsPage ,所以请注意你在正确的文件中)。

src\App.tsx

代码语言:javascript
复制
import ProjectsPage from './projects/ProjectsPage';
+ import ProjectPage from './projects/ProjectPage';

function App() {
  return (
    <Router>
      <header className="sticky">
        <span className="logo">
          <img src="/assets/logo-3.svg" alt="logo" width="49" height="99" />
        </span>
        <NavLink to="/"  className="button rounded">
          <span className="icon-home"></span>
          Home
        </NavLink>
        <NavLink to="/projects/" className="button rounded">
          Projects
        </NavLink>
      </header>
      <div className="container">
        <Routes>
          <Route path="/"  element={<HomePage />} />
          <Route path="/projects"  element={<ProjectsPage /> } />
+         <Route path="/projects/:id" element={<ProjectPage />} />
        </Routes>
      </div>
    </Router>
  );
}

通过在名称和描述周围添加 <Link /> 组件,使它们可单击。

src\projects\ProjectCard.tsx

代码语言:javascript
复制
+ import { Link } from 'react-router-dom';
...
  <section className="section dark">
+  <Link to={'/projects/' + project.id}>
    <h5 className="strong">
    <strong>{project.name}</strong>
    </h5>
    <p>{formatDescription(project.description)}</p>
    <p>Budget : {project.budget.toLocaleString()}</p>
+  </Link>
  <button
    type="button"
    className=" bordered"
    onClick={() => {
    handleEditClick(project);
    }}
   >
    <span className="icon-edit "></span>
    Edit
  </button>
  </section>
...

测试 通过以下步骤验证新路由是否正常工作:

  1. 访问站点的根目录: http://localhost:3000/ 并在浏览器中刷新页面
  2. 单击导航中的 Projects
  3. 验证你是否被带到 /projects 路由和 ProjectsPage 显示
  4. 单击任何项目卡片中的名称或描述
  5. 验证你是否被带到 /projects/1 路由,并且 ProjectPage 显示 ProjectDetail 组件

image-20230623131154637

image-20230623131255726

image-20230623131425782

22. 构建并部署

构建一个 React.js 应用

运行以下命令以安装名为 serve 的 Node.js Web 服务器:

代码语言:javascript
复制
# yarn 全局安装 serve
yarn global add serve

# npm 全局安装 serve
npm install -g serve

构建

代码语言:javascript
复制
yarn build

build 完成后,验证是否已创建 keeptrack\build 目录

image-20230623133129112

将应用部署到 Web 服务器

运行以下命令以启动 Web 服务器并提供在上一步中创建的 build 目录的内容

代码语言:javascript
复制
serve build

假设你想要提供单页应用程序或仅提供静态文件(无论是在你的设备上还是在本地网络上), 包 serve 是提供静态内容的 Web 服务器。

image-20230623133219903

测试

  1. 打开 http://localhost:5000/
  2. 点击 PROJECTS, 导航过去, 并再次点击其中一个项目,发现一切正常显示
  3. 在一个项目详细页面,刷新浏览器,会发现 404
  4. 发现除了跟路径 / , 其它刷新页面(或通过 url 直接访问),都将会 404,而通过路由导航的方式就正常

image-20230623133731909

  1. 使用 Ctrl+C 停止 Web 服务器
  2. 再次启动 Web 服务器,但为 单页应用程序添加 -s 标志
代码语言:javascript
复制
serve -s build
  1. 访问网站根目录 http://localhost:5000/
  2. 单击页面顶部导航菜单中的项目,应显示项目列表
  3. 导航到项目路由后,刷新浏览器
  4. 你应该会看到项目页面刷新并显示项目, 而不是 404

23. Redux: 安装 并 配置

安装 Redux

代码语言:javascript
复制
yarn add redux react-redux redux-devtools-extension redux-thunk
yarn add --dev @types/react-redux

PS:

代码语言:javascript
复制
# npm
npm install redux react-redux redux-devtools-extension redux-thunk
npm install --save-dev @types/react-redux

完成后, 打开 package.json 文件, 验证 dependenciesdevDependencies

配置 Redux

src\state.ts

代码语言:javascript
复制
import { createStore, applyMiddleware } from "redux";
import ReduxThunk from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";
import { combineReducers } from "redux";

const reducer = combineReducers({});

export default function configureStore(preloadedState: any) {
  const middlewares = [ReduxThunk];
  const middlewareEnhancer = applyMiddleware(...middlewares);

  //Thunk is middleware
  //DevTools is an enhancer (actually changes Redux)
  //applyMiddleware wraps middleware and returns an enhancer

  // to use only thunk middleware
  // const enhancer = compose(middlewareEnhancer);

  //to use thunk & devTools
  const enhancer = composeWithDevTools(middlewareEnhancer);

  const store = createStore(reducer, preloadedState, enhancer);
  return store;
}

export interface AppState {}

export const initialAppState: AppState = {};

export const store = configureStore(initialAppState);

测试编译是否成功

24. Redux: Actions 和 Reducer

定义类型:Action 类型、Action 接口和状态

定义项目 actions typesaction interfacesstate

src\projects\state\projectTypes.ts

代码语言:javascript
复制
import { Project } from '../Project';

//action types
export const LOAD_PROJECTS_REQUEST = 'LOAD_PROJECTS_REQUEST';
export const LOAD_PROJECTS_SUCCESS = 'LOAD_PROJECTS_SUCCESS';
export const LOAD_PROJECTS_FAILURE = 'LOAD_PROJECTS_FAILURE';
export const SAVE_PROJECT_REQUEST = 'SAVE_PROJECT_REQUEST';
export const SAVE_PROJECT_SUCCESS = 'SAVE_PROJECT_SUCCESS';
export const SAVE_PROJECT_FAILURE = 'SAVE_PROJECT_FAILURE';
export const DELETE_PROJECT_REQUEST = 'DELETE_PROJECT_REQUEST';
export const DELETE_PROJECT_SUCCESS = 'DELETE_PROJECT_SUCCESS';
export const DELETE_PROJECT_FAILURE = 'DELETE_PROJECT_FAILURE';

interface LoadProjectsRequest {
  type: typeof LOAD_PROJECTS_REQUEST;
}

interface LoadProjectsSuccess {
  type: typeof LOAD_PROJECTS_SUCCESS;
  payload: { projects: Project[]; page: number };
}

interface LoadProjectsFailure {
  type: typeof LOAD_PROJECTS_FAILURE;
  payload: { message: string };
}

interface SaveProjectRequest {
  type: typeof SAVE_PROJECT_REQUEST;
}

interface SaveProjectSuccess {
  type: typeof SAVE_PROJECT_SUCCESS;
  payload: Project;
}

interface SaveProjectFailure {
  type: typeof SAVE_PROJECT_FAILURE;
  payload: { message: string };
}

interface DeleteProjectRequest {
  type: typeof DELETE_PROJECT_REQUEST;
}

interface DeleteProjectSuccess {
  type: typeof DELETE_PROJECT_SUCCESS;
  payload: Project;
}

interface DeleteProjectFailure {
  type: typeof DELETE_PROJECT_FAILURE;
  payload: { message: string };
}

export type ProjectActionTypes =
  | LoadProjectsRequest
  | LoadProjectsSuccess
  | LoadProjectsFailure
  | SaveProjectRequest
  | SaveProjectSuccess
  | SaveProjectFailure
  | DeleteProjectRequest
  | DeleteProjectSuccess
  | DeleteProjectFailure;

export interface ProjectState {
  loading: boolean;
  projects: Project[];
  error: string | undefined;
  page: number;
}

创建 action 创建器函数

定义操作创建器函数并返回 ThunkActionfunction ) 而不仅仅是 Actionobject) 来处理 HTTP 调用的异步性质。

src\projects\state\projectActions.ts

代码语言:javascript
复制
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import { projectAPI } from '../projectAPI';
import { Project } from '../Project';
import {
  LOAD_PROJECTS_REQUEST,
  LOAD_PROJECTS_SUCCESS,
  LOAD_PROJECTS_FAILURE,
  SAVE_PROJECT_REQUEST,
  SAVE_PROJECT_SUCCESS,
  SAVE_PROJECT_FAILURE,
  ProjectState,
} from './projectTypes';

//action creators
export function loadProjects(
  page: number
): ThunkAction<void, ProjectState, null, Action<string>> {
  return (dispatch: any) => {
    dispatch({ type: LOAD_PROJECTS_REQUEST });
    return projectAPI
      .get(page)
      .then((data) => {
        dispatch({
          type: LOAD_PROJECTS_SUCCESS,
          payload: { projects: data, page },
        });
      })
      .catch((error) => {
        dispatch({ type: LOAD_PROJECTS_FAILURE, payload: error });
      });
  };
}

export function saveProject(
  project: Project
): ThunkAction<void, ProjectState, null, Action<string>> {
  return (dispatch: any) => {
    dispatch({ type: SAVE_PROJECT_REQUEST });
    return projectAPI
      .put(project)
      .then((data) => {
        dispatch({ type: SAVE_PROJECT_SUCCESS, payload: data });
      })
      .catch((error) => {
        dispatch({ type: SAVE_PROJECT_FAILURE, payload: error });
      });
  };
}

实现 reducer

src\projects\state\projectReducer.ts

代码语言:javascript
复制
import {
  ProjectActionTypes,
  LOAD_PROJECTS_REQUEST,
  LOAD_PROJECTS_SUCCESS,
  LOAD_PROJECTS_FAILURE,
  DELETE_PROJECT_REQUEST,
  DELETE_PROJECT_SUCCESS,
  DELETE_PROJECT_FAILURE,
  SAVE_PROJECT_REQUEST,
  SAVE_PROJECT_SUCCESS,
  SAVE_PROJECT_FAILURE,
  ProjectState,
} from './projectTypes';
import { Project } from '../Project';

export const initialProjectState: ProjectState = {
  projects: [],
  loading: false,
  error: undefined,
  page: 1,
};

export function projectReducer(
  state = initialProjectState,
  action: ProjectActionTypes
) {
  switch (action.type) {
    case LOAD_PROJECTS_REQUEST:
      return { ...state, loading: true, error: '' };
    case LOAD_PROJECTS_SUCCESS:
      let projects: Project[];
      const { page } = action.payload;
      if (page === 1) {
        projects = action.payload.projects;
      } else {
        projects = [...state.projects, ...action.payload.projects];
      }
      return {
        ...state,
        loading: false,
        page,
        projects,
        error: '',
      };
    case LOAD_PROJECTS_FAILURE:
      return { ...state, loading: false, error: action.payload.message };
    case SAVE_PROJECT_REQUEST:
      return { ...state };
    case SAVE_PROJECT_SUCCESS:
      if (action.payload.isNew) {
        return {
          ...state,
          projects: [...state.projects, action.payload],
        };
      } else {
        return {
          ...state,
          projects: state.projects.map((project: Project) => {
            return project.id === action.payload.id
              ? Object.assign({}, project, action.payload)
              : project;
          }),
        };
      }

    case SAVE_PROJECT_FAILURE:
      return { ...state, error: action.payload.message };
    case DELETE_PROJECT_REQUEST:
      return { ...state };
    case DELETE_PROJECT_SUCCESS:
      return {
        ...state,
        projects: state.projects.filter(
          (project: Project) => project.id !== action.payload.id
        ),
      };
    case DELETE_PROJECT_FAILURE:
      return { ...state, error: action.payload.message };
    default:
      return state;
  }
}

配置项目 reducer 和状态

配置 projectReducerProjectState

src\state.ts

代码语言:javascript
复制
import { createStore, applyMiddleware } from 'redux';
import ReduxThunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
import { combineReducers } from 'redux';

+ import { ProjectState } from './projects/state/projectTypes';
+ import { initialProjectState } from './projects/state/projectReducer';
+ import { projectReducer } from './projects/state/projectReducer';

const reducer = combineReducers({
+  projectState: projectReducer
});

...

export interface AppState {
+  projectState: ProjectState;
}

export const initialAppState: AppState = {
+  projectState: initialProjectState
};

export const store = configureStore(initialAppState);

测试 验证应用程序是否已编译成功

25. Redux: 使 Redux 与 React 结合

  1. 重构页面(容器)组件以使用 React Redux Hooks
  2. 重构表单组件以调度操作 (dispatch an action)

重构页面(容器)组件以使用 React Redux Hooks

删除页面(容器)组件的本地状态,并使用 useSelector 替换为 Redux 状态。 此外,使用 useDispatch 获取对 store 的调度函数的引用,以便我们可以调度操作。

确保你在 ProjectsPage.tsx 而不是 ProjectPage.tsx 中

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
- import React, { useState, useEffect } from 'react';
+ import React, { useEffect } from 'react';
import ProjectList from './ProjectList';
import { Project } from './Project';
+ import { useSelector, useDispatch } from 'react-redux';
+ import { AppState } from '../state';

function ProjectsPage() {
-  const [projects, setProjects] = useState<Project[]>([]);
-  const [loading, setLoading] = useState(false);
-  const [error, setError] = useState<string | undefined>(undefined);
-  const [currentPage, setCurrentPage] = useState(1);

+  const loading = useSelector(
+    (appState: AppState) => appState.projectState.loading
+  );
+  const projects = useSelector(
+    (appState: AppState) => appState.projectState.projects
+  );
+  const error = useSelector(
+    (appState: AppState) => appState.projectState.error
+  );
+  const currentPage = useSelector(
+    (appState: AppState) => appState.projectState.page
+  );
+  const dispatch = useDispatch<ThunkDispatch<ProjectState, any, AnyAction>>();

...
}

上面写完后, 会有一些报错, 先无视

将状态 setter 函数调用和 API 调用替换为调度传递操作创建者的调用。 此外,删除 onSave 函数并停止将其作为 props 传递给 <ProjectList/> 组件。

src\projects\ProjectsPage.tsx

代码语言:javascript
复制
...
- import { Project } from './Project';
- import { projectAPI } from './projectAPI';

+ import { loadProjects } from './state/projectActions';
+ import { AnyAction } from 'redux';
+ import { ThunkDispatch } from 'redux-thunk';
+ import { ProjectState } from './state/projectTypes';

function ProjectsPage() {
  ...
+  const dispatch = useDispatch<ThunkDispatch<ProjectState, any, AnyAction>>();

-  useEffect(() => {
-    setLoading(true);
-    projectAPI
-      .get(currentPage)
-      .then((data) => {
-        setLoading(false);
-        if (currentPage === 1) {
-          setProjects(data);
-        } else {
-          setProjects((projects) => [...projects, ...data]);
-        }
-      })
-      .catch((e) => {
-        setLoading(false);
-         if (e instanceof Error) {
-             setError(e.message);
-        }
-      });
-  }, [currentPage]);

+  useEffect(() => {
+    dispatch(loadProjects(1));
+  }, [dispatch]);

  const handleMoreClick = () => {
-    setCurrentPage((currentPage) => currentPage + 1);
+    dispatch(loadProjects(currentPage + 1));
  };

-  const saveProject = (project: Project) => {
-    projectAPI
-      .put(project)
-      .then((updatedProject) => {
-        let updatedProjects = projects.map((p: Project) => {
-          return p.id === project.id ? project : p;
-        });
-        setProjects(updatedProjects);
-      })
-      .catch((e) => {
-         if (e instanceof Error) {
-             setError(e.message);
-        }
-      });
-  };

  return (
    <Fragment>
      ...
-      <ProjectList onSave={saveProject} projects={projects} />
+      <ProjectList projects={projects} />
      ...
    </Fragment>
  );
}
...

改完后, 会有报错: Property 'onSave' is missing in type, 先无视

提供 store

src\App.tsx

代码语言:javascript
复制
import ProjectPage from './projects/ProjectPage';
+ import { Provider } from 'react-redux';
+ import { store } from './state';

function App() {
  return (
+    <Provider store={store}>
      <Router>
        <header className="sticky">
          <span className="logo">
            <img src="/assets/logo-3.svg" alt="logo" width="49" height="99" />
          </span>
          <NavLink to="/" className="button rounded">
            <span className="icon-home"></span>
            Home
          </NavLink>
          <NavLink to="/projects/" className="button rounded">
            Projects
          </NavLink>
        </header>
        <div className="container">
          <Routes>
            <Route path="/"  component={HomePage} />
            <Route path="/projects" component={ProjectsPage} />
            <Route path="/projects/:id" component={ProjectPage} />
          </Routes>
        </div>
      </Router>
+   </Provider>
  );
};

export default App;

重构表单组件以调度操作 (dispatch an action)

重构 Form 组件,使其调度 saveProject 操作,而不是将函数作为 prop 接收。

请务必在下一个代码块中包含用于调用 useDispatch 挂钩的行,如下所示 const dispatch = useDispatch(); 。 如果不这样做,编辑器可能会实现一个名为 dispatch 的占位符方法,该方法会引发错误。

src\projects\ProjectForm.tsx

代码语言:javascript
复制
import React, { SyntheticEvent, useState } from 'react';
import { Project } from './Project';
+ import { useDispatch } from 'react-redux';
+ import { saveProject } from './state/projectActions';
+ import { ThunkDispatch } from 'redux-thunk';
+ import { ProjectState } from './state/projectTypes';
+ import { AnyAction } from 'redux';

interface ProjectFormProps {
  project: Project;
- onSave: (project: Project) => void;
  onCancel: () => void;
}

function ProjectForm({
  project: initialProject,
- onSave,
  onCancel,
}: ProjectFormProps) {
  const [project, setProject] = useState(initialProject);
  const [errors, setErrors] = useState({
    name: '',
    description: '',
    budget: '',
  });

+  const dispatch = useDispatch<ThunkDispatch<ProjectState, any, AnyAction>>();

  const handleSubmit = (event: SyntheticEvent) => {
    event.preventDefault();
    if (!isValid()) return;
-    onSave(project);
+    dispatch(saveProject(project));
  };

  const handleChange = (event: any) => {
    ...
  };

  function validate(project: Project) {
  ...
  }

  function isValid() {
    ...
  }

  return (
    <form className="input-group vertical" onSubmit={handleSubmit}>
    ...
    </form>
  );
}

export default ProjectForm;

提供 store

  • 这已经在 src\App.tsx 中完成,因为它继承自父页面组件:页面 =>List=>Form

ProjectList 组件中, 删除 ProjectListProps 接口中的 onSave 并将组件更新为不传递 onSave<ProjectForm> , 因为它现在在导入此操作后自行调度此操作。

src\Projects\ProjectList.tsx

代码语言:javascript
复制
import React, { useState } from 'react';
import { Project } from './Project';
import ProjectCard from './ProjectCard';
import ProjectForm from './ProjectForm';

interface ProjectListProps {
  projects: Project[];
-  onSave: (project: Project) => void;
}

- function ProjectList({ projects, onSave }: ProjectListProps) {
+ function ProjectList({ projects }: ProjectListProps) {
  const [projectBeingEdited, setProjectBeingEdited] = useState({});

  const handleEdit = (project: Project) => {
    setProjectBeingEdited(project);
  };

  const cancelEditing = () => {
    setProjectBeingEdited({});
  };

  return (
    <div className="row">
      {projects.map((project) => (
        <div key={project.id} className="cols-sm">
          {project === projectBeingEdited ? (
            <ProjectForm project={project}
-             onSave={onSave}
              onCancel={cancelEditing} />
          ) : (
            <ProjectCard project={project} onEdit={handleEdit} />
          )}
        </div>
      ))}
    </div>
  );
}

export default ProjectList;

测试 验证应用程序是否仍然有效,包括加载和更新项目

26. Testing: 第一个组件测试

安装 React Testing Library

代码语言:javascript
复制
yarn test

npm

代码语言:javascript
复制
npm test
npm run test

image-20230625010920976

image-20230625010959801

a 运行所有测试

image-20230625011035869

w 查看更多, 按 q 退出

src/App.test.tsx

代码语言:javascript
复制
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';

- test('renders learn react link', () => {
-  const { getByText } = render(<App />);
-  const linkElement = getByText(/learn react/i);
-  expect(linkElement).toBeInTheDocument();
-});

+ test('should render without crashing', () => {
+ render(<App />);
+ });

验证测试现在是否通过

代码语言:javascript
复制
yarn test

image-20230625011407890

创建您的第一个组件测试

创建文件 src\home\HomePage.test.tsx

添加测试以验证组件浅层渲染而不会崩溃

src\home\HomePage.test.tsx

代码语言:javascript
复制
import React from 'react';
import { render, screen } from '@testing-library/react';
import HomePage from './HomePage';

test('renders home heading', () => {
  render(<HomePage />);
  expect(screen.getByRole('heading')).toHaveTextContent('Home');
});

验证测试是否通过

代码语言:javascript
复制
yarn test

image-20230625013344526

27. Testing: 快照测试

安装 React 的测试渲染器

代码语言:javascript
复制
yarn add --dev react-test-renderer
yarn add --dev @types/react-test-renderer

npm

代码语言:javascript
复制
npm i react-test-renderer --save-dev
npm i @types/react-test-renderer --save-dev

创建您的第一个快照测试

为组件添加快照测试。在套件中组织测试(描述函数)

src\home\HomePage.test.tsx

代码语言:javascript
复制
import React from 'react';
import HomePage from './HomePage';
+ import renderer from 'react-test-renderer';

+ describe('<HomePage />', () => {

  test('should render without crashing', () => {
    render(<HomePage />);
  });

+  test('snapshot', () => {
+    const tree = renderer.create(<HomePage />).toJSON();
+    expect(tree).toMatchSnapshot();
+  });

+ });

验证快照是否已创建

代码语言:javascript
复制
yarn test

image-20230625015356678

打开 src\home\__snapshots__\HomePage.test.tsx.snap 并查看内容

代码语言:javascript
复制
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<HomePage /> snapshot 1`] = `
<h2>
  Home
</h2>
`;

28. Testing: 更多组件测试

测试设置

创建目录 src\projects\__tests__

创建文件 src\projects\__tests__\ProjectCard-test.tsx

添加下面的设置代码以测试组件

src\projects\__tests__\ProjectCard-test.tsx

代码语言:javascript
复制
import { render, screen } from "@testing-library/react";
import React from "react";
import { Project } from "../Project";
import ProjectCard from "../ProjectCard";

describe("<ProjectCard />", () => {
  let project: Project;
  let handleEdit: jest.Mock;
  beforeEach(() => {
    project = new Project({
      id: 1,
      name: "Mission Impossible",
      description: "This is really difficult",
      budget: 100,
    });
    handleEdit = jest.fn();
  });

  it("should initially render", () => {
    render(<ProjectCard project={project} onEdit={handleEdit} />);
  });
});

验证测试是否失败

代码语言:javascript
复制
yarn test

image-20230625022326887

image-20230625022351203

将组件包装在 MemoryRouter

代码语言:javascript
复制
import { render, screen } from '@testing-library/react';
import React from 'react';
+ import { MemoryRouter } from 'react-router-dom';
import { Project } from '../Project';
import ProjectCard from '../ProjectCard';

describe('<ProjectCard />', () => {
  let project: Project;
  let handleEdit: jest.Mock;
  beforeEach(() => {
    project = new Project({
      id: 1,
      name: 'Mission Impossible',
      description: 'This is really difficult',
      budget: 100,
    });
    handleEdit = jest.fn();
  });

  it('should initially render', () => {
    render(
+      <MemoryRouter>
        <ProjectCard project={project} onEdit={handleEdit} />
+      </MemoryRouter>
    );
  });
});

<MemoryRouter> - 是一个 <Router> ,用于将“URL”的历史记录保存在内存中(不读取或写入地址栏)。 在测试和非浏览器环境(如 React Native)中很有用。

验证初始测试现在是否通过

代码语言:javascript
复制
yarn test

image-20230625022841042

PS: 可以不退出 yarn test, 保持在后台运行,会自动监控代码更新,重新运行

测试 props

打开命令提示符或终端并运行以下命令,从 React 测试库后面的核心测试库中安装 user-event

代码语言:javascript
复制
yarn remove @testing-library/user-event
yarn add --dev @testing-library/user-event @testing-library/dom

测试项目属性是否正确呈现。

src\projects\__tests__\ProjectCard-test.tsx

代码语言:javascript
复制
...

describe('<ProjectCard />', () => {
  let project: Project;
  let handleEdit: jest.Mock;
  beforeEach(() => {
    project = new Project({
      id: 1,
      name: 'Mission Impossible',
      description: 'This is really difficult',
      budget: 100,
    });
    handleEdit = jest.fn();
  });

  it('should initially render', () => {
    render(
      <MemoryRouter>
        <ProjectCard project={project} onEdit={handleEdit} />
      </MemoryRouter>
    );
  });

+  it('renders project properly', () => {
+    render(
+      <MemoryRouter>
+        <ProjectCard project={project} onEdit={handleEdit} />
+      </MemoryRouter>
+    );
+    expect(screen.getByRole('heading')).toHaveTextContent(project.name);
+    // screen.debug(document);
+    screen.getByText(/this is really difficult\.\.\./i);
+    screen.getByText(/budget : 100/i);
+  });

});

验证测试是否通过

image-20230625030442451

测试函数 Prop

src\projects\__tests__\ProjectCard-test.tsx

代码语言:javascript
复制
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Project } from '../Project';
import ProjectCard from '../ProjectCard';
+ import userEvent from '@testing-library/user-event';

describe('<ProjectCard />', () => {
  let project: Project;
  let handleEdit: jest.Mock;
  beforeEach(() => {
    project = new Project({
      id: 1,
      name: 'Mission Impossible',
      description: 'This is really difficult',
      budget: 100,
    });
    handleEdit = jest.fn();
  });

  it('should initially render', () => {
    render(
      <MemoryRouter>
        <ProjectCard project={project} onEdit={handleEdit} />
      </MemoryRouter>
    );
  });

  it('renders project properly', () => {
    render(
      <MemoryRouter>
        <ProjectCard project={project} onEdit={handleEdit} />
      </MemoryRouter>
    );
    expect(screen.getByRole('heading')).toHaveTextContent(project.name);
    // screen.debug(document);
    screen.getByText(/this is really difficult\.\.\./i);
    screen.getByText(/budget : 100/i);
  });

+  it('handler called when edit clicked', async () => {
+    render(
+      <MemoryRouter>
+        <ProjectCard project={project} onEdit={handleEdit} />
+      </MemoryRouter>
+    );
+    // this query works screen.getByText(/edit/i)
+    // but using role is better
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /edit/i }));
+    expect(handleEdit).toBeCalledTimes(1);
+    expect(handleEdit).toBeCalledWith(project);
+  });
});

验证测试是否通过

image-20230625225859778

重构 ProjectCard-test.tsx 以使用 setup 函数在每个测试开始时呈现组件。

代码语言:javascript
复制
import { render, screen } from "@testing-library/react";
import React from "react";
import { MemoryRouter } from "react-router-dom";
import { Project } from "../Project";
import ProjectCard from "../ProjectCard";
import userEvent from "@testing-library/user-event";
import renderer from "react-test-renderer";

describe("<ProjectCard />", () => {
  let project: Project;
  let handleEdit: jest.Mock;

  const setup = () =>
    render(
      <MemoryRouter>
        <ProjectCard project={project} onEdit={handleEdit} />
      </MemoryRouter>
    );

  beforeEach(() => {
    project = new Project({
      id: 1,
      name: "Mission Impossible",
      description: "This is really difficult",
      budget: 100,
    });
    handleEdit = jest.fn();
  });

  it("should initially render", () => {
    setup();
  });

  it("renders project properly", () => {
    setup();
    expect(screen.getByRole("heading")).toHaveTextContent(project.name);
    // screen.debug(document);
    screen.getByText(/this is really difficult\.\.\./i);
    screen.getByText(/budget : 100/i);
  });

  it("handler called when edit clicked", async () => {
    setup();
    // this query works screen.getByText(/edit/i)
    // but using role is better
    // eslint-disable-next-line testing-library/render-result-naming-convention
    const user = userEvent.setup();
    await user.click(screen.getByRole("button", { name: /edit/i }));
    expect(handleEdit).toBeCalledTimes(1);
    expect(handleEdit).toBeCalledWith(project);
  });

  test("snapshot", () => {
    const tree = renderer
      .create(
        <MemoryRouter>
          <ProjectCard project={project} onEdit={handleEdit} />
        </MemoryRouter>
      )
      .toJSON();
    expect(tree).toMatchSnapshot();
  });
});

为什么不直接将设置代码放在 beforeEach 中?请参阅 此 ESLint 规则 以获取 react-testing-library。

拍摄快照

拍摄组件的快照。

src\projects\__tests__\ProjectCard-test.tsx

代码语言:javascript
复制
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Project } from '../Project';
import ProjectCard from '../ProjectCard';
import userEvent from '@testing-library/user-event';
+ import renderer from 'react-test-renderer';

describe('<ProjectCard />', () => {
  let project: Project;
  let handleEdit: jest.Mock;
  beforeEach(() => {
    ...
  });

...

+  test('snapshot', () => {
+    const tree = renderer
+      .create(
+        <MemoryRouter>
+          <ProjectCard project={project} onEdit={handleEdit} />
+        </MemoryRouter>
+      )
+      .toJSON();
+    expect(tree).toMatchSnapshot();
+  });
});

验证是否已拍摄快照。

image-20230625231603981

src\projects\__tests__\__snapshots__\ProjectCard-test.tsx.snap

代码语言:javascript
复制
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<ProjectCard /> snapshot 1`] = `
<div
  className="card"
>
  <img
    alt="Mission Impossible"
    src=""
  />
  <section
    className="section dark"
  >
    <a
      href="/projects/1"
      onClick={[Function]}
    >
      <h5
        className="strong"
      >
        <strong>
          Mission Impossible
        </strong>
      </h5>
      <p>
        This is really difficult...
      </p>
      <p>
        Budget : 
        100
      </p>
    </a>
    <button
      className="bordered"
      onClick={[Function]}
    >
      <span
        className="icon-edit"
      />
      Edit
    </button>
  </section>
</div>
`;

29. Testing: 嵌套组件

https://handsonreact.com/docs/labs/ts/T4-NestedComponents

Q&A

App.test.tsx: Property 'toBeInTheDocument' does not exist on type 'JestMatchers<HTMLElement>'.

参考:

代码语言:javascript
复制
yarn start

ERROR in src/App.test.tsx:8:23
TS2339: Property 'toBeInTheDocument' does not exist on type 'JestMatchers<HTMLElement>'.
     6 |   render(<App />);
     7 |   const linkElement = screen.getByText(/learn react/i);
  >  8 |   expect(linkElement).toBeInTheDocument();
       |                       ^^^^^^^^^^^^^^^^^
     9 | });
    10 |
代码语言:javascript
复制
yarn remove @testing-library/jest-dom
yarn add -D @testing-library/jest-dom@^4.2.4

经过测试, 上方没有解决, 只能暂时关闭提示/注释

默认为

代码语言:javascript
复制
"dependencies": {
    "@testing-library/jest-dom": "^5.14.1",
},

最终解决, 安装下方版本即可

代码语言:javascript
复制
yarn add --dev @testing-library/jest-dom@^5.16.4

package.json: devDependencies

测试库非应用 build 后所需,仅开发环境需要建议放入 devDependencies

代码语言:javascript
复制
yarn add --dev @testing-library/react
#  等同于 下方
yarn add -D @testing-library/react
# npm
npm install --save-dev @testing-library/react

Usage Error: The 'yarn global' commands have been removed in 2.x

参考:

代码语言:javascript
复制
🦄  yarn global add serve
Usage Error: The 'yarn global' commands have been removed in 2.x - consider using 'yarn dlx' or a third-party plugin instead

$ yarn run [--inspect] [--inspect-brk] [-T,--top-level] [-B,--binaries-only] <scriptName> ...

'createStore' is deprecated

参考:

image-20230623135942198

'createStore' is deprecated.ts(6385)

index.d.ts(375, 4): The declaration was marked as deprecated here.

代码语言:javascript
复制
(alias) function createStore<S, A extends Action<any>, Ext, StateExt>(reducer: Reducer<S, A>, enhancer?: StoreEnhancer<Ext, StateExt>): Store<S & StateExt, A> & Ext (+1 overload)
import createStore

@deprecated

We recommend using the configureStore method of the @reduxjs/toolkit package, which replaces createStore.

Redux Toolkit is our recommended approach for writing Redux logic today, including store setup, reducers, data fetching, and more.

For more details, please read this Redux docs page: https://redux.js.org/introduction/why-rtk-is-redux-today

configureStore from Redux Toolkit is an improved version of createStore that simplifies setup and helps avoid common bugs.

You should not be using the redux core package by itself today, except for learning purposes. The createStore method from the core redux package will not be removed, but we encourage all users to migrate to using Redux Toolkit for all Redux code.

If you want to use createStore without this visual deprecation warning, use the legacy_createStore import instead:

代码语言:javascript
复制
import { legacy_createStore as createStore} from 'redux'

thunk ?

参考:

其实 thunk 是函数编程届的一个专有名词,主要用于 calculation delay,也就是延迟计算。

用代码演示如下:

代码语言:javascript
复制
function wrapper(arg) {
  // 内部返回的函数就叫`thunk`
  return function thunk() {
    console.log('thunk running, arg: ', arg)
  }
}
// 我们通过调用wrapper来获得thunk
const thunk = wrapper('wrapper arg')

// 然后在需要的地方再去执行thunk
thunk()

``

补充

安装 yarn

参考:

Node.js >=16.10 默认提供 npm 包管理器,Corepack 为您提供 Yarn 和 pnpm,而无需安装它们。

代码语言:javascript
复制
corepack enable

corepack prepare yarn@stable --activate
代码语言:javascript
复制
🦄  corepack enable
Internal Error: EPERM: operation not permitted, open 'D:\Program Files\nodejs\pnpm'
Error: EPERM: operation not permitted, open 'D:\Program Files\nodejs\pnpm'

解决, 用管理员权限执行

create-react-app 重写 webpack 配置

参考:

prettier

参考:

项目级 prettier

项目级安装 prettier

代码语言:javascript
复制
yarn add --dev --exact prettier

创建一个空的配置文件, 以便让编辑器和其它工具知道你使用 prettier

代码语言:javascript
复制
echo {}> .prettierrc.json

创建一个 .prettierignore 文件,让 Prettier CLI 和编辑器知道哪些文件不格式化。 下面是一个示例:

.prettierignore

代码语言:javascript
复制
# Ignore artifacts:
build
coverage

使用 Prettier CLI 格式化文件

  • yarn prettier 运行本地安装的 Prettier 版本
  • prettier --write . 格式化所有内容
  • 可以运行 prettier --write app/ 格式化 app/ 目录
代码语言:javascript
复制
yarn prettier --write .

如果您有 CI 设置,请运行以下命令作为其中的一部分,以确保每个人都运行更漂亮。这避免了合并冲突和其他协作问题!

--check 类似于 --write ,但仅检查文件是否已格式化,而不是覆盖它们。 prettier --writeprettier --check 是运行 Prettier 的最常见方法。

代码语言:javascript
复制
npx prettier --check .

编辑器

注意: 不要跳过常规的本地(项目级)安装! 编辑器插件将选取你的本地版本 Prettier,确保您在每个项目中使用正确的版本。 (你不希望编辑器意外地引起大量更改,因为当没有本地安装 prettier 时, 就会使用编辑器扩展自带的 prettier) 能够从命令行运行 Prettier 仍然是一个很好的后备,并且是 CI/CD 所必需的。

参考:

Visual Studio Code

参考

感谢帮助!

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-06-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • 本地开发环境
  • 1. 创建一个新项目
  • 2. 运行项目
  • 3. 使用 CSS 预处理器 Sass
  • 4. 你的首个组件
  • 5. Data
  • 6. 传递数据到组件
  • 7. 展示列表数据
    • 格式化列表数据中每一项
      • 格式化列表数据作为卡片
      • 8. 更多的可重用组件
        • 创建另一个可重用组件
          • 渲染可重用组件
          • 9. 响应事件
            • 添加按钮
              • 处理点击事件
              • 10. 创建表单以编辑数据
                • 创建表单组件
                  • 渲染表单组件
                  • 11. 从子组件到父组件通信
                    • 在子组件中,函数接收 props
                      • 在父组件中,实现一个函数并将其作为 prop 传递给子组件
                      • 12. 隐藏和显示组件
                        • 向组件添加状态
                          • 隐藏和显示组件
                          • 13. 更多组件通信
                            • 在子组件中,在 props 中接收函数并调用它
                              • 在父组件中,实现一个函数并将其作为 prop 传递给子组件
                              • 14. 通过多级进行组件通信
                                • 在子组件中,在 props 中接收函数并调用它并传递参数
                                  • 在组件层次结构的下一级,在 props 中接收函数并调用它
                                    • 在父组件中,实现一个函数并将其作为 prop 传递给子组件
                                    • 15. 表单值存到状态 state
                                      • 将表单数据添加到组件状态
                                        • 使表单域(字段)控制组件
                                          • 处理表单的提交
                                          • 16. 添加表单验证
                                          • 17. REST API 后端
                                            • 安装后端: REST API 服务
                                              • 创建自定义 script 用于运行 REST API 服务
                                                • 启动 REST API 服务
                                                • 18. HTTP GET
                                                  • 创建一个 API 对象用于从 REST API 加载数据
                                                    • 更新组件到使用 API 对象
                                                      • 添加分页
                                                      • 19. HTTP PUT
                                                      • 20. 路由器 Router
                                                        • 创建另一个页面(容器组件)
                                                          • 添加基本路由(安装、配置)
                                                            • 创建导航菜单
                                                            • 21. 路由参数
                                                              • 导航到带有参数的路由
                                                              • 22. 构建并部署
                                                                • 构建一个 React.js 应用
                                                                  • 将应用部署到 Web 服务器
                                                                  • 23. Redux: 安装 并 配置
                                                                    • 安装 Redux
                                                                      • 配置 Redux
                                                                      • 24. Redux: Actions 和 Reducer
                                                                        • 定义类型:Action 类型、Action 接口和状态
                                                                          • 创建 action 创建器函数
                                                                            • 实现 reducer
                                                                              • 配置项目 reducer 和状态
                                                                              • 25. Redux: 使 Redux 与 React 结合
                                                                                • 重构页面(容器)组件以使用 React Redux Hooks
                                                                                  • 重构表单组件以调度操作 (dispatch an action)
                                                                                  • 26. Testing: 第一个组件测试
                                                                                    • 安装 React Testing Library
                                                                                      • 创建您的第一个组件测试
                                                                                      • 27. Testing: 快照测试
                                                                                        • 安装 React 的测试渲染器
                                                                                          • 创建您的第一个快照测试
                                                                                          • 28. Testing: 更多组件测试
                                                                                            • 测试设置
                                                                                              • 测试 props
                                                                                                • 测试函数 Prop
                                                                                                  • 拍摄快照
                                                                                                  • 29. Testing: 嵌套组件
                                                                                                  • Q&A
                                                                                                    • App.test.tsx: Property 'toBeInTheDocument' does not exist on type 'JestMatchers<HTMLElement>'.
                                                                                                      • package.json: devDependencies
                                                                                                        • Usage Error: The 'yarn global' commands have been removed in 2.x
                                                                                                          • 'createStore' is deprecated
                                                                                                            • thunk ?
                                                                                                              • ``
                                                                                                              • 补充
                                                                                                                • 安装 yarn
                                                                                                                  • create-react-app 重写 webpack 配置
                                                                                                                    • prettier
                                                                                                                      • 项目级 prettier
                                                                                                                      • 编辑器
                                                                                                                  • 参考
                                                                                                                  相关产品与服务
                                                                                                                  腾讯云服务器利旧
                                                                                                                  云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
                                                                                                                  领券
                                                                                                                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档