React 事件响应及单组件状态处理

React 事件响应及单组件状态处理

事件响应

事件处理函数

使用 React 可以在 JSX 中添加 事件处理函数。其中事件处理函数为自定义函数,它将在响应交互(如点击、悬停、表单输入框获得焦点等)时触发。

React 官方文档响应事件

大多元素均可绑定响应的事件,以按钮为例,最常使用的事件为点击事件。在 Ract 中为一个按钮添加点击事件非常简单,只需要在组件内创建一个事件处理函数,并在标签返回时,将其绑定到 onClick 属性上即可:

为按钮绑定点击事件
1
2
3
4
5
6
7
8
9
10
11
12
13
function App() {
  function handleButtonClick() {
    alert("Button clicked.");
  }

  return (
    <button onClick={handleButtonClick}>
      Click me
    </button>
  );
}

export default App;

事件处理函数通常有如下特点:

  • 通常在 组件内部 定义。(这样做的好处是可以直接访问组件内部的 props 。)
  • 名称以 handle 开头,后跟事件名称。(这只是一种惯例,并非强制。)

一种更为简洁的方式为使用 Javascript 的箭头函数,为一个元素内联绑定事件处理函数,这种方式当处理函数比较小的情况下是非常方便的:

内联函数绑定事件处理
1
2
3
4
5
6
7
8
9
10
11
function App() {
  return (
    <button onClick={()=>{
  alert("Button clicked.");
    }}>
  Click me
    </button>
  );
}

export default App;

通过 props 传递事件处理函数

通常,会在父组件中定义子组件的事件处理函数,这样做提高了子组件的可重用性。例如官网如下示例,根据需要,将不通的事件处理函数传递给按钮组件后,可以实现文件上传、电影点播等不同功能,但是子组件 Button 内部编码却不需要做任何改变。

传递事件处理函数给子组件
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
 // 定义公共组件,其点击事件在使用时传入
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>
  );
}

事件传播行为

事件处理函数还会捕获任何来自子组件的事件,即事件从发生的地方开始,沿着 UI 树向上传播。例如:

子组件事件传播行为
1
2
3
4
5
6
7
8
9
10
11
export default function Toolbar() {
return (
<div className="Toolbar" onClick={() => {
alert('你点击了 toolbar !');
}}>
<button onClick={() => alert('正在播放!')}>
播放电影
</button>
</div>
);
}

上述实例中,div 及其子组件 button 均添加了点击事件,虽然行为不同,但是当点击按钮出发其事件处理后,对应的点击事件会沿着UI树向上传播至 div 中,进而触发 div 的点击事件回调其处理函数。所以会看到两次弹窗。
但是,这种传播是可以被中断的。每个事件处理函数接收一个 事件对象 作为唯一的参数,可以使用此对象来读取有关事件的信息。同样的,若想阻止事件向上传播,可以在子组件事件处理函数的开始处,借助事件的 e.stopPropagation() 方法实现。(浏览器内置组件可以通过自定义组件来包装一下。)

阻止子组件事件传播行为
1
2
3
4
5
6
7
8
9
10
function Button({ onClick, children }) {
return (
<button onClick={e => {
e.stopPropagation(); // 阻止事件传播至父组件
onClick();
}}>
{children}
</button>
);
}

阻止组件默认行为

某些浏览器事件具有与事件相关联的默认行为。例如,点击 <form> 表单内部的按钮会触发表单提交事件,默认情况下将重新加载整个页面,但是这种行为也是可以事件对象的 e.preventDefault() 方法来阻止的:

阻止组件的默认行为
1
2
3
4
5
6
7
8
9
10
11
export default function Signup() {
return (
<form onSubmit={e => {
e.preventDefault(); // 阻止组件的默认行为
alert('提交表单!');
}}>
<input />
<button>发送</button>
</form>
);
}

State

事件处理函数是执行副作用的最佳位置。同渲染函数不同,事件处理函数不需要是 纯函数,因此它是用来更改某些值的绝佳位置。例如,更改输入框的值以响应键入,或者更改列表以响应按钮的触发。但是,在修改数据前,通常需要记住修改的值,这在 React 中便是通过 State (状态)来实现的。
通常情况下,在组件内部使用普通变量保存状态数据后,在事件(例如按钮的点击事件)处理过程中改变其数据后,无法实现新内容的渲染,这是因为:

  • 局部变量无法在多次渲染中持久保存。React 再次渲染组件时,会从头开始渲染,不会考虑之前对局部变量的任何更改。
  • 更改局部变量不会触发渲染。 React 没有意识到它需要使用新数据再次渲染组件。

React 中,以上功能可以通过 Hook 函数 useState 实现,该函数接收1个参数,为状态的初始值;返回值可以解构为两个值:其中第一个取值为 状态 ,第二个值为 setter 函数,用于改变状态的取值,进而触发 React 重新渲染组件。

当编写一个存有 state 的组件时,需要考虑使用多少个 state 变量以及它们都是怎样的数据格式。尽管选择次优的 state 结构下也可以编写正确的程序,但有几个原则可以帮助做出更好的决策:

  1. 合并关联的 state。如果总是同时更新两个或更多的 state 变量,可以考虑将它们合并为一个单独的 state 变量。
  2. 避免互相矛盾的 state。当 state 结构中存在多个相互矛盾或“不一致”的 state 时,就可能为此会留下隐患。应尽量避免这种情况。
  3. 避免冗余的 state。如果能在渲染期间从组件的 props 或其现有的 state 变量中计算出一些信息,则不应将这些信息放入该组件的 state 中。
  4. 避免重复的 state。当同一数据在多个 state 变量之间或在多个嵌套对象中重复时,这会很难保持它们同步。应尽可能减少重复。
  5. 避免深度嵌套的 state。深度分层的 state 更新起来不是很方便。如果可能的话,最好以扁平化方式构建 state

添加一个 state 变量

要添加 state 变量,先从文件顶部的 React 中导入 useState

导入 useState Hook
1
import { useState } from 'react';

然后接收 Hook 返回的两个值,这两个取值是相互对应的:

解构 useState 的返回值
1
const [state, setXxx] = useState(inieState);

官网提供的完整实例如下:

官方示例
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 { useState } from 'react';
import { sculptureList } from './data.js';

export default function Gallery() {
const [index, setIndex] = useState(0);

function handleClick() {
// React 会记住中间值
// 即使再次渲染时看到的仍是初始值,但是会使用中间值进行相应的渲染
setIndex(index + 1);
}

let sculpture = sculptureList[index];
return (
<>
<button onClick={handleClick}>
Next
</button>
<h2>
<i>{sculpture.name} </i>
by {sculpture.artist}
</h2>
<h3>
({index + 1} of {sculptureList.length})
</h3>
<img
src={sculpture.url}
alt={sculpture.alt}
/>
<p>
{sculpture.description}
</p>
</>
);
}

Hooks ——以 use 开头的函数——只能在组件或自定义 Hook 的最顶层调用。 你不能在条件语句、循环语句或其他嵌套函数内调用 HookHook 是函数,但将它们视为关于组件需求的无条件声明会很有帮助。在组件顶部 “use” React 特性,类似于在文件顶部“导入”模块。

React 文档 – state 组件的记忆

每个组件可以同时定义多个不同的 state ,不会相互影响。但如果发现经常同时更改多个 state 变量,那么可以由一个值为对象的 state 变量的多个字段对应一个 state 变量,在事件处理过程中,通过更改对象的相应字段即可。

State 是隔离且私有的

State 是屏幕上组件实例内部的状态。换句话说,如果你渲染同一个组件两次,每个副本都会有完全隔离的 state!改变其中一个不会影响另一个。

React 文档 – state 组件的记忆

渲染和提交

React 中,UI 的更新通常有三个步骤:

  • 触发一次渲染
    • 初次渲染。应用启动时,会触发初次渲染。这个过程通过调用目标 DOM 节点的 createRoot,然后再调用组件调用 render 函数完成。
    • 状态更新时重新渲染。一旦组件被初次渲染,就可以通过使用 setter 函数更新其状态来触发之后的渲染。更新组件的状态会自动将一次渲染送入队列。
      set 函数 仅更新 下一次 要渲染的状态变量。如果在调用 setter 函数后读取状态变量,则还是会看到之前的旧值;如果提供的新值与当前 state 相同,则不会触发对应组件及子组件的渲染。
  • React 渲染组件
    • 在进行初次渲染时, React 会调用根组件。
    • 对于后续的渲染, React 会调用内部状态更新触发了渲染的函数组件。
      这个过程是递归的:如果更新后的组件会返回某个另外的组件,那么 React 接下来就会渲染返回的组件,而如果返回的组件又返回了某个组件,那么 React 接下来就会渲染继续渲染后续返回的组件,以此类推。这个过程会持续下去,直到没有更多的嵌套组件并且 React 确切知道哪些东西应该显示到屏幕上为止。
  • React 把更改提交到 DOM 上
    • 对于初次渲染, React 会使用 appendChild() DOM API 将其创建的所有 DOM 节点放在屏幕上。
    • 对于重渲染, React 将应用最少的必要操作(在渲染时计算。),以使得 DOM 与最新的渲染输出相互匹配。

state 的进一步理解

state 如同一张快照 是官方对 state 的一个描述。据此,每次状态变化触发渲染时,渲染的结果都是根据上一次渲染后的结果计算得到。因此,即使在事件处理时多次根据状态的值计算的下一次渲染状态,得到的结果都是相同的。例如:

根据快照 number 计算下一次渲染的结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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>
</>
)
}

上述官方示例中,虽然在按钮的事件处理函数中对 number 状态连续做了三次加一,但是因为都是基于上一次渲染结果的快照(即组件加载时的 0)计算更新,所以最终在下一次渲染时,number 的值为 1,而非 3。另一方面,根据官方文档:

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

React 文档 – state 如同一张快照

在一个新的场景下,如果先对状态进行更新,触发组件重新渲染以后,在直接使用对应的状态(例如弹窗显示等),实际上使用的值是上一次渲染的快照值。

渲染组建后随机使用状态做弹窗
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useState } from 'react';

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

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

多次更新状态值

React 会等到事件处理函数中的 所有 代码都运行完毕再处理你的 state 更新。

React 文档 – 把一系列 state 更新加入队列

实际上,事件处理函数及其中任何代码执行完成 之后,UI 才会更新,也就是所谓的批处理,这可以保证渲染结果的正确性。
在上一节中的示例中,更新状态值都是基于快照的值进行的,而传递给更新函数的都是计算后的值。有时候需要连续多次的对状态的值进行更新,例如,这种场景下,可以传入一个函数给到设值函数,这样一个更新函数接收相应的参数,用于累积结果:

根据更新队列中的值 n,计算后续更新的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useState } from 'react';

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

return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>增加数字</button>
</>
)
}

更新 state 中的对象及数组

React 中,如果状态对应的是一个对象或者数组,如果单纯相应对象的某个属性发生变化,是不会通知到 React 进行重新渲染的。只有通过设值函数利用修改后的 新对象 或者 新数组 更新状态,让 React 感知到相应变化,才能触发组件的重新渲染。总体上,把握一个原则, 不要妄想直接改变原有对象触发渲染,不管什么时候,要想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
27
28
29
30
31
32
import { useState } from 'react';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0
});
return (
<div
onPointerMove={e => {
setPosition({ // 传入一个新的对象
x: e.clientX,
y: e.clientY
});
}}
style={{
position: 'relative',
width: '100vw',
height: '100vh',
}}>
<div style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}} />
</div>
);
}

如果对象比较复杂,可以使用 展开语法 进行对象复制:

展开语法复制对象/数组
1
2
3
4
5
6
7
8
9
10
11
// 复制对象
setPerson({
...person, // 复制上一个 person 中的所有字段
firstName: e.target.value // 但是覆盖 firstName 字段
});

// 复制数组
setArtists([
{ id: nextId++, name: name },
...artists // 将原数组中的元素放在末尾
]);

如果对象比较复杂,数据一些逻辑较为啰嗦,可以利用 Immer 库简化相应的更新操作。添加相应的依赖:

immer 依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"dependencies": {
"immer": "1.7.3",
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"use-immer": "0.5.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
},
"devDependencies": {}
}

Immer 使用举例:

Immer 用法
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
import { useState } from 'react';
import { useImmer } from 'use-immer';

const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
];

...
export default function BucketList() {
const [myList, updateMyList] = useImmer(
initialList
);

function handleToggleMyList(id, nextSeen) {
updateMyList(draft => {
const artwork = draft.find(a =>
a.id === id
);
artwork.seen = nextSeen;
});
}

return (
<>
<h1>艺术愿望清单</h1>
<h2>我想看的艺术清单:</h2>
<ItemList
artworks={myList}
onToggle={handleToggleMyList} />
<h2>你想看的艺术清单:</h2>
<ItemList
artworks={yourList}
onToggle={handleToggleYourList} />
</>
);
}
...
作者

Jeill

发布于

2024-03-03

更新于

2024-03-17

许可协议

评论