React 学习笔记-系列
React-记-1
React-记-2
React-记-3

前言

Reactzh-hans

开始

1
2
pnpm create vite@latest
# npm init vite@latest

项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
├───📁 public/ # 静态资源目录,直接复制到最终的构建输出目录
├───📁 src/
│ ├───📁 assets/ # 存放项目中需要构建、打包的资源文件
│ ├───📄 App.css
│ ├───📄 App.tsx # App 组件,通常作为根组件
│ ├───📄 index.css
│ ├───📄 main.tsx # 入口 tsx 文件,类似 Vue 的 main.js
│ └───📄 vite-env.d.ts # 类型声明文件,三斜线引入了 vite/client 声明,做了各种文件后缀的类型声明。
├───📄 .gitignore
├───📄 eslint.config.js
├───📄 index.html # vite 入口文件 index.html,拦截 /src/main.tsx 的引入
├───📄 package.json
├───📄 pnpm-lock.yaml
├───📄 README.md
├───📄 tsconfig.app.json # 源码 tsconfig
├───📄 tsconfig.json
├───📄 tsconfig.node.json # node 环境 tsconfig
└───📄 vite.config.ts

JSX & TSX 简介

就像 Vue 有 .vue 文件,React 也创造了一种新的文件类型 .jsx(JavaScript XML)。

JSX 并不仅为 React 服务,其语法没有定义运行时语义,使得它能被编译成各种不同的输出形式。Vue 也能使用 JSX(文档),但语法和 React 略有不同,编译也需要独特的插件 @vue/babel-plugin-jsx。相关文章:What’s a JSX Transform?。后文仅聊 JSX in React。

JSX 被设计为 JS 的语法扩展,可以在 JS 中直接写 XML(HTML) 标签,和大多数类 JS 模板文件一样,这是为了将渲染逻辑标签(视图)存于同一个地方,独立成为组件,承载了 HTML 构建页面的职责。

此外,IDE 对 JSX 支持较好,VSCode 开箱即用地支持 React(文档),而无需额外安装语言支持插件,Vue+TS+Volar,开发性能,emmm,有点吃力

JSX 与 React

vite插件:
@vitejs/plugin-react 默认使用 babel 平台转换编译 JSX 文件。
也可以使用 @vitejs/plugin-react-swc 通过 swc 平台编译。

babel 是一个 JavaScript 编译器,它将 JS 代码转为 AST 抽象语法树,并根据配置规则转换、生成目标 JS 代码,例如将 ES6 转为 ES5 语法,通过 Polyfill 垫片方式支持新特性,将 JSX 转为 React.createElement 等函数调用。
swc 是基于 Rust 的 web 可扩展平台,既有 babel 的编译转换功能,也可用于打包(建设中),原生支持 TS,速度比 babel 快很多,配置也更加简单。
这两个东西以后再开个文章学学。

babel 插件:
JSX 的语法实现依赖两个 babel 插件:
@babel/plugin-syntax-jsx 使 babel 能够解析 JSX 语法。
@babel/plugin-transform-react-jsx 转换 JSX 语法,内部已经调用了 plugin-syntax-jsx。允许自定义配置将 JSX 编译为哪个函数调用,默认为 React.createElement_jsx(17之后)。

转换变化:
在 React 17 之前,JSX 中的元素节点会被编译成 React.createElement 函数调用,所以即使没有使用到 React,也需要在源码中引入它。
而在 React 17 之后,介绍全新的 JSX 转换,将转换为 _jsx 函数调用,并在编译时自动引入 _jsxRuntime api

本质上 JSX 只是 React 函数的语法糖,React 本身并不依赖 JSX,仍然可以使用 React.createElement 函数手动创建 React 元素节点。

初识 React 组件

React 应用通过组件的嵌套、组合构建而成。
每个组件都是一个独立的 UI 单元,可以包含状态生命周期事件处理等逻辑,也可以接受属性子组件等参数。
但要注意,函数组件本身是纯粹的,只负责与渲染相关的逻辑,不应直接产生副作用,其它 React 特性应该通过 Hook 实现。

在React中,副作用指的是那些会影响组件外部状态或产生其他不可预期结果的操作,比如数据获取、DOM操作、订阅等。副作用通常通过useEffect钩子处理,以确保在组件生命周期的特定时刻执行这些操作。

组件的本质是一个函数(函数组件 React.FC),或者是继承自 React.Component ,至少具有 render 方法的(类组件),都需要返回一个 React 元素节点(或者 null),也就是按 JSX 语法编写的标签。

1
2
3
4
5
6
7
8
9
10
11
// 函数组件
function App() {
return <div>Hello, World!</div>;
}
// const App: React.FC 或者你会想要这么写,显式声明这是一个 React 函数组件
// 类组件
class App extends React.Component {
render() {
return <div>Hello, World!</div>;
}
}

建议使用函数式组件,而不是类式组件过时的 API

组件名(函数 or 类名),必须以大写字母开头,以便 React 能够区分组件和 HTML 标签。

避免嵌套组件定义,这是性能低下且易出 BUG 的。详见:嵌套和组织组件

当子组件需要使用父组件的数据时,需要通过 props 进行传递,而不是嵌套定义。

1
2
3
4
export default function Gallery() {
// 🔴 永远不要在组件中定义组件
function Profile() {}
}

JSX 语法

写 JSX 总是离不开原生 JS 的思想,它尽可能避免了传统模板语言的复杂性,具有少量的语法扩展与抽象。
HTML 转 JSX 工具:html-to-jsx

标签规则

JSX 语法中,标签的写法与 HTML 类似,但必须使用闭合标签

1、部分属性别名:
class 属性需要写成 className,for 属性写成 htmlFor,因为 class 和 for 是 JS 的保留字。

1
2
3
4
5
6
// JSX
<div className="container">Hello, World!</div>
<label htmlFor="name">Name:</label>
// HTML
<div class="container">Hello, World!</div>
<label for="name">Name:</label>

2、属性驼峰命名法:
所有 DOM 属性和特性(包括事件处理程序)都应采用驼峰命名法。除了 aria-*data-* 属性,它们应该保持小写

1
2
3
4
// JSX
<input type="text" onChange={handleChange} />
// HTML
<input type="text" onchange="handleChange()" />

3、括号包裹标签:
元素节点写成一行时,可以省略外层的括号,但是多行时需要使用括号包裹

1
2
3
4
5
6
7
// JSX
return (
<div>
<h1>Hello, World!</h1>
</div>
);
return <div><h1>Hello, World!</h1></div>;

4、Fragment 组件
React 要求组件只能返回一个根元素,但有时候我们需要返回多个元素,就需要额外添加一个父标签包裹多个元素。
React 16 之后,提供了Fragment组件 <></>,用于包裹多个兄弟节点,不会在 DOM 中渲染额外的节点。

1
2
3
4
5
6
return (
<>
<h1>Hello, World!</h1>
<p>React is awesome!</p>
</>
);

5、泛型加逗号:
tsx 会将泛型写法视为 JSX 标签,加个逗号解决。

1
2
3
4
// <T> -> <T,>
const MyComponent = <T,>(props: { value: T }) => {
return <div>{props.value}</div>;
};

插值语法 {}

{} 用于在 JSX 中插入 JS 表达式,可以是变量函数调用三元运算逻辑运算等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const [count, setCount] = useState(0);
const flag = true;
const jsxXml = <div>一个标签</div>;
const classList = ["a", "b", "c"];

return (
<>
{/* 1、表达式 */}
<h1>{new Date().getTime()}</h1>
<p>{flag ? "真" : "假"}</p>
<p>计数: {count}</p>
{/* 2、绑定事件,传参需要使用高阶函数 */}
<button
onClick={(e) => {
setCount(count + 1);
console.log(e);
}}
>
增加
</button>
{/* 3、插入 JSX 标签 */}
{jsxXml}
{/* 4、绑定 class */}
<div className={classList.join(" ")}>绑定 class</div>
</>
);

style 传入对象动态绑定内联样式,css 属性同样使用驼峰命名法,数字类型的属性值会自动添加 px 单位。

1
2
3
4
5
const style = {
color: "red",
fontSize: 20,
};
return <p style={style}>内联样式</p>;

dangerouslySetInnerHTML 属性用于插入 HTML,需要传入一个对象,包含 __html 属性。和 v-html、innerHTML 类似,小心 XSS 攻击。

1
2
const markup = { __html:'<p>插入的 html</p>' };
return <div dangerouslySetInnerHTML={markup} />;

条件与列表渲染

配合原生 JS 实现类似 v-if 和 v-for 的条件渲染和列表渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const isShow = true;
const dataList = [
{ id: 1, name: "张三" },
{ id: 2, name: "李四" },
];
return (
<>
{isShow && (
<ul>
{dataList.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)}
</>
);

和 Vue 相同,React 也需要 key 属性来唯一标识列表中的元素,以便 React 能够更高效地更新 DOM。详见:用 key 保持列表项的顺序。另外,key 属性不会被当作 props 的一部分。

如果每个列表项需要渲染多个元素,可以使用完整的 Fragment 组件包裹,而非简写的 <></>

1
2
3
4
5
6
{dataList.map((item) => (
<Fragment key={item.id}>
<dt>{item.name}</dt>
<dd>{item.id}</dd>
</Fragment>
))}

应该先了解的东西

在正式学习 React 之前,应该知道一些概念。React 哲学React 规则,内容不多,但很重要。

使用纯函数

应该尽量保持组件的纯函数特性,不产生副作用

  1. 只负责自己的任务。它不会更改在该函数调用前就已存在的对象或变量。
  2. 输入相同,则输出相同。给定相同的输入,组件应该总是返回相同的 JSX。

内容不多,主要是开发时注意点,养成纯函数的思想即可,详见:保持组件纯粹

树结构 UI

和 DOM 树类似,React 抽象出的 UI 也是一个树形结构。详见:将 UI 视为树

这很熟悉,Vue 也是如此,基于模块的依赖关系、实际的渲染逻辑,可以分析出渲染树组件树fiber树等,总之树结构体现了各个组件实体之间的关系。

React 是跨平台的 UI 框架,渲染树不仅可以渲染到浏览器 DOM,还可以渲染到原生应用等环境。

React 渲染

React 渲染指组件将其状态和属性(state 和 props)转化为用户界面元素的过程。在浏览器环境中,React 组件会转化为 DOM 元素。分为三步:触发渲染提交文档-渲染和提交

1、触发:两种情况会触发 React 组件的重新渲染。

  1. 组件的初次渲染createRoot().render()
  2. 组件(或其祖先)的状态改变。即调用 set 函数传入新值。

2、渲染:调用组件,在内存中创建实际 DOM 节点。

  1. 初次渲染时,调用根组件。
  2. 重渲染时,调用内部状态更新触发了渲染的函数组件,通过 diff 算法比较新旧虚拟 DOM,标记需要更新的节点。

都会递归调用所有后代组件。

3、提交:修改实际 DOM 树。

  1. 初次渲染时,将虚拟 DOM 转化为实际 DOM。
  2. 重渲染时 根据节点的标记,最小更新实际 DOM。

修改完 DOM 树后,就到浏览器的回流和重绘阶段了。

实际上的内部渲染过程很复杂,React 会将组件关系转化为Fiber 树,并通过调度器控制渲染的优先级、时间,还会拆分渲染任务到浏览器每个渲染帧的空闲时刻,后面再深入了解。

props 组件传参

React 组件使用 props 来互相通信。父组件给子组件绑定 props 属性,子组件在函数参数中接收 props 对象。

props 是组件的唯一参数,它应该保持只读,不应该在组件内部更改 props 的值,可以很方便地通过解构赋值获取 props 的属性值,并指定默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 父组件
<Props title="qcqx">
<div>Props children</div>
</Props>
// 子组件
interface Props {
title: string;
children: React.ReactNode;
}
function PropsTest({ title = "chuckle", children }: Props) {
return (
<div>
<p>{title}</p>
{children}
</div>
);
}

将 JSX 作为标签内容传递给子组件,会添加 props.children,类似于 Vue 的插槽

注意在 18 后需要手动定义 children 类型为 React.ReactNode

1
children: React.ReactNode

React 使用 Object.freeze 冻结 props,以维持单向数据流,子组件不能直接修改父组件的props。

兄弟组件通信

兄弟组件通信可以将共享状态提升到共同的父组件中,变为父子组件通信。

当然也可以使用发布订阅、全局状态管理等方式,可能需要引入额外的库,如 mitt、Redux 等。状态容器后面再学习,原生浏览器已经实现了发布订阅模式,直接使用!

Card1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Card1: React.FC = () => {
const event = new Event("on-card"); // 创建一个事件对象
function handleClick() {
event.params = { name: "Card1" };
window.dispatchEvent(event); // 触发事件
}
return (
<>
<h2 onClick={handleClick}>Card1</h2>
</>
);
};

export default Card1;
1
2
3
4
5
6
7
8
9
10
11
12
const Card2: React.FC = () => {
window.addEventListener("on-card", (e: Event<{ name: string }>) => {
console.log(e.params.name); // "Card1"
});
return (
<>
<h2>Card2</h2>
</>
);
};

export default Card2;

还得扩展事件对象,使其具有参数属性,当然为了方便,也可以封装一个 CustomEvent,继承自 Event。

custom.d.ts
1
2
3
4
5
export declare global {
interface Event<T = any> {
params: T; // 使用泛型 T 来指定 params 的类型,默认为 any
}
}

React 事件

在 React 中,所有的内置浏览器组件,如 <div>,都支持一些常见的属性和事件。普通组件响应事件

React 事件虽然和原生 DOM 事件用法、名称类似,但实际上是抽象封装过后的,React 事件是合成事件,它是跨浏览器的,它符合与底层 DOM 事件相同的标准,但修复了一些浏览器不一致性。

所谓合成事件,就是在模拟事件时,可能由多个原生事件组合而成。部分原生事件不会冒泡,如 focusblurchange 等,但在 React 中,它们都会被模拟成可冒泡事件

基本原理:
React 通过事件委托的方式,将所有事件绑定到最外层容器,每个事件各绑定一次冒泡和捕获阶段(原生不冒泡只绑定捕获阶段)。当一个 React 元素节点触发事件后,在 DOM 上会触发两次 React 的事件统一处理函数,先找到事件源,再根据 return 指针向上遍历,收集所有对应事件、阶段的处理函数,分别形成事件队列,依次执行。

在命名上采用小驼峰 on[<name>][Capture],加上 Capture 表示捕获阶段执行。
事件对象也被封装成了合成事件对象,但仍然可以通过 e.nativeEvent 属性访问原生事件对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
return (
<>
<div
onClick={() => {
console.log("click");
}}
>
点击 div
</div>
<div
onChange={(e) => {
console.log(e);
}}
>
<div
onChangeCapture={(e) => {
console.log(e.nativeEvent);
}}
>
<input
onChange={(e) => {
console.log(e.target.value);
}}
/>
</div>
</div>
</>
);

使用 e.stopPropagation()e.preventDefault() 方法,可以阻止事件的冒泡和默认行为,但这两个方法也是合成事件的封装,并不是原生事件对象的方法。

合成事件与原生 DOM 事件的类型并不一定相同,例如 React 中的 onChange 实际上是 input 事件的封装,其行为也和 onInput 没区别。

state 组件状态

纯函数式的组件不具有状态,但 React 内置了多种 Hook,可以让组件更加灵活,使用状态(state)和其他 React 特性,也就是保持状态、获取上下文缓存计算结果等。

很容易可以想到,局部变量无法在多次渲染中持久保存,更改局部变量也不会触发渲染,毕竟 React 没有 像 Vue 那样使用 Proxy 劫持数据。

useState 应该是学习 React 首个遇到的 Hook。它允许你向组件添加一个状态变量

1
2
import { useState } from "react";
const [count, setCount] = useState(0); // 数组解构赋值

总应该使用 const 变量接收 useState 返回的两个东西。

  1. data:第一个是当前的数据,实际上就是传入 useState 或 setter 的参数,如果传入了引用数据,它们指向的引用地址是相同的。
  2. setter:第二个是 set 函数,用于更新数据,其行为是异步的,触发渲染,会标记该组件需要重新渲染,并在下一次渲染周期内重新渲染该组件。
1
2
3
const obj1 = { num: 0 };
const [obj2, setObj] = useState(obj1);
console.log(obj1 === obj2); // true

React 会在内部维护好当前组件实例的状态,通过组件在渲染树中的位置将它保存的每个状态与正确的组件关联起来,保证多次渲染间不变,返回的是浅拷贝快照。只有在下一次渲染后,才能获取到最新的快照。

更新了数据无法立即拿到,本质是 setObj 传入了新的对象,需要在下一次渲染时重新赋值获取
1
2
3
4
5
6
const [obj, setObj] = useState({ num: 0 });
// 加个判断,否则会进入无限循环,Too many re-renders
if (obj.num === 0) {
setObj({ ...obj, num: 1 });
console.log(obj); // { num: 0 }
}

State 是组件实例内部的状态,渲染同一个组件两次,每个实例都会有完全隔离的 state。

set 函数会浅比较新旧值,如果相同,会认为数据没有变化,不会触发重新渲染。这是 React 的 Immutable 思想的体现,这意味这不能只修改属性,把原来的对象传入 setter,而是应该传入一个新对象,一个新的引用地址。

应该将 state 视为只读的,无论其是基本类型还是引用类型,都应该通过 set 函数来更新,而不是直接修改 state 的值或属性。
为什么在 React 中不推荐直接修改 state?

惰性初始化函数

经常的,初始 state 需要通过一个计算得到,这时可以传入一个函数,而不是直接执行它,函数的返回值会作为初始 state。

useState 会将传入的函数作为一个惰性初始化函数。React 会在组件首次渲染时调用它,并将其(同步的)返回值作为 useState 的初始值。

1
2
3
4
// 只会在首次渲染时调用一次
const [catList, setCatList] = useState(setupCatList);
// 每次重渲染时都会调用,如果计算量大,会影响性能
const [catList, setCatList] = useState(setupCatList());

这个函数应该是同步的,如果存在副作用,应该使用 useEffect Hook。

函数式更新

在一个渲染周期中多次调用 set 函数,可能并没有预期的叠加结果。

1
2
3
const [count, setCount] = useState(0);
setCount(count + 1);
setCount(count + 1);

以原生 JS 的视角来看,这就是设置了两次 setCount(1),下一次渲染时,count 当然是 1。

如果你需要 set 函数根据最新的 set 结果计算新的 state,可以使用函数式更新,传入一个函数,接收最新的 state,返回当前 state 值。这是在 React 内部处理的。

1
2
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);

下一次渲染时,count 就会是 2 了。

在学习 React 时,不要忘记原生 JS 的特性,如闭包异步事件循环等,React 也是利用了这些特性,实现的功能。

Immer 库

1
pnpm i immer use-immer

使用 Immer 库,无需手动创建新对象,而是直接修改 draft 对象,保持代码简洁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { useImmer } from "use-immer";
const [obj1, setObj1] = useState({ num: 0 });
const [obj2, setObj2] = useImmer({ num: 0 });
return (
<div>
<h2>{obj1.num}</h2>
<button
onClick={() => {
setObj1((obj) => {
return { num: obj.num + 1 };
});
}}
>
useState 增加
</button>
<h2>{obj2.num}</h2>
<button
onClick={() => {
setObj2((draft) => {
draft.num++; // 通过 Immer 可以很方便的修改属性值
});
}}
>
useImmer 增加
</button>
</div>
);

实际上 draft 是一个 Proxy 对象,这下很熟悉了,通过 Proxy 拦截对象操作,Immer 就可以知道你修改了什么,然后构建一个新的对象给 State。

更好地管理状态

有关 React 如何更好地管理状态,官网的教程非常详细,状态管理

随着应用不断变大,更有意识的去关注应用状态如何组织,以及数据如何在组件之间流动会对你很有帮助。冗余或重复的状态往往是缺陷的根源。

状态的保留、重置

React 在内部维护了每个组件实例的 state。React 在移除一个组件时,也会销毁它的 state。

换句话说,相同组件被渲染在 UI 树的相同位置,React 就会保留它的 state

对 React 来说,这样渲染 Counter 是相同位置,因为是由三元表达式返回一个组件
1
2
3
4
5
{isPlayerA ? (
<Counter person="Taylor" />
) : (
<Counter person="Sarah" />
)}

对 React 来说重要的是组件在 UI 树中的位置,而不是在 JSX 中的位置。

这样渲染 Counter 是不同位置,因为是两个组件占了两个位置,尽管代码上他们是互斥的
1
2
3
4
5
6
{isPlayerA &&
<Counter person="Taylor" />
}
{!isPlayerA &&
<Counter person="Sarah" />
}

还可以改变 key 属性,强制 React 重新渲染组件,即使它们在 UI 树中的位置没有改变。因为 key 属性是 React 用来区分组件的唯一标识,不同的 key 会被认为是不同的组件

1
2
3
4
5
{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}

需要注意,key 并不是全局唯一的,它只相对于父元素是唯一的。当设置 key 后,React 会将 key 作为组件标识,而不是在父组件 UI 树中的位置。

useReducer

useReducer 是另一个可以用来管理组件状态的 Hook,它是 useState 的替代方案,用于处理复杂的状态逻辑

下面先写一个简单的 todoList。

src\components\Task\type.ts
1
2
3
4
5
6
export interface Task {
id: string | number;
title: string;
done: boolean;
}
export type TaskList = Task[];
src\components\Task\TaskList.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import "./TaskList.css";
import TaskItem from "./TaskItem";
import AddTask from "./AddTask";
import type { Task, TaskList } from "./type";

interface TaskListProps {
taskList: TaskList;
onChangeTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onAddTask: (task: Task) => void;
}

export default function TaskList({
taskList,
onChangeTask,
onDeleteTask,
onAddTask,
}: TaskListProps) {
return (
<div className="task-list">
<AddTask onAddTask={onAddTask}></AddTask>
<ul>
{taskList.map((task) => (
<li key={task.id}>
<TaskItem
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>
</li>
))}
</ul>
</div>
);
}
src\components\Task\TaskItem.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import { useState } from "react";
import { Task } from "./type";

interface TaskProps {
task: Task;
onChange: (task: Task) => void;
onDelete: (task: Task) => void;
}

export default function TaskItem({ task, onChange, onDelete }: TaskProps) {
const [isEdit, setIsEdit] = useState(false);
const [title, setTitle] = useState(task.title);

let taskContent = null;
if (isEdit) {
taskContent = (
<>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
}}
/>
<button
onClick={() => {
if (!title.trim()) {
return;
}
setIsEdit(false);
onChange({ ...task, title });
}}
>
保存
</button>
<button
onClick={() => {
setIsEdit(false);
setTitle(task.title);
}}
>
取消
</button>
</>
);
} else {
taskContent = (
<>
<span>{task.title}</span>
<button
onClick={() => {
setIsEdit(true);
}}
>
编辑
</button>
</>
);
}

return (
<div>
<input
type="checkbox"
checked={task.done}
onChange={() => {
onChange({ ...task, done: !task.done });
}}
/>
{taskContent}
<button
onClick={() => {
onDelete(task);
}}
>
删除
</button>
</div>
);
}
src\components\Task\AddTask.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useState } from "react";
import type { Task } from "./type";

interface AddTaskProps {
onAddTask: (task: Task) => void;
}

export default function AddTask({ onAddTask }: AddTaskProps) {
const [title, setTitle] = useState("");

return (
<div>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
}}
/>
<button
onClick={() => {
if (!title.trim()) {
return;
}
onAddTask({ id: Date.now(), title, done: false });
setTitle("");
}}
>
添加
</button>
</div>
);
}

然后使用该组件。

src\components\Task\TaskApp.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { useState } from "react";
import TaskList, { Task } from "../Task/TaskList";

const initialState = [
{ id: 1, title: "任务1", done: false },
{ id: 2, title: "任务2", done: false },
{ id: 3, title: "任务3", done: false },
{ id: 4, title: "任务4", done: false },
];

export default function TaskApp() {
const [taskList, setTaskList] = useState<Task[]>(initialState);

function handleChangeTask(task: Task) {
setTaskList(
taskList.map((item) => {
if (item.id === task.id) {
return task;
}
return item;
})
);
}

function handleDeleteTask(task: Task) {
setTaskList(taskList.filter((item) => item.id !== task.id));
}

function handleAddTask(task: Task) {
setTaskList([task, ...taskList]);
}

return (
<div>
<TaskList
taskList={taskList}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
onAddTask={handleAddTask}
></TaskList>
</div>
);
}

可以看到,handleChangeTask、handleDeleteTask、handleAddTask 三个函数都是对 taskList 的操作。

目前处理逻辑还较为简单,但随着业务逻辑的增加,这些操作可能会变得复杂,可以使用 useReducer 将状态操作逻辑提取到 reducer 函数中。

首先需要准备一个 reducer 函数,接收两个参数,当前状态action

src\components\Task\TaskApp.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface TaskAction {
type: "add" | "delete" | "change";
payload: Task;
}
function taskListReducer(taskList: Task[], action: TaskAction) {
switch (action.type) {
case "add":
return [action.payload, ...taskList];
case "delete":
return taskList.filter((item) => item.id !== action.payload.id);
case "change":
return taskList.map((item) => {
if (item.id === action.payload.id) {
return action.payload;
}
return item;
});
default:
return taskList;
}
}

reducer 函数和初始状态传给 useReducer,返回当前状态和 dispatch 函数。

src\components\Task\TaskApp.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export default function TaskApp() {
// const [taskList, setTaskList] = useState<Task[]>(initialTasksList);
const [taskList, dispatch] = useReducer(taskListReducer, initialTasksList);

function handleChangeTask(task: Task) {
dispatch({ type: "change", payload: task });
}

function handleDeleteTask(task: Task) {
dispatch({ type: "delete", payload: task });
}

function handleAddTask(task: Task) {
dispatch({ type: "add", payload: task });
}

return (
<div>
<TaskList
taskList={taskList}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
onAddTask={handleAddTask}
></TaskList>
</div>
);
}

传给 dispatch 的对象,称为 action,它会被 reducer 函数的第二个参数接收,是触发动作的描述,可以包含任意字段,通常包含一个 type 字段,用来描述动作的类型,以及一个 payload 字段,用来传递数据。

使用了 useReducer 后,可以将状态操作逻辑提取到 reducer 函数中,使组件更加清晰,也更容易测试和复用。

可以使用 useImmerReducer 继续简化 reducer 函数,不需要手动创建新对象,直接修改 draft 对象。

参考

react.dev
React JSX
React技术揭秘