react practice:mosh gamehub
观前提示:
- 默认读者有基础的 js, ts, html 基础, 其中 html 和 js 在MDN Web Docs上看入门教程即可,ts 可以看mosh 的视频或者 google 一下 typescript tutorial 即可;
- 本人水平有限,错漏和不足之处敬请谅解
- 本笔记对应的视频: 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,启动!
react 使用 JSX, JSX 被编译成 pure js(with react)
编写 react 的时候需要遵守 PascalCasting, 即组件名为 AppName 而不是 app_name 或者 appName
export
和 export 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:
- React 更新 State 是异步的 async
- state 实际存储在组件之外
- only use hook at the top level of function: Hook 不能被放在 条件,循环等等之中
BEST PRACTICE:
- 避免冗余 state
- 使用对象将相关的 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 版本降为工作区版本即可
前端部分:
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(徽章)类
添加加载时骨架
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 丢失的情况。要一劳永逸的解决这个问题,两个方法
- 将
useQuery
的queryFn
改成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自上而下传递的,也就是说如果我们要在组件树上的两个部分之间共享某个状态,就必须将这个状态上移到公共父组件,这导致了几个问题
-
如果公共父组件很远,即组件层数很深,那么 Props 要传递很多层,非常恶心
-
如果组件很多,那么类似 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 回原来的主页,路由的部分就这么些了
完结撒花!