Skip to main content

react practice:mosh gamehub

· 66 min read
ayanami

观前提示:

  1. 默认读者有基础的 js, ts, html 基础, 其中 html 和 js 在MDN Web Docs上看入门教程即可,ts 可以看mosh 的视频或者 google 一下 typescript tutorial 即可;
  2. 本人水平有限,错漏和不足之处敬请谅解
  3. 本笔记对应的视频: CodeWithMosh - React 18 for Beginners对应初级部分;code with mosh - React: Intermediate Topics对应进阶部分;相关代码可以在 github 找到。

gamehub: react + ts + ...

初级部分

前端部分

一切的开始:初识 React

vite: 一个比 create-react-app 更轻量级的创建应用工具

npm create vite@latest

然后选择 react, ts

之后 npm install, npm run dev

ps: 笔者在后续发现,如果 npm 的镜像源默认改成了 cnpm, (有时候)会找不到 dev 而报错,疑似和 vite 有冲突

react,启动!

image-20240116224507307


react 使用 JSX, JSX 被编译成 pure js(with react)

编写 react 的时候需要遵守 PascalCasting, 即组件名为 AppName 而不是 app_name 或者 appName

exportexport default 的区别:export default 是唯一的,使得导入的时候不受命名空间冲突影响,可以起别名

vite 自动有热启动

.tsx 为后缀的文件可以使用 JSX 语法(tsx: ts+ jsx),.ts 为后缀的文件不行

JSX 暴打原始人(暴论):回想起实战 1 里面,需要定义 pug 文件,往 pug 里面传参数(提前想好传哪些),在 pug 里面写乱七八糟的语法,相对不方便复用......

how react works?

react 维护一个 virtual dom(document object model)树,代码会更新这棵树,然后 react 将这棵树和浏览器的真实 DOM 树比较,并更新浏览器上的 DOM

what is react?

react 是“库 lib”式的单个工具,而不是“框架 framework”一组完善的建立网站的工具

react 专注于建立组件式可交互 UI

但 react 不解决 router, http, form validation, internationalization... 等等问题

这些在下面的内容之中(笑

bootstrap: 现代感 UI, css 库

在文档内搜索后复制示例代码即可

返回多个块

差的方法:使用<div></div>包裹,语法上没有问题,但是在 react 的 DOM 上多加了一个组件 div

好的方法:使用<fragment></fragment>包裹

简写,使用<></>包裹

component 快捷键: React ES7+插件,rafce

JSX: 里面只能有 html 元素/React 组件/包裹的表达式

"for loop in JSX"? 不能这么干,但可以通过 map 方法实现

function ListGroup() {
const items = ["SJTU", "PKU", "THU", "FDU"];
return (
<>
<h1>List Title</h1>
<ul className="list-group">
{items.map((item) => (
<li className="list-group-item">{item}</li>
))}
</ul>
</>
);
}
export default ListGroup;

“if else in JSX”?也不行,但可以用表达式代替

function ListGroup() {
let items = ["SJTU", "PKU", "THU", "FDU"];
items = [];
// const Message = items.length === 0 ? <p>No items found</p> : null;
// 另一种方法
const Message = items.length === 0 && <p>No items found</p>; // true && something === something
return (
<>
<h1>List Title</h1>
{Message}
<ul className="list-group">
{items.map((item) => (
<li className="list-group-item" key={item}>
{item}
</li>
))}
</ul>
</>
);
}
export default ListGroup;

在使用 map 方法时,需要给 item 一个唯一的 key,以便于 React 追踪

map 可以有第二个可选参数 index, 代表迭代时的序号

处理单击事件:每一个 React 元素都有内置的 onClick 方法,接受一个可选的 event

处理函数命名规范 handleClick

让 react 意识到组件有内部状态,可能随时间变化,变化时需要更新(重新渲染):useState

又叫 stateHook, 通过 Hook,我们可以不用直接接触 DOM,而专注于组件和它的状态

把上面的知识组合起来的应用:动态添加类,鼠标激活效果实现

ps: 动态添加类是解释性语言,如 js,python 的专属; 这样添加 active 效果实际上也算是自定义 active 实现的方法

import { useState } from "react";
function ListGroup() {
let items = ["SJTU", "PKU", "THU", "FDU"];
const [selectIndex, setSelectIndex] = useState(-1);
const Message = items.length === 0 ? <p>No items found</p> : null;

return (
<>
<h1>List Title</h1>
{Message}
<ul className="list-group">
{items.map((item, index) => (
<li
className={
selectIndex === index
? "list-group-item active"
: "list-group-item"
}
key={item}
onClick={() => setSelectIndex(index)}
>
{item}
</li>
))}
</ul>
</>
);
}
export default ListGroup;

让组件可重用:props

刚好可以写成 ts 的 interface

props is immutable: 传入的参数可以认为是某种”复制",不应该直接在子组件之中修改 Props,(直接改了也不会反映到父组件上),如果要根据子组件改父组件状态,把父组件的 setState 作为回调函数传下来

如果父组件想从子组件得到信息?例如,选择?

React 并没有提供组件书“自底向上”传递信息的方法,这样的“将顶层逻辑下放到可重用的子组件”也破坏了子组件的重用性;实现方法应该放在父组件,在传递的 props 里面多加一个函数参数,让子组件得到信息之后调用这个父组件定义的函数,还是自顶向下传递。

示例:

App.tsx

import { useState } from "react";
import ListGroup from "./components/ListGroup";

import "./App.css";

function App() {
let schools = ["PKU", "FDU", "THU", "ZJU", "SJTU"];
let cities = ["Beijing", "Shanghai", "Hangzhou", "Nanjing", "Wuhan"];
const onSelectItem = (item: string) => {
console.log(item);
};
return (
<div>
<ListGroup title="Schools" items={schools} onSelectItem={onSelectItem} />
<ListGroup title="Cities" items={cities} onSelectItem={onSelectItem} />
</div>
);
}

export default App;

ListGroup.tsx

import { useState } from "react";
interface ListGroupProps {
title: string;
items: string[];
onSelectItem: (item: string) => void;
}
function ListGroup({ items, title, onSelectItem }: ListGroupProps) {
const [selectIndex, setSelectIndex] = useState(-1);
const Message = items.length === 0 ? <p>No items found</p> : null;

return (
<>
<h1>{title}</h1>
{Message}
<ul className="list-group">
{items.map((item, index) => (
<li
className={
selectIndex === index
? "list-group-item active"
: "list-group-item"
}
key={item}
onClick={() => {
setSelectIndex(index);
onSelectItem(item);
}}
>
{item}
</li>
))}
</ul>
</>
);
}
export default ListGroup;

传递文本和 html?

直接 props:略丑

使用 children:ReactNode 的 interface

例:

App.tsx

import Alert from "./components/Alert";
function App() {
return (
<Alert>
<p>Note:</p>This is a Alert
</Alert>
);
}

export default App;

Alert.tsx

import { ReactNode } from "react";
interface AlertProps {
children: ReactNode;
}
const Alert = ({ children }: AlertProps) => {
return <div className="alert alert-danger">{children}</div>;
};

export default Alert;

react-developer-tool

组件重用示例 2

Button.tsx

interface ButtonProps {
children: string;
color?:
| "primary"
| "secondary"
| "success"
| "warning"
| "danger"
| "info"
| "light"
| "dark";
onClick?: () => void;
}
const Button = ({
children,
color = "primary",
onClick = () => {
console.log("Clicked");
},
}: ButtonProps) => {
return (
<div className={"btn btn-" + color} onClick={onClick}>
{children}
</div>
);
};

export default Button;

App.tsx

import Button from "./components/Button";
function App() {
return (
<>
<Button children="a" />
<Button children="b" />
<Button children="c" />
</>
);
}

export default App;

Styling: 更进一步的 CSS

plain css: not recommended

css module:用于解决多个 css 同名类的冲突覆盖问题。 .css 文件变成 .module.css 文件,里面定义的 css 类变成整个 module 对象的属性,可以通过 import {style} from "./sth.module.css" 之后 style["some-css-class"]使用

如果需要一个模块的多个 css 类,使用 [style['classA'], style['classB']].join(' ')语法

CSS in JS: styled-component

Seperation of Concerns 原则:把复杂度隐藏在接口之下,一个模块的复杂度不应该暴露在外面

inline CSS: not recommended also

有用的热门 CSS 库(个人只会调库,笑)

  • bootstrap
  • Material UI
  • tailwind
  • chakra UI
  • ......

添加 icon: react-icon 库, font awesome icon


Managing Component State

NOTE:

  1. React 更新 State 是异步的 async
  2. state 实际存储在组件之外
  3. only use hook at the top level of function: Hook 不能被放在 条件,循环等等之中

BEST PRACTICE:

  1. 避免冗余 state
  2. 使用对象将相关的 state 套起来是好的,但不要深度嵌套

ps: 后补:第二点可能比较抽象,详细说就是,如果我的组件取决于一个 state game, 然后 game 可能有多个需要维护的 state 例如 price,id, ... 与其把 price, id,...分别使用 setState,不如只设一个 State,就是 game, 也就是说 useState 可以接受一个复制的对象作为 state 而不局限于基本类型。

而“不要深度嵌套”是因为,这样包含了多个属性的 state 每次更新都要 用...复制一次(React 检测作为对象的 State 变化并不会深入下去比较对象内部的每一个值,所以传入新的 State 的时候需要传入一个新的对象),例如useState([...game, newPrice: 20]), 而 game 如果是对象套对象,那就要再多一层... ......深起来既浪费,又不好维护

KEEPING COMPONENT PURE:

ReactDOM 的 strictMode 会检测不纯的组件,并执行两次

在 state 是一个对象的时候,React 并不会实时检测这个对象的变化,视为 immutable

所以如果要将一个 drink 对象的 price 属性改变

const Incorrect = () => {
drink.price = 6;
setDrink(drink); // false: React不会比对drink对象的属性有改变,当传递的还是原来的drink对象的时候,ReactDOM就不会重新渲染
};
const Correct = () => {
let newDrink = {
...drink, // 复制原先drink对象的属性
price: 6,
};
setDrink(newDrink); //这样drink对象本身改变了,ReactDOM重新渲染
};

ATTENTION:

如果对象里面嵌套对象,由于...复制对象属性的时候对对象元素默认是浅复制,需要在嵌套的对象里面也使用...来避免修改原来对象的值

对于 state 是数组的情况同理

const [arr, setArr] = useState([0, 1]);
const handleClick = () => {
// 不使用arr.push()方法
let newEle = 2;
// Add
setArr([...arr, newEle]);
// Remove
serArr([arr.filter((ele) => ele !== 0)]);
// Update
setArr([arr.map((ele) => (ele === 0 ? 1 : ele))]);
};

sharing state between component: 把需要在组件中共享的 state 在组件层次架构里面向上提升一级

练习,可折叠文字

生成随机字符串 lorem + 一个数, 比如 lorem200,生成长为 200 个字符的随机字符串


Forms 表单

React Hook Forms && Zod

基础表单

表单的基础和 bootstrap 里面的表单, div.md-3>lable.form-label+input.form-control + [TAB]的简写格式

.是类className+是并列, >是包含(子组件),*是多个)

在 React 中,你可以通过将<button>元素放入<form>元素内部,并设置type="submit",来将按钮的点击事件与表单的提交事件绑定。当点击这个按钮时,表单的onSubmit事件将被触发。

你需要在<form>元素上添加一个onSubmit事件处理器来处理表单提交

React useRef

useRef 产生一个 React 元素(DOM Node)的引用

直接看具体代码

import "bootstrap/dist/css/bootstrap.css";
import "../index.css";
import { useRef } from "react";
const Form = () => {
const nameRef = useRef<HTMLInputElement>(null);
return (
<>
<form
onSubmit={(event) => {
event.preventDefault();
console.log(nameRef.current?.value);
}}
>
<div className="mb-3">
<label htmlFor="name" className="form-label">
Name
</label>
<input ref={nameRef} id="name" type="text" className="form-control" />
<div className="mb-3">
<label htmlFor="age" className="form-lable">
Age
</label>
<input id="age" type="number" className="form-control" />
</div>
</div>
<button className="btn btn-primary" type="submit">
Submit
</button>
</form>
</>
);
};

export default Form;

但上述代码有一个问题

在 React 中,useRef创建的引用(reference)的变化不会触发组件的重新渲染。这是因为useRef返回的对象在组件的所有渲染中保持不变,所以 React 不会知道引用的值已经改变。

所以要是需要重新渲染,还得是 useState,引出了下面的技术

controlled components

主要解决使用 useState 和 onChange 方法来控制 state 时候,不同组件同时修改一个 state 的异步问题

解决方法,在组件加一个 value={obj.value},每次调用前先同步,把组件状态的管理转交给 React

const [state, setState]=useState(...);

...

<Component value={state}

onChange={handleStateChange} />

更简单:使用 react-hook-form 库

import { FieldValues, useForm } from "react-hook-form";
const Form = () => {
const { register, handleSubmit } = useForm();
const onSubmit = (data: FieldValues) => console.log(data);
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<label htmlFor="name" className="form-label">
Name
</label>
<input
{...register("name")}
id="name"
type="text"
className="form-control"
/>
<div className="mb-3">
<label htmlFor="age" className="form-lable">
Age
</label>
<input
{...register("age")}
id="age"
type="number"
className="form-control"
/>
</div>
</div>
<button className="btn btn-primary" type="submit">
Submit
</button>
</form>
</>
);
};

export default Form;
Validation

react-hook-form 的 register 可以有第二个参数,一个对象

import "bootstrap/dist/css/bootstrap.css";
import "../index.css";
import { useRef } from "react";
import { FieldValues, useForm } from "react-hook-form";
const Form = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
console.log(errors);
const onSubmit = (data: FieldValues) => console.log(data);
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<label htmlFor="name" className="form-label">
Name
</label>
<input
{...register("name", { required: true, minLength: 3 })} // 这里
id="name"
type="text"
className="form-control"
/>
{errors.name?.type === "required" && <p>You must give a name.</p>}
{errors.name?.type === "minLength" && (
<p>The length of name must be longer than 3 characters.</p>
)}
<div className="mb-3">
<label htmlFor="age" className="form-lable">
Age
</label>
<input
{...register("age")}
id="age"
type="number"
className="form-control"
/>
</div>
</div>
<button className="btn btn-primary" type="submit">
Submit
</button>
</form>
</>
);
};

export default Form;

基于模式的验证:Zod + @hookhome/resolvers 看代码

需要注意的是,这并不是实时的,需要 submit 才会有 error,如果同时启用了下面的 invalid 不 submit 和这个, 这个相当于没设置

import "bootstrap/dist/css/bootstrap.css";
import "../index.css";
import { FieldValues, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
name: z.string().min(3, { message: "Name must be at least 3 characters" }),
age: z.number({ invalid_type_error: "Age field is required" }).min(18),
});
type FormData = z.infer<typeof schema>;
const Form = () => {
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
console.log(errors);
const onSubmit = (data: FieldValues) => console.log(data);
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<label htmlFor="name" className="form-label">
Name
</label>
<input
{...register("name")}
id="name"
type="text"
className="form-control"
/>
{errors.name && <p>{errors.name.message}</p>}
<div className="mb-3">
<label htmlFor="age" className="form-label">
Age
</label>
<input
{...(register("age"), { valueAsNumber: true })}
id="age"
type="number"
className="form-control"
/>
</div>
{errors.age && <p>{errors.age.message}</p>}
</div>
<button className="btn btn-primary" type="submit">
Submit
</button>
</form>
</>
);
};

export default Form;

用淘宝源,SJTUG 没有; 装@hookhome/resolvers 的时候记得打""( npm --registry https://registry.npm.taobao.org install "@hookform/resolvers"),避免和手动指定源的 “/” 混一起

禁用 submit,

const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm();
<button disabled={!isValid} className="btn btn-primary" type="submit">

给 useState 加类型

useState\<Type\>

导出类型 export type

练习:记账本

非常好的练习,和 TodoList 一个实现道理,最好自己试着做一做

个人实现(后面回来看感觉很多地方没写好):

//FormDataType.tsx
import "bootstrap/dist/css/bootstrap.css";
import { z } from "zod";
type Category = "All Categories" | "Groceries" | "Utilities" | "Entertainment";

const schema = z.object({
description: z.string().min(3),
amount: z
.number({ invalid_type_error: "Amount field is required" })
.positive(),
category: z.enum([
"All Categories",
"Groceries",
"Utilities",
"Entertainment",
]),
});
type ExpenseFormData = z.infer<typeof schema>;

export { schema };
export type { ExpenseFormData, Category };
// index.tsx
import ExpenseForm from "./ExpenseForm";
import { ExpenseFormData } from "./FormDataType";
import { useState } from "react";
import FilterTable from "./FilterTable";
const ExpenseTracker = () => {
const [dataArr, setDataArr] = useState<ExpenseFormData[]>([]);
const pushCallback = (newData: ExpenseFormData) => {
setDataArr([...dataArr, newData]);
};
const deleteCallback = (index: number) => {
setDataArr(dataArr.filter((_, i) => i !== index));
};
return (
<>
<ExpenseForm dataArr={dataArr} pushCallback={pushCallback} />
<FilterTable dataArr={dataArr} callback={deleteCallback} />
</>
);
};

export default ExpenseTracker;
// FilterTable.tsx
import "bootstrap/dist/css/bootstrap.css";
import { ExpenseFormData, Category } from "./FormDataType";
import { useState } from "react";
interface FilterTableProps {
dataArr: ExpenseFormData[];
callback: (index: number) => void;
}
const FilterTable = ({ dataArr, callback }: FilterTableProps) => {
const [filterSelection, setFilter] = useState<Category>("All Categories");
const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setFilter(event.target.value as Category);
};
return (
<>
<select
value={filterSelection}
onChange={handleFilterChange}
className="form-select"
aria-label="Category"
>
<option selected>All Categories</option>
<option value="Groceries">Groceries</option>
<option value="Utilities">Utilities</option>
<option value="Entertainment">Entertainment</option>
</select>
<table className="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Description</th>
<th scope="col">Amount</th>
<th scope="col">Categories</th>
</tr>
</thead>
<tbody>
{dataArr.map((data: ExpenseFormData, index) => {
return (
(filterSelection === "All Categories" ||
data.category === filterSelection) && (
<tr>
<th scope={"row"}>{index + 1}</th>
<td>{data.description}</td>
<td>{data.amount}</td>
<td>{data.category}</td>
<td>
<button
onClick={() => callback(index)}
className="btn btn-warning"
>
Delete
</button>
</td>
</tr>
)
);
})}
<tr>
<th scope={"row"}>Total</th>
<td>
{dataArr.reduce((total, cur) => {
if (
filterSelection === "All Categories" ||
cur.category === filterSelection
) {
return total + cur.amount;
}
return total;
}, 0)}
</td>
</tr>
</tbody>
</table>
</>
);
};

export default FilterTable;
// ExpenseForm.tsx
import "bootstrap/dist/css/bootstrap.css";
import { FieldValues, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { schema } from "./FormDataType";
import { ExpenseFormData } from "./FormDataType";
interface ExpenseFormProps {
dataArr: ExpenseFormData[];
pushCallback: (newData: ExpenseFormData) => void;
}
const ExpenseForm = ({ dataArr, pushCallback }: ExpenseFormProps) => {
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<ExpenseFormData>({ resolver: zodResolver(schema) });
const onSubmit = (data: FieldValues) => {
if (isValid) {
pushCallback(data as ExpenseFormData);
console.log(dataArr);
}
};

return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<label htmlFor="Description" className="form-label">
Description
</label>
<input
{...register("description")}
id="Description"
type="text"
className="form-control"
/>
</div>
{errors.description && <p>{errors.description.message}</p>}
<div className="mb-3">
<label htmlFor="Amount" className="form-label">
Amount
</label>
<input
{...register("amount", { valueAsNumber: true })}
id="Amount"
type="number"
className="form-control"
/>
</div>
{errors.amount && <p>{errors.amount.message}</p>}
<select
{...register("category")}
className="form-select"
aria-label="Category"
>
<option selected>All Categories</option>
<option value="Groceries">Groceries</option>
<option value="Utilities">Utilities</option>
<option value="Entertainment">Entertainment</option>
</select>
{errors.category && <p>{errors.category.message}</p>}
<button type="submit" className="btn btn-primary">
Submit
</button>
</form>
</>
);
};

export default ExpenseForm;

看视频,上面的一些缺点:

  • type 还是有一些耦合,category 应该独立出来成为单独的模块
  • 可以继续调 css, 美观度不足(e.g 使用 mb-3 mb-5 class 加边框,改 validation text 颜色等等)

后端部分

  • Express
  • Django
  • Ruby on Rail
  • Spring
  • ASP.NET
  • ......

链接 React 和后端

useEffect 和 Effect Hook

为什么需要 useEffect:

React 引入useEffect是为了在函数组件中处理副作用。在 React 中,副作用包括数据获取、订阅或手动修改 DOM 等操作。这些操作都会在组件渲染之后执行,可能会影响组件之外的事物。

例如一个窗口组件,需要检测它是否获取到焦点,如果我手动处理,就必须在窗口函数之中记录和维护焦点信息,而这和 React 的设计逻辑——一个只关注渲染的组件相违背

而使用了 useEffect 之后,焦点相关的代码实质上被独立出 ReactDOM, 而窗口组件重新成为纯函数,在 DOM 渲染完毕之后,React 检测 useEffect 生效与否,最后执行副作用,保证了副作用的执行顺序,使得整个 App 最后依然是纯函数

例:(这个例子并不实际,因为浏览器默认有聚焦行为覆盖了这个代码,不需要这样手动加,并且这样也只是在第一次点击的时候有聚焦)

import "bootstrap/dist/css/bootstrap.css";
import { useRef, useEffect } from "react";
const FocusInput = () => {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
return (
<div className="md-3">
<input ref={ref} type="text" className="form-control" />
</div>
);
};

export default FocusInput;

useEffect 接受两个参数,第一个是回调函数,第二个是控制何时执行的对象——只有当该对象改变时,useEffect 才会执行回调函数

如果只执行一次,传入一个空数组

如果第二个参数什么都不传递,那么每次组件重新渲染的时候,react 都会调用回调函数,如果回调函数之中又有 setState,就成功的无限循环了(

useEffect Clean up: useEffect 的第一个参数(回调函数 f)可以有非空返回值,返回另一个函数 g 时,当屏幕上 unmount 该组件时,该函数(g)会被调用,一般用于清理

Fetching Data

JSON Place Holder

Sending HTTP Request:

  • 原生 Fetch 方法
  • Axios 库 [recommended]

Promise: A promise is an object that holds the eventual result or failure of an asynchronous operation

promise 有一个 then() 方法,用于执行回调函数

Handling Errors

promise 有一个 catch()方法,捕获错误,执行回调

示例代码:

import axios from "axios";
import { useEffect, useState } from "react";
interface User {
id: number;
name: string;
}
function App() {
const [users, setUsers] = useState<User[]>([]);
const [err, setError] = useState("");
useEffect(() => {
axios
.get("https://jsonplaceholder.typicode.com/users")
.then((res) => setUsers(res.data))
.catch((err) => setError(err.message));
}, []);
return (
<>
{err && <p className="text-danger"> {err}</p>}
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</>
);
}

export default App;

还有一种方法是使用 async 和 await,但略繁琐

ps:实际上 then 和 catch 好像是后面才提出来的语法糖(

useEffect(() => {
const fetchUsers = async () => {
try {
const res = await axios.get("https://jsonplaceholder.typicode.com/users");
setUsers(res.data);
} catch (err) {
setError((err as AxiosError).message);
}
};
fetchUsers();
}, []);

当用户离开此页怎么办?不再需要 Fetch data,使用先前提到的 useEffect Clean up

使用 Controller

AbortController class,浏览器内置,允许取消或中止异步操作

  const controller = new AbortController();
useEffect(() => {
axios
.get("https://jsonplaceholder.typicode.com/users"{signal: controller.signal})
.then((res) => setUsers(res.data))
.catch((err) => {
if (err instanceof CanceledError) return;
setError(err.message);
});
return () => controller.abort();
}, []);

Loading 效果:测试,在 network 选项卡里面加 throtting 模拟慢速情况

在两个组件之间加点空白

<ComponentA />{" "}<ComponentB />

更优的处理,使用 ListGroup 类和 flex 类来调整空间布局

CRUD

删除 Delete:delete

乐观更新/悲观更新:先更新 UI 响应(预计 Server 更新会成功)还是先更新 Server 数据(预计 Server 更新会失败)

一般为了用户体验会选择乐观更新

const onDelete = (id: number) => {
const originalUsers = [...users];
setUsers(users.filter((user) => user.id !== id));
// 先setUsers再让server delete,乐观更新
axios
.delete(`https://jsonplaceholder.typicode.com/xusers/${id}`)
.catch((err) => {
setError(err.message);
setUsers(originalUsers); // 错误时要回到原始状态
});
};

创建 Create: post

axios.post("https://jsonplaceholder.typicode.com/users", newUser).then((res)=>setUsers[res.data, ...users]).catch((err)=>{
setError(err.message);
setUsers(originalUsers);
})

更新 Update: put/patch

const onUpdate = (User: User) => {
const originalUsers = [...users];
const newUser = { ...User, name: User.name + "!" };
setUsers(users.map((user) => (user.id === User.id ? newUser : user)));
axios
.patch("https://jsonplaceholder.typicode.com/users/" + User.id, newUser)
.catch((err) => {
setError(err.message);
setUsers(originalUsers);
});
};

A reusable API client: 思考怎么重构解耦

将 axios 和 baseURL 放到 service 文件夹里面去

进一步,再把整个 HTTPRequest 的逻辑放到 userService 里面去

再进一步,使用泛型把 userService 的逻辑放到 HTTPService 里面去

再进一步,一堆 Hook 可以放到一个大的自定义 Hook 里面去

一个 Hook 没什么神奇的,就是一个导出{obj, setObj, ...}的函数组件,只要它的内部还是 useState 之类,就不需要探知到 React 的底层 DOM


Build A Project!

流程实录

如果想做的最好对着 mosh 的视频做,这里只是杂乱的一点做的笔记

chakra UI: 官网找 vite 下载版本,然后 ChakraProvider 包裹 App

ps: 如果 chakra UI 引入的组件报错 "Expression produces a union type that is too complex to represent.", 别慌,能正常运行,这是 ts 版本不匹配导致的, 参见此处, 将 ts 版本降为工作区版本即可

前端部分:

Grid

1fr: 一个分数单位

在 CSS Grid 中,fr 是一个单位,代表 "fractional unit"(分数单位)。它用于分配可用空间。

如果你设置 grid-template-columns: repeat(5, 1fr);,这意味着你想要创建五列,每列的宽度都是可用空间的一个等份。例如,如果可用空间是 500px,那么每列的宽度就是 100px。

针对移动端和大屏幕设计 UI, F12 模拟移动端

import { useState } from "react";
import reactLogo from "./assets/react.svg";
import "./App.css";
import { Grid, GridItem, Show } from "@chakra-ui/react";
function App() {
const templateAreas = {
base: `"nav" "main"`, // 移动端小屏幕没有aside侧边栏
lg: `"nav nav" "aside main"`, // > 1000px
};
return (
<div className="App">
<Grid templateAreas={templateAreas}>
<GridItem area="nav" bg="coral">
Nav
</GridItem>
<Show above="lg">
<GridItem area="aside" bg="gold">
Aside
</GridItem>
</Show>

<GridItem area="main" bg="dodgerblue">
Main
</GridItem>
</Grid>
</div>
);
}

export default App;

theme:colormode

使用 colorMode 来实现暗色模式和亮色模式的切换(切完 F12-> App-> storage,清除 local storage 的颜色 mode 验证)

使用 useColorMode 和 Switch 来做开关

使用 Hstack 和 justify-content 来调整位置

FetchGames:

查官方 api 文档,axios 建一个 api-client (create 方法, baseURL + key), 然后再用 get 方法 fetch 数据

GameCard && GameGrid: 使用 SimpleGrid && Card 组件

移动端适配:不硬编码 column,传入一个对象{sm: 1, md: 2, lg: 3, xl: 5}

添加图标:使用第三方图标库 https://chakra-ui.com/docs/components/icon/usage#using-a-third-party-icon-library

先从 API 拿到 slug(全小写名字),再写一个 map 函数来对应图标

const IconMap: { [key: string]: IconType } = {
pc: FaWindows,
playstation: FaPlaystation,
xbox: FaXbox,
nintendo: SiNintendo,
mac: FaApple,
ios: MdPhoneIphone,
linux: FaLinux,
};

注意=和...在传递 props 时候的区别

props={} ,传递的是一个对象; ...传递的是对象里面所有的属性

        <PlatformIconList
{...(game.platforms.map((item) => item.platform) as Platform[])}
/> // 传递的是表达式对象里面的数组展开后的结果
<PlatformIconList
platforms={(game.platforms.map((item) => item.platform) as Platform[])}
/> // 传递的是一个数组用{}包裹后的对象
// 用那种取决于子组件接受的是一个对象还是一个数组,一般是在子组件定义一个接口,然后接受一个对象,并用解构接收
interface PlatformIconListProps {
platforms: Platform[];
}
const PlatformIconList = ({ platforms }: PlatformIconListProps) => {
// 类似这样,这样在传递的时候就是传一个数组用{}包裹后的对象,即=语法

添加评分

Badge(徽章)类

添加加载时骨架

skeleton 类

refactor:将重复的样式提出成为一个 GameCardContainer 组件

fetch the genres: 类似 useGames 的 custom hook,取来数据之后再放到 Aside 位置上即可

侧边栏加 Icon: 调 padding(内间距)和 spacing(外间距) , 利用 Grid 类的 templateColumn 200px 1fr 来达到自适应

完善根据 genre 选择呈现列表

需要注意的是如何修改 useEffect,如何上提控制状态和下放控制逻辑和 axios 之中方法所带的 AxiosRequestConfig 参数的使用(在请求体之中用来携带数据)

这里的逻辑是每次切换 selectedGenre 都重新向 API(模拟的后端)请求一次数据,而不是用先前请求的数据做 filter,filter 的逻辑放给后端做了

const useGames = (selectedGenre: Genre | null) => {
return useData<Game>("/games", { params: { genres: selectedGenre?.id } }, [
selectedGenre,
]);
};

useData 的处理方法

import { useEffect, useState } from "react";
import ApiClient from "./Api-Client";
import { AxiosRequestConfig, CanceledError } from "axios";
interface dataResponse<T> {
count: number;
results: T[];
}
const useData = <T,>(
endpoint: string,
config?: AxiosRequestConfig,
dep?: any[]
) => {
const [data, setData] = useState<T[]>([]);
const [err, setError] = useState<string>("");
const [loading, setLoading] = useState(true);
const controller = new AbortController();
const FetchGames = useEffect(
() => {
setLoading(true); // 每次重新取数据重置loading
ApiClient.get<dataResponse<T>>(endpoint, {
signal: controller.signal,
...config,
})
.then((res) => {
setData(res.data.results);
setLoading(false);
})
.catch((err) => {
if (err instanceof CanceledError) return;
setError(err.message);
setLoading(false);
});
},
dep ? [...dep] : []
);
return { data, err, loading };
};
export default useData;

highlight selectedGenre: fontweight 属性即可

fontWeight={genre.id === selectedGenre?.id ? "bold" : "normal"}

filterByPlatform

Menu 类 下拉列表, 如果有错误,宁愿什么都不返回(取消这个组件)而不是将错误扔在用户脸上的设计原则

其他逻辑同 genre

重构:使用 Query Object Model,传入的各个 select 的 props 打包成 Query

下一步: 加排序 Sort,多种排序顺序还是一个 Menu

如果一直向 api 发送请求(体现在 UI 上是一直闪烁), 可能是不恰当地传递了解构又重构的对象(即使是临时的对象),这样之后,看起来对象内部的值没有变化,但是对象不断地更新,然后 React 一直重复渲染

处理没有图片的情况:Image 的 src 不能使用相对路径,需要通过 import 导入

加上动态的 Heading

处理 Button 的文本和图片重叠问题:改它的 whiteSpace 属性为"normal", 再设置 textAlign=left左对齐

objectfit="cover"保持图片长宽比

shipping static data,当然,作为之前选择的回旋镖,也可以选择将 Genre,Platform 这种不怎么改变的数据存储在本地一个 data 文件夹之中,而不是每次都到 API 里面的查询

可以使用官方文档上的方法自定义颜色主题

部署

先本地部署

npm run build

会生成 dist 文件夹,里面有编译完的 js 代码,所有需要的外部包和 asset......

在 Vercel(https://vercel.com/)上部署

使用 github 账号注册,登录,下载 CLI

npm install -g vercel

之后

vercel

接受默认值即可部署

ps: 这个好像在使用 cnpm 的时候会有问题,我没有部署成功,两者交互会有问题

What's Next

  • Routing,

  • State Management,

  • Fetching Data With React Query

  • Authentication

  • Error Handling

  • Performance Optimazation

  • ......

进阶部分

课程内容:

  • React Query : 数据管理和缓存
  • Global State Management
  • React Router

React Query

basic query

What is react query?

一个管理数据的库: A powerful lib for managing DATA FETCHING and CACHING in React apps

安装

npm i @tanstack/react-query

QueryClient 是核心组件:在项目之中启用 React Query

import "bootstrap/dist/css/bootstrap.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";

const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

useQuery Hook:

接受一个对象,其中至少要有两个属性:

  • queryKey: 用于标识数据在 Query 之中存储的唯一性的键,type 为字符串数组(数组可以包含更多的信息,一般在数组的第一个元素放名字,后面放参数,如queryKey:['todos', userId]
  • queryFn: 一个返回 Promise 的函数,这个 Promise 要么解析解析数据,要么抛出错误

返回一个对象,该对象有以下重要属性:

  • data: 存放 Promise 解析的值( if isSuccess 属性为真)
  • isError: 如果 isError, 则可以通过 error 属性得到错误
  • isPending:如果 isPending, 查询正在进行之中,尚无结果

ps: 在有些版本之中,isPending 的名字是 isLoading

使用 useQuery Hook 的好处:

  • Auto retries: 可以配置 React Query,让它自动重试几次
  • Auto reFetch:可以配置 React Query, 让它每隔一段时间自动重新从服务器取数据
  • 更简洁的 Error handling && Loading handling && fecthing , 避免 useState 和 useEffect 的滥用
  • 提供数据缓存

useEffect 的 dep 数组实际上是通过 queryKey 来实现同样的功能的:

具体地说,将 dep 通过字符串数组拼接或者传递对象等拼到 queryKey 上之后,如果 dep 改变,那么就会产生一个新的 queryKey, React Query 看缓存里没有就执行回调函数了

示例代码,useQuery 取代了 useState, useEffect 等代码

注意 useQuery 一定要放在 React 组件内部

import { useQuery } from "@tanstack/react-query";
import axios from "axios";

interface Todo {
id: number;
title: string;
userId: number;
completed: boolean;
}
const fetchTodos = () =>
axios
.get<Todo[]>("https://jsonplaceholder.typicode.com/todos")
.then((res) => res.data);

const TodoList = () => {
// if (isLoading) return <p>Loading...</p>;
const { isError, data, error } = useQuery<Todo[], Error>({
queryKey: ["todos"],
queryFn: fetchTodos,
});
if (isError) return <p>{error.message}</p>;

return (
<ul className="list-group">
{data?.map((todo) => (
<li key={todo.id} className="list-group-item">
{todo.title}
</li>
))}
</ul>
);
};

export default TodoList;

然后重构一下,把 fetchdata 相关逻辑封装到 useTodo 的 hook 里面去

React Query Devtools 工具

安装

npm install @tanstack/react-query-devtools

非常有用!

学到的一个教训: 不要用 cnpm,碰都不要碰,会变得不幸 不知道网上那么多复制粘贴 cnpm 的自己有没有用过

请用 nrm,然后在 taobao、tencent 和 sjtu 三个源上横跳,一般都能找得到包,但他们每一个都缺点东西,单独用还是不行 cnpm 下是能下,下完了全是乱七八糟的错误,并且根本找不到修的方法

cnpm 的出现了无法解决的问题......react query devtools Error: No QueryClient set, use QueryClientProvider to set one

这个问题用正常 npm 不会出现,且网上的解决方法对 cnpm 不生效

还有就是npm config get proxy看一下,笔者的 proxy 是 null, 如果设置了一个无效的 proxy,外面再怎么换源代理都是没用的

npm install -g nrm
nrm ls
nrm add https://mirrors.sjtug.sjtu.edu.cn/npm-registry
nrm use tencent

自定义 React Query 配置: 修改 QueryClient 对象, 例:

const queryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000 * 60 * 5, // 5 minutes
retry: 3,
staleTime: 1000 * 10, // 10 seconds, After how long the data will be treated as old data.
},
},
});

三种默认情况下,react query 会自动更新数据:

  • 网络重连
  • 组件被挂载
  • 窗口重新聚焦(切到了另一个 tab 再切回来)

也可以自定义,将它们关掉,如refetchOnWindowFocus:false

react query 先给用户旧的数据(cache),同时向后端请求新数据,更新之后再重新渲染

当然,这是全局的设置,也可以针对每一个 query 自定义,在传入 useQuery 的对象里面加上 cacheTime,retry 等属性即可

实现筛选:

和先前的一样,不多赘述;首先使用 useState 和 select 组件维护选择状态,之后根据 API 的路由情况,传入筛选对应 id 之类组成新的 url,改一下 queryKey 即可

实现分页:

上代码, 思路是将页面作为维护的变量,

而后端请求实现发送从 \_start开始数目为  \_limit的数据

即分页完全是前端的工作,后端只负责发数据

上一页按钮需要在页面为 1 是 disable

然后为了用户体验,在 useQuery 里面加上 keepPreviousData: true, 使得在 loading 的时候给用户缓存的先前结果而不是一个 loading

usePost.tsx

import axios from "axios";
import { useQuery } from "@tanstack/react-query";

interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export interface PostQuery {
pagesize: number;
page: number;
}
const fetchPosts = <Post,>(params: PostQuery) =>
axios
.get<Post>("https://jsonplaceholder.typicode.com/todos", {
params: {
_start: (params.page - 1) * params.pagesize,
_limit: params.pagesize,
},
})
.then((res) => res.data);

const usePosts = (query: PostQuery) => {
return useQuery<Post[], Error>({
queryKey: ["posts", query],
queryFn: () => fetchPosts(query),
keepPreviousData: true,
cacheTime: 1000 * 60 * 5, // 5 minutes
retry: 3,
staleTime: 1000 * 10,
});
};
export default usePosts;

PostList.tsx

import { useState } from "react";
import usePosts, { PostQuery } from "./components/usePosts";

const PostList = () => {
const [query, setQuery] = useState<PostQuery>({ page: 1, pagesize: 10 });
const { error, data: posts, isError, isLoading } = usePosts(query);

if (error) return <p>{error.message}</p>;

return (
<>
{isLoading && <p>Loading...</p>}
<ul className="list-group">
{posts?.map((post) => (
<li key={post.id} className="list-group-item">
{post.title}
</li>
))}
</ul>
<button
className="btn btn-primary mb-3 ms-1 my-3 "
disabled={query.page === 1}
onClick={() => setQuery({ ...query, page: query.page - 1 })}
>
Prev
</button>
<button
className="btn btn-primary mb-3 ms-1 my-3"
onClick={() => setQuery({ ...query, page: query.page + 1 })}
>
Next
</button>
</>
);
};

export default PostList;

实现无限页面(Load More)

使用 React Query 自带的 useInfiniteQuery 替代 useQuery

具体的方法如下

PostList.tsx

import { useState } from "react";
import usePosts, { PostQuery } from "./components/usePosts";

const PostList = () => {
const [query, setQuery] = useState<PostQuery>({ pagesize: 10, pageparam: 1 });
const {
error,
data: posts,
isError,
isLoading,
fetchNextPage,
isFetching,
} = usePosts(query);

if (error) return <p>{error.message}</p>;

return (
<>
{isLoading && <p>Loading...</p>}
<ul className="list-group">
{posts?.pages.map((post, index) => (
<>
{post.map((item) => (
<li key={index} className="list-group-item">
{item.title}
</li>
))}
</>
))}
</ul>
<button
className="btn btn-primary mb-3"
onClick={() => fetchNextPage()}
disabled={isFetching}
>
{isFetching ? "Loading" : "Load more"}
</button>
</>
);
};

export default PostList;

usePosts.tsx

import useData from "./hooks";
import axios from "axios";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";

interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export interface PostQuery {
pagesize: number;
pageparam: number;
}
const fetchPosts = <Post,>(params: PostQuery) =>
axios
.get<Post>("https://jsonplaceholder.typicode.com/todos", {
params: {
_start: (params.pageparam - 1) * params.pagesize,
_limit: params.pagesize,
},
})
.then((res) => res.data);

const usePosts = (query: PostQuery) => {
return useInfiniteQuery<Post[], Error>({
queryKey: ["posts", query],
queryFn: () => fetchPosts({ ...query, pageparam: 1 }),
keepPreviousData: true,
cacheTime: 1000 * 60 * 5, // 5 minutes
retry: 3,
staleTime: 1000 * 10,
getNextPageParam: (lastPage, allPages) => {
return lastPage.length > 0 ? allPages.length + 1 : undefined;
// 此处用到了json的特性:当试图访问一个allPages数组之中不存在的元素(超过数组长度),会返回空数组
// 此时lastPage的length为0
},
});
};
export default usePosts;

mutation

const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (todo: Todo) =>
axios
.post<Todo>("https://jsonplaceholder.typicode.com/todos")
.then((res) => res.data),
onSuccess: (savedData, newData) => {
// Approach 1: invalidate the cache and refetch the data, unable to deal with fake backend
// queryClient.invalidateQueries({ queryKey: ["todos"] });
// Approach 2: reset query data in cache
queryClient.setQueryData<Todo[]>(["todos"], (oldTodos) => [
newData,
...(oldTodos || []),
]);
},
});

useMutation hook 有几个重要属性:

  • mutationFn: 在调用 mutation.mutate 方法的时候调用的函数,用于执行向服务器的操作,接受一个 object 作为参数
  • onSuccess: mutationFn 成功的情况下的回调函数,接受三个参数,第一个参数 data 是 mutate 函数的返回值,第二个参数variable 是传递给 mutate 函数的值,在这里就是新的 Todo 元素,第三个参数是 onMutate 方法的返回值

Refactor: ApiClient

之前的代码有耦合的问题,网络相关的逻辑不应该出现在自定义 Hook 之中,并且这一段代码还多次重复

在初级部分之中,我们用于解耦合的 ApiClient 模块只是简短的

import axios from "axios";

const ApiClient = axios.create({
baseURL: "https://api.rawg.io/api",
params: { key: "c93352ea62ed4c7d9095e81593af922f" },
});

export default ApiClient;

而在中级部分之中,我们还可以给它完善一下,变成一个类, 例如:

import axios, { Axios, AxiosRequestConfig } from "axios";

const apiClientInstance = axios.create({
baseURL: "https://jsonplaceholder.typicode.com",
});

class ApiClient<T> {
endpoint: string;
constructor(endpoint: string) {
this.endpoint = endpoint;
}
getAll(params?: any) {
const config: AxiosRequestConfig = {
url: this.endpoint,
method: "GET",
params: params,
};
const data = apiClientInstance
.get<T[]>(this.endpoint, config)
.then((res) => res.data);
return data;
}
post(data: T) {
const config: AxiosRequestConfig = {
url: this.endpoint,
method: "POST",
data: data,
};
const dataResponse = apiClientInstance
.post<T>(this.endpoint, data, config)
.then((res) => res.data);
return dataResponse;
}
}
export default ApiClient;

之后我们的 useTodos 和 usePosts 两个自定义 Hook 的代码逻辑就可以大幅度简化了

例如 useTodos

import { useQuery } from "@tanstack/react-query";
import ApiClient from "../service/ApiClient";

export interface Todo {
id: number;
title: string;
userId: number;
completed: boolean;
}
interface TodoQuery {
pagesize: number;
page: number;
}

const fetchTodos = () => {
const ApiClientInstance = new ApiClient<Todo>("/todos");
const data = ApiClientInstance.getAll();
return data;
};

const useTodos = () => {
return useQuery<Todo[], Error>({ queryKey: ["todos"], queryFn: fetchTodos });
};
export default useTodos;

一行代码debugger; 会在这里停止

注意:这里因为我传递的是 fetchTodos 而不是 ApiClientInstance.getAll, 所以 getAll 方法被隐式地绑定到对象上了;如果传递 ApiClientInstance.getAll, 会出现 this 丢失的情况。要一劳永逸的解决这个问题,两个方法

  • useQueryqueryFn改成ApiClientInstance.getAll.bind(ApiCilentInstance),使用 bind 显式指定这个方法属于 ApiClientInstance
  • 将 getAll 等方法从一般的函数改为箭头函数,如 getAll<T>{...}改写成getAll\<T\>=()=>{...}

原理:

箭头函数没有自己的 this 上下文,它的 this是继承于定义时的上下文(上级对象)的

例如

const obj = {
value: "hello",
print: () => {
console.log(this.value);
},
};

obj.print(); // undefined

此处,this 不会被绑定到调用时的上下文 obj,而是会被绑定到定义时的上下文(全局作用域),所谓上下文可以用栈帧 frame 来理解

而对于一个 class 的方法, 箭头函数绑定到定义时的上下文(这个类),而非箭头函数会根据调用时的上下文来决定 this

如果只是简单的调用,那么两者的结果是相同的。但是如果将类的非箭头函数方法作为回调函数传递,例如传给一个参数或者是其他量 const sth = instance.method 这样,会造成 this 错误绑定

继续重构

目前 endpoint 如果写错了还是很麻烦,并且 type 是定义在 hooks 之中的,这不好。把 type 和 endpoint 抽出来放在 service 之中

import ApiClient from "./ApiClient";

export interface Post {
id: number;
title: string;
body: string;
userId: number;
}

const postService = new ApiClient<Post>("/posts");
export default postService;

这样重构实际上是在给整个应用分层

  • 最底层是 ApiClient, 负责处理向后端发送请求
  • 上面是 HTTPService,本质上是 ApiClient 的实例,同时,不同的 Service 为上端不同的功能定义最基本的类型
  • 再上面是 Custom Hook, 将对应的 HTTPService 的数据进一步打包成基本的工具方法
  • 最上面才是我们的 React Components,真正的前端

分层的核心逻辑在于:一层只干一件事情,处理好自己那一层的输入输出即可,让我们的代码解耦、易懂

实战

Genres

没啥好说的,按照前面说的重构就行,在 React Query 里面设置 initialData 倒是一个值得记住的效果实现

useGenre.tsx

import genres from "../data/genres";
import useData, { queryConfig } from "./useData";

export interface Genre {
id: number;
name: string;
image_background: string;
}

// const useGenres = () => ({ data: genres, isLoading: false, error: null })
const useGenres = () => {
const queryConfig = {
cacheTime: 24 * 60 * 60 * 1000,
staleTime: 24 * 60 * 60 * 1000,
initialData: genres,
} as queryConfig;
return useData<Genre>({ endpoint: "/genres", queryConfig: queryConfig });
};
export default useGenres;

useData.tsx

import ApiClient from "../services/api-client";
import { AxiosRequestConfig, CanceledError } from "axios";
import { useQuery } from "@tanstack/react-query";
export type queryConfig = {
[key: string]: any;
};
type useDataProps = {
endpoint: string;
config?: AxiosRequestConfig;
dep?: any[];
queryConfig?: queryConfig;
};
const useData = <T,>({ endpoint, config, dep, queryConfig }: useDataProps) => {
const apiClient = new ApiClient<T>(endpoint, config);
return useQuery({
queryKey: [endpoint].concat(dep || []),
queryFn: apiClient.get,
...queryConfig,
});
};
export default useData;

实现无限滚动

 npm i react-infinite-scroll-component@6.1

后面逻辑比较重复

简化时间表达: ms 库,非常简单


Global state management

Reducer

Reducer: A function that allows us to centralize state in a component

Reducer 将 React 组件的状态管理抽离出组件本身,使得整个状态管理只需要关注 Reducer,并且支持管理逻辑的重用

reducer 函数接受两个参数,state 和 action(一般作为一个对象传递,其 type 参数对应了执行的动作,有点像状态机)

直接看代码

reducer/counterReducer.ts

interface Action {
type: "INCREMENT" | "DECREMENT" | "RESET";
}
const counterReducer = (state: number, action: Action) => {
switch (action.type) {
case "INCREMENT":
return state + 1;
case "DECREMENT":
return state - 1;
case "RESET":
return 0;
default:
return state;
}
return state;
};
export default counterReducer;

counter.tsx

import { useReducer, useState } from "react";
import counterReducer from "./reducers/counterReducer";

const Counter = () => {
// const [value, setValue] = useState(0);
const [value, dispatch] = useReducer(counterReducer, 0);
return (
<div>
Counter ({value})
<button
onClick={() => dispatch({ type: "INCREMENT" })}
className="btn btn-primary mx-1"
>
Increment
</button>
<button
onClick={() => dispatch({ type: "RESET" })}
className="btn btn-primary mx-1"
>
Reset
</button>
</div>
);
};

export default Counter;

当执行的任务变复杂时,需要多个接口:

例如

interface Task {
id: number;
title: string;
}

interface AddTask {
type: "ADD";
newTask: Task;
}
interface DeleteTask {
type: "DELETE";
id: number;
}

type Action = AddTask | DeleteTask;

const taskReducer = (state: Task[], action: Action): Task[] => {
switch (action.type) {
case "ADD":
return [...state, action.newTask];
case "DELETE":
return state.filter((task) => task.id !== action.id);
default:
return state;
}
};
export default taskReducer;

React Context

在初级部分之中,React 组件之间共享状态是通过 props自上而下传递的,也就是说如果我们要在组件树上的两个部分之间共享某个状态,就必须将这个状态上移到公共父组件,这导致了几个问题

  1. 如果公共父组件很远,即组件层数很深,那么 Props 要传递很多层,非常恶心

  2. 如果组件很多,那么类似 App 这样的顶层父组件就会全都是状态,影响理解

这种问题称之为 Props drilling,指 Props 把组件钻了一堆孔

但是我们先前也有提到,React 的 State 实质上是全局存储的,所以能够提供一种更好的机制来实现不同组件之间的状态共享,这就是 React Context

React Context 解决了第一个问题,对第二个问题帮助较小

context/taskContext.ts

import React from "react";
import { Action, Task } from "../reducers/taskReducers";

interface TaskContextType {
tasks: Task[];
dispatch: React.Dispatch<Action>;
}

export const TaskContext = React.createContext<TaskContextType>(
{} as TaskContextType
);

有了这个 TaskContext 之后,

只需要在想传递的位置用它的 Provider 包裹就行

function App() {
const [tasks, dispatch] = useReducer(taskReducer, []);
return (
<TaskContext.Provider value={{ tasks, dispatch }}>
<NavBar />
<HomePage />
</TaskContext.Provider>
);
}

之后在 NavBar 里面就可以直接

const { tasks, dispatch } = useContext(TaskContext);

provider 一多,代码略丑

可以把 provider 变成一个 Components**(Custom Provider)**

import React, { useReducer } from "react";
import taskReducer from "./reducers/taskReducers";
import { TaskContext } from "./contexts/taskContext";
interface TaskProviderProps {
children: React.ReactNode;
}
const TaskProvider = ({ children }: TaskProviderProps) => {
const [tasks, dispatch] = useReducer(taskReducer, []);
return (
<TaskContext.Provider value={{ tasks, dispatch }}>
{children}
</TaskContext.Provider>
);
};

export default TaskProvider;

然后 App 就变成了简洁的样子

import "./App.css";
import NavBar from "./state-management/NavBar";
import HomePage from "./state-management/HomePage";
import AuthProvider from "./state-management/AuthProvider";
import TaskProvider from "./state-management/TaskProvider";

function App() {
return (
<AuthProvider>
<TaskProvider>
<NavBar />
<HomePage />
</TaskProvider>
</AuthProvider>
);
}

export default App;

也可以将整个 useConText 放到自定义 Hook 里面去,进一步减轻心智压力,只需要调自己的 Hook 就行了,不需要考虑上下文

让整个代码更加模块化

我们现在有 TaskList, taskContext, TaskProvider,taskReducer,useTask 5 个和 task 相关的文件

但是它们散落在整个项目的各个地方,对于 task 这么一个小功能都写了 5 个文件,一旦功能多起来,文件量的增加会加巨耦合,也对我们找和理解带来心智负担

把它们放到一起去,新建一个 task 文件夹,全丢进去

task 文件夹内部是 task 模块的实现,而整个模块的实现不是所有的都需要暴露在外面的

要暴露的只有

  • TaskList
  • [optional] TaskProvider

剩下的之中,useTask hook 和 taskReducer 在大部分情况下应该只在 Task 模块内部使用,taskContext 被包装进了 TaskProvider

于是我们可以再加一个 index.ts ,其中导入 TaskList 和 TaskProvider 再导出,而外部模块使用 Task 模块的内容的时候,应该从 Task 文件夹导入(转到 index),不应该触及内部结构和内部实现

task/index.ts

import TaskList from "./TaskList";
import TaskProvider from "./TaskProvider";
export { TaskList, TaskProvider };

Best practice: When to use ......

When to use context?

在一个项目之中可能有很多的状态,但这些状态可以分解成

  • (从)Server 端获取的状态:例如数据
  • Client UI 状态: 例如组件的一些 Local State

两个部分

对于 Server 端状态,不应该用 Context,用 React Query,React Query 就是为了避免组件树变成一大坨屎被设计出来的,一堆 Context 和 State 实际上都用 useQuery 里面的 queryKey 标识了之后委托给 React Query 管理了。如果用 Context,元素一多 Provider 就会一直叠,最终不可维护

而对于 Client 端状态,如有共享的需要,例如 Navbar 可能需要组件树底部的组件的信息,应该用 Context 来提供组件之间传递状态的灵活性

When to use reducer?

看逻辑复杂与否,如果逻辑很复杂,放在组件里很丑陋,难以理解,操作很多还容易出错的时候,就应该用 reducer 把逻辑独立出来

如果逻辑很简单,就一行代码的事情,那直接 useState 也挺好

When to use Redux?

Redux 和 React Context 不同的地方是,Redux 是一个将所有组件的状态全部交给 Redux 管理,这样以后,就可以和 React 解耦、更好的管理状态......;而 React Context 只是把状态在组件之间更好的传递。一个是仓库,一个是卡车。

mosh 给出的观点是,Redux 很强大,但为了支持那些强大(却未必用得上)的功能(例如撤销),反而在大多数不需要的项目之中带来了太多的复杂性

在 Server 端,React Query 已经足够好用,并且大大简化了 Redux 的功能

在 Client 端,首先一般的应用程序客户端状态比较少,其次有简单的 useState 和 useContext, 逻辑复杂时有 reducer,再复杂 mosh 也推荐用 Zustand 这个轻量化的管理器

Zustand

安装:

npm i zustand@4.3.7

Zustand 使用 create 方法创建一个全局状态,返回一个 React Hook,这个 hook 可以在任何模块之中使用

import { create } from "zustand";

interface CounterStore {
counter: number; // states
increment: () => void; // actions or methods
reset: () => void;
}

const useCounter = create<CounterStore>((set) => ({
counter: 0,
increment: () => set((state) => ({ counter: state.counter + 1 })),
reset: () => set({ counter: 0 }),
}));

export default useCounter;

create 接受一个类型参数,这个类型是一个对象,其中可以有属性和方法,如果把它看成状态机的话就是 state 和改变 state 的 method,传入的参数是一个 set 函数

set 函数根据接受的参数不同有两种“重载”

  • 如果接受一个对象,它将 create 出来的状态用这个新对象更新,类似 setState
  • 如果接受一个函数,这个函数接受原先的 state 作为传入参数(传入参数为监听对象), 而函数的返回值将作为新的 state 更新

这样之后 Counter 的逻辑就非常简洁了, 没有了表意尚有一些模糊的dispatch({type:"somestring"})

import { useReducer, useState } from "react";
import counterReducer from "./counterReducer";
import useCounter from "./store";

const Counter = () => {
// const [value, setValue] = useState(0);
// const [value, dispatch] = useReducer(counterReducer, 0);
const { counter, increment, reset } = useCounter();
return (
<div>
Counter ({counter})
<button onClick={() => increment()} className="btn btn-primary mx-1">
Increment
</button>
<button onClick={() => reset()} className="btn btn-primary mx-1">
Reset
</button>
</div>
);
};

export default Counter;

还能用它来避免不必要的 rerender

方法:假设 CounterStore 还有另一个属性 max, 在调用 useCounter 的时候,如果我只想要 Counter 组件在 counter 的值改变时才重新渲染,只需要useCounter(s=>s.counter)

理解: 相当于改变了 create 的监听对象(类比 useState),返回的值也会做对应改变,但是整个状态的返回值会正常更新

如果监听 s.counter,返回的就是 counter 属性

如果监听 s.reset, 返回的就是 reset 方法

检查 Zustand 状态

npm i simple-zustand-devtools
npm i -D @types/node

ps: 这个工具我下载了但是没找到视频演示的模块,就放这了(doge

实战:使用 Zustand 重构 gamehub

所有 gameQuery 这个 local state 传来传去的地方全部删光,太爽了!!!

有了这个几乎不需要任何的 Props

最后的 app.tsx 超级干净,不传入任何函数和变量

import "./App.css";
import { Box, Grid, GridItem, HStack, Show } from "@chakra-ui/react";
import NavBar from "./components/NavBar";
import GameGrids from "./components/GameGrids";
import Aside from "./components/Aside";
import PlatformSelector from "./components/PlatformSelector";
import SortSelector from "./components/SortSelector";
import GameHeading from "./components/GameHeading";

function App() {
const templateAreas = {
base: `"nav" "main"`, // 移动端小屏幕没有aside侧边栏
lg: `"nav nav" "aside main"`, // > 1000px
};
const templateColumns = {
base: "1fr",
lg: "200px 1fr",
};

return (
<div className="App">
<Grid templateAreas={templateAreas} templateColumns={templateColumns}>
<GridItem area="nav">
<NavBar />
</GridItem>
<Show above="lg">
<GridItem area="aside" paddingX={"10px"}>
<Aside />
</GridItem>
</Show>

<GridItem area="main">
<Box paddingLeft={2}>
<GameHeading />
<HStack spacing={5}>
<PlatformSelector />
<SortSelector />
</HStack>
<GameGrids />
</Box>
</GridItem>
</Grid>
</div>
);
}

export default App;

这种方法的弊端:

降低了组件可重用性,现在所有的组件都依赖于 Store 了

如果是简单的 Props,还是直接父子组件 Props 或者 Context 之类传传

React Router

  • Setting up routes
  • Handling Errors
  • Navigating between pages
  • Dynamic routes
  • Nested routes
  • Private routes

安装

npm i react-router-dom

它的版本兼容比较嗯嗯,所以最好提前选好版本(

最基本的 router

import { createBrowserRouter } from "react-router-dom";
import HomePage from "./HomePage";
import UserListPage from "./UserListPage";
const router = createBrowserRouter([
{
path: "/",
element: <HomePage />,
},
{
path: "/users",
element: <UserListPage />,
},
]);

export default router;

然后,在 main.tsx 之中把 App 组件换成 RouterProvider

import "bootstrap/dist/css/bootstrap.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import "./index.css";
import { RouterProvider } from "react-router-dom";
import router from "./routing/router";

const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> // 这里
<ReactQueryDevtools />
</QueryClientProvider>
</React.StrictMode>
);

React-Router 里面有\<Link /\>组件,比起使用 a href 跳转,优点是跳转的时候不会向 Server 重新发送请求,省资源

使用的时候平替即可

例如

<a href="/users">Users</a>

换成

<Link to="/users">Users</Link>

还有就是useNavigate Hook, 它会返回一个 navigateFunction, 然后传递给这个 function 一个 to, 即可实现跳转

import { useNavigate } from "react-router-dom";

const ContactPage = () => {
const navigate = useNavigate();
return (
<form
onSubmit={(event) => {
event.preventDefault();
// Redirect the user to the home page
navigate("/");
}}
>
<button className="btn btn-primary">Submit</button>
</form>
);
};

export default ContactPage;

动态路由

就是实战一里面的 在路由里面传递参数 /post/:id这样子

  {
path: "/user/:id",
element: <UserDetailPage />,
},

对应的, Link 用模板字符串就好了

<Link to={`/user/${user.id}`}>{user.name}</Link>

几个常用的 Hook

  • useParams:返回一个记录了所有动态路由的传递的参数的对象
  • useSearchParams: 返回一个记录了路由传递的查询参数的对象和设置它的函数的数组(就是[searchParams, setSearchParams]), 然后对searchParams调用get方法, 传入一个name能够得到路由传递的查询参数里面name的值;调用toString方法能够返回原始的查询字符串
  • useLocation返回当前路由的 location 对象

The useParams hook returns an object of key/value pairs of the dynamic params from the current URL that were matched by the <Route path>. Child routes inherit all params from their parent routes.

Build A NavBar:嵌套路由

直接看代码

import { createBrowserRouter } from "react-router-dom";
import HomePage from "./HomePage";
import UserListPage from "./UserListPage";
import ContactPage from "./ContactPage";
import UserDetailPage from "./UserDetailPage";
import Layout from "./Layout";
const Router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
// { path: "", element: <HomePage /> },
// 上面下面的写法均可
{ index: true, element: <HomePage /> },
{ path: "users", element: <UserListPage /> },
{ path: "user/:id", element: <UserDetailPage /> },
{ path: "contact", element: <ContactPage /> },
],
},
]);

export default Router;

可以这样定义路由的子组件

然后,在 Layout.tsx 里面

import { Outlet } from "react-router-dom";
import NavBar from "./NavBar";

const Layout = () => {
return (
<>
<NavBar />
<div id="main">
<Outlet /> //
这个Outlet就是子组件的占位符,根据路由在children之中选择匹配的值
</div>
</>
);
};

export default Layout;

然后不管切换到哪个子路由, <NavBar />组件总是可视的

可以继续嵌套,达成一些有趣的效果,比如左半边屏幕是 user, 右半边是 detail

import { createBrowserRouter } from "react-router-dom";
import HomePage from "./HomePage";
import ContactPage from "./ContactPage";
import UserDetail from "./UserDetail";
import Layout from "./Layout";
import UserPage from "./UserPage";
const Router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
// { path: "", element: <HomePage /> },
// 上面下面的写法均可
{ index: true, element: <HomePage /> },
{
path: "users/",
element: <UserPage />,
children: [{ path: ":id", element: <UserDetail /> }], // 再嵌套一次
},
{ path: "contact", element: <ContactPage /> },
],
},
]);

export default Router;

UserPage.tsx

import UserList from "./UserList";
import { Outlet } from "react-router-dom";

const UserPage = () => {
return (
<div className="row">
<div className="col">
<UserList />
</div>
<div className="col">
<Outlet />
</div>
</div>
);
};

export default UserPage;

错误处理

在 router 里面加上 errorElement

const Router = createBrowserRouter([
{
path: "/",
element: <Layout />,
errorElement: <ErrorPage />,

这个 errorElement 会捕获两种类型的错误:

  • 代码内部抛出的错误
  • 不存在对应路由

React Router 还提供了一个 hook, useRouteError

ErrorPage.tsx

import { isRouteErrorResponse, useRouteError } from "react-router-dom";

const ErrorPage = () => {
const error = useRouteError();
console.log(error); // 这里可以引入外部库,将error记录到日志之中
return (
<>
<h1>Oops...</h1>
<p>{isRouteErrorResponse(error) ? "Invalid Page" : "Unexpected Error"}</p>
// 如果是错误的路由的Error,就返回"Invalid Page",
否则(代码内部抛出错误),返回 "Unexpected Error"
</>
);
};

export default ErrorPage;

隐私路由(Private Route):

说实话没太听懂,大体思路就是将需要隐私保护的路由放到新的路由组里面去

然后这个新路由组的 element 内部可以加登录验证,如果没有登录,就 return 一个 <Navigate>元素进行跳转

因为需要隐私保护的路由此时都是这个新路由组的子路由,所以都会被这个"根"的 element 路由保护

实战:给 gameHub 加上详细页面

我们想要一直保持 NavBar 组件可视,那么先整个 Layout

Layout.tsx

import { Outlet } from "react-router-dom";
import NavBar from "../components/NavBar";

const Layout = () => {
return (
<>
<NavBar />
<Outlet />
</>
);
};

export default Layout;

这个 Layout 就是路由"/"啦,剩下的元素应该作为它的嵌套路由,放在<Outlet />之中

然后将原来的 App.tsx 的组件放到新的pages文件夹的HomePage.tsx

再将GameCard组件里面的<Text>对象改成

Link to={`/game/${game.id}\`}

也就是结合我们前面动态路由的知识,就有了这样的路由

import { createBrowserRouter } from "react-router-dom";
import GameDetail from "../pages/GameDetail";
import HomePage from "../pages/HomePage";
import Layout from "../pages/Layout";

const GameRouter = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <HomePage /> },
{ path: "game/:id", element: <GameDetail /> },
],
},
]);

export default GameRouter;

别的 detail 页面的细节实现就是前面初级部分的内容了(趴

值得注意的就是 detail 页面上的 NavBar 组件的搜索要想返回一个可视化的结果

需要稍稍修改,在 onSubmit 函数里面再调用 useNavigate 然后 navigate 回原来的主页,路由的部分就这么些了

完结撒花!

Loading Comments...