重构初级 React 代码 - 代码量减少 43%,数据结构更优
作为一名(有志于成为)初级 React 开发者,很难判断自己的代码是否优秀以及如何改进。如果能得到经验丰富的开发者的评价就更好了。但分享代码需要勇气。而且在线社区里的评论通常很肤浅。
最近,一位开发者勇敢地申请了代码审查。值得称赞。这个功能很有意思,代码也还不错。但是,其中有一些在初级 React 开发者中很常见的错误。
所以,现在你有机会从这些错误中吸取教训。在这个页面上,你可以找到代码审查和逐步的重构之旅。
- 使用更好的数据结构
- 删除不必要的状态和
- 一些其他重构和清理
我们提高了代码的性能,使其更易于阅读和维护,并将组件从最初的 177 行缩短到 102 行(这不是一个好的指标)。
如果您更喜欢视频,您可以在这里观看完整的审查和重构过程。
注意:本页上的代码与视频中使用 Set 而不是 Map 的代码相比略有改进。
目录
需要审查的组件
功能
该组件是一个包含一些稍微复杂功能的表格。以下是一段简短的视频:
该表格呈现了问题列表。问题的状态可以是“未解决”或“已解决”,状态会反映在最后一列。状态为“已解决”的问题无法选择。
当选中部分行(而非全部行)时,顶部的复选框会被部分选中。点击该复选框允许用户选择或取消选择所有状态为“未决”的问题。
原始代码
原始代码还不错。它能正常工作,并且完成了它的任务。在大多数情况下,它的可读性很高,尤其是在高层级。它与许多初级 React 开发人员编写的代码类似。同时,它也存在一些初级开发人员中常见的问题。我们稍后会讨论这些问题。
但首先,这是组件的代码。你也可以在 CodeSandbox 上找到一个可以运行的版本。
import { useState } from "react";
import classes from "./Table.module.css";
function Table({ issues }) {
const [checkedState, setCheckedState] = useState(
new Array(issues.length).fill({
checked: false,
backgroundColor: "#ffffff",
})
);
const [selectDeselectAllIsChecked, setSelectDeselectAllIsChecked] =
useState(false);
const [numCheckboxesSelected, setNumCheckboxesSelected] = useState(0);
const handleOnChange = (position) => {
const updatedCheckedState = checkedState.map((element, index) => {
if (position === index) {
return {
...element,
checked: !element.checked,
backgroundColor: element.checked ? "#ffffff" : "#eeeeee",
};
}
return element;
});
setCheckedState(updatedCheckedState);
const totalSelected = updatedCheckedState
.map((element) => element.checked)
.reduce((sum, currentState, index) => {
if (currentState) {
return sum + issues[index].value;
}
return sum;
}, 0);
setNumCheckboxesSelected(totalSelected);
handleIndeterminateCheckbox(totalSelected);
};
const handleIndeterminateCheckbox = (total) => {
const indeterminateCheckbox = document.getElementById(
"custom-checkbox-selectDeselectAll"
);
let count = 0;
issues.forEach((element) => {
if (element.status === "open") {
count += 1;
}
});
if (total === 0) {
indeterminateCheckbox.indeterminate = false;
setSelectDeselectAllIsChecked(false);
}
if (total > 0 && total < count) {
indeterminateCheckbox.indeterminate = true;
setSelectDeselectAllIsChecked(false);
}
if (total === count) {
indeterminateCheckbox.indeterminate = false;
setSelectDeselectAllIsChecked(true);
}
};
const handleSelectDeselectAll = (event) => {
let { checked } = event.target;
const allTrueArray = [];
issues.forEach((element) => {
if (element.status === "open") {
allTrueArray.push({ checked: true, backgroundColor: "#eeeeee" });
} else {
allTrueArray.push({ checked: false, backgroundColor: "#ffffff" });
}
});
const allFalseArray = new Array(issues.length).fill({
checked: false,
backgroundColor: "#ffffff",
});
checked ? setCheckedState(allTrueArray) : setCheckedState(allFalseArray);
const totalSelected = (checked ? allTrueArray : allFalseArray)
.map((element) => element.checked)
.reduce((sum, currentState, index) => {
if (currentState && issues[index].status === "open") {
return sum + issues[index].value;
}
return sum;
}, 0);
setNumCheckboxesSelected(totalSelected);
setSelectDeselectAllIsChecked((prevState) => !prevState);
};
return (
<table className={classes.table}>
<thead>
<tr>
<th>
<input
className={classes.checkbox}
type={"checkbox"}
id={"custom-checkbox-selectDeselectAll"}
name={"custom-checkbox-selectDeselectAll"}
value={"custom-checkbox-selectDeselectAll"}
checked={selectDeselectAllIsChecked}
onChange={handleSelectDeselectAll}
/>
</th>
<th className={classes.numChecked}>
{numCheckboxesSelected
? `Selected ${numCheckboxesSelected}`
: "None selected"}
</th>
</tr>
<tr>
<th />
<th>Name</th>
<th>Message</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{issues.map(({ name, message, status }, index) => {
let issueIsOpen = status === "open";
let onClick = issueIsOpen ? () => handleOnChange(index) : null;
let stylesTr = issueIsOpen
? classes.openIssue
: classes.resolvedIssue;
return (
<tr
className={stylesTr}
style={checkedState[index]}
key={index}
onClick={onClick}
>
<td>
{issueIsOpen ? (
<input
className={classes.checkbox}
type={"checkbox"}
id={`custom-checkbox-${index}`}
name={name}
value={name}
checked={checkedState[index].checked}
onChange={() => handleOnChange(index)}
/>
) : (
<input
className={classes.checkbox}
type={"checkbox"}
disabled
/>
)}
</td>
<td>{name}</td>
<td>{message}</td>
<td>
{issueIsOpen ? (
<span className={classes.greenCircle} />
) : (
<span className={classes.redCircle} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
export default Table;
表中呈现的问题数组如下所示。
[
{
"id": "c9613c41-32f0-435e-aef2-b17ce758431b",
"name": "TypeError",
"message": "Cannot read properties of undefined (reading 'length')",
"status": "open",
"numEvents": 105,
"numUsers": 56,
"value": 1
},
{
"id": "1f62d084-cc32-4c7b-943d-417c5dac896e",
"name": "TypeError",
"message": "U is not a function",
"status": "resolved",
"numEvents": 45,
"numUsers": 34,
"value": 1
},
...
]
该组件较为复杂,共有 177 行代码 (LOC)。但正如前文所述,从总体上看,它的可读性还是不错的。以下是函数折叠后的效果。
function Table({ issues }) {
// decides which checkboxes is selected
const [checkedState, setCheckedState] = useState(...);
// decides if the top checkbox is checked or not
const [selectDeselectAllIsChecked, setSelectDeselectAllIsChecked] =
useState(false);
// represents the number of selected checkboxes
const [numCheckboxesSelected, setNumCheckboxesSelected] = useState(0);
// handles the click on a row / checkbox
const handleOnChange = (position) => { ... };
// responsible for setting the partially checked state of the top checkbox
const handleIndeterminateCheckbox = (total) => { ... };
// handles the click on the top checkbox
const handleSelectDeselectAll = (event) => { ... };
return (
<table>
...
</table>
);
}
还不错。但还有改进的空间。
原始代码的问题
问题 1:次优数据结构
让我们看一下组件的顶部。
function Table({ issues }) {
const [checkedState, setCheckedState] = useState(
new Array(issues.length).fill({ checked: false, backgroundColor: "#ffffff" })
);
...
- 该
checkedState
变量是一个数组。 - 它被初始化为具有与问题数组相同的长度。
- 每个条目都是一个代表问题检查状态的对象。
第一个问题是对象数组总是难以改变。我们必须通过数组中的索引来访问正确的对象。所以我们需要找到并传递索引。
第二个问题是这种方法的扩展性不太好。例如,如果我们有 10k 个问题,那么我们就会得到一个 10k 长的checkedState
数组,其中大部分都是false
布尔值。
替代方案:我们可以使用不同的数据结构,例如将问题 ID 映射到其检查值的对象或Map 。
// alternative data structure (example when two checkboxes are checked)
{
"issue-id-1": true,
"issue-id-4": true,
}
更好的是,JavaScript 有一个原生的Set,它类似于数组,但只能保存唯一值,并且针对以高性能方式访问它们进行了优化。
问题 2:没有从状态中导出变量
再次在组件的顶部,我们可以看到初级开发人员代码中的另一个非常常见的问题:可以从 props 或其他状态派生出的不必要的状态变量。
function Table({ issues }) {
const [checkedState, setCheckedState] = useState(
new Array(issues.length).fill({ checked: false, backgroundColor: "#ffffff" })
);
const [selectDeselectAllIsChecked, setSelectDeselectAllIsChecked] =
useState(false);
const [numCheckboxesSelected, setNumCheckboxesSelected] = useState(0);
...
在这段代码中,我们有三个不必要的状态:
backgroundColor
是一种可以从值中派生出来的样式checked
(#eee
如果检查结果为真,则为#fff
)selectDeselectAllIsChecked
用于设置checked
顶部复选框的 prop(可从checkedState
和派生issues
)numCheckboxesSelected
是该复选框旁边的数字(可以从中得出checkedState
)
问题 3:直接访问 DOM
进一步阅读代码,我们可以找到处理indeterminate
顶部复选框状态的函数:
function Table({ issues }) {
...
const handleIndeterminateCheckbox = (total) => {
const indeterminateCheckbox = document.getElementById(
"custom-checkbox-selectDeselectAll"
);
let count = 0;
issues.forEach((element) => {
if (element.status === "open") {
count += 1;
}
});
if (total === 0) {
indeterminateCheckbox.indeterminate = false;
setSelectDeselectAllIsChecked(false);
}
if (total > 0 && total < count) {
indeterminateCheckbox.indeterminate = true;
setSelectDeselectAllIsChecked(false);
}
if (total === count) {
indeterminateCheckbox.indeterminate = false;
setSelectDeselectAllIsChecked(true);
}
};
...
我们可以看到,顶部的复选框是通过 访问的document.getElementById(...)
。这在 React 应用中相对不常见,而且通常没有必要。
indeterminateCheckbox.indeterminate
请注意,必须直接在 DOM 元素上设置。我们不能将其设置indeterminate
为复选框上的 prop。
问题4:变量命名
这段代码的作者在变量和函数名称的选择上花了不少功夫。值得称赞。
不幸的是,有些名称有点令人困惑。我们可以在组件顶部找到一些示例:
function Table({ issues }) {
const [checkedState, setCheckedState] = useState(
new Array(issues.length).fill({ checked: false, backgroundColor: "#ffffff" })
);
const [selectDeselectAllIsChecked, setSelectDeselectAllIsChecked] =
useState(false);
const [numCheckboxesSelected, setNumCheckboxesSelected] = useState(0);
...
例如,checkedState
是多余的,而且容易引起误解。我期望这里是一个布尔值,但这是一个对象数组。更好的名字可能是checkedItems
。
selectDeselectAllIsChecked
有点容易理解,但读起来有点费劲。它告诉我们所有复选框是否都被选中了,但我得读上五遍。更好的名字可能是isEveryItemChecked
……
重构
现在我们已经分析了原始代码的一些问题,让我们开始逐步重构它。
步骤 1:将数据结构从数组重构为集合
最有效的一步是使用Set而不是数组作为checkedState
。我们也可以使用对象,但 Set 有一些优势,比如可以直接访问,这size
一点我们会经常用到。与 Map 相比,Set 在这里更适合,因为我们只需要知道所有选定项的 ID,而不是键值对。
由于我们已经接触到此代码,因此我们还将backgroundColor
从状态中删除并重命名状态变量。
//before
const [checkedState, setCheckedState] = useState(
new Array(issues.length).fill({ checked: false, backgroundColor: "#ffffff" })
);
// after
const [checkedById, setCheckedById] = useState(new Set());
提醒一下,状态的形状checkedById
(表示为数组)现在看起来像这样:
// shape of checkedById (when two checkboxes are checked)
["issue-id-1", "issue-id-4"]
这个改动意义重大,它会影响我们其余的代码。我们将逐一看看代码的每个部分是如何受到影响的。
让我们从连接到表中的复选框的处理函数开始。
以下是代码的前后情况:
// before
const handleOnChange = (position) => {
const updatedCheckedState = checkedState.map((element, index) => {
if (position === index) {
return {
...element,
checked: !element.checked,
backgroundColor: element.checked ? "#ffffff" : "#eeeeee",
};
}
return element;
});
setCheckedState(updatedCheckedState);
const totalSelected = updatedCheckedState
.map((element) => element.checked)
.reduce((sum, currentState, index) => {
if (currentState) {
return sum + issues[index].value;
}
return sum;
}, 0);
setNumCheckboxesSelected(totalSelected);
handleIndeterminateCheckbox(totalSelected);
};
// after
const handleOnChange = (id) => {
const updatedCheckedById = new Set(checkedById);
if (updatedCheckedById.has(id)) {
updatedCheckedById.delete(id);
} else {
updatedCheckedById.add(id);
}
setCheckedById(updatedCheckedById);
const totalSelected = updatedCheckedById.size;
setNumCheckboxesSelected(totalSelected);
handleIndeterminateCheckbox(totalSelected);
};
我们清理了不少杂物。以下是一些简短的说明:
- 原始代码中有一行
return sum + issues[index].value
很有意思。它使用数据中的一个值来增加总和,并计算所选元素的数量。这看起来过于复杂了。 - 在重构的代码中,我们现在可以简单地使用它
updatedCheckedById.size
来获取选定元素的数量。 - 由于我们不希望
false
我们的状态中包含任何值,因此我们必须在通过取消选择复选框时删除相应的值updatedCheckedById.delete(id)
。
让我们继续讨论与顶部复选框相连的处理函数。
这是前后对比。
// before
const handleSelectDeselectAll = (event) => {
let { checked } = event.target;
const allTrueArray = [];
issues.forEach((element) => {
if (element.status === "open") {
allTrueArray.push({ checked: true, backgroundColor: "#eeeeee" });
} else {
allTrueArray.push({ checked: false, backgroundColor: "#ffffff" });
}
});
const allFalseArray = new Array(issues.length).fill({
checked: false,
backgroundColor: "#ffffff",
});
checked ? setCheckedState(allTrueArray) : setCheckedState(allFalseArray);
const totalSelected = (checked ? allTrueArray : allFalseArray)
.map((element) => element.checked)
.reduce((sum, currentState, index) => {
if (currentState && issues[index].status === "open") {
return sum + issues[index].value;
}
return sum;
}, 0);
setNumCheckboxesSelected(totalSelected);
setSelectDeselectAllIsChecked((prevState) => !prevState);
};
// after
const handleSelectDeselectAll = (event) => {
if (event.target.checked) {
const openIssues = issues.filter(({ status }) => status === "open");
const allChecked = new Set(openIssues.map(({ id }) => id));
setCheckedById(allChecked);
setNumCheckboxesSelected(allChecked.size);
} else {
setCheckedById(new Set());
setNumCheckboxesSelected(0);
}
setSelectDeselectAllIsChecked((prevState) => !prevState);
};
再次简单说明一下:
allTrueArray
原始实现中,使用和有点繁琐allFalseArray
。我们也可以重构它,但由于我们现在使用对象,所以我们可以直接删除这段代码。- 因为我们在新的实现中使用了 Set,所以创建新
allChecked
状态非常简单。我们筛选所有未解决的问题,然后根据 ID 数组构造一个新的 Set。
我们需要调整的最后一段代码是从组件返回的 JSX。特别是元素内部的代码<tbody>
。
// before
<tbody>
{issues.map(({ name, message, status }, index) => {
let issueIsOpen = status === "open";
// we have to use the id of the issue instead of the index here
let onClick = issueIsOpen ? () => handleOnChange(index) : null;
let stylesTr = issueIsOpen
? classes.openIssue
: classes.resolvedIssue;
return (
<tr
className={stylesTr}
// the backgroundColor isn't part of checkedState anymore
// so we have to adjust this
style={checkedState[index]}
key={index}
onClick={onClick}
>
<td>
{issueIsOpen ? (
<input
className={classes.checkbox}
type={"checkbox"}
id={`custom-checkbox-${index}`}
name={name}
value={name}
// we have to use the id instead of the index here
checked={checkedState[index].checked}
onChange={() => handleOnChange(index)}
/>
) : (
<input
className={classes.checkbox}
type={"checkbox"}
disabled
/>
)}
</td>
...
</tr>
);
})}
</tbody>
// after
<tbody>
{issues.map(({ id, name, message, status }, index) => {
let issueIsOpen = status === "open";
let onClick = issueIsOpen ? () => handleOnChange(id) : null;
let stylesTr = issueIsOpen
? classes.openIssue
: classes.resolvedIssue;
return (
<tr
key={id}
className={stylesTr}
style={{ backgroundColor: checkedById.has(id) ? "#eee" : "#fff" }}
onClick={onClick}
>
<td>
{issueIsOpen ? (
<input
className={classes.checkbox}
type={"checkbox"}
id={`custom-checkbox-${index}`}
name={name}
value={name}
checked={checkedById.has(id)}
onChange={() => handleOnChange(id)}
/>
) : (
<input
className={classes.checkbox}
type={"checkbox"}
disabled
/>
)}
</td>
...
</tr>
);
})}
</tbody>
再次简单说明一下:
index
我们必须更改此代码中的大部分内容。- 现在,我们不再使用元素
key={index}
上的写操作,而是使用as 键。这在当前版本中不会改变任何内容,因为问题的顺序不会改变。但这是最佳实践,将来我们可能会在表中引入某种排序功能。<tr>
id
- 以前,行的背景颜色是通过以下代码设置的:
style={checkedState[index]}
。这需要一点时间来理解。对于任何给定的行,这都转换为例如style={{ checked: true, backgroundColor: "#fff" }}
。 - 新版本在这方面更加明确:
style={{ backgroundColor: checkedById[id] ? "#eee" : "#fff" }}
您可以在 GitHub 上找到所有代码更改,并在 CodeSandbox 上找到此步骤的功能版本(请注意,您需要进行更改App.jsx
才能在浏览器中看到此版本的组件呈现)。
步骤 2:使用派生状态
我们上面讨论的第二个问题是不必要的状态变量selectDeselectAllIsChecked
和numCheckboxesSelected
。如上所述,这些可以很容易地从checkedById
状态 和issues
数据数组中得出。
// before
function Table({ issues }) {
const [checkedById, setCheckedById] = useState(new Set());
const [selectDeselectAllIsChecked, setSelectDeselectAllIsChecked] =
useState(false);
const [numCheckboxesSelected, setNumCheckboxesSelected] = useState(0);
...
// after
function Table({ issues }) {
const [checkedById, setCheckedById] = useState(new Set());
const openIssues = issues.filter(({ status }) => status === "open");
const numOpenIssues = openIssues.length;
const numCheckedIssues = checkedById.size;
...
这里我们不一定需要这个openIssues
变量。但这样我们就可以在重构后的handleSelectDeselectAll
函数中重用它(这里没有显示)。
除了这些更改之外,我们还必须从其余代码中删除对状态设置器的调用。这些更改有两个优点:
- 我们去掉了几行代码。
- 更重要的是,我们不再需要管理额外的状态。这很棒,因为我们很容易忘记更新状态,从而引入错误。
最后一步,我们可以记忆计算numOpenIssues
。当前版本不需要此功能,但一旦我们处理更大的数据集,此功能可能就有必要了。
const openIssues = useMemo(
() => issues.filter(({ status }) => status === "open"),
[issues]
);
您可以在 GitHub 上查看代码更改的完整列表,并在 CodeSandbox 上查看此步骤的运行版本。
步骤 3:删除 document.getElementById()
我们重构之旅的最后一步重要步骤是以 React 方式访问顶部复选框,而不是使用document.getElementById()
。
React 的方式是什么?我们使用ref
。
// before
function Table({ issues }) {
...
const handleIndeterminateCheckbox = (total) => {
const indeterminateCheckbox = document.getElementById(
"custom-checkbox-selectDeselectAll"
);
let count = 0;
issues.forEach((element) => {
if (element.status === "open") {
count += 1;
}
});
if (total === 0) {
indeterminateCheckbox.indeterminate = false;
}
if (total > 0 && total < count) {
indeterminateCheckbox.indeterminate = true;
}
if (total === count) {
indeterminateCheckbox.indeterminate = false;
}
};
...
// after
function Table({ issues }) {
...
const topCheckbox = useRef();
const handleIndeterminateCheckbox = (checkedById) => {
const numSelected = checkedById.size;
if (numSelected === 0) {
topCheckbox.current.indeterminate = false;
} else if (numSelected === numOpenIssues) {
topCheckbox.current.indeterminate = false;
} else {
topCheckbox.current.indeterminate = true;
}
};
...
return (
<table className={classes.table}>
<thead>
<tr>
<th>
<input
ref={topCheckbox}
...
一些简短的说明:
- 最重要的是我们
document.getElementById()
用替换了 。这也允许我们从复选框中useRef()
删除。id
- 原始代码中这行代码
issues.forEach((element) => {
负责计算未解决的问题数量。这种方法有点繁琐,可以很容易地用 替换。但是我们已经在组件顶部issues.filter(...).length
定义了变量。所以不再需要这行代码了。numOpenIssues
- 我们用了
if ... else if ... else
三个if
语句。我们还调整了条件,使唯一true
值放在底部。我认为这让我们的大脑更容易处理。
实际上,我们甚至不需要该if ... else if ... else
语句。我们可以简单地用一行代码替换它:
topCheckbox.current.indeterminate =
numSelected > 0 && numSelected < numOpenIssues;
使用此行,保留该功能也没有多大意义handleIndeterminateCheckbox
。
您可以在 GitHub 上找到所有代码更改,并在 CodeSandbox 上找到此步骤的功能版本。
最终代码
经过一些额外的清理(这里是更改)后,我们能够将代码从最初的 177 LOC 减少到 102 LOC。
import { useMemo, useState, useRef } from "react";
import classes from "./Table.module.css";
function Table({ issues }) {
const topCheckbox = useRef();
const [checkedById, setCheckedById] = useState(new Set());
const openIssues = useMemo(
() => issues.filter(({ status }) => status === "open"),
[issues]
);
const numOpenIssues = openIssues.length;
const numCheckedIssues = checkedById.size;
const handleOnChange = (id) => {
const updatedCheckedById = new Set(checkedById);
if (updatedCheckedById.has(id)) {
updatedCheckedById.delete(id);
} else {
updatedCheckedById.add(id);
}
setCheckedById(updatedCheckedById);
const updatedNumChecked = updatedCheckedById.size;
topCheckbox.current.indeterminate =
updatedNumChecked > 0 && updatedNumChecked < numOpenIssues;
};
const handleSelectDeselectAll = (event) => {
if (event.target.checked) {
const allChecked = new Set(openIssues.map(({ id }) => id));
setCheckedById(allChecked);
} else {
setCheckedById(new Set());
}
};
return (
<table className={classes.table}>
<thead>
<tr>
<th>
<input
type="checkbox"
ref={topCheckbox}
className={classes.checkbox}
checked={numOpenIssues === numCheckedIssues}
onChange={handleSelectDeselectAll}
/>
</th>
<th className={classes.numChecked}>
{numCheckedIssues
? `Selected ${numCheckedIssues}`
: "None selected"}
</th>
</tr>
<tr>
<th />
<th>Name</th>
<th>Message</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{issues.map(({ id, name, message, status }) => {
const isIssueOpen = status === "open";
return (
<tr
key={id}
className={
isIssueOpen ? classes.openIssue : classes.resolvedIssue
}
style={{ backgroundColor: checkedById.has(id) ? "#eee" : "#fff" }}
>
<td>
<input
type="checkbox"
className={classes.checkbox}
checked={checkedById.has(id)}
disabled={!isIssueOpen}
onChange={() => handleOnChange(id)}
/>
</td>
<td>{name}</td>
<td>{message}</td>
<td>
<span
className={
isIssueOpen ? classes.greenCircle : classes.redCircle
}
/>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
export default Table;
您可以在 GitHub 上找到所有代码更改,并在 CodeSandbox 上找到最终组件的版本。
从我的角度来看,这不仅更简洁,而且更具可读性。
总结
- 注意可能从其他状态或数据中得出的不必要的状态。
- 使用对象、映射或集合通常比数组更容易、更高效。
- React 访问 DOM 元素的方式是
useRef()
而不是document.getElementById()
。