在 React 中,随时间变化的数据被称为状态(state
)。
事件处理程序是开发者自己写的的函数,它将在用户交互时被触发,如点击、悬停、焦点在表单输入框上等等。
等内置组件只支持内置浏览器事件,如
onClick
。但是,开发者也可以创建自己的组件,并给它们的事件处理程序 props
指定名称。
如果需要添加一个事件处理函数,需要先定义一个函数,然后将其作为prop
传入合适的JSX标签.
事件处理函数有如下特点:
handle
开头,后跟事件名称当函数体较短时,内联事件处理函数会很方便。比如:
<button onClick={function handleClick() {
alert('你点击了我!');
}}>
// 箭头函数
<button onClick={() => {
alert('你点击了我!');
}}>
传递给事件处理函数的函数应直接传递,而非调用
。加上
()
后函数会立即执行,而不是点击按钮时才执行。传递内联函数时,应该将内联事件处理函数包装在匿名函数中。
props
由于事件处理函数声明于组件内部,因此它们可以直接访问组件的 props。
props
传递将组件从父组件接收的 prop 作为事件处理函数传递。
function Button({ onClick, children }) {
return (
<button onClick={onClick}>
{children}
</button>
);
}
function PlayButton({ movieName }) {
function handlePlayClick() {
alert(`正在播放 ${movieName}!`);
}
return (
<Button onClick={handlePlayClick}>
播放 "{movieName}"
</Button>
);
}
function UploadButton() {
return (
<Button onClick={() => alert('正在上传!')}>
上传图片
</Button>
);
}
export default function Toolbar() {
return (
<div>
<PlayButton movieName="魔女宅急便" />
<UploadButton />
</div>
);
}
on
开头,后跟一个大写字母。HTML
标签。如果子组件定义了一个函数,那么在子组件函数被触发后,会向上冒泡到父级组件层级。
在 React 中所有事件都会传播,除了
onScroll
,它仅适用于你附加到的 JSX 标签。
事件处理函数接收一个 事件对象 作为唯一的参数。按照惯例,它通常被称为 e
,代表 “event”(事件)。这个事件对象还允许阻止传播。如果想阻止一个事件到达父组件,需要调用 e.stopPropagation()
。
function Button({ onClick, children }) {
return (
<button onClick={e => {
e.stopPropagation();
onClick();
}}>
{children}
</button>
);
}
export default function Toolbar() {
return (
<div className="Toolbar" onClick={() => {
alert('你点击了 toolbar !');
}}>
<Button onClick={() => alert('正在播放!')}>
播放电影
</Button>
<Button onClick={() => alert('正在上传!')}>
上传图片
</Button>
</div>
);
}
当点击按钮时:
的 onClick
处理函数。e.stopPropagation()
,阻止事件进一步冒泡。onClick
函数,它是从 Toolbar
组件传递过来的 prop。Toolbar
组件中定义的函数,显示按钮对应的 alert。 的 onClick
处理函数不会执行。
若想对每次点击进行埋点记录,可以通过在事件名称末尾添加 Capture
来实现。
<div onClickCapture={() => { /* 这会首先执行 */ }}>
<button onClick={e => e.stopPropagation()} />
<button onClick={e => e.stopPropagation()} />
</div>
每个事件分三个阶段传播:
- 它向下传播,调用所有的
onClickCapture
处理函数。
- 它执行被点击元素的
onClick
处理函数。
- 它向上传播,调用所有的
onClick
处理函数。
捕获事件对于路由或数据分析之类的代码很有用。
2.1.7 传递处理函数作为事件传播的替代方案
此处的点击事件处理函数先执行了一行代码,然后调用了父组件传递的 onClick
prop:
function Button({ onClick, children }) {
return (
<button onClick={e => {
e.stopPropagation();
onClick();
}}>
{children}
</button>
);
}
也可以在调用父元素 onClick
函数之前,向这个处理函数添加更多代码。
2.1.8 阻止默认行为
某些浏览器事件具有与事件相关联的默认行为。例如,点击
表单内部的按钮会触发表单提交事件,默认情况下将重新加载整个页面:
export default function Signup() {
return (
<form onSubmit={() => alert('提交表单!')}>
<input />
<button>发送</button>
</form>
);
}
可以调用事件对象中的 e.preventDefault()
来阻止这种情况。
export default function SignUp() {
return (
<form onSubmit={e => {
e.preventDefault();
alert('提交表单!');
}}>
<input />
<button>发送</button>
</form>
);
}
e.stopPropagation()
阻止触发绑定在外层标签上的事件处理函数。
e.preventDefault()
阻止少数事件的默认浏览器行为。
2.2 State: 组件的记忆
要使用新数据更新组件,需要做两件事:
- 保留 渲染之间的数据。
- 触发 React 使用新数据渲染组件(重新渲染)。
useState
Hook 提供了这两个功能:
- State 变量 用于保存渲染间的数据。
- State setter 函数 更新变量并触发 React 再次渲染组件。
2.2.1 添加一个state变量
import { useState } from 'react'
const [index, setIndex] = useState[0];
function handleClick() {
setIndex(index + 1);
}
2.2.2 Hook函数
在 React 中,useState
以及任何其他以“use
”开头的函数都被称为 Hook。
Hooks ——以 use
开头的函数——只能在组件或自定义 Hook 的最顶层调用。 你不能在条件语句、循环语句或其他嵌套函数内调用 Hook。Hook 是函数,但将它们视为关于组件需求的无条件声明会很有帮助。在组件顶部 “use” React 特性,类似于在文件顶部“导入”模块。
2.2.3 剖析useState
注意:惯例是将这对返回值命名为 const [thing, setThing]
。
useState
的唯一参数是 state 变量的初始值。
每次你的组件渲染时,useState
都会给你一个包含两个值的数组:
- state 变量 (
index
) 会保存上次渲染的值。
- state setter 函数 (
setIndex
) 可以更新 state 变量并触发 React 重新渲染组件。
2.2.3 赋予一个组件多个state变量
可以在一个组件中拥有任意多种类型的 state
变量。
useState
的实现依靠的是数组:
在 React 内部,为每个组件保存了一个数组,其中每一项都是一个 state
对。它维护当前 state
对的索引值,在渲染之前将其设置为 “0
”。每次调用 useState
时,React 都会为你提供一个 state
对并增加索引值。
2.2.4 State是隔离且私有的
如果你渲染同一个组件两次,每个副本都会有完全隔离的 state
!改变其中一个不会影响另一个。
与 props
不同,state
完全私有于声明它的组件。
State 变量仅用于在组件重渲染时保存信息。在单个事件处理函数中,普通变量就足够了。当普通变量运行良好时,不要引入 state 变量。比如:
export default function FeedbackForm() {
function handleClick() {
const name = prompt('What is your name?');
alert(`Hello, ${name}!`);
}
return (
<button onClick={handleClick}>
Greet
</button>
);
}
2.3 渲染和提交
React请求和提供UI的过程总共包括三个步骤:
- 触发渲染
- 组件的 初次渲染。
- 组件(或者其祖先之一)的 状态发生了改变。
- 渲染组件
- 在进行初次渲染时, React 会调用根组件
root
。
- 对于后续的渲染, React 会调用那些使内部状态更新从而触发渲染的函数组件。
- 提交到DOM
- 对于初次渲染, React 会使用
appendChild()
DOM API 将其创建的所有 DOM 节点放在屏幕上。
- 对于重复渲染, React 将只执行必要渲染操作,以使得 DOM节点 与最新的渲染输出结果匹配一致。
2.4 state在渲染时不会发生更改
一个 state 变量的值永远不会在一次渲染的内部发生变化, 即使其事件处理函数的代码是异步的。
2.5 把一系列state更新加入队列
2.5.1 React会对state更新进行批处理
React 会等到事件处理函数中的 所有 代码都运行完毕再处理你的 state 更新。
比如
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);
组件的重新渲染只会发生在这三次setNumber()
调用之后。
2.5.2 在下次渲染前多次更新同一个state
若多次更新同一个state
,React
会将每一次state
的更新状态存入队列,并把最后的结果更新到state
中。这称为批处理。
以下是可以考虑传递给 setNumber
state 设置函数的内容:
- 一个更新函数(例如:
n => n + 1
)会被添加到队列中。
- 任何其他的值(例如:数字
5
)会导致“替换为 5
”被添加到队列中,已经在队列中的内容会被忽略。
2.5.3 state更新函数的命名惯例
通常可以通过相应 state 变量的第一个字母来命名更新函数的参数,也可以用更明晰的命名:
setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);
2.6 更新state中的对象
应该 把所有存放在 state 中的 JavaScript 对象都视为只读的。在改变state时,不能改变state中现有的对象,要重新创建一个对象把原来的对象替换掉。比如下面两种写法是正确且等价的:
// 第一种
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);
// 第二种
setPosition({
x: e.clientX,
y: e.clientY
});
2.6.1 使用展开语法复制对象
通常,你会希望把 现有 数据作为你所创建的新对象的一部分。例如,你可能只想要更新表单中的一个字段,其他的字段仍然使用之前的值。那么此时就可以用展开语法...
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: '[email protected]'
});
function handleFirstNameChange(e) {
setPerson({
...person,
firstName: e.target.value
});
}
function handleLastNameChange(e) {
setPerson({
...person,
lastName: e.target.value
});
}
function handleEmailChange(e) {
setPerson({
...person,
email: e.target.value
});
}
return (
<>
<label>
First name:
<input
value={person.firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:
<input
value={person.lastName}
onChange={handleLastNameChange}
/>
</label>
<label>
Email:
<input
value={person.email}
onChange={handleEmailChange}
/>
</label>
<p>
{person.firstName}{' '}
{person.lastName}{' '}
({person.email})
</p>
</>
);
}
请注意 ...
展开语法本质是是“浅拷贝”——它只会复制一层。这使得它的执行速度很快,但是也意味着当你想要更新一个嵌套属性时,你必须得多次使用展开语法。
2.6.2 使用一个事件处理函数来更新多个字段
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: '[email protected]'
});
function handleChange(e) {
setPerson({
...person,
[e.target.name]: e.target.value // 重点是这里,使用 DOM 元素的 name属性
});
}
return (
<>
<label>
First name:
<input
name="firstName"
value={person.firstName}
onChange={handleChange}
/>
</label>
<label>
Last name:
<input
name="lastName"
value={person.lastName}
onChange={handleChange}
/>
</label>
<label>
Email:
<input
name="email"
value={person.email}
onChange={handleChange}
/>
</label>
<p>
{person.firstName}{' '}
{person.lastName}{' '}
({person.email})
</p>
</>
);
}
在这里,e.target.name
引用了
这个 DOM 元素的 name
属性。
2.6.3 更新一个嵌套的对象
如果对象拥有多层嵌套,那么可以创建新的对象:
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
或者写成一个函数调用:
setPerson({
...person, // 复制其它字段的数据
artwork: { // 替换 artwork 字段
...person.artwork, // 复制之前 person.artwork 中的数据
city: e.target.value // 但是将 city 的值替换为 New Delhi!
}
});
对象并非真正嵌套,只是属性"指向"彼此而已。
2.6.4 使用Immer编写更简洁的更新逻辑
由 Immer
提供的 draft
是一种特殊类型的对象,被称为 Proxy,它会记录你用它所进行的操作。从原理上说,Immer
会弄清楚 draft
对象的哪些部分被改变了,并会依照你的修改创建出一个全新的对象。
使用Immer
:
- 运行
npm install use-immer
添加 Immer
依赖
- 用
import { useImmer } from 'use-immer'
替换掉 import { useState } from 'react'
import { useImmer } from 'use-immer';
export default function Form() {
const [person, updatePerson] = useImmer({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
function handleNameChange(e) {
updatePerson(draft => {
draft.name = e.target.value;
});
}
function handleTitleChange(e) {
updatePerson(draft => {
draft.artwork.title = e.target.value;
});
}
function handleCityChange(e) {
updatePerson(draft => {
draft.artwork.city = e.target.value;
});
}
function handleImageChange(e) {
updatePerson(draft => {
draft.artwork.image = e.target.value;
});
}
return (
<>
<label>
Name:
<input
value={person.name}
onChange={handleNameChange}
/>
</label>
<label>
Title:
<input
value={person.artwork.title}
onChange={handleTitleChange}
/>
</label>
<label>
City:
<input
value={person.artwork.city}
onChange={handleCityChange}
/>
</label>
<label>
Image:
<input
value={person.artwork.image}
onChange={handleImageChange}
/>
</label>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img
src={person.artwork.image}
alt={person.artwork.title}
/>
</>
);
}
为什么在 React 中不推荐直接修改 state?
- 调试时使用
console.log()
可以很容易发现前后两次渲染发生了什么变化.
- React常见的优化策略依赖于如果之前的
props
或者 state
的值和下一次相同就跳过渲染。
- 如果用户需求变更,可以很容易恢复到以前的版本。
2.7 更新State中的数组
同对象一样,当想要更新存储于 state
中的数组时,需要创建一个新的数组(或者创建一份已有数组的拷贝值),并使用新数组设置 state
。
当操作 React state 中的数组时,需要避免使用左列能改变原数组的方法,而首选右列能返回一个新数组的方法:
避免使用(会改变原始数组)
推荐使用(返回一个新数组)
添加元素
push
/unshift
concat
/[...arr]
展开语法
删除元素
pop
/shift
/splice
filter
/slice
替换元素
splice
/arr[i]=...赋值
map
排序
reverse
/sort
先将数组复制一份、toSorted
或者使用Immer
。
2.7.1 更新数组内部的对象
即使拷贝了数组,还是不能直接修改其内部的元素。这是因为数组的拷贝是浅拷贝——新的数组中依然保留了与原始数组相同的元素。
比如这样就不行:
const nextList = [...list];
nextList[0].seen = true; // 问题:直接修改了 list[0] 的值
setList(nextList);
正确的做法是再次拷贝一份,然后进行修改,我们可以使用map
函数:
setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// 创建包含变更的*新*对象
return { ...artwork, seen: nextSeen };
} else {
// 没有变更
return artwork;
}
}));
或者使用更简洁的immer
:
updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});
增
最简单的一种就是使用 ...
数组展开 语法:
setArtists( // 替换 state
[ // 是通过传入一个新数组实现的
...artists, // 新数组包含原数组的所有元素
{ id: nextId++, name: name } // 并在末尾添加了一个新的元素
]
);
数组展开运算符还允许你把新添加的元素放在原始的 ...artists
之前:
setArtists([
{ id: nextId++, name: name },
...artists // 将原数组中的元素放在末尾
]);
这样一来,展开操作就可以完成 push()
和 unshift()
的工作,将新元素添加到数组的末尾和开头.
删
从数组中删除一个元素最简单的方法就是将它过滤出去。可以通过 filter
方法实现:
// 使用filter方法删除元素
setArtists(
artists.filter(a =>a.id !== artist.id)
);
改
如果想改变数组中的某些或全部元素,可以先用 map()
创建一个新数组。再使用新的数组进行重新渲染。
import { useState } from 'react';
let initialShapes = [
{ id: 0, type: 'circle', x: 50, y: 100 },
{ id: 1, type: 'square', x: 150, y: 100 },
{ id: 2, type: 'circle', x: 250, y: 100 },
];
export default function ShapeEditor() {
const [shapes, setShapes] = useState(
initialShapes
);
function handleClick() {
const nextShapes = shapes.map(shape => {
if (shape.type === 'square') {
// 不作改变
return shape;
} else {
// 返回一个新的圆形,位置在下方 50px 处
return {
...shape,
y: shape.y + 50,
};
}
});
// 使用新的数组进行重渲染
setShapes(nextShapes);
}
return (
<>
<button onClick={handleClick}>
所有圆形向下移动!
</button>
{shapes.map(shape => (
<div
key={shape.id}
style={{
background: 'purple',
position: 'absolute',
left: shape.x,
top: shape.y,
borderRadius:
shape.type === 'circle'
? '50%' : '',
width: 20,
height: 20,
}} />
))}
</>
);
}
插入元素
向数组特定位置插入一个元素。可以将数组展开运算符 ...
和 slice()
方法一起使用。
function handleClick() {
const insertAt = 1; // 可能是任何索引
const nextArtists = [
// 插入点之前的元素:
...artists.slice(0, insertAt),
// 新的元素:
{ id: nextId++, name: name },
// 插入点之后的元素:
...artists.slice(insertAt)
];
setArtists(nextArtists);
setName('');
}
其他改变数组中元素的情况,可以先拷贝这个数组,再改变这个拷贝后的值。