
开场:我曾经遇到一个页面,某个列表组件每次用户操作都要重渲染 12 次。用户体验糟糕,代码看起来也没什么问题。折腾了两天,我才意识到 —— 这不是 bug,是我对 React 的误解。后来我才知道,这个坑 95% 的前端都会踩。
你知道吗,那些觉得 React "很难"的人,大多数是没搞清楚 React 在想什么。
React 其实就干一件事:追踪依赖。
简单到不能再简单:
听起来简单吧?但大多数人就是在这三个地方踩坑。让我们一个一个拆开来看。
这个场景你一定见过。我也见过,反复见过:
// ❌ 反面案例 - 某个真实项目里的代码
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
useEffect(() => {
// 从服务器获取用户信息
fetchUser(userId).then(setUser);
}, [userId]);
useEffect(() => {
// 获取用户的帖子
if (user) {
fetchPosts(user.id).then(setPosts);
}
}, [user]); // ⚠️ 这里依赖了 user
useEffect(() => {
// 记录用户行为
logUserAction(user, posts);
}, [user, posts]); // ⚠️ 每次 user 或 posts 变化都会触发
return<div>{/* ... */}</div>;
}
看起来没问题?但在实际应用中会发生什么呢?
userId 变化 → 触发第一个 effect,调用 fetchUsersetUser 执行 → 组件重渲染user 依赖变化 → 触发第二个 effect,调用 fetchPostssetPosts 执行 → 组件重渲染user 和 posts 都变化 → 触发第三个 effect,调用 logUserActionlogUserAction 返回了新的对象... → 无限循环这就是那个"12 次重渲染"的来源。
很多人不理解 useEffect 的本质。它不是"当某个变量改变时运行代码",而是"当依赖数组中的任何值改变时,重新运行此 effect"。
关键点:
这形成了一条"依赖链":
fetchUser(userId)
↓ setUser
user 对象改变
↓ 触发 useEffect([user])
fetchPosts(user.id)
↓ setPosts
posts 数组改变
↓ 触发 useEffect([user, posts])
logUserAction(user, posts)
↓ 如果返回新对象...
无限循环 ↺
第一步:重新审视你真正需要的依赖
很多时候,你不应该在 effect 中创建新的依赖,而是应该提取依赖或理性地管理它们。
// ✅ 改进方案 1:依赖最小化
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
// 只在 userId 变化时获取用户信息
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// 不依赖 user 对象本身,而依赖 user.id
useEffect(() => {
if (!user) return;
fetchPosts(user.id).then(setPosts);
}, [user?.id]); // ✅ 用 user.id 代替 user
// 分离日志逻辑,使用简单的依赖
useEffect(() => {
if (user) {
logUserAction({ userId: user.id, postCount: posts.length });
}
}, [user?.id, posts.length]); // ✅ 用原始值代替对象
}
这样做的好处:
user.id,posts.length)第二步:Use Derived Values(派生值) —— React 18+ 推荐的新范式
// ✅ 最佳实践:不使用 useState + useEffect 同步数据
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
// 派生值:直接从 user 计算,而不是用 useState
const userName = user?.name ?? 'Loading...';
const postCount = posts.length;
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
useEffect(() => {
if (!user) return;
fetchPosts(user.id).then(setPosts);
}, [user?.id]);
return (
<div>
<h1>{userName}</h1>
<p>Posts: {postCount}</p>
</div>
);
}
这是关键的思想转变:
阿里巴巴的 fusion 组件库团队曾分享过一个原则:**"useEffect 应该是稀缺的,而不是充满整个组件的"**。
一个健康的组件应该是这样的:
组件代码 80 行
├─ 状态管理: 10 行
├─ 事件处理: 30 行
├─ 渲染逻辑: 30 行
└─ useEffect: 2~3 个(最多)
如果你的组件里有 5 个以上的 useEffect,那通常意味着:
想象这个场景:你有一个用户列表页面,每条用户信息是一个 UserCard 组件。当你改变搜索条件时,整个列表都在重渲染,即使列表中的大部分用户没变。
// ❌ 没有做任何优化
function UserList({ users, filter, onSelect }) {
return (
<div>
{users.map(user => (
<UserCard
key={user.id}
user={user}
filter={filter}
onSelect={onSelect}
/>
))}
</div>
);
}
function UserCard({ user, filter, onSelect }) {
console.log('UserCard rendered:', user.id); // 会打印很多次
return (
<div onClick={() => onSelect(user.id)}>
{user.name}
</div>
);
}
你会看到控制台里大量的 "UserCard rendered: 1", "UserCard rendered: 2", ... 甚至在你没改变任何用户数据的时候。
React 的重渲染规则看似简单,实际上很残忍:
当父组件重渲染时,所有子组件都会被重渲染
(除非子组件通过 memo、useMemo 或其他手段阻止)
这是一个 O(n) 的成本问题:如果你有 100 个用户卡片,每次 filter 改变都会导致 100 个组件重渲染。
// ✅ 第一层防护:使用 React.memo
const UserCard = React.memo(function UserCard({ user, filter, onSelect }) {
console.log('UserCard rendered:', user.id);
return (
<div onClick={() => onSelect(user.id)}>
{user.name}
</div>
);
}, (prevProps, nextProps) => {
// 自定义比较逻辑
// 返回 true 表示 props 没变,跳过重渲染
// 返回 false 表示 props 变了,需要重渲染
return (
prevProps.user.id === nextProps.user.id &&
prevProps.filter === nextProps.filter &&
prevProps.onSelect === nextProps.onSelect
);
});
但这还不够,因为 onSelect 是一个函数,每次父组件重渲染时,这个函数都是新创建的。
// ✅ 稳定回调函数
function UserListContainer({ users }) {
const [filter, setFilter] = useState('');
// ⚠️ 如果不用 useCallback,这个函数每次都是新的
const handleSelect = useCallback((userId) => {
console.log('Selected:', userId);
// 调用 API 或其他逻辑
}, []); // 空依赖 = 永不改变
return (
<UserList
users={users}
filter={filter}
onSelect={handleSelect}
/>
);
}
// ✅ 完整优化方案
function UserList({ users, filter, onSelect }) {
// 计算过滤后的用户列表 —— 但不是每次都重新计算
const filteredUsers = useMemo(() => {
console.log('Computing filtered users...'); // 只在 users/filter 变化时打印
return users.filter(u => u.name.includes(filter));
}, [users, filter]);
return (
<div>
{filteredUsers.map(user => (
<UserCard
key={user.id}
user={user}
onSelect={onSelect}
/>
))}
</div>
);
}
const UserCard = React.memo(function UserCard({ user, onSelect }) {
return (
<div onClick={() => onSelect(user.id)}>
{user.name}
</div>
);
});
让我用一个真实的性能测试来说明。假设有 1000 个用户:
场景:改变 filter,导致父组件重渲染
┌─────────────────────────────────────────────┐
│ 无优化 │
├─────────────────────────────────────────────┤
│ 重渲染组件数: 1000 │
│ 时间: ~150ms │
│ 体感: 明显卡顿 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 使用 React.memo │
├─────────────────────────────────────────────┤
│ 重渲染组件数: 0 (memo 阻止了) │
│ 时间: ~2ms │
│ 体感: 流畅 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 使用 React.memo + useCallback + useMemo │
├─────────────────────────────────────────────┤
│ 重渲染组件数: 0 │
│ 时间: ~1ms │
│ 体感: 非常流畅 │
└─────────────────────────────────────────────┘
规则很简单,但大多数人搞反了:
场景 | 该用吗 | 原因 |
|---|---|---|
大列表(100+ 项) | ✅ 必须 | 防止 O(n) 重渲染 |
复杂计算 | ✅ 必须 | 避免重复计算 |
传给子组件的对象/函数 | ✅ 必须 | 防止破坏子组件的 memo |
简单的原始值计算 | ❌ 不必 | 收益不足以抵消成本 |
一次性组件 | ❌ 不必 | 不会频繁渲染 |
关键洞察:memoization 的成本是 JavaScript 对象比较,如果你的组件本来就很快,memoization 反而是浪费。
在某个电商平台的结账页面,当用户填入快递地址时,每输入一个字符都要等待 200ms。表单有 20 多个字段,每一个都是这样。
// ❌ 天真的受控表单实现
function CheckoutForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
address: '',
city: '',
zipcode: '',
// ... 还有很多字段
});
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<form>
<input name="name" onChange={handleChange} />
<input name="email" onChange={handleChange} />
<input name="phone" onChange={handleChange} />
<input name="address" onChange={handleChange} />
{/* ... 20 个输入框 ... */}
</form>
);
}
看起来没问题?问题就是:每次单个字段改变,整个 formData 对象都会重新创建,导致整个组件重渲染。
// 这一行代码是罪魁祸首
setFormData({ ...formData, [e.target.name]: e.target.value });
// 每次执行时:
// 1. 创建新的对象
// 2. 触发组件重渲染
// 3. 所有依赖 formData 的派生值都要重新计算
// 4. 所有使用 formData 的 useEffect 都可能触发
推荐:使用 React Hook Form + Zod
这是字节、阿里等大厂都在用的方案。核心原理是将表单状态从 React 中剥离出去,只在必要时更新。
import { useForm, Controller } from'react-hook-form';
import { z } from'zod';
import { zodResolver } from'@hookform/resolvers/zod';
// 1. 定义验证 schema
const checkoutSchema = z.object({
name: z.string().min(1, '姓名必填'),
email: z.string().email('邮箱格式不正确'),
phone: z.string().regex(/^1[0-9]{10}$/, '手机号格式不正确'),
address: z.string().min(5, '地址至少 5 个字符'),
city: z.string().min(1, '城市必填'),
zipcode: z.string().regex(/^\d{6}$/, '邮编必须是 6 位数字'),
});
// 2. 使用 Hook Form
function CheckoutForm() {
const { register, handleSubmit, control, formState: { errors } } = useForm({
resolver: zodResolver(checkoutSchema),
mode: 'onChange', // 实时验证
});
const onSubmit = (data) => {
console.log('Form data:', data);
// 提交到服务器
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 3. 使用 register 绑定输入框 */}
<input
{...register('name')}
placeholder="姓名"
/>
{errors.name && <span>{errors.name.message}</span>}
<input
{...register('email')}
placeholder="邮箱"
/>
{errors.email && <span>{errors.email.message}</span>}
<input
{...register('phone')}
placeholder="手机"
/>
{errors.phone && <span>{errors.phone.message}</span>}
<input
{...register('address')}
placeholder="地址"
/>
{errors.address && <span>{errors.address.message}</span>}
<button type="submit">提交</button>
</form>
);
}
React Hook Form 的核心优化:
传统受控表单的流程:
输入改变 → setState → 组件重渲染 → 所有验证重新计算
React Hook Form 的流程:
输入改变 → 直接更新内部状态 → 仅验证该字段 → 仅更新该字段的错误信息
数据对比:
这 10 倍的差异来自于:
我见过很多项目,用 Redux 管理:
结果?Redux 的 actions、reducers、selectors 充满了项目的一半。而真正的业务逻辑,反而被淹没了。
状态管理的三个层级
┌────────────────────────────────────────────┐
│ 全局状态(Global State) │
│ Redux / Zustand / Context │
│ 例如:用户信息、应用主题、权限管理 │
├────────────────────────────────────────────┤
│ 区域状态(Regional State) │
│ useReducer / 自定义 Hook │
│ 例如:列表的排序、过滤、分页 │
├────────────────────────────────────────────┤
│ 本地状态(Local State) │
│ useState │
│ 例如:输入框的值、下拉菜单打开/关闭 │
└────────────────────────────────────────────┘
❌ 常见的错误:把所有状态都推到全局
✅ 正确的做法:让每个状态留在最小必要的层级
// ✅ 方案:分层管理状态
// 1. 全局状态 - Redux(真正的全局数据)
import { createSlice, configureStore } from'@reduxjs/toolkit';
const userSlice = createSlice({
name: 'user',
initialState: { profile: null, isLoading: false },
reducers: {
setUser: (state, action) => {
state.profile = action.payload;
},
},
});
// 2. 区域状态 - useReducer(列表页的状态)
function useListState(initialData) {
const [state, dispatch] = useReducer(
(state, action) => {
switch (action.type) {
case'SORT':
return { ...state, sortBy: action.payload };
case'FILTER':
return { ...state, filters: action.payload };
case'PAGINATE':
return { ...state, page: action.payload };
default:
return state;
}
},
{
data: initialData,
sortBy: 'createdAt',
filters: {},
page: 1,
}
);
return [state, dispatch];
}
// 3. 本地状态 - useState(单个组件的状态)
function SearchInput() {
const [query, setQuery] = useState('');
return<input value={query} onChange={e => setQuery(e.target.value)} />;
}
一个简单的判断标准:
问题 1:这个状态在多个无关的组件中需要用到吗?
❌ 否 → 使用本地状态
✅ 是 → 下一步
问题 2:这个状态会频繁改变吗(每秒多次)?
✅ 是 → 不适合 Redux,用 Context 或其他方案
❌ 否 → 下一步
问题 3:这个状态改变时,是否需要复杂的转换逻辑?
✅ 是 → 使用 Redux(拥有完整的中间件生态)
❌ 否 → 使用 Zustand(更轻量)
你的应用在本地运行完美,但一旦部署到生产环境,用户分享的链接就失效了。
// ❌ 常见的路由配置错误
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
path: 'products',
element: <ProductList />,
children: [
{
path: ':id', // ⚠️ 问题:这个路由必须要通过点击导航才能到达
element: <ProductDetail />,
},
],
},
],
},
]);
当用户直接访问 https://example.com/products/123 时,React Router 无法匹配这个路由。为什么?
因为在客户端路由中,必须经过父路由的组件才能到达子路由。
// ✅ 改进方案 1:平铺所有路由
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
},
{
path: '/products',
element: <ProductList />,
},
{
path: '/products/:id', // ✅ 直接定义完整路径
element: <ProductDetail />,
},
]);
或者,使用布局路由来保持层级关系:
// ✅ 改进方案 2:使用布局路由
const router = createBrowserRouter([
{
element: <Layout />, // 没有 path —— 只用于布局
children: [
{
path: '/',
element: <Home />,
},
{
path: 'products',
element: <ProductListLayout />, // 这个组件使用 <Outlet />
children: [
{
index: true, // 匹配 /products
element: <ProductList />,
},
{
path: ':id', // 匹配 /products/123
element: <ProductDetail />,
},
],
},
],
},
]);
// ❌ 问题代码
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<Outlet />
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</>
);
}
当用户在模态框打开时刷新页面,模态框会消失。为什么?
因为 React 状态在页面刷新时被重置。
// ✅ 改进方案:将模态框状态保存到 URL
function ProductList() {
const navigate = useNavigate();
const { productId } = useParams();
return (
<div>
{/* 产品列表 */}
{products.map(p => (
<div
key={p.id}
onClick={() => navigate(`/products/${p.id}`)}
>
{p.name}
</div>
))}
{/* 模态框 - 由 URL 控制 */}
{productId && (
<Modal
productId={productId}
onClose={() => navigate('/products')}
/>
)}
</div>
);
}
现在即使用户刷新页面,URL 中的 productId 仍然存在,模态框也会被恢复。
// ❌ 错误的受保护路由实现
function ProtectedRoute({ element }) {
const { isAuthenticated } = useAuth();
return isAuthenticated ? element : <Navigate to="/login" />;
}
// 使用方式
<Route
path="/dashboard"
element={<ProtectedRoute element={<Dashboard />} />}
/>
问题:这种方式会在认证检查期间短暂地显示组件,然后再重定向。
更好的方案是使用布局路由作为保护层:
// ✅ 改进方案:使用布局路由保护
function ProtectedLayout() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return<LoadingScreen />;
if (!isAuthenticated) return<Navigate to="/login" />;
return<Outlet />; // 渲染所有受保护的子路由
}
// 路由配置
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
path: 'login',
element: <LoginPage />,
},
{
element: <ProtectedLayout />, // 保护所有子路由
children: [
{
path: 'dashboard',
element: <Dashboard />,
},
{
path: 'profile',
element: <ProfilePage />,
},
],
},
],
},
]);
// ❌ 危险代码
function UserCard({ user }) {
return (
<div>
<h1>{user.name}</h1> {/* ⚠️ 如果 user 是 null 会崩溃 */}
<p>{user.email}</p>
</div>
);
}
// 调用方式
<UserCard user={null} /> // 💥 运行时错误
// ❌ 看起来安全,但其实不是
function UserList({ users }) {
return (
<ul>
{users && users.map(user => ( // ✅ 检查了 users
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// 但如果 users 是 [](空数组)呢?
<UserList users={[]} /> // 正常工作 ✓
// 如果 users 是 undefined 呢?
<UserList users={undefined} />// 正常工作 ✓
// 如果 user 的某个属性在某些情况下不存在呢?
const users = [
{ id: 1, name: 'Alice' },
{ id: 2 }, // ⚠️ 缺少 name 字段
];
// 第二行会显示 "undefined"
第一层:类型检查
// 使用 TypeScript 或 PropTypes
interface User {
id: number;
name: string;
email: string;
}
function UserCard({ user }: { user: User | null }) {
// TypeScript 会强制检查 user 的存在
if (!user) return<div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
第二层:默认值和降级 UI
// ✅ 提供合理的默认值
function UserCard({ user = {} }) {
const { name = 'Unknown', email = 'N/A' } = user;
return (
<div>
<h1>{name}</h1>
<p>{email}</p>
</div>
);
}
第三层:骨架屏(Skeleton Screen)
// ✅ 最佳实践:显示加载状态
function UserCardContainer({ userId }) {
const { data: user, isLoading, error } = useQuery(
['user', userId],
() => fetchUser(userId)
);
if (isLoading) return<UserCardSkeleton />; // 骨架屏
if (error) return<ErrorFallback error={error} />; // 错误降级
if (!user) return<NotFound />; // 空状态
return<UserCard user={user} />;
}
function UserCardSkeleton() {
return (
<div className="skeleton">
<div className="skeleton-line" />
<div className="skeleton-line short" />
</div>
);
}
// ❌ 没有 Error Boundary 的应用
function App() {
return (
<div>
<Header />
<Content /> {/* ⚠️ 如果这里某个子组件出错... */}
<Footer />
</div>
);
}
// Content 组件内部的某个地方出错了:
function SomeDeepComponent() {
thrownewError('Something went wrong!'); // 💥
}
// 结果:整个应用崩溃,显示白屏
在 React 中,组件中抛出的错误会一路向上传播,直到应用顶级。如果没有捕获,就显示白屏。
组件树:
App
├─ Header ✓
├─ Content
│ ├─ ProductList ✓
│ ├─ BuggyComponent ❌ 抛出错误
│ │ └─ DeepChild
│ └─ AnotherComponent ❌ 不会渲染
└─ Footer ❌ 不会渲染
结果:整个应用崩溃
第一步:创建全局 Error Boundary
// ✅ 全局 Error Boundary
import { ErrorBoundary } from'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div style={{ padding: '20px', background: '#fee', borderRadius: '8px' }}>
<h2>😞 出错了</h2>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>重试</button>
</div>
);
}
function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<div>
<Header />
<Content />
<Footer />
</div>
</ErrorBoundary>
);
}
第二步:路由级别的 Error Boundary
// ✅ 路由级别的隔离错误
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <RootErrorPage />, // 全局错误页
children: [
{
path: 'products',
element: <ProductListPage />,
errorElement: <ProductListErrorPage />, // 局部错误页
},
{
path: 'products/:id',
element: <ProductDetailPage />,
errorElement: <ProductDetailErrorPage />, // 局部错误页
},
],
},
]);
第三步:异步错误处理
// ⚠️ Error Boundary 无法捕获异步错误
// 比如 Promise 被 reject 了
function Component() {
useEffect(() => {
fetch('/api/data')
.catch(error => {
throw error; // ❌ Error Boundary 无法捕获这个
});
}, []);
}
// ✅ 正确的做法:自己处理异步错误
function Component() {
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/data')
.catch(error => {
setError(error); // ✅ 保存到状态
});
}, []);
if (error) return<ErrorFallback error={error} />;
return<div>Content</div>;
}
// 或者使用更现代的方式:useQuery
function Component() {
const { data, error, isLoading } = useQuery(
['data'],
() => fetch('/api/data').then(r => r.json())
);
if (error) return<ErrorFallback error={error} />;
if (isLoading) return<Loading />;
return<div>{data}</div>;
}
经过这 7 个陷阱的深度分析,我想提炼出 React 开发的 3 个核心原则:
react 的核心是依赖追踪。一个好的 React 应用,应该是:
- 明确的依赖链(useEffect 的 deps 很少)
- 原始值作为依赖(不是对象)
- 最小化的依赖范围(派生值不需要保存)
很多人觉得 React 很慢,其实是不知道:
- 无谓的重渲染比逻辑错误的成本更高
- 1000 个组件 × 1ms = 1 秒延迟
- memo / useMemo / useCallback 从"可选"变成"必需"
状态管理的黄金法则:
- 本地化优先(useState)
- 区域管理次之(useReducer)
- 全局状态最后(Redux)
过度工程是 React 最常见的杀手。
字节跳动、阿里巴巴、腾讯等公司的 React 核心开发者,他们给出的共同建议是:
"不要被 React 的表面简洁所迷惑。学会阅读组件的心理模型,理解每一行代码的成本,你就能写出既快又稳定的应用。"
关键点:
why-did-you-render 库,找到不必要的重渲染Q: 我应该给所有组件都加上 React.memo 吗?
A: 不,这是常见的误区。只在两种情况下加:(1) 大列表的每一项,(2) 接收不稳定 props 的子组件。
Q: useEffect 的依赖数组能为空吗?
A: 可以,但要明确知道自己在做什么。空依赖数组意味着这个 effect 只会在挂载和卸载时运行。
Q: 我应该用 Redux 还是 Zustand?
A: 如果需要时间旅行调试、中间件生态,用 Redux。如果只需要简单的状态管理,Zustand 是更好的选择。
React 曾经让我哭过。不是因为框架有多难,而是因为我没有理解它的本质。
当我开始思考"这个组件会重渲染吗?"、"这个依赖是必要的吗?"、"我是在过度优化还是不足优化?"这些问题时,一切都变得清晰了。
React 不是拿来速成的。它需要理解、实践、和不断的反思。
希望这篇文章能让你避免我走过的弯路。
感谢你一直在看。如果这篇文章对你有帮助,我想邀请你:
✨ 关注《前端达人》 我们定期分享像这样的硬核技术文章,涵盖 React、Vue、Next.js、Web APIs 等前端前沿技术。这里没有浮躁的速成教程,只有真实的开发经验和深度思考。
👍 点赞和分享 如果你觉得这篇文章值得,请给我点赞和分享。让更多的开发者受益,让我们一起打造一个更健康的技术社区。
💬 在评论区留言 你还遇到过哪些 React 的"怪异行为"?或者你有不同的解决方案?我很想听听你的故事。