写前端页面时,最让人头疼的不是静态布局,而是当数据变化后如何高效地让界面同步更新。React 的解决思路很直接:把界面拆成组件,用声明式语法描述不同状态下的 UI,框架负责差异计算与 DOM 更新。这种模式让复杂交互变得可控,但随着项目变大,状态管理和渲染细节也会成为新的挑战。本文结合上手体验和长期维护经验,把关键概念和注意事项讲清楚。

组件化:从 HTML 模板到可复用函数
传统开发方式里,页面逻辑往往散落在各个事件监听和选择器操作中。比如点击按钮后要手动找到某个 <div> 元素并修改它的文本内容,一旦交互多了,代码就会变得难以追踪。React 采用组件化思维,把一个页面拆成多个独立的小块,每个小块负责自己的展示和交互逻辑,组合起来就能形成完整界面。
函数组件是目前最主流的写法。一个组件本质上就是一个 JavaScript 函数,它接收 props 作为输入,返回需要渲染的 JSX 结构。JSX 看起来像 HTML,但实际上会被编译成 React.createElement 调用。下面是一个最简单的计数器组件,展示了状态更新和声明式渲染的基本流程。
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
当前计数:{count}
</button>
);
}
export default Counter;
这个例子中,我们不需要手动操作 DOM,只需要更新 count 状态,React 会自动重新渲染按钮中的文本。长期使用下来会发现,组件拆分越合理,后续维护成本越低。但也要注意不要过度拆分,每个组件应该承担明确职责,否则 props 传递会变得繁琐。
状态与副作用:Hooks 的威力与常见陷阱
React 16.8 引入 Hooks 之后,函数组件才真正具备了完整的状态管理和副作用处理能力。useState 用来声明状态,useEffect 用来处理数据请求、订阅、定时器等副作用。很多新手会在 useEffect 的依赖数组上栽跟头:如果第二个参数传空数组,只在挂载时执行一次;如果省略第二个参数,每次渲染后都会执行;如果传入具体依赖,则只在依赖变化时执行。
闭包陷阱是长期使用中特别容易遇到的问题。当 useEffect 内部引用了某个状态,但依赖数组没有包含它时,回调函数里拿到的可能是旧值。例如下面的搜索框组件,通过 setTimeout 模拟请求,每次 keyword 变化后先清理上一个定时器,再创建新的定时器。如果依赖数组写成空数组,keyword 将始终是初始值,导致搜索结果不更新。
import React, { useState, useEffect } from 'react';
function SearchBox() {
const [keyword, setKeyword] = useState('');
const [result, setResult] = useState('');
useEffect(() => {
if (!keyword) {
setResult('');
return;
}
const timer = setTimeout(() => {
setResult('搜索结果:' + keyword);
}, 300);
return () => clearTimeout(timer);
}, [keyword]);
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<p>{result}</p>
</div>
);
}
export default SearchBox;
另一个容易忽视的点是清理函数。useEffect 返回的函数会在组件卸载或下一次执行副作用前被调用,适合清除定时器、取消订阅或移除事件监听。React 18 的严格模式在开发环境下会故意执行两次挂载和卸载,目的是暴露那些缺少清理逻辑的副作用。如果你发现请求被发了两次,先不要急着怀疑框架,检查一下是否没有正确处理清理。
性能优化:避免不必要的重渲染
React 的默认行为是父组件更新时,所有子组件都会重新渲染。对于小型应用来说这通常不是问题,但当列表项很多或者组件树较深时,频繁的重渲染会带来明显卡顿。React 提供了 React.memo 来包裹函数组件,只有在 props 变化时才重新渲染。不过 React.memo 对引用类型的 props 需要配合 useMemo 或 useCallback 使用,否则每次父组件渲染都会生成新的函数或对象,导致 memo 失效。
useMemo 用于缓存计算结果,useCallback 用于缓存函数引用。下面这个例子中,filteredItems 的计算依赖 items 和 filter,只有这两个值变化时才重新执行过滤逻辑。如果不过滤直接渲染长列表,每次输入一个字符都可能导致几百个节点重新计算,性能差距会非常明显。
import React, { useMemo, useState } from 'react';
function ExpensiveList({ items }) {
const [filter, setFilter] = useState('');
const filteredItems = useMemo(() => {
return items.filter(item => item.includes(filter));
}, [items, filter]);
return (
<ul>
{filteredItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
export default ExpensiveList;
需要注意的是,useMemo 和 useCallback 并不是越多越好。它们自身也有开销,而且会让代码可读性下降。如果组件本身渲染很快,或者依赖值频繁变化,缓存反而可能得不偿失。一般来说,只有在出现明显的性能瓶颈时再考虑这些优化手段,先保证代码正确,再谈性能。
常见问题与注意事项
列表渲染时,key 的选择至关重要。很多开发者习惯用数组索引作为 key,这在纯静态列表中问题不大,但如果列表项可能增删、排序或筛选,索引 key 会导致 React 错误地复用组件状态,出现输入框内容错位等问题。最好使用稳定且唯一的业务 ID 作为 key,没有 ID 时可以组合多个字段生成唯一字符串。
受控组件和非受控组件的区别也经常让人困惑。受控组件是指表单元素的值由 React state 控制,每次输入都通过 onChange 更新 state;非受控组件则直接使用 ref 读取 DOM 元素的值。受控组件更符合 React 的数据流理念,便于实时校验和联动,但写法稍繁琐;非受控组件适合表单字段很多且不需要实时处理的场景。同一个表单里可以混合使用,但要注意不要出现既设置了 value 又不提供 onChange 的情况,否则输入框会变成只读。
错误边界是长期维护中不可忽视的机制。React 组件在渲染或生命周期中抛出的错误,如果没有被捕获,会导致整个组件树卸载。可以通过 class 组件实现 componentDidCatch 和 getDerivedStateFromError 来捕获子组件错误,展示降级 UI。目前函数组件还无法直接实现错误边界,需要借助第三方库或手动封装。
最后要强调的是,不要在渲染过程中直接修改 state。比如写 items.push(newItem) 再调用 setItems(items) 是错误用法,因为 React 依赖不可变数据来判断状态是否变化。应该使用数组展开、filter、map 等方法返回一个新数组,或者使用 Immer 这类不可变数据工具。这个习惯一旦养成,很多诡异的重渲染问题都会自然消失。