为最新的 next.js 做点基础功课,学习 react18 的文档

记录一些对比之前不太一样的地方或者值得注意的地方

也会相对用的比较多的 Vue3,谈一些 React18 的理解

纯函数

react 特别强调函数要纯

不论是组件,还是对 state 的修改,一定要保持函数的纯粹性

https://zh-hans.react.dev/learn/keeping-components-pure

一个函数只做一件事情
输入相同,则输出相同
纯函数仅仅执行计算,因此调用它们两次不会改变任何东西

React 假设你编写的所有组件都是纯函数

不应该改变任何用于组件渲染的输入。这包括 props、state 和 context。通过 state 来更新界面,而不要改变预先存在的对象。

编写纯函数是编写 react 组件的前提条件

与函数的纯粹性相对的副作用,应该在 事件处理 或者 useEffect 中处理

严格模式

https://zh-hans.react.dev/learn/keeping-components-pure#detecting-impure-calculations-with-strict-mode

React 提供了 “严格模式”,在严格模式下开发时,它将会调用每个组件函数两次。通过重复调用组件函数,严格模式有助于找到违反这些规则的组件。

引入严格模式,可以用 <React.StrictMode> 包裹根组件。一些框架会默认这样做。

state 的快照理解和 Immer

下面这个例子,点击一次并不会使 number 的结果从 0 变成 3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useState } from "react";

export default function Counter() {
const [number, setNumber] = useState(0);

return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}
>
+3
</button>
</>
);
}

因为其等价于

1
2
3
4
5
6
7
8
9
<button
onClick={() => {
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);
}}
>
+3
</button>

哪怕用 setTimeout 包裹其中一个 setTimeout 也不会影响结果:

1
2
3
4
5
6
7
<h1>{number}</h1>   // 0 -> 5
<button onClick={() => {
setNumber(number + 5);
setTimeout(() => {
alert(number); // 0
}, 3000);
}}>+5</button>

一个 state 变量的值永远不会在一次渲染的内部发生变化, 即使其事件处理函数的代码是异步的。
React 会使 state 的值始终“固定”在一次渲染的各个事件处理函数内部。

渲染期间改变 state 的值

setNumber 的参数写成一个 纯函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useState } from "react";

export default function Counter() {
const [number, setNumber] = useState(0);

return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
}}
>
+3
</button>
</>
);
}

在一次渲染期间,会产生一个 state 的更新队列。例如上面的 state 更新队列可以表示为 [(n) => n + 1,(n) => n + 1,(n) => n + 1]

更新对象形式的 state

https://zh-hans.react.dev/learn/updating-objects-in-state#treat-state-as-read-only

如果 state 是一个对象,这个时候直接通过对象的引用关系去修改 state 是不正确的,这不会触发渲染

1
2
3
const [position, setPosition] = useState({ x: 0, y: 0 });

position.x = 1; // 当你这样做时,就制造了一个 mutation, state 应该是只读的

正确的写法应该是

1
2
3
4
setPosition({
y: 0,
x: 1,
});

如果对象复杂嵌套,这样书写也将变得非常繁琐,官方会推荐使用 Immer

使用 Immer

Immer 有点像 Vue3 中的 reactive,其原理都是使用 Proxy 记录对 state 的修改,并创建出新的对象

安装:npm install use-immer

1
2
const [position, updatePosition] = useImmer({ x: 0, y: 0 });
updatePosition((draft) => (draft.x = 1));

更新数组形式的 state

不论是数组还是对象,都要保持 state 的只读性

在使用setState去更新数组的时候,应该避免使用会修改原数组的方法,比如 push,unshift,pop,shift,splice,splice,arr[i] = ...,reverse,sort

如果使用 useImmer 声明 state,则可以使用所有的方法

状态管理的原则

这一部分算是响应式框架通用的一些原则,遵守这些原则,在代码组织上会显得更加合理

设计组件的思路

https://zh-hans.react.dev/learn/reacting-to-input-with-state#thinking-about-ui-declaratively

  1. 分析组件所有可能存在的状态
  2. 确定哪些因素可以改变这些状态,人为输入 or 计算机输入
  3. 通过代码表示状态,useState 或者 ref
  4. 连接状态和事件处理函数

构建 state 的原则

合并相关联的 state

如果一个动作往往要改动两个 state,两个 state 又相互独立,那么可以把两个 state 用一个对象包裹起来,每次都修改这个对象

避免矛盾的 state

用 一个 state 表示 typing sending sent 比 三个 state 表示 isTyping isSending isSent 要更好

用尽可能少的 state

相对来说,组件状态越少,组件维护起来的成本就越小。避免冗余的和重复的 state

没有必要把 props 镜像出一个 state

避免深度嵌套的 state

尽可能创建扁平化的 state

组件之间的 state

如果子组件之间有相互影响的 state,那么最好把它交给父组件管理

对于独特的状态,都应该有单一的数据源(state),这个 state 可以被传递,但是最好不要复制

组件内 state 的移除和保留

和 vue 一样,react 也有特殊的属性 key,更新 key 就会强制重置组件

统一管理状态和动作: 编写 reducer

https://zh-hans.react.dev/learn/extracting-state-logic-into-a-reducer#step-2-write-a-reducer-function

当组件的状态非常多的情况下,频繁地 setState 会让代码难以维护

如果能把更新状态的操作封装起来,用一个动作去表示,然后传入参数,就可以使引用更加清晰

reducer 是一个调度器,接收并执行不同的动作,返回新的结果

dispatch 用来发出动作,给出动作和计算新状态所需要的额外信息

要将状态设置逻辑从事件处理程序移到 reducer 函数中,你需要:

  1. 声明当前状态(tasks)作为第一个参数;
  2. 声明 action 对象作为第二个参数;
  3. 从 reducer 返回 下一个 状态(React 会将旧的状态设置为这个最新的状态)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function reducer(state, action) {
switch(action) {
case: 'action A' {
const stateA = {
...state,
// 一些因为 action A 而产生的变化
}
return stateA
}
case: 'action B'{
const stateB = {
...state,
// 一些因为 action B 而产生的变化
}
return stateB
}
default: {
throw Error('未知 action: ' + action);
}
}
}

使用 useReducer 替代 useState

https://zh-hans.react.dev/reference/react/useReducer#dispatch

1
2
3
4
5
6
7
8
9
import { useReducer } from "react";
const [state, dispatch] = useReducer(reducer, initStates);

function handleEvent() {
dispatch({
type: "action",
// new state
});
}

与 setState 相同,reducer 也必须是纯函数,在修改对象或者数组的状态时,也需要替换整个数组或者对象

可以用 useImmerReducer 替换 useReducer 来简化 reducer 的写法

使用 context 深层传递参数

React 的 context 特性让我想到了 vue 的 provide 和 inject

两者都是解决组件参数传递层级过深的问题,只是写法不一样而已

创建 context

1
2
3
4
// 创建一个context, context.js
import { createContext } from "react";

export const Context = createContext(1);

父组件提供 context

1
2
3
4
5
6
7
8
9
import { Context } from "context.js";

export default function Section({ level, children }) {
return (
<section>
<Context.Provider value={level}>{children}</Context.Provider>
</section>
);
}

子组件接收 context

1
2
3
4
5
6
7
import { useContext } from "react";
import { Context } from "context.js";

export default function Child() {
const level = useContext(Context);
return (...)
}

结合 useReducer 和 context,创建局部 store

https://zh-hans.react.dev/learn/scaling-up-with-reducer-and-context#combining-a-reducer-with-context

useReducer 可以将对数据的修改整理成 action ,通过 dispatch 一个动作更新状态

createContext 提供了一个局部共享的状态

useReducer 所提供的 statedispatch函数 通过 context 传递给子组件,那么子组件就可以通过 action 修改父组件提供的状态了

这样做的好处是

  1. 将 reducer 封装组件,子组件不需要关心父组件动作的对状态的具体修改,只需要派发动作就可以修改父组件的状态
  2. 把 context 和 reducer 放在一起统一管理,使得负责构建 UI 的文件更加纯粹,视图和逻辑相分离
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
// AppContext.jsx ---------------------------
import { useReducer, createContext } from "react";

const AppContext = createContext();
// reducer 并不需要暴露出去
function reducer() {
// ...
}

export function AppContextProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
}

// 父组件, 提供 context ------------

import { AppContextProvider } from "AppContext.jsx";

export default function ({ children }) {
return <AppContextProvider>{children}</AppContextProvider>;
}

// 子组件, 消费 context --------------

import { useContext } from "react";

export function useAppContext() {
const context = useContext(AppContext);
if (!context) {
throw new Error("useAppContext must be used within an AppProvider");
}
return context; // { state, dispatch }
}

react 里的 useRef

在我理解中的 Vue 和 React 框架设计的区别是,Vue 是响应性数据驱动更新视图,而 React 是状态驱动更新视图。
Vue 中的响应性数据:ref()reactive() 声明的数据承载了表示状态的功能的同时也承载了可变性数据计算的功能。
React 的状态和可变性数据是分开的两个概念,state 只能由纯函数修改,并且具有不可变性,用来更新视图。ref 可以被随意修改,用来做与视图无关的计算
所以 React 中的 ref 并不会像 Vue 中的那样触发视图更新,只能用来承载组件内的数据计算或者 dom 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 点击按钮后,打印结果会变成 1 ,但是视图依然是0
export default function MyRefComponent() {
const ref = useRef(0);
function handleChange() {
ref.current += 1;
console.log(ref.current);
}
return (
<div>
<p>{ref.current}</p>
<button onClick={handleChange}>+1</button>
</div>
);
}

useRef 的 dom 操作

在这方面,react 和 vue 的差别不大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useRef } from "react";

export default function Form() {
const inputRef = useRef(null);

function handleClick() {
inputRef.current.focus();
}

return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}

使用 flushSync 更新 state

flushSync 可以确保其回调函数内的更新立即反映到 dom 上
它和 nextTick 解决的问题相似:视图的更新是异步的,某些情况下希望在操作数据之后立刻对新的 dom 进行操作
如果回调内有大量的 dom 更新,将会出现性能问题

官方给出的例子

处理渲染本身产生的副作用:Effect

组件的状态变更会引起渲染,渲染会引起一些副作用,有些时候需要处理渲染引起的副作用,这就会用到 useEffect

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useEffect } from "react";

function MyComponent() {
useEffect(
() => {
// ...
return () => {}; // 组件在重新执行effect之前,以及组件被卸载的时候,会调用这个清理函数
},
[
/* 指定依赖 */
]
);
}

useEffect 的回调函数会在渲染的提交阶段(也就是屏幕更新渲染之后)执行,使用 useEffect 需要显式指定依赖

指定依赖的数组中可以省略 ref

开发环境中 Effect 的重复执行问题

重复执行 React 的调试行为,Effect 的双重执行有利于开发者检测自己的 Effect 是否有清理函数终止掉多余的副作用

https://zh-hans.react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development

Effect 常见用法

数据获取

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useEffect, useState } from "react";

function DataFetchingComponent() {
const [data, setData] = useState(null);

useEffect(() => {
fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => setData(data));
}, []); // 空数组表示这个 effect 只在组件挂载时执行一次

return <div>{data ? JSON.stringify(data) : "Loading..."}</div>;
}

订阅或监听事件

在组件挂载时注册事件监听器或订阅某些服务,并在组件卸载时取消订阅。这些操作包括 WebSocket 连接、Redux store 订阅、浏览器事件(如键盘或鼠标事件)等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useEffect } from "react";

function EventListenerComponent() {
useEffect(() => {
const handleResize = () => {
console.log("Window resized");
};
window.addEventListener("resize", handleResize);

// 清理函数,组件卸载时移除事件监听器
return () => {
window.removeEventListener("resize", handleResize);
};
}, []); // 空数组,effect 只在挂载和卸载时执行

return <div>Resize the window and check the console.</div>;
}

定时器或者间隔

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useEffect, useState } from "react";

function TimerComponent() {
const [count, setCount] = useState(0);

useEffect(() => {
const interval = setInterval(() => {
setCount((c) => c + 1);
}, 1000);

// 清理函数,组件卸载时清除定时器
return () => clearInterval(interval);
}, []); // 空数组,effect 只在挂载时执行一次

return <div>Count: {count}</div>;
}

依赖变化

类似于 Vue 中的 watch

1
2
3
4
5
6
7
8
9
import { useEffect, useState } from "react";

function CounterComponent({ count }) {
useEffect(() => {
console.log("Count changed:", count);
}, [count]); // 只有 count 改变时,effect 才会重新执行

return <div>Count: {count}</div>;
}

手动操作 dom

利用了 useEffect 在组件挂载之后执行一次的特性

不需要 Effect 的常见场景

https://zh-hans.react.dev/learn/you-might-not-need-an-effect

Effect 的生命周期

在 React 中,React 希望开发者将 Effect 的生命周期与组件的生命周期区分开来看待

组件的生命周期:

挂载 -> 更新 -> 卸载

Effect 的生命周期:

启动(Effect 函数执行) -> 依赖项更新(Effect 函数执行) -> 停止(清理函数执行)

即使没有依赖项,Effect 也会按照这个生命周期执行(仅执行一次,因为没有依赖项,自然也没有更新)

使用自定义 hook 抽离公共逻辑

Vue3 中也有类似的例子,在组件 setup 的时候,执行一些方法(同样约定以 use 开头),为组件提供一些预先封装好的功能

这是 React 给出的一个自定义 hook,用来给组件提供一个封装好 onlineoffline 事件并给出 isOnline 返回值的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}

hook 和组件

React 应用是由组件构成,而组件由内置或自定义 Hook 构成。

React 约定
React 组件名称必须以大写字母开头
Hook 的名称必须以use开头,hook 可以返回任意值

关于 hook 的命名原则,是否所有渲染期间的调用函数都应该以 use 开头

使用 hook 封装的公共方法,应该被视为 hook,并以use作以标识

hook 的内容

自定义 hook 虽然可以提供 state,但是其重点在于所封装的逻辑,同时必须保持每次调用 hook 的独立性

自定义 hook 必须是纯函数

自定义 Hook 共享的只是状态逻辑而不是状态本身。对 Hook 的每个调用完全独立于对同一个 Hook 的其他调用。

学习 react18 过程中的术语概念

state & 快照

纯函数 & effect

ref

prop & context

hook

reducer & dispatch & action