🔗 原文链接:# Common Beginner Mistakes with React
👨💻 原作者:Josh W. Comeau
📅 发布时间:2023年3月6日
🕐 最后更新:2024年10月13日
⚠️ 关于本译文本文基于 Josh W. Comeau 的原文进行忠实翻译,力求准确传达原作者的技术观点和逻辑结构。
🎨 特色亮点:
- 保持原文的完整性和技术准确性
- 采用自然流畅的中文表达,避免翻译腔
- 添加画外音板块,提供译者的补充解读和实践心得
- 使用生动比喻帮助理解复杂概念
💡 画外音说明: 文中标注为画外音的部分是译者基于实际开发经验添加的拓展解释,旨在帮助读者更好地理解和应用这些概念,不代表原作者观点。
🖼️ 关于交互式示例: 本文中的图片和交互式演示以截图和GIF动图形式呈现。如需体验完整的交互式功能,可前往原文进行实际操作。
几年前我在当地训练营教 React 的时候,发现学生们总是在同样的地方翻车。就像有个看不见的陷阱一直在那儿等着大家往里跳!
这篇文章就是要盘点 9 个最坑人的陷阱。学会了这些,你就能华丽地绕过这些坑,避免无数次想砸电脑的冲动。
咱们就不深究这些坑为啥存在了,直接上干货,把这当成一本"避坑指南"来用就行。
🎯 这篇文章适合谁看?
如果你已经知道 React 的基本概念,但还在新手村里打怪升级,那这篇文章就是为你量身定制的。
错误1️⃣:用零值进行条件渲染
咱们先从一个特别常见的坑开始。这个坑我在生产环境里见过好多次,简直是新手收割机!
看看下面这个例子:

我们本来想这样做:如果购物车里有东西就显示购物清单,没有就什么都不显示。听起来很简单对吧?
结果界面上蹦出来一个莫名其妙的 0
!
这是咋回事呢?原来 items.length
算出来是 0
,而在 JavaScript 里,0
是个假值,所以 &&
操作符直接短路了,整个表达式就变成了 0
。
简单说就是相当于你写了这样的代码:
jsx
function App() {
return (
<div>
{0}
</div>
);
}
💡 画外音: 这绝对是 React 新手最容易掉的坑,没有之一!和其他假值(空字符串、
null
、false
这些)不一样,数字0
在 JSX 里是个合法的显示内容。毕竟咱们有时候确实需要显示0
这个数字嘛!
🔧 怎么修复?
简单!让表达式返回真正的布尔值就行了:
jsx
function App() {
const [items, setItems] = React.useState([]);
return (
<div>
{items.length > 0 && (
<ShoppingList items={items} />
)}
</div>
);
}
items.length > 0
永远只会返回 true
或 false
,再也不会出幺蛾子了。
当然你也可以用三元表达式(看个人喜好):
jsx
function App() {
const [items, setItems] = React.useState([]);
return (
<div>
{items.length
? <ShoppingList items={items} />
: null}
</div>
);
}
这两种写法都没问题,选你看着顺眼的就行。
错误2️⃣: 直接修改 state
继续拿购物清单举例子。假设现在要给它加个添加商品的功能:

用户提交新商品的时候会调用 handleAddItem
函数。结果...完全没反应!明明输入了商品还点了提交,但购物清单还是老样子。

问题在哪儿? 我们犯了 React 里的大忌------直接改 state!
具体来说就是这行代码有问题:
jsx
function handleAddItem(value) {
items.push(value);
setItems(items);
}
React 是怎么知道 state 变了的呢?它看的是对象的"身份证"。你用 push
往数组里塞东西,数组的身份证没变,React 就觉得"咦?没变化啊,我就不更新了"。
💡画外音: 这玩意儿刚开始真的很反直觉!其他语言里直接改数据是常规操作,但 React 偏偏要搞特殊。习惯了就好,就当它有洁癖吧。
🔧 怎么修复?
简单粗暴------造个新数组:
jsx
function handleAddItem(value) {
const nextItems = [...items, value];
setItems(nextItems);
}
我没有改原来的数组,而是造了个全新的。用 ...
把原来的内容全部复制过来,再加上新商品。
记住一个原则:给 setCount
这类 "state 设置"函数传值的时候,必须传个"崭新"的东西。
对象也是同样的道理:
jsx
// ❌ 直接改现有对象,React会装作没看见
function handleChangeEmail(nextEmail) {
user.email = nextEmail;
setUser(user);
}
// ✅ 造个新对象,React开心了
function handleChangeEmail(nextEmail) {
const nextUser = { ...user, email: nextEmail };
setUser(nextUser);
}
...
这个语法就是个复制粘贴神器,把原来的东西全部拷贝到新地方,保证 React 能认出来"哦,这是新的!"
错误 3️⃣ 没有给列表项加 key
你肯定见过这个让人头疼的警告:
Warning: Each child in a list should have a unique "key" prop.
最常出现的场景就是用 map
渲染列表的时候。来看个经典的反面教材:

每次渲染数组的时候,React 需要知道"这些东西谁是谁",所以需要给每个元素一个唯一的身份标识。
网上很多教程会告诉你直接用数组的索引:
jsx
function ShoppingList({ items }) {
return (
<ul>
{items.map((item, index) => {
return (
<li key={index}>{item}</li>
);
})}
</ul>
);
}
但我觉得这就是在挖坑! 这种方法有时候能用,但很多时候会出大问题。
💡 画外音: 用 index 做 key 确实是网上最常见的"快速解决方案",但这就像用创可贴修水管------治标不治本,早晚出事!
等你对 React 理解更深了,你可能能判断什么时候能这么用,但说实话,我觉得直接用永远安全的方法更省心。毕竟谁愿意天天提心吊胆呢?
🔧 正确的做法
每次往列表里加东西的时候,给它生成一个唯一 ID:
jsx
function handleAddItem(value) {
const nextItem = {
id: crypto.randomUUID(),
label: value,
};
const nextItems = [...items, nextItem];
setItems(nextItems);
}
crypto.randomUUID
是浏览器自带的方法(不用装额外的包),所有主流浏览器都支持。而且跟加密货币没半毛钱关系。
这个方法会生成一个独一无二的字符串,长这样:d9bb3c4c-0459-48b9-a94c-7ca3963f7bd0
。
这样每次用户提交的时候,我们都能保证每个商品都有自己专属的 ID。
然后这么用:
jsx
function ShoppingList({ items }) {
return (
<ul>
{items.map((item) => {
return (
<li key={item.id}>
{item.label}
</li>
);
})}
</ul>
);
}
记住一定要在更新 state 的时候生成 ID,千万别这么写:
jsx
// ❌ 这样写就是在作死
<li key={crypto.randomUUID()}>
{item.label}
</li>
在 JSX 里面这么生成会导致每次渲染 key 都变,React 会疯狂地删除重建元素,性能直接拉胯。
💡画外音: 从服务器拿数据的时候也可以这么搞。就算后端没给 ID,咱们前端自己造一个也行。
错误4️⃣:缺少空白字符
这是个特别阴险的坑,我在网上见过无数次。

看到没?两个句子直接黏在一起了,就像连体字一样!

这是为啥呢?JSX 编译器(就是那个把你写的 JSX 转换成浏览器能懂的 JavaScript 的工具)分不清哪些空白是为了美化代码的,哪些是真的需要显示的空格。
🔧 怎么修复?
手动加个空格字符就行了:
jsx
<p>
欢迎来到 Corpitech.com!
{' '}
<a href="/login">登录继续</a>
</p>
小贴士: 如果你用 Prettier,它会自动帮你加这些空格!前提是你得让它自由发挥(别提前手动换行)。
React 团队为啥不修这个 bug?
刚知道这个解决方案的时候,我就想:React 团队咋不把这个修了呢?让它按正常逻辑工作不行吗!
后来我才明白,这事儿没有完美解决方案。如果 React 把所有缩进都当成空格,虽然解决了这个问题,但会引发一堆新的麻烦。
所以虽然看起来有点 hack,但这确实是最合理的选择。总得有人妥协不是?
💡 画外音这确实是 React 设计上的一个取舍。刚开始觉得别扭,用久了就习惯了。反正程序员嘛,适应能力都很强的。
错误5️⃣: 改变 state 后立即访问
这个问题迟早会整到每个人。我当老师的时候,学生们拿着这个问题来找我的次数,我都数不过来了。
来看个简单的计数器:点击按钮数字就加 1。你能发现哪里有问题吗?

我们改完 count 之后马上打印到控制台,结果...打印出来的是旧值!

问题在哪? React 里像 setCount
这样的 state 设置函数是异步的!
就是这段代码有问题:
jsx
function handleClick() {
setCount(count + 1);
console.log({ count });
}
很容易以为 setCount
跟普通赋值一样,就像这样:
jsx
count = count + 1;
console.log({ count });
但 React 不是这么工作的!调用 setCount
的时候,你不是在重新赋值变量,而是在"预约"一个更新。
想要理解这个概念,有个小窍门:你根本就不能给 count 重新赋值,因为它是个常量!
jsx
// 用的是 `const`,不是 `let`,所以根本改不了
const [count, setCount] = React.useState(0);
count = count + 1; // Uncaught TypeError:
// Assignment to constant variable
🔧 怎么修复?
既然我们知道新值应该是啥,那就把它存到变量里呗:
jsx
function handleClick() {
const nextCount = count + 1;
setCount(nextCount);
// 想用新值的时候就用 `nextCount`:
console.log({ nextCount });
}
我喜欢用 "next" 开头来命名这些变量(nextCount、nextItems、nextEmail 啥的)。这样一看就知道这是"下一个值",不是当前值。
💡 画外音: 这个异步更新的概念刚开始确实绕脑子,但这是 React 高效更新界面的秘诀。一旦想通了,你就会发现 React 的很多"奇怪"行为都有道理了。
错误6️⃣: 返回多个元素
有时候组件需要返回好几个并列的元素。
比如这样:

我们希望 LabeledInput
组件能返回两个东西:一个 <label>
和一个 <input>
。结果报错了:
Adjacent JSX elements must be wrapped in an enclosing tag.
为啥会这样呢?因为 JSX 最终会编译成普通的 JavaScript。上面的代码编译后长这样:
jsx
function LabeledInput({ id, label, ...delegated }) {
return (
React.createElement('label', { htmlFor: id }, label)
React.createElement('input', { id: id, ...delegated })
);
}
在 JavaScript 里,你不能一下子返回两个东西。就像这样写肯定不行:
jsx
function addTwoNumbers(a, b) {
return (
"答案是"
a + b
);
}
🔧 怎么修复?
以前的老办法是用个 <div>
包起来:
jsx
function LabeledInput({ id, label, ...delegated }) {
return (
<div>
<label htmlFor={id}>
{label}
</label>
<input
id={id}
{...delegated}
/>
</div>
);
}
把 <label>
和 <input>
塞进 <div>
里,就只返回一个顶级元素了!
但现在有更好的办法------用 Fragment:
jsx
function LabeledInput({ id, label, ...delegated }) {
return (
<React.Fragment>
<label htmlFor={id}>
{label}
</label>
<input
id={id}
{...delegated}
/>
</React.Fragment>
);
}
React.Fragment
就是专门为了解决这个问题而生的。它能把多个元素打包,但不会在 DOM 里留下任何痕迹。这样就不会有多余的 <div>
污染页面结构了!
还有个更简洁的写法:
jsx
function LabeledInput({ id, label, ...delegated }) {
return (
<>
<label htmlFor={id}>
{label}
</label>
<input
id={id}
{...delegated}
/>
</>
);
}
我觉得这个设计很巧妙:React 团队用空标签 <>
来表示"这里不会产生真实的 HTML 标签"。
💡 画外音: Fragment 是 React 16 引入的神器。在这之前每个组件都得有个根元素,经常搞得 DOM 结构一层套一层,看着就难受。
错误7️⃣: 从非受控切换到受控
来看个典型的表单,把输入框绑定到 React state:

你开始在输入框里打字的时候,会看到这样的警告:
Warning: A component is changing an uncontrolled input to be controlled.
🔧 怎么修复?
把 email 的初始值设为空字符串就行了:
jsx
const [email, setEmail] = React.useState('');
当你设置 value
属性的时候,实际上是在告诉 React "这个输入框我要管"。但前提是你得传个有效值!把 email 初始化为空字符串,就能保证 value 永远不会是 undefined。
受控输入框(Controlled inputs)
如果你想深入了解为什么要这样做,以及什么是"受控输入框",可以看看作者最近发布的教程:
➡️ React 中的数据绑定(Data Binding in React)**
💡 画外音:受控组件 vs 非受控组件是 React 里的重要概念。受控组件就是"我全听你的",值完全由 React state 说了算;非受控组件就是"我自己做主",靠 DOM 自己的状态。
错误8️⃣: 缺少 style 括号
JSX 看起来跟 HTML 很像,但细节上有些差异经常坑人。
大部分差异都有很好的提示,控制台警告也很友好。比如你写成 class
而不是 className
,React 会直接告诉你哪里错了。
但有个微妙的差异特别容易踩坑:style 属性。
在 HTML 里,style 是这样写的:
html
<button style="color: red; font-size: 1.25rem">
Hello World
</button>
但在 JSX 里,你得写成对象形式,还要用驼峰命名:
下面这样写肯定报错,你能看出问题在哪吗?

问题就是 需要用双花括号:
jsx
<button
// 是 "{{",不是 "{":
style={{ color: 'red', fontSize: '1.25rem' }}
>
Hello World
</button>
为啥要这样呢?咱们来拆解一下这个语法。
在 JSX 里,花括号是用来创建表达式槽(expression slot) 的。你可以在里面放任何有效的 JS 表达式,比如:
html
<button className={isPrimary ? 'btn primary' : 'btn'}>
花括号里的内容会被当作 JavaScript 执行,结果会设置给这个属性。
对于 style
,我们需要先创建一个表达式槽,然后往里面传一个 JavaScript 对象。
提取成变量可能更好理解:
jsx
// 1. 先创建 style 对象:
const btnStyles = { color: 'red', fontSize: '1.25rem' };
// 2. 然后传给 `style` 属性:
<button style={btnStyles}>
Hello World
</button>
// 当然也可以一步到位:
<button style={{ color: 'red', fontSize: '1.25rem' }}>
外层花括号在 JSX 里创建"表达式槽",内层花括号创建装样式的 JS 对象。
💡 画外音: 这个双花括号语法刚开始确实让人摸不着头脑,但记住"外层是 JSX 语法,内层是 JavaScript 对象"就容易理解了。
错误9️⃣: 异步 effect 函数
假设我们要在组件挂载的时候从 API 拉取用户数据。用 useEffect
钩子,想用 await
关键字。
第一次尝试是这样的:

结果报错了:
'await' is only allowed within async functions
行吧,那给 effect 回调加个 async
关键字:
jsx
React.useEffect(async () => {
const url = `${API}/get-profile?id=${userId}`;
const res = await fetch(url);
const json = await res.json();
setUser(json);
}, [userId]);
结果还是不行,又出现了一个莫名其妙的错误:
destroy is not a function
🔧 正确的做法
在 effect 里面单独创建一个异步函数:
jsx
React.useEffect(() => {
// 先创建个异步函数...
async function runEffect() {
const url = `${API}/get-profile?id=${userId}`;
const res = await fetch(url);
const json = await res.json();
setUser(json);
}
// ...然后调用它:
runEffect();
}, [userId]);
为啥要这么绕呢?咱们得理解 async
关键字到底干了啥。
比如说,你觉得这个函数返回什么?
js
async function greeting() {
return "Hello world!";
}
看起来应该返回字符串 "Hello world!"
对吧?但实际上,这个函数返回的是一个 Promise ,这个 Promise 会解析成字符串 "Hello world!"
。
问题就在这儿!useEffect
钩子不想要你返回 Promise!它想要的是要么什么都不返回,要么返回一个清理函数。
清理函数(Cleanup functions)的内容超出了本教程的范围,但它们非常重要。大多数 effect 都会有某种收尾逻辑,我们需要尽快把它提供给 React,这样 React 就能在依赖项变化或组件卸载时调用它。
用我们这种"单独异步函数"的策略,还是可以正常返回清理函数的:
jsx
React.useEffect(() => {
async function runEffect() {
// Effect 逻辑在这里
}
runEffect();
return () => {
// 清理逻辑在这里
}
}, [userId]);
这个函数你想叫啥都行,我喜欢叫 runEffect
,一看就知道是主要的 effect 逻辑。
💡 画外音: useEffect 的设计确实有点绕,但掌握了这些套路之后,异步操作就很好理解了。记住,React Hooks 的设计理念就是让副作用管理更加清晰可控。
🎯 培养直觉
看完这些修复方法,你可能会想:为啥非得提供唯一的 key?为啥不能在改 state 后立马访问?为啥 useEffect
这么事儿多?!
React 确实不好掌握,特别是有了 hooks 之后。需要时间慢慢消化,让一切都 点击 到位。
我从 2015 年开始用 React,当时就想:"这玩意儿太酷了,但我完全搞不懂它咋工作的。"😅
这些年来,我一点点搭建自己的 React 心理模型。经历了无数次顿悟,每次都让我的理解更深一层。慢慢地,我开始明白 React 为什么 要这么设计。
最爽的是,我不用再死记硬背那些规则了,完全可以凭直觉判断。这让写 React 变成了一件超级有趣的事情!
过去两年,我一直在打磨一门叫《The Joy of React》的交互式课程。这门课程的出发点很简单:帮你建立对 React 的直觉理解,而不是机械地记忆规则。
不同于传统的视频教学,这门课程融合了多种学习方式------既有视频讲解,也有交互式文章、实战练习、真实项目案例,甚至还穿插了一些小游戏。如果你对系统性学习 React 感兴趣,可以去了解一下。
译者注: 这是原作者的课程推广。虽然是付费内容,但从文章质量来看,课程应该也不错。国内也有很多优秀的 React 学习资源,可以根据自己的情况选择。
💡 画外音 React 的学习曲线确实很陡,但翻过这座山后你会发现一片新天地。记住,每个 React 大牛都踩过这些坑,重要的是踩完坑要爬起来,总结经验,继续前进!