React入门

本文最后更新于:3 个月前

了解官方 Hooks 的适用场景和用法,了解自定义 Hook 需要注意的问题

React入门

一、官方Hooks

1. useState

什么场景下需要useState?

useState这个Hook提供了两个功能:

  • (1)在组件的多次渲染之间,保存数据
  • (2)在数据发生变化时,触发组件重新熏染

当存在这两种需求时,就应该使用useState()

useState如何使用

1
2
import { useState } from 'react';
const [index, setIndex] = useState(0);

惯例是将这对返回值命名为 const [thing, setThing]。你也可以将其命名为任何你喜欢的名称,但遵照约定俗成能使跨项目合作更易理解。

thing被称为statesetThing被称为state setter

state setter传入 函数/值 的差异

state setter可以接受:

  • 更新函数,eg:n=>n+1。更新函数会被添加到队列中,依次执行
  • 任何其它的值,eg:数字6,其实也相当于更新函数n=>6,只是参数n并没有使用

通过示例了解一下这两者的差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default function Counter() {
const [number, setNumber] = useState(0);

return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>增加数字</button>
</>
)
}
// 点击按钮,最后渲染出的结果是:1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
}}>增加数字</button>
</>
)
}
// 点击按钮,最后渲染出的结果是:3

(更深入的介绍参考把一系列 state 更新加入队列 – React 中文文档 (docschina.org)

当state保存的是对象时

当要修改state中保存的对象时,不应该直接修改存在在state中的对象(或数组)!而需要创建一个对象副本再进行修改!

state的更新需要排队

调用state setter之后,state的更新并不会立即生效,而是将setter()加入到队列中,等待所有的同步代码执行完之后,再执行队列中setter()

2. useReducer

什么是reducer函数

先看一个例子

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
// 首先编写一个reducer函数
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case 'changed': {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('未知 action: ' + action.type);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 然后使用reducer函数
import { useReducer } from 'react';

let nextId = 3;
const initialTasks = [
{id: 0, text: '参观卡夫卡博物馆', done: true},
{id: 1, text: '看木偶戏', done: false},
{id: 2, text: '打卡列侬墙', done: false}
];

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
// useReducer接受两个参数:reducer函数、初始state
// useReducer返回(一个数组)两个元素:有状态的值、dispatch函数

function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}

dispatch()函数的参数被视为action对象,通常,用type来表示用户要发生的动作,并用其它字段来传递额外的信息

reducer函数设计的理念是:它告诉React,发生了什么类型的事件(即调用dispatch()函数);而对应事件的处理逻辑定义在另外一个专门的地方(即reducer()函数)。通过这样来实现:视图与逻辑的分离

回顾对比 state,它是直接告诉React应该做什么,这样做的一个坏处可能是,在视图中夹带了大量的逻辑代码而变得不易阅读和维护,就像下面这样:

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
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}

function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}

function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}

使用reducers需要注意

reducers 必须是纯粹的。 它们不应该包含异步请求、定时器或者任何副作用(对组件外部有影响的操作)。它们应该以不可变值的方式去更新 对象数组

每个 action 都描述了一个单一的用户交互,即使它会引发数据的多个变化。 举个例子,如果用户在一个由 reducer 管理的表单(包含五个表单项)中点击了 重置按钮,那么 dispatch 一个 reset_form 的 action 比 dispatch 五个单独的 set_field 的 action 更加合理。

3. useContext

context的用途

先上结论,Context 用来允许父组件向其下层无论多深的任何组件提供信息,而无需通过 props 显式传递。

下面继续看Context如何使用

context使用方式

使用分为3个步骤,分别是创建Context、提供Context、使用Context

第一步,创建Context

1
2
3
4
5
import { createContext } from 'react';

// 这里的1是默认值,如果在第2步中没有提供Context,第3步中获取到的将是默认值
// 如果你不打算提供默认值,可以传入null
export const LevelContext = createContext(1);

第二步,提供Context。使用 context provider 包裹,并用value提供context的值

1
2
3
4
5
6
7
8
9
10
11
import { LevelContext } from './LevelContext.js';

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

第三步,使用context

1
2
3
4
5
6
7
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

export default function Heading({ children }) {
const level = useContext(LevelContext);
// ...
}

4. useRef

什么时候使用ref

当组件中的某个变量,存在被修改的需求,但是并不需要被同步渲染时,使用ref。

作为对比,可以看一下什么时候应该使用state:数据需要被修改并且修改能够在重新渲染后得到保留而不是被刷新,数据的修改需要引起组件的刷新

如何使用ref

1
2
3
4
5
6
7
import { useRef } from 'react';

export default function Stopwatch(){
const countRef = useRef(null);

countRef.current = 1;
}

使用ref操作DOM

ref的另外一大用途,是用来在React中获取对DOM节点的引用,以实现一些特殊的操作,如:让输入框获得焦点、让DOM节点滚动到可视范围内。下面通过一个示例,演示如何利用ref实现这样的操作:

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>
</>
)
}

但是,该方法只对原生的html元素生效,对自定义组件不生效!如果需要在自定义组件上使用ref,需要结合forwardRef使用,详细请参考:访问另一个组件的DOM节点 – React 中文文档 (docschina.org)

5. useEffect

useEffect的特点

Effect指定由渲染这个行为本身去触发一些行为,而不是由特定事件(如:用户点击)来触发一些行为。

默认情况下,Effect会在每次渲染之后都执行;但如果指定了Effect依赖,它将变成按需执行(减少执行次数)。

1
2
3
4
5
6
function MyComponent() {
useEffect(() => {
// 每次渲染后都会执行此处的代码
});
return <div />;
}

每当你的组件渲染时,React 将更新屏幕,然后运行 useEffect 中的代码。换句话说,useEffect 会把这段代码放到屏幕更新渲染之后执行!即,useEffect会将副作用从渲染过程中分离出去。这也是useEffect存在的作用

避免陷入死循环!

需要特别注意一点:不要在useEffect包裹的副作用中触发组件重新渲染,这会使代码陷入死循环!

1
2
3
4
5
6
7
// 这是一个错误的示例! 以下代码会陷入死循环!
function ErrorExample(){
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
}

不同依赖项的区别

1
2
3
4
5
6
7
8
9
10
11
useEffect(() => {
// 这里的代码会在每次渲染后执行
});

useEffect(() => {
// 这里的代码只会在组件挂载后执行
}, []);

useEffect(() => {
//这里的代码只会在每次渲染后,并且 a 或 b 的值与上次渲染不一致时执行
}, [a, b]);

cleanup清理函数

如何指定 cleanup 函数?如下,在useEffect包裹的函数中,使用return返回一个函数,该函数即为cleanup函数

1
2
3
4
5
6
7
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);

cleanup函数的执行时机?(1)重新执行Effect之前被调用;(2)组件被卸载时被调用

Effect被执行两次?

在开发环境下,useEffect包裹的副作用会被执行两次,这是React”故意“的。下面是官方解释:

想象 ChatRoom 组件是一个大规模的 App 中许多界面中的一部分。用户切换到含有 ChatRoom 组件的页面上时,该组件被挂载,并调用 connection.connect() 方法连接服务器。然后想象用户此时突然导航到另一个页面,比如切换到“设置”页面。这时,ChatRoom 组件就被卸载了。接下来,用户在“设置”页面忙完后,单击“返回”,回到上一个页面,并再次挂载 ChatRoom。这将建立第二次连接,但是,第一次时创建的连接从未被销毁!当用户在应用程序中不断切换界面再返回时,与服务器的连接会不断堆积。

如果不进行大量的手动测试,这样的错误很容易被遗漏。为了帮助你快速发现它们,在开发环境中,React 会在初始挂载组件后,立即再挂载一次。

Effect Event(实验性API)

React 稳定版中 还没有发布的实验性 API

Effect Event用于从useEffect中提取出非响应式的逻辑。下面通过一个示例来了解它的作用:

1
2
3
4
5
6
7
8
9
10
11
// 需求:在连接到一个聊天室的时候,展示一个通知,并且读取出当前的theme值
// 但是下面这段代码有一个弊端:当theme变化的时候也会导致Effect执行一遍:连接聊天室、展示通知;而这并不符合预期
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection('https://localhost:1234', roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);

之所以会出现预期之外的行为,是因为上述代码不得不将theme列为依赖项。下面展示如何破解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}

这个方法解决了问题。注意你必须从 Effect 依赖项中 移除 onConnectedEffect Event 是非响应式的并且必须从依赖项中删除

6. useCallback

用法 & 作用

1
2
3
const cachedFn = useCallback(fn, dependencies)
// fn - 需要缓存的函数
// dependencies - 依赖项,可能是:props, state, 组件内声明的变量、函数

在初次渲染时,useCallback 返回你已经传入的 fn 函数;

在之后的渲染中, 如果依赖没有改变,useCallback 返回上一次渲染中缓存的 fn 函数;否则返回这一次渲染传入的 fn

什么时候需要用useCallback

除非是有特殊需要,否则不必将函数包裹在 useCallback 中!

  • 场景一:将被缓存的函数作为 props 传递给包装在 [memo] 中的组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useCallback } from 'react';

function ProductPage({ productId, referrer, theme }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]);
// ...
return (
<div className={theme}>
<ShippingForm onSubmit={handleSubmit} />
</div>
);
};

默认情况下,当一个组件重新渲染时, React 将递归渲染它的所有子组件

如上,handleSubmit被作为参数传入子组件,如果不将handleSubmituseCallback包裹,那么每次父组件ProductPage重新渲染,都会生成一个新的handleSubmit函数对象(即使它们看起来一模一样),这就会进一步导致子组件也跟着重新渲染;但实际上子组件的重新渲染时是不必要的!!

通过useCallback包裹,如果依赖项不发生变化,handleSubmit也不会发生变化,因此不会引起子组件的重新渲染(前提:子组件使用memo方法包裹。memo的作用是:如果props不发生变化,那么其内部包裹的组件就不会重新渲染

  • 场景二:被缓存的函数可能被作为某些 Hook 的依赖项
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');

const createOptions = useCallback(() => {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}, [roomId]); // ✅ 仅当 roomId 更改时更改

useEffect(() => {
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // ✅ 仅当 createOptions 更改时更改
// ...

如果不将createOptions包裹,ChatRoom组件每次重新渲染都会重新连接聊天室。但其实除了使用useCallback,针对上面这种问题,还有另外一种处理方法——将createOptions函数移入 useEffect 内部!

  • 场景三:如果是自定义Hook,建议将所有返回的函数都用 useCallback 包裹(但这是为什么呢??)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function useRouter() {
const { dispatch } = useContext(RouterStateContext);

const navigate = useCallback((url) => {
dispatch({ type: 'navigate', url });
}, [dispatch]);

const goBack = useCallback(() => {
dispatch({ type: 'back' });
}, [dispatch]);

return {
navigate,
goBack,
};
}

7. useMemo

用法 & 作用

1
2
3
const cachedResult = useMemo(calculateValue, dependencies)
// 第一个参数,要缓存计算值的函数,没有任何参数的纯函数。这个函数会被调用,然后其返回值被缓存起来
// 第二个参数,依赖项数组

在初次渲染时,你从 useMemo 得到的 值 将会是你的 calculation 函数执行的结果。

在随后的每一次渲染中,React 将会比较前后两次渲染中的 所有依赖项 是否相同。如果通过 Object.is 比较所有依赖项都没有发生变化,那么 useMemo 将会返回之前已经计算过的那个值。否则,React 将会重新执行 calculation 函数并且返回一个新的值。

适用场景

使用 useMemo 进行优化仅在少数情况下有价值:

  • 你在 useMemo 中进行的计算明显很慢,而且它的依赖关系很少改变
  • 将计算结果作为 props 传递给包裹在 memo 中的组件。当计算结果没有改变时,你会想跳过重新渲染。记忆化让组件仅在依赖项不同时才重新渲染。
  • 你传递的值稍后用作某些 Hook 的依赖项。例如,也许另一个 useMemo 计算值依赖它,或者 useEffect 依赖这个值。

二、Hooks

1. 什么是Hook?

Hooks ——以 use 开头的函数——只能在组件或自定义 Hook 的最顶层调用。 你不能在条件语句、循环语句或其他嵌套函数内调用 Hook

我的理解:hooks就是抽离出公共的函数逻辑(以达到复用的目的),component组件就是抽离出公共的函数逻辑 + UI(以达到复用的目的),hooks就是组件少了UI那部分。

下面这个是React Hooks 入门教程 - 阮一峰的网络日志 (ruanyifeng.com)中的例子,可以很好地帮助理解:如何自定义hooks,什么是hooks

首先是不封装hooks的版本:(这是一个展示个人信息的组件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const Person = ({ personId }) => {
const [loading, setLoading] = useState(true);
const [person, setPerson] = useState({});

useEffect(() => {
setLoading(true);
fetch(`https://swapi.co/api/people/${personId}/`)
.then(response => response.json())
.then(data => {
setPerson(data);
setLoading(false);
});
}, [personId])

if (loading === true) {
return <p>Loading ...</p>
}

return <div>
<p>You're viewing: {person.name}</p>
<p>Height: {person.height}</p>
<p>Mass: {person.mass}</p>
</div>
}

下面我们将「纯函数」之外的部分(即:外部功能、副作用等等)提取出来,封装成单独的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const usePerson = {personId} => {
const [loading, setLoading] = useState(true)
const [person, setPerson] = useState()

useEffect(()=>{
setLoading(true)
fetch(`https://swapi.co/api/people/${personId}/`)
.then(response => response.json())
.then(data => {
setPerson(data);
setLoading(false);
});
},[personId])

return [loading, person]
}

然后,看看应该如何使用自定义hooks

1
2
3
4
5
6
7
8
9
10
11
12
13
const Person = ({personId} => {
const [loading, person] = usePerson(personId)

if (loading === true) {
return <p>Loading ...</p>
}

return <div>
<p>You're viewing: {person.name}</p>
<p>Height: {person.height}</p>
<p>Mass: {person.mass}</p>
</div>
})

2. Hook公约

  • 命名方式:Hook 的名称必须以 use 开头,然后紧跟一个大写字母,就像内置的 useState 或者本文早前的自定义 useOnlineStatus 一样。
  • 返回值:Hook 可以返回任意值
  • 调用:Hook只能在组件顶层调用;而且不能再循环或条件语句中调用

三、其它API

1. flushSync同步更新DOM

React 执行完封装在 flushSync 中的代码后,会立即同步更新 DOM!

1
2
3
4
flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
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
import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

export default function TodoList() {
const listRef = useRef(null);
const [text, setText] = useState('');
const [todos, setTodos] = useState(
initialTodos
);

function handleAdd() {
const newTodo = { id: nextId++, text: text };
flushSync(() => {
setText('');
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}

return (
<>
<button onClick={handleAdd}>
添加
</button>
<input
value={text}
onChange={e => setText(e.target.value)}
/>
<ul ref={listRef}>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</>
);
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
initialTodos.push({
id: nextId++,
text: '待办 #' + (i + 1)
});
}

更多细节介绍见:用flushSync同步更新state – React 中文文档 (docschina.org)

2. memo缓存组件

如果将组件用memo包裹之后,当组件的props与上一次渲染相同,组件将跳过重新渲染。

1
2
3
4
5
import { memo } from 'react'
const YourComponent = memo((props)=>{
//...
return (...)
})

四、生命周期

每个 React 组件都经历相同的生命周期:

  • 当组件被添加到屏幕上时,它会进行组件的 挂载
  • 当组件接收到新的 props 或 state 时,通常是作为对交互的响应,它会进行组件的 更新
  • 当组件从屏幕上移除时,它会进行组件的 卸载

只有两件事可以触发React组件的重新渲染:(1)组件的props发生变化;(2)组件中的state变量发生变化。

重新渲染时,组件中的所有代码将重新执行一遍(当然也有例外,如指定了依赖项的useEffectuseCallbackuseMemo等语句块)

QA

  • Q:在什么场景下必须/需要使用 useState、useRef、useEffect、useCallback?
    A:在上面各自的介绍中进行了回答
  • Q:React组件的生命周期是怎样的?React组件在哪些情况下会发生重新渲染?
    A:

链接

轻松学会 React 钩子:以 useEffect() 为例 - 阮一峰的网络日志 (ruanyifeng.com)

React Hooks 入门教程 - 阮一峰的网络日志 (ruanyifeng.com)

React 中文文档 (docschina.org)


React入门
http://timegogo.top/2023/07/16/React/React入门/
作者
丘智聪
发布于
2023年7月16日
更新于
2023年11月26日
许可协议