
更新 state 中的数组
markdown
<Intro>
state 中可以保存任意类型的 JavaScript 值,包括数组。但是,你不应该直接修改存放在 React state 中的数组。相反,当你想要更新一个数组时,你需要创建一个新的数组(或者将其拷贝一份),然后将 state 更新为此数组。
</Intro>
<YouWillLearn>
- 如何正确地更新 React state 中的数组
- 如何在不产生 mutation 的情况下添加、删除、修改数组元素
- 什么是不可变性(immutability),以及如何不破坏它
- 如何使用 Immer 使复制数组不那么繁琐
</YouWillLearn>
## 什么是 mutation? {/*whats-a-mutation*/}
你可以在 state 中存放任意类型的 JavaScript 值。
```js
const [todos, setTodos] = useState([]);
到目前为止,你已经尝试过在 state 中存放数字、字符串和布尔值,这些类型的值在 JavaScript 中是不可变(immutable)的,这意味着它们不能被改变或是只读的。你可以通过替换它们的值以触发一次重新渲染。
js
setTodos([...todos, { id: nextId, title: '新的待办事项' }]);
state todos 从空数组变为包含一个元素的数组,但是原来的空数组本身并没有发生改变。在 JavaScript 中,数组是可变的——你可以直接修改数组的内容。
现在考虑 state 中存放数组的情况:
js
const [todos, setTodos] = useState([{ id: 0, title: 'Buy milk' }]);
从技术上来讲,可以改变数组自身的内容。当你这样做时,就制造了一个 mutation:
js
todos.push({ id: 1, title: 'Buy bread' });
然而,虽然严格来说 React state 中存放的数组是可变的,但你应该像处理数字、布尔值、字符串一样将它们视为不可变的。因此你应该替换它们的值,而不是对它们进行修改。
将 state 视为只读的 {/treat-state-as-read-only/}
换句话说,你应该 把所有存放在 state 中的 JavaScript 数组都视为只读的。
在下面的例子中,我们用一个存放在 state 中的数组来表示待办事项列表。当你点击“添加待办事项”按钮时,新的待办事项本应被添加到列表中。但是实际上列表没有变化:
-
js
import { useState } from 'react';
let nextId = 3;
const initialTodos = [
{ id: 0, title: 'Buy milk', done: true },
{ id: 1, title: 'Eat tacos', done: false },
{ id: 2, title: 'Brew tea', done: false },
];
export default function TaskList() {
const [todos, setTodos] = useState(initialTodos);
function handleAddTodo() {
todos.push({
id: nextId++,
title: 'New task',
done: false,
});
}
return (
<>
<button onClick={handleAddTodo}>
Add todo
</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.title} {todo.done ? ' ✔' : ''}
</li>
))}
</ul>
</>
);
}
css
button { margin-bottom: 10px; }
li { list-style: none; margin: 4px 0; }
问题出在下面这段代码中。
js
todos.push({
id: nextId++,
title: 'New task',
done: false,
});
这段代码直接修改了 上一次渲染中 分配给 todos 的数组。但是因为并没有使用 state 的设置函数,React 并不知道数组已更改。所以 React 没有做出任何响应。这就像在吃完饭之后才尝试去改变要点的菜一样。虽然在一些情况下,直接修改 state 可能是有效的,但我们并不推荐这么做。你应该把在渲染过程中可以访问到的 state 视为只读的。
在这种情况下,为了真正地 触发一次重新渲染,你需要创建一个新数组并把它传递给 state 的设置函数:
js
setTodos([...todos, {
id: nextId++,
title: 'New task',
done: false,
}]);
通过使用 setTodos,你在告诉 React:
- 使用这个新数组替换
todos的值 - 然后再次渲染这个组件
现在你可以看到,点击按钮时待办事项会正常添加:
js
import { useState } from 'react';
let nextId = 3;
const initialTodos = [
{ id: 0, title: 'Buy milk', done: true },
{ id: 1, title: 'Eat tacos', done: false },
{ id: 2, title: 'Brew tea', done: false },
];
export default function TaskList() {
const [todos, setTodos] = useState(initialTodos);
function handleAddTodo() {
setTodos([
...todos,
{
id: nextId++,
title: 'New task',
done: false,
}
]);
}
return (
<>
<button onClick={handleAddTodo}>
Add todo
</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.title} {todo.done ? ' ✔' : ''}
</li>
))}
</ul>
</>
);
}
css
button { margin-bottom: 10px; }
li { list-style: none; margin: 4px 0; }
局部 mutation 是可以接受的 {/local-mutation-is-fine/}
像这样的代码是有问题的,因为它改变了 state 中现有的数组:
js
todos.push({
id: nextId++,
title: 'New task',
done: false,
});
但是像这样的代码就 没有任何问题,因为你改变的是你刚刚创建的一个新的数组:
js
const nextTodos = [...todos];
nextTodos.push({
id: nextId++,
title: 'New task',
done: false,
});
setTodos(nextTodos);
事实上,它完全等同于下面这种写法:
js
setTodos([
...todos,
{
id: nextId++,
title: 'New task',
done: false,
}
]);
只有当你改变已经处于 state 中的 现有 数组时,mutation 才会成为问题。而修改一个你刚刚创建的数组就不会出现任何问题,因为 还没有其他的代码引用它。改变它并不会意外地影响到依赖它的东西。这叫做“局部 mutation”。你甚至可以 在渲染的过程中 进行“局部 mutation”的操作。这种操作既便捷又没有任何问题!
使用展开语法复制数组 {/copying-arrays-with-the-spread-syntax/}
在之前的例子中,你始终会基于现有数组创建一个新的数组。但是通常,你会希望把 现有 数据作为你所创建的新数组的一部分。例如,你可能只想要修改数组中的一个元素,而保留其他元素不变。
下面的代码中,复选框并不会正常运行,因为 onChange 直接修改了 state 中数组里的对象:
-
js
import { useState } from 'react';
const initialTodos = [
{ id: 0, title: 'Buy milk', done: true },
{ id: 1, title: 'Eat tacos', done: false },
{ id: 2, title: 'Brew tea', done: false },
];
export default function TaskList() {
const [todos, setTodos] = useState(initialTodos);
function handleChangeTodo(todo) {
todo.done = !todo.done;
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => handleChangeTodo(todo)}
/>
{todo.title}
</label>
</li>
))}
</ul>
);
}
css
li { list-style: none; margin: 4px 0; }
例如,下面这行代码修改了上一次渲染中的 state:
js
todo.done = !todo.done;
想要实现你的需求,最可靠的办法就是创建一个新的数组,并通过 map 方法创建出已修改元素的新版本:
js
setTodos(todos.map(todo => {
if (todo.id === changedTodo.id) {
return { ...todo, done: !todo.done };
} else {
return todo;
}
}));
在这里,map 会遍历原始数组,并返回一个全新的数组。对于其中 id 匹配的元素,你创建一个新的对象,并反转它的 done 字段;其他元素则保持原样。
现在复选框可以正常运行了!
js
import { useState } from 'react';
const initialTodos = [
{ id: 0, title: 'Buy milk', done: true },
{ id: 1, title: 'Eat tacos', done: false },
{ id: 2, title: 'Brew tea', done: false },
];
export default function TaskList() {
const [todos, setTodos] = useState(initialTodos);
function handleChangeTodo(changedTodo) {
setTodos(todos.map(todo => {
if (todo.id === changedTodo.id) {
return { ...todo, done: !todo.done };
} else {
return todo;
}
}));
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => handleChangeTodo(todo)}
/>
{todo.title}
</label>
</li>
))}
</ul>
);
}
css
li { list-style: none; margin: 4px 0; }
请注意 ... 展开语法本质是“浅拷贝”——它只会复制一层。对于数组来说,这意味着新数组中包含的是原数组元素的引用,而不是深层拷贝。因此,当你想要更新数组中某个对象时,你需要在 map 回调里为那个对象创建新的版本。
常用数组更新操作 {/common-array-update-operations/}
下表列出了常见的数组更新操作。你可以使用展开语法和 map、filter、slice 等非 mutation 方法来实现它们。
| 操作 | 避免 mutation 的方法 | 示例 |
|---|---|---|
| 添加元素 | [...arr, newItem] |
setTodos([...todos, newTodo]) |
| 删除元素 | arr.filter(item => item.id !== id) |
setTodos(todos.filter(todo => todo.id !== id)) |
| 修改元素 | arr.map(item => item.id === id ? { ...item, done: true } : item) |
setTodos(todos.map(todo => todo.id === id ? { ...todo, done: true } : todo)) |
| 插入元素 | [...arr.slice(0, index), newItem, ...arr.slice(index)] |
setTodos([...todos.slice(0, 1), newTodo, ...todos.slice(1)]) |
| 排序 | [...arr].sort((a, b) => a - b) |
setTodos([...todos].sort((a, b) => a.title.localeCompare(b.title))) |
更新数组中的对象 {/updating-objects-inside-arrays/}
数组常常包含对象。当你需要更新数组中某个对象的属性时,你不能直接修改那个对象,而应该创建一个新对象,并通过 map 生成一个新数组。
考虑下面这种待办事项列表:
js
const [todos, setTodos] = useState([
{ id: 0, title: 'Buy milk', done: true },
{ id: 1, title: 'Eat tacos', done: false },
{ id: 2, title: 'Brew tea', done: false },
]);
如果你想要更新某一条待办事项的 title,用 mutation 来实现的方法非常容易理解:
js
todos[1].title = 'Eat more tacos';
但是在 React 中,你需要将 state 视为不可变的!为了修改 title 的值,你首先需要使用 map 找出那条待办事项,为它创建一个新对象,然后生成一个新数组:
js
setTodos(todos.map(todo => {
if (todo.id === 1) {
return { ...todo, title: 'Eat more tacos' };
} else {
return todo;
}
}));
或者,你也可以先复制整个数组,再在副本上使用“局部 mutation”:
js
const nextTodos = [...todos];
nextTodos[1] = {
...nextTodos[1],
title: 'Eat more tacos'
};
setTodos(nextTodos);
这虽然看起来有点冗长,但对于很多情况都能有效地解决问题:
js
import { useState } from 'react';
const initialTodos = [
{ id: 0, title: 'Buy milk', done: true },
{ id: 1, title: 'Eat tacos', done: false },
{ id: 2, title: 'Brew tea', done: false },
];
export default function TaskList() {
const [todos, setTodos] = useState(initialTodos);
function handleChangeTitle(todo, newTitle) {
setTodos(todos.map(item => {
if (item.id === todo.id) {
return { ...item, title: newTitle };
} else {
return item;
}
}));
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
value={todo.title}
onChange={e => handleChangeTitle(todo, e.target.value)}
/>
</li>
))}
</ul>
);
}
css
li { list-style: none; margin: 4px 0; }
input { margin-left: 5px; }
数组并非真正嵌套 {/arrays-are-not-really-nested/}
下面这个数组从代码上来看是“嵌套”的:
js
let todos = [
{ id: 0, title: 'Buy milk', done: true },
{ id: 1, title: 'Eat tacos', done: false },
];
然而,当我们思考数组的特性时,“嵌套”并不是一个非常准确的方式。当这段代码运行的时候,不存在“嵌套”的数组。你实际上看到的是三个不同的对象——一个包含两个对象的数组,以及两个独立的对象。数组只是通过索引“指向”它们而已。
如果你有另一个数组 nextTodos 也引用了同样的对象:
js
let nextTodos = [todos[0], todos[1]];
那么直接修改 nextTodos[0].title,就会同时影响 todos[0].title。这是因为 nextTodos[0] 和 todos[0] 指向同一个对象。当你用“嵌套”的方式看待数组时,很难看出这一点。相反,它们是相互独立的对象,只不过是被数组用索引“指向”而已。
使用 Immer 编写简洁的更新逻辑 {/write-concise-update-logic-with-immer/}
如果你的 state 有多层的嵌套,你或许应该考虑 将其扁平化。但是,如果你不想改变 state 的数据结构,你可能更喜欢用一种更便捷的方式来实现嵌套展开的效果。Immer 是一个非常流行的库,它可以让你使用简便但可以直接修改的语法编写代码,并会帮你处理好复制的过程。通过使用 Immer,你写出的代码看起来就像是你“打破了规则”而直接修改了数组:
js
updateTodos(draft => {
const todo = draft.find(todo => todo.id === 1);
todo.title = 'Eat more tacos';
});
但是不同于一般的 mutation,它并不会覆盖之前的 state!
Immer 是如何运行的? {/how-does-immer-work/}
由 Immer 提供的 draft 是一种特殊类型的对象,被称为 Proxy,它会记录你用它所进行的操作。这就是你能够随心所欲地直接修改数组的原因所在!从原理上说,Immer 会弄清楚 draft 数组的哪些部分被改变了,并会依照你的修改创建出一个全新的数组。
尝试使用 Immer:
- 运行
npm install use-immer添加 Immer 依赖 - 用
import { useImmer } from 'use-immer'替换掉import { useState } from 'react'
下面我们把上面的例子用 Immer 实现一下:
js
import { useImmer } from 'use-immer';
const initialTodos = [
{ id: 0, title: 'Buy milk', done: true },
{ id: 1, title: 'Eat tacos', done: false },
{ id: 2, title: 'Brew tea', done: false },
];
export default function TaskList() {
const [todos, updateTodos] = useImmer(initialTodos);
function handleChangeTitle(todo, newTitle) {
updateTodos(draft => {
const item = draft.find(t => t.id === todo.id);
item.title = newTitle;
});
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
value={todo.title}
onChange={e => handleChangeTitle(todo, e.target.value)}
/>
</li>
))}
</ul>
);
}
json package.json
{
"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"
}
}
css
li { list-style: none; margin: 4px 0; }
input { margin-left: 5px; }
可以看到,事件处理函数变得更简洁了。你可以随意在一个组件中同时使用 useState 和 useImmer。如果你想要写出更简洁的更新处理函数,Immer 会是一个不错的选择,尤其是当你的 state 中有嵌套,并且复制数组会带来重复的代码时。
为什么在 React 中不推荐直接修改 state? {/why-is-mutating-state-not-recommended-in-react/}
有以下几个原因:
- 调试:如果你使用
console.log并且不直接修改 state,你之前日志中的 state 的值就不会被新的 state 变化所影响。这样你就可以清楚地看到两次渲染之间 state 的值发生了什么变化 - 优化:React 常见的 优化策略 依赖于如果之前的 props 或者 state 的值和下一次相同就跳过渲染。如果你从未直接修改 state ,那么你就可以很快看到 state 是否发生了变化。如果
prevArr === arr,那么你就可以肯定这个数组内部并没有发生改变。 - 新功能:我们正在构建的 React 的新功能依赖于 state 被 像快照一样看待 的理念。如果你直接修改 state 的历史版本,可能会影响你使用这些新功能。
- 需求变更:有些应用功能在不出现任何修改的情况下会更容易实现,比如实现撤销/恢复、展示修改历史,或是允许用户把表单重置成某个之前的值。这是因为你可以把 state 之前的拷贝保存到内存中,并适时对其进行再次使用。如果一开始就用了直接修改 state 的方式,那么后面要实现这样的功能就会变得非常困难。
- 更简单的实现:React 并不依赖于 mutation ,所以你不需要对数组进行任何特殊操作。它不需要像很多“响应式”的解决方案一样去劫持数组的方法、总是用代理把数组包裹起来,或者在初始化时做其他工作。这也是 React 允许你把任何数组存放在 state 中——不管数组有多大——而不会造成有任何额外的性能或正确性问题的原因。
在实践中,你经常可以“侥幸”直接修改 state 而不出现什么问题,但是我们强烈建议你不要这样做,这样你就可以使用我们秉承着这种理念开发的 React 新功能。未来的贡献者甚至是你未来的自己都会感谢你的!
- 将 React 中所有的 state 都视为不可直接修改的。
- 当你在 state 中存放数组时,直接修改数组并不会触发重渲染,并会改变前一次渲染“快照”中 state 的值。
- 不要直接修改一个数组,而要为它创建一个 新 版本,并通过把 state 设置成这个新版本来触发重新渲染。
- 你可以使用
[...arr, newItem]这样的数组展开语法来创建数组的拷贝。 - 你也可以使用
filter和map来创建删除了某些元素或修改了某些元素的新数组。 - 更新数组中的对象时,需要为被修改的对象创建新版本,然后通过
map生成新数组。 - 想要减少重复的拷贝代码,可以使用 Immer。
修复错误的 state 更新代码 {/fix-incorrect-state-updates/}
这个待办事项列表有几个 bug。试着点击几次“+1”按钮。你会注意到分数并没有增加。然后试着编辑一下名字字段,你会注意到分数突然“响应”了你之前的修改。最后,试着编辑一下姓氏字段,你会发现分数完全消失了。
你的任务就是修复所有的这些 bug。在你修复它们的同时,解释一下它们为什么会产生。
-
js
import { useState } from 'react';
export default function Scoreboard() {
const [player, setPlayer] = useState({
firstName: 'Ranjani',
lastName: 'Shettar',
score: 10,
});
function handlePlusClick() {
player.score++;
}
function handleFirstNameChange(e) {
setPlayer({
...player,
firstName: e.target.value,
});
}
function handleLastNameChange(e) {
setPlayer({
lastName: e.target.value
});
}
return (
<>
<label>
Score: <b>{player.score}</b>
{' '}
<button onClick={handlePlusClick}>
+1
</button>
</label>
<label>
First name:
<input
value={player.firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:
<input
value={player.lastName}
onChange={handleLastNameChange}
/>
</label>
</>
);
}
css
label { display: block; margin-bottom: 10px; }
input { margin-left: 5px; margin-bottom: 5px; }
下面是两个 bug 都得到修复后的代码:
js
import { useState } from 'react';
export default function Scoreboard() {
const [player, setPlayer] = useState({
firstName: 'Ranjani',
lastName: 'Shettar',
score: 10,
});
function handlePlusClick() {
setPlayer({
...player,
score: player.score + 1,
});
}
function handleFirstNameChange(e) {
setPlayer({
...player,
firstName: e.target.value,
});
}
function handleLastNameChange(e) {
setPlayer({
...player,
lastName: e.target.value
});
}
return (
<>
<label>
Score: <b>{player.score}</b>
{' '}
<button onClick={handlePlusClick}>
+1
</button>
</label>
<label>
First name:
<input
value={player.firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:
<input
value={player.lastName}
onChange={handleLastNameChange}
/>
</label>
</>
);
}
css
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }
代码中 handlePlusClick 函数的问题在于它直接修改了 player 对象。这就造成了 React 并不知道需要重新渲染的原因,也就没有更新屏幕上分数的值。这就是为什么,当你修改名字字段的时候,state 发生了更新,state 更新触发了重新渲染,重新渲染同时也更新了屏幕上的分数。
代码中 handleLastNameChange 的问题在于它没有把 ...player 中已有的属性复制到新的对象中。因此,当你编辑姓氏字段时,分数就丢失了。
发现并修复 mutation {/find-and-fix-the-mutation/}
在静止的背景上有一个可以拖动的方形。你可以使用下拉框来修改方形的颜色。
但是这里有个 bug。当你先移动了方形,再去修改它的颜色时,背景会突然“跳”到方形所在的位置(实际上背景的位置并不应该发生变化!)。但是这并不是我们想要的,Background 的 position 属性被设置为 initialPosition,也就是 { x: 0, y: 0 }。为什么修改颜色之后,背景会移动呢?
找到 bug 并修复它。
如果有一些出乎意料的改变,就是 mutation,在 App.js 中找到 mutation 并修复它。
-
js
import { useState } from 'react';
import Background from './Background.js';
import Box from './Box.js';
const initialPosition = {
x: 0,
y: 0
};
export default function Canvas() {
const [shape, setShape] = useState({
color: 'orange',
position: initialPosition
});
function handleMove(dx, dy) {
shape.position.x += dx;
shape.position.y += dy;
}
function handleColorChange(e) {
setShape({
...shape,
color: e.target.value
});
}
return (
<>
<select
value={shape.color}
onChange={handleColorChange}
>
<option value="orange">orange</option>
<option value="lightpink">lightpink</option>
<option value="aliceblue">aliceblue</option>
</select>
<Background
position={initialPosition}
/>
<Box
color={shape.color}
position={shape.position}
onMove={handleMove}
>
Drag me!
</Box>
</>
);
}
js src/Box.js
import { useState } from 'react';
export default function Box({
children,
color,
position,
onMove
}) {
const [
lastCoordinates,
setLastCoordinates
] = useState(null);
function handlePointerDown(e) {
e.target.setPointerCapture(e.pointerId);
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
}
function handlePointerMove(e) {
if (lastCoordinates) {
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
const dx = e.clientX - lastCoordinates.x;
const dy = e.clientY - lastCoordinates.y;
onMove(dx, dy);
}
}
function handlePointerUp(e) {
setLastCoordinates(null);
}
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
width: 100,
height: 100,
cursor: 'grab',
backgroundColor: color,
position: 'absolute',
border: '1px solid black',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: `translate(
${position.x}px,
${position.y}px
)`,
}}
>{children}</div>
);
}
js src/Background.js
export default function Background({
position
}) {
return (
<div style={{
position: 'absolute',
transform: `translate(
${position.x}px,
${position.y}px
)`,
width: 250,
height: 250,
backgroundColor: 'rgba(200, 200, 0, 0.2)',
}} />
);
};
css
body { height: 280px; }
select { margin-bottom: 10px; }
问题出在 handleMove 中的 mutation 。它直接修改了 shape.position,但是此时 initialPosition 所指向的也是同一个对象。因此方形和背景都发生了移动。(因为它是 mutation,所以直到一个不相关更新——颜色变化——触发了一次重新渲染,变化才反映到屏幕上。)
修复问题的方法就是从 handleMove 中移除这个 mutation,然后用展开运算符来复制方形对象。请注意 += 是 mutation 的一种,所以你需要对它进行重写来使用普通的 + 操作符。
js src/App.js
import { useState } from 'react';
import Background from './Background.js';
import Box from './Box.js';
const initialPosition = {
x: 0,
y: 0
};
export default function Canvas() {
const [shape, setShape] = useState({
color: 'orange',
position: initialPosition
});
function handleMove(dx, dy) {
setShape({
...shape,
position: {
x: shape.position.x + dx,
y: shape.position.y + dy,
}
});
}
function handleColorChange(e) {
setShape({
...shape,
color: e.target.value
});
}
return (
<>
<select
value={shape.color}
onChange={handleColorChange}
>
<option value="orange">orange</option>
<option value="lightpink">lightpink</option>
<option value="aliceblue">aliceblue</option>
</select>
<Background
position={initialPosition}
/>
<Box
color={shape.color}
position={shape.position}
onMove={handleMove}
>
Drag me!
</Box>
</>
);
}
js src/Box.js
import { useState } from 'react';
export default function Box({
children,
color,
position,
onMove
}) {
const [
lastCoordinates,
setLastCoordinates
] = useState(null);
function handlePointerDown(e) {
e.target.setPointerCapture(e.pointerId);
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
}
function handlePointerMove(e) {
if (lastCoordinates) {
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
const dx = e.clientX - lastCoordinates.x;
const dy = e.clientY - lastCoordinates.y;
onMove(dx, dy);
}
}
function handlePointerUp(e) {
setLastCoordinates(null);
}
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
width: 100,
height: 100,
cursor: 'grab',
backgroundColor: color,
position: 'absolute',
border: '1px solid black',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: `translate(
${position.x}px,
${position.y}px
)`,
}}
>{children}</div>
);
}
js src/Background.js
export default function Background({
position
}) {
return (
<div style={{
position: 'absolute',
transform: `translate(
${position.x}px,
${position.y}px
)`,
width: 250,
height: 250,
backgroundColor: 'rgba(200, 200, 0, 0.2)',
}} />
);
};
css
body { height: 280px; }
select { margin-bottom: 10px; }
使用 Immer 更新数组 {/update-an-array-with-immer/}
这里的例子和上面那段有 bug 的代码是相同的。这一次,试着用 Immer 来修复 mutation 的问题。为了方便你的练习,useImmer 已经被引入了,因此你只需要修改 shape 这个 state 变量来使用它。
-
js
import { useState } from 'react';
import { useImmer } from 'use-immer';
import Background from './Background.js';
import Box from './Box.js';
const initialPosition = {
x: 0,
y: 0
};
export default function Canvas() {
const [shape, setShape] = useState({
color: 'orange',
position: initialPosition
});
function handleMove(dx, dy) {
shape.position.x += dx;
shape.position.y += dy;
}
function handleColorChange(e) {
setShape({
...shape,
color: e.target.value
});
}
return (
<>
<select
value={shape.color}
onChange={handleColorChange}
>
<option value="orange">orange</option>
<option value="lightpink">lightpink</option>
<option value="aliceblue">aliceblue</option>
</select>
<Background
position={initialPosition}
/>
<Box
color={shape.color}
position={shape.position}
onMove={handleMove}
>
Drag me!
</Box>
</>
);
}
js src/Box.js
import { useState } from 'react';
export default function Box({
children,
color,
position,
onMove
}) {
const [
lastCoordinates,
setLastCoordinates
] = useState(null);
function handlePointerDown(e) {
e.target.setPointerCapture(e.pointerId);
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
}
function handlePointerMove(e) {
if (lastCoordinates) {
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
const dx = e.clientX - lastCoordinates.x;
const dy = e.clientY - lastCoordinates.y;
onMove(dx, dy);
}
}
function handlePointerUp(e) {
setLastCoordinates(null);
}
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
width: 100,
height: 100,
cursor: 'grab',
backgroundColor: color,
position: 'absolute',
border: '1px solid black',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: `translate(
${position.x}px,
${position.y}px
)`,
}}
>{children}</div>
);
}
js src/Background.js
export default function Background({
position
}) {
return (
<div style={{
position: 'absolute',
transform: `translate(
${position.x}px,
${position.y}px
)`,
width: 250,
height: 250,
backgroundColor: 'rgba(200, 200, 0, 0.2)',
}} />
);
};
css
body { height: 280px; }
select { margin-bottom: 10px; }
json package.json
{
"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"
}
}
下面的代码是使用 Immer 重写的。请注意代码中的事件处理函数仍然是以直接修改对象的方式书写的,但是代码不会产生任何问题了。这是因为从原理上来说,Immer 从来没有直接修改现有的对象。
js src/App.js
import { useImmer } from 'use-immer';
import Background from './Background.js';
import Box from './Box.js';
const initialPosition = {
x: 0,
y: 0
};
export default function Canvas() {
const [shape, updateShape] = useImmer({
color: 'orange',
position: initialPosition
});
function handleMove(dx, dy) {
updateShape(draft => {
draft.position.x += dx;
draft.position.y += dy;
});
}
function handleColorChange(e) {
updateShape(draft => {
draft.color = e.target.value;
});
}
return (
<>
<select
value={shape.color}
onChange={handleColorChange}
>
<option value="orange">orange</option>
<option value="lightpink">lightpink</option>
<option value="aliceblue">aliceblue</option>
</select>
<Background
position={initialPosition}
/>
<Box
color={shape.color}
position={shape.position}
onMove={handleMove}
>
Drag me!
</Box>
</>
);
}
js src/Box.js
import { useState } from 'react';
export default function Box({
children,
color,
position,
onMove
}) {
const [
lastCoordinates,
setLastCoordinates
] = useState(null);
function handlePointerDown(e) {
e.target.setPointerCapture(e.pointerId);
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
}
function handlePointerMove(e) {
if (lastCoordinates) {
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
const dx = e.clientX - lastCoordinates.x;
const dy = e.clientY - lastCoordinates.y;
onMove(dx, dy);
}
}
function handlePointerUp(e) {
setLastCoordinates(null);
}
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
width: 100,
height: 100,
cursor: 'grab',
backgroundColor: color,
position: 'absolute',
border: '1px solid black',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: `translate(
${position.x}px,
${position.y}px
)`,
}}
>{children}</div>
);
}
js src/Background.js
export default function Background({
position
}) {
return (
<div style={{
position: 'absolute',
transform: `translate(
${position.x}px,
${position.y}px
)`,
width: 250,
height: 250,
backgroundColor: 'rgba(200, 200, 0, 0.2)',
}} />
);
};
css
body { height: 280px; }
select { margin-bottom: 10px; }
json package.json
{
"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"
}
}
帮助我们改进文档
发现翻译问题或内容错误?请告诉我们。
