这期开始,将开启系列文章的第二部分——基础知识。接下来,会开始系统介绍 React Hooks API。
本文将结合例子介绍 useState, useReducer
原文链接:https://daveceddia.com/usestate-hook-examples/
https://daveceddia.com/usereducer-hook-examples
4 个例子带你认识 useState
React hooks 中有不少的钩子,其中 useState
可以说是使用频率最高的钩子之一。在本文中,我们将讨论如何应用 useState
操作一般类型、对象、数组,还将介绍几种操作复杂多值数据的方法。
在 React 16.8 版本发布之前,如果你使用函数创建了一个组件,而在后续的迭代中,发现需要添加状态值,那么你必须将该组件转化为一个类组件。今天,你可以通过 useState
实现完全一样的功能效果,提升自己的工作效率。
如果你是第一次使用 React,这里建议你先阅读并学习 React 的基础知识,再回来学习
useState
的高级用法。
React.useState
都做了些什么?
useState
钩子让你可以在函数组件中添加状态。在函数组件中调用 React.useState
之后,将在该组件中关联一个简单的状态。(每一个钩子都以 ”use“ 作为前缀,useState
如字面意思,让你在函数组件中使用状态)
在类组件中,state
通常是一个对象,我们会将多个数值存储到这个对象中。但使用 hooks,state
可以是任意你想要的类型——你可以使用一个数组状态、一个对象状态、一个数值状态、一个布尔值状态、一个字符串状态,任意你想定义的类型都可以。每一次调用 useState
,都会创建一个单独的状态,可以向其中存储一个任意类型的值。
useState
钩子更适用于处理小组件的一些本地状态,对于大体积的 app,或者是以后可能扩展的组件,可能需要采用其它的状态管理方法来扩展 useState
。
例一:显示/隐藏一个组件(布尔状态)
这个例子中的组件内部会显示一部分文字,同时底部会有一个 ”更多“ 的链接,当点击 ”更多“ 时,将显示剩余部分的文本信息。
阅读注释来了解这个过程到底发生了什么。
// 首先:导入 useState,这是 'react' 导出的一个变量名
// 如果想要略过导入这一步骤,则需要在函数体中使用 React.useState
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
// 该组件需要传入两个参数
// text - 需要展示的文本
// maxLength - 在 “更多”之前需要显示的文本字段长度
function LessText({ text, maxLength }) {
// 创建一个独立的状态,并初始化为 `true`
// `hidden` 状态将始终保存最新的状态值
// `setHidden` 方法允许我们更新 `hidden` 的值
const [hidden, setHidden] = useState(true);
// 如果文本信息很短,则直接渲染即可
if (text.length <= maxLength) {
return {text};
}
// 渲染文本(部分或全部),后接一个链接以折叠/展开
// 当点击链接时,更新 `hidden` 的值,这会触发组件的重新渲染
return (
{hidden ? `${text.substr(0, maxLength).trim()} ...` : text}
{hidden ? (
setHidden(false)}> read more
) : (
setHidden(true)}> read less
)}
);
}
ReactDOM.render(
,
document.querySelector('#root')
);
仅仅使用一行代码,我们就让这个函数变成有状态的:
const [hidden, setHidden] = useState(true);
之后,”更多“ / ”收起“ 的链接只需要在点击时,调用 setHidden
方法即可。
useState
返回一个有两个元素的数组,使用 ES6 的解构赋值来取值。第一个元素是当前状态的值,第二个元素是状态赋值函数——调用时,只需要传入一个新值,状态将自动更新为新的值,并且组件也将触发重新渲染。
但是,useState
到底做了些什么呢?如果在每次 render 时都被调用(确实是这样的!),那么它如何保存状态值呢?
诡异的钩子
背后的 ”魔力“ 是,React 会在幕后为每个组件维护一个对象,在这个持久对象中,有一个「状态单元」数组。当你调用 useState
时,React 会将该状态存储在下一个可用的单元格中,并将指针移至下一位(数组索引)。
假设你的钩子总是以相同的顺序调用(如果你遵循 Hooks 的使用规则的话,它将是如此),React 便能够根据调用顺序找到上一次的状态值。也就是说,组件内第一个调用 useState
设置的状态被存储在第一个数组元素中,第二个设置的状态被存储在第二个数组元素中,以此类推。
这并不惊奇,但这依赖于一个你可能没有想到的事实:React 本身就在调用你的组件,所以它可以预先进行设置。
// React 在做某些事情的伪代码
// 追踪有哪些组件正在被调用
// 维护一个 hooks 列表
currentComponent = YourComponent;
hooks[currentComponent] = []
// 调用组件。如果内部有使用 useState,则更新上述的 `hooks`
YourComponent();
例二:根据前一个状态值更新当前状态值(数值状态)
接下来,我们会创建一个简单的”步骤追踪器“。每次执行一步操作,比如简单地点击按钮。当天结束时,它会告诉你执行了多少步骤。
import React, { useState } from 'react';
function StepTracker() {
const [steps, setSteps] = useState(0);
function increment() {
setSteps(prevState => prevState + 1);
}
return (
Today you've taken {steps} steps!
);
}
ReactDOM.render(
,
document.querySelector('#root')
);
首先,我们使用 useState
创建了一个独立的状态,并初始化为 0。它返回一个数组,包括初始值和一个用于状态更新的函数,将其解构赋值到 steps
和 setSteps
中。此外,还实现了一个 increment
函数用于增加步骤计数器。你可能注意到这里使用一个函数或者说是 ”更新程序“ 作为 setSteps
的参数,而不是传递一个值。
React 会调用更新函数,并传入上一个状态值,使用返回值更新状态值。在这个例子中,使用了 preState
来命名,实际上,你可以使用任意符合命名规范的变量命名。
我们也可以直接调用 setSteps(steps + 1)
,它可以实现一样的功能。这里使用 ”更新程序“ 的传参方式,是因为当你的更新发生在一个捕获了过去状态值的闭包中,它将特别有用。
我们做的另外一件事是额外写了一个独立的 increment
函数,而不是直接使用箭头函数直接内敛与 button 的 onClick
参数中。我们也可以这么写,它具有同样的效果:
两种实现方法都是可行的。当函数很复杂,我一般会将它独立在外,而当函数比较简单时,我就直接内联了。
例三:数组状态
注意:状态可以保存任意类型的值,这是一个使用 useState
保存数组状态的示例。在本例中,在文本框中输入文本并回车时,将把它添加到列表中。
function ListOfThings() {
const [items, setItems] = useState([]);
const [itemName, setItemName] = useState("");
const addItem = event => {
event.preventDefault();
setItems([
...items,
{
id: items.length,
name: itemName
}
]);
setItemName("");
};
return (
<>
{items.map(item => (
- {item.name}
))}
>
);
}
我们调用 useState
并传入一个空数组 []
作为初始值,接下来我们看一下 addItem
函数。状态更新器(这里指 setItems
)并没有 “合并” 新旧两个值,而是直接使用新的数值重写状态。这点跟类组件中的 this.setState
有很大的不同。为了将新元素加入数组中,我们使用了 ES6 的展开操作符 ...
将已有的元素复制到新数组中,并在最后插入新的数组。
另外,这个示例使用了 const
和箭头函数,而不是上一个示例中的 function
,只是为了说明两种声明都是可行的。
例四(1):多次调用 useState
如果你想要在函数组件中存储多个变量,你有两种可选的方法:
- 多次调用
useState
- 将所有变量存到一个对象中
多次调用 useState
并没有什么不合适,而且这也是我常用来存储多个变量值的方法。一旦你有 4、5 个 useState
时,可能会有一点点笨重,但只要你自己不在意,React 也不会报错。
接下来,看看你该怎样多次调用 useState
来存储 usename
和 password
。
function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const printValues = e => {
e.preventDefault();
console.log(username, password);
};
return (
);
}
你会发现这和单次调用 useState
没有特别大的区别。每次调用都会创建一个独立的状态和对应的更新函数,将其赋值给相关变量。当用户在输入框中输入时,将触发 onChange
事件,事件监听函数接收到输入事件对象并使用其更新 username
/ password
的状态值。
例四(2):对象状态
我们以第二种方法实现与上例相同的登录功能,你可以对比之后选择你喜欢的。为了能够一次调用 useState
存储多个值,你需要将这些值包裹到一个对象中,同时在更新状态时,你需要特别小心。
function LoginForm() {
const [form, setState] = useState({
username: '',
password: ''
});
const printValues = e => {
e.preventDefault();
console.log(form.username, form.password);
};
const updateField = e => {
setState({
...form,
[e.target.name]: e.target.value
});
};
return (
);
}
首先,我们使用一个对象来初始化一个独立的状态:
const [form, setState] = useState({
username: '',
password: ''
});
这看起来和在类组件中初始化没什么区别。
然后需要一个函数来控制提交,包括了一个 preventDefaul
来阻止页面刷新和打印表单值。
const printValues = e => {
e.preventDefault();
console.log(form.username, form.password);
};
(我们会调用更新方法 setState
,实际也可以取别的名字)
updateField
函数中是实际的执行函数,内部调用了 setState
并传入一个对象,如果不想重写状态的话就需要保证使用 ...form
复制了已有的状态值。可以尝试移除 ...form
看看会有什么变化。
在底部,我们用一个非常标准的 JSX 块来呈现表单及其输入。我们给输入框定义了一个 name
属性,updateField
函数会利用它更新对应的状态属性。这样可以避免你对每一个字段编写对应的事件处理函数。
如果你需要处理复杂的状态,那么使用 useState
存储多个值可能会有点麻烦。
下一章节,我们将讨论 useReducer
,更适用于管理包含多个值的状态。
带你认识 useReducer
“reducer” 这个词可能会让你想起 Redux ——但我像你保证,在阅读本章之前,你并不需要理解 Redux,或者使用过 useReducer
这一钩子。
我们将讨论 “reducer” 到底是什么,你应该怎么充分利用 useReducer
的有点来管理组件内的复杂状态,以及这个钩子函数对 Redux 意味着什么。
在本章中,我们关注 useReducer
钩子,在管理复杂状态时,它比 useState
要有用得多。
什么是 Reducer ?
“reducer” 是一个需要 2 个参数值并返回 1 个值得函数。(如果你使用过 Redux 或者数组中的 reduce
方法,你估计已经知道 “reducer” 是什么了!)
如果你有一个数组,你希望把数组中的元素合并为一个值,最有效的代码实现就是使用数组的 reduce
方法。比如,如果你想把一个数组中的数字进行求和,你可以写一个 reducer 函数,并将其传入到 reduce
中,如下所示:
let numbers = [1, 2, 3];
let sum = numbers.reduce((total, number) => {
return total + number;
}, 0);
如果你在此之前没有看过类似的用法,你可能觉得不可思议。这个函数将对数组中的每一个元素都执行一遍,传入上一次计算结果 total
和当前的元素 number
, 返回的值将作为下一次的 total
传入。reduce
方法的第二个参数(示例中为 0
)会作为 total
的初始值。在本例中,传入 reduce 的函数将被执行 3 次:
- 传入
(0,1)
,返回 1 - 传入
(1, 2)
,返回 3 - 传入
(3, 3)
, 返回6 -
reduce
返回 6,并保存到sum
中。
useReducer
都做了些什么?
我花了很多篇幅去解释数组的 reduce
方法,因为 useReducer
也是传入同样的参数,基本上也是同样的执行逻辑。你传入一个 reducer 函数和一个初始值(初始化状态)。 reducer 接收当前的 state
和一个 action
,最后返回一个新的 state
。写一个类似于求和的 reducer:
useReducer((state, action) => {
return state + action;
}, 0);
这个方法怎么被执行? action
又是怎么传入的?emmmm……这是个好问题。
useReducer
返回一个包含两个元素的数组,这点与 useState
钩子相似。第一个返回当前的状态值,第二个是一个 dispatch
函数,如下所示:
const [sum, dispatch] = useReducer((state, action) => {
return state + action;
}, 0);
我们使用了 ES6 的解构赋值语法将数组中的 2 个值复制到 sum
和 dispatch
变量中。必须提醒一下,“state” 可以是任意类型的值,不一定非得是对象,也可以是数值、数组或其它类型。
下面的例子将在组件中使用 useReducer
来增加计数值:
import React, { useReducer } from 'react';
function Counter() {
// First render will create the state, and it will
// persist through future renders
const [sum, dispatch] = useReducer((state, action) => {
return state + action;
}, 0);
return (
<>
{sum}
>
);
}
你可以看到单击按钮时,将分派一个值为 1 的 action
,当前状态值将增加对应的步长,然后组件使用新的状态值(更大的!)重新渲染。
在这个示例中,我特地避免 “action” 像 {type: "INCREMENT_BY", value: 1 }
的结构,因为 hooks 中创建的 reducers 不需要遵循 Redux 的经典模式。Hooks 代表了一种全新的开发模式:如果你觉得过去的规则更有价值,你可以保留,当然你也可以改变过去的模式。
一个更复杂的示例
接下来的例子,会更接近于传统的 Redux reducer
。我们将创建一个用于管理购物清单的组件,这里我们会提及另外一个钩子:useRef
。
首先,我们需要引入两个钩子:
import React, { useReducer, useRef } from 'react';
接着,在组件中创建一个 ref 和一个 reducer。ref 将保存表单中的 input 节点,以便于直接获取到它的值。(我们也可以用状态来管理 input 的输入值,像之前那样使用功能 value
和 onChange
属性来更新和获取它的值,但这里是介绍 useRef
的好机会!)
function ShoppingList() {
const inputRef = useRef();
const [items, dispatch] = useReducer((state, action) => {
switch (action.type) {
// do something with the action
}
}, []);
return (
<>
{items.map((item, index) => (
-
{item.name}
))}
>
);
}
这里的状态是一个数组,我们将其初始化为一个空数组( useReducer
的第二个参数就是初始值)。后续,我们会补充完整 reducer 函数,返回一个新的数组。
useRef
都做了些什么?
这里先插叙一下 useRef
,解释一个 useRef
都做了些什么。
useRef
钩子允许你创建一个持久化的 ref,指向 DOM 节点,当然,也可以指向任意的值。React 会在组件的整个生命周期中,保持该值的一致性。
在这里使用是因为我们不能将数值存储在本地变量中——一旦组件返回,就跳出了作用域(记住,React 组件本身只是一个函数)。
调用 useRef
创建一个默认的空对象,或者你也可以传入参数将其初始化为别的值。这个对象永远有一个 current
属性,我们可以使用 inputRef.current
获取到保存的值。别忘了 .current
!
如果你熟悉 React.createRef()
的话,会发现这两者十分相似。要点就是:将一个 ref 对象传给 DOM 元素的 ref
属性,React 就会在渲染后自动将该 ref 对象的 current
属性关联到 DOM 元素上。于是,你就可以使用 theRefVarible.current
获取到 DOM 节点。
useRef
所返回的对象不仅仅可以用于保存 DOM 节点,也可以保存组件实例中的任意类型的值,并且渲染过程中保持不变。
useRef
可用于创建泛型实例变量,如同你可以在 React 类组件中随便使用 this.whatever = value
。唯一不同的是,值的写入近似于一个副作用,应尽量避免在渲染过程中修改 ref,最好只在事件回调或 useEffect
中更改它。你可以查阅 Hook FAQ 中的示例。
回到 useReducer
的案例
-
增加项
案例中,我们使用 form
包裹了输入框,如此按下回车键时就会触发 submit
函数(这是 HTML 中表单的默认行为)。现在,需要编写 handleSubmit
,该函数会x向列表添加一个项,并处理 reducer 中的 action
。
function ShoppingList() {
const inputRef = useRef();
const [items, dispatch] = useReducer((state, action) => {
switch (action.type) {
case 'add':
return [
...state,
{
id: state.length,
name: action.name
}
];
default:
return state;
}
}, []);
function handleSubmit(e) {
e.preventDefault();
dispatch({
type: 'add',
name: inputRef.current.value
});
inputRef.current.value = '';
}
return (
// ... same ...
);
}
我们在 reducer 函数中添加两个分支,一个是在 action
满足 type === 'add'
是触发,另一个 default
用于兜底。当 reducer 拿到 “add” 的 action
时,它返回一个数组,数组中包括了之前所有元素,并在最后加入了新增的项。
这里我使用了数组的长度作为元素的自增 ID,在这里是可行的。但对于真正的应用程序而言,不是一个好的习惯,可能会导致重复的 ID,甚至引发 bug。(最好使用像 uuid 这样的库,或者让服务器返回一个唯一的 ID !)
当用户输入完成并敲击回车时,将触发 handleSubmit
函数,因此我们需要调用 preventDefault
来阻止页面刷新。接着调用 dispatch
并传入一个 action
,并且清空输入框。
在这个应用程序中,传入的 action
是一个包括了 type
属性和其它相关数据的对象,这些数据可以是任何你想要的(纯字符串、数字、更复杂的对象等)。
接下来,我们给列表添加删除项的功能。
-
删除项
我们需要给每个项增加一个删除按钮,这个按钮将 dispatch 一个带有 type === 'remove'
的行为,以及想要删除的这个项的下表。然后我们需要在 reducer 中处理该操作,过滤数组删除该项。
function ShoppingList() {
const inputRef = useRef();
const [items, dispatch] = useReducer((state, action) => {
switch (action.type) {
case 'add':
// ... same as before ...
case 'remove':
// keep every item except the one we want to remove
return state.filter((_, index) => index != action.index);
default:
return state;
}
}, []);
function handleSubmit(e) { /*...*/ }
return (
<>
{items.map((item, index) => (
-
{item.name}
))}
>
);
}
所以…… Redux 是要完了吗?
许多人在看到 useReducer
钩子时的第一个想法是…“好吧,React现在已经内置了reducer,并且它有上下文来传递数据,所以 Redux 已经完了!“我想在这里谈谈这个问题,因为你可能会想知道。
我不认为 useReducer
会像 context 那样威胁到 Redux。它只是进一步扩展了 React 在状态管理方面的能力,因此真正需要 Redux 的情况会更少。
Redux 仍然比 Context + useReducer 功能更丰富,它有 ReduxDevTools 来助力调试,还有很多具有可定制性的中间件,以及一个完整的帮助库生态系统,我认为它仍然有持续发展的力量。
一个很大的区别是 Redux 提供了一个全局存储区,你可以在这里集中管理应用程序的数据。而 useReducer 只能为组件管理局部状态。
但是,你也可以使用 useReducer 和 useContext 来构建自己的迷你 Redux !如果你想这么做,而且它能够满足你的需求,那就去做吧!不过,要使它具有可扩展性和高性能就很难了。如果你的项目中的数据变化比较频繁,可能就需要了解 Redux Toolkit、MobX 或 Recoil 或其他状态管理方案。