很多 React 项目“写得很顺”,但过了 3 个月会慢慢变成两类问题:一类是性能(渲染太多、交互卡顿、列表滚动掉帧),另一类是可维护性(状态散、组件耦合、边界不清)。下面这 12 条技巧偏实战,目标是把“默认正确”内置进你的写法里。
1. 先做状态建模,再写组件
写 UI 之前先回答三个问题:
- 哪些是“源状态”(source of truth),哪些是派生(derived)
- 状态的生命周期在哪(页面级、组件级、全局、URL)
- 写入路径在哪里(谁能改、怎么改、何时改)
一个很好用的经验:把“派生状态”从 useState 里移除,改用计算(并用 useMemo 作为性能优化而非正确性依赖)。
function ProductList({ products }: { products: { price: number }[] }) {
const [minPrice, setMinPrice] = useState(0);
const filtered = useMemo(() => {
return products.filter(p => p.price >= minPrice);
}, [products, minPrice]);
return (
<>
<input
value={minPrice}
onChange={e => setMinPrice(Number(e.target.value))}
type="number"
/>
<ul>{filtered.map((p, i) => <li key={i}>{p.price}</li>)}</ul>
</>
);
}
这里 filtered 就是典型的派生状态:不需要额外同步、不会出现“两个状态相互打架”。
2. 别把对象/数组字面量直接塞进依赖数组
依赖数组的核心不是“我写了啥”,而是“引用是否稳定”。如果你在 render 里创建了新对象,useEffect/useMemo/useCallback 的依赖就会每次变化,等于失效。
function Search() {
const [q, setQ] = useState("");
const params = useMemo(() => ({ q, limit: 20 }), [q]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(params.q)}&limit=${params.limit}`, {
signal: controller.signal,
});
return () => controller.abort();
}, [params]);
return <input value={q} onChange={e => setQ(e.target.value)} />;
}
更进一步的做法是把依赖拆到基础类型上([q])来避免引用问题,但上面的模式可读性更强。
3. 用 useCallback 之前先问:我真的需要稳定引用吗?
useCallback 不是“性能银弹”,它是“缓存函数引用”的工具,适用场景通常只有两个:
- 你把函数传给了
memo组件,需要避免子组件无意义 re-render - 你把函数放进依赖数组,需要引用稳定
否则,滥用 useCallback 往往只是在增加复杂度与内存占用,还可能造成“依赖漏写”的 bug。
4. 把 “渲染频率” 当成一等公民:从输入事件开始
输入框 onChange 里做重计算是常见卡顿来源。处理方式通常是“分离即时输入与昂贵渲染”:
- 即时输入:同步更新,保证输入手感
- 昂贵渲染:延迟/降频更新
React 18+ 可以用 useDeferredValue 或 startTransition。
function SearchPage({ data }: { data: string[] }) {
const [q, setQ] = useState("");
const deferredQ = useDeferredValue(q);
const result = useMemo(() => {
const keyword = deferredQ.trim().toLowerCase();
if (!keyword) return data;
return data.filter(x => x.toLowerCase().includes(keyword));
}, [data, deferredQ]);
return (
<>
<input value={q} onChange={e => setQ(e.target.value)} />
<ResultList items={result} />
</>
);
}
5. key 的职责是“身份”,不是“索引”
用索引做 key 会让 React 在插入/删除时错误复用 DOM 与组件实例,常见后果是输入框跳字、动画错乱、局部状态串线。
规则很简单:只要列表会重排/增删,就用稳定且业务唯一的 id。
6. 大列表:先虚拟化,再谈 memo
列表卡顿通常不是因为某个组件“没 memo”,而是因为 DOM 数量太多、布局/绘制太重。优先考虑:
- 分页/按需加载(infinite scroll)
- 虚拟列表(只渲染可视区)
- 降低每行的 DOM 与样式开销
memo/useMemo 是在“已经把 DOM 数量控制住”之后的第二层手段。
7. 数据获取:把“并发、取消、竞态”写进默认路径
即使你不用专门的数据层库,也应该至少把两件事做好:
- 请求可取消(页面切换/参数变化时 abort)
- 竞态可控(只保留最后一次请求结果)
前者用 AbortController,后者可以用递增 requestId 或者只在同一 controller 下处理结果。
8. 把“边界”显式化:Error Boundary + Suspense
对于稳定线上体验来说,“出错时如何退化”比“永不出错”更现实。
- Error Boundary:组件树局部错误不至于白屏
- Suspense:异步加载时统一 loading 体验
如果你用的是 React Router / Next.js 这类框架,通常都有更完整的边界能力,优先用框架提供的方式保持一致。
9. Context 不是状态管理库,Context 是“依赖注入”
把频繁变化的状态(例如输入、滚动位置、实时数据)塞进 Context,往往会导致一整棵子树一起 re-render。
更稳的做法:
- Context 放“相对稳定的依赖”(配置、服务实例、主题、权限能力)
- 高频状态用组件本地 state,或用更细粒度的订阅模型
10. 用 TypeScript 把“允许的状态”收紧到类型里
与其在代码里写一堆 if (!x) return null,不如把状态机写进联合类型,让“不可达状态”变成编译错误。
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
写 UI 时你会天然得到穷举检查与更清晰的分支渲染。
11. 性能优化从“测量”开始:React DevTools Profiler
不要靠感觉优化。建议按这个顺序做:
- 先用 Profiler 找到 commit 次数和耗时最大的组件
- 再判断问题是“渲染太频繁”还是“每次渲染太重”
- 最后选择手段:减少更新来源、拆分组件、降低 DOM、缓存计算、虚拟化列表
如果你在“还没定位问题”时就开始把所有组件都包一层 memo,大概率只会让代码更难读。
12. 组件设计:优先“组合”,慎用“万能组件”
“万能组件”通常会带来三类长期成本:
- props 爆炸:每次需求都加一个参数
- 逻辑耦合:互斥组合越来越多
- 难以测试:分支路径太多
更推荐的写法是把通用部分做小、做纯,把差异点暴露为组合(children/render props)或更细的子组件。
收尾:一个可复制的自检清单
每次提交前,快速过一遍:
- 列表
key是否稳定且唯一 - 是否把派生状态放进
useState - effect 是否具备取消/竞态控制
- 高频变化的状态是否被放进 Context
- 性能优化是否有 Profiler 证据
把这些习惯变成默认动作,React 项目通常会在性能与维护成本上同时变好。你会更少“救火”,更多“推进”。