组件化思维:从拆页面到拆状态

很多项目做久了会变成「一个巨型组件加十几个布尔开关」,根因不是没拆组件,而是按视觉区域拆而不是按状态边界拆。正确的切分依据是:这块状态由谁拥有、谁需要读、谁需要改。状态放在拥有它的最近公共祖先;只读的通过属性传递,需要改的传函数或回调。

组件化的本质是数据流设计:把状态收敛到少数几个位置,其余部分只负责渲染,界面不一致的问题才会从根上消失。
拆分的四条判据
  • 状态边界同一份状态只保留一个来源,其余层级派生计算。
  • 可推导就不存能由已有状态算出的值,不要另存一份。
  • 纯展示组件只接收属性并渲染,便于复用与测试。
  • 受控与派生表单元素受控于状态,列表项由数据映射生成。
React 组件化开发配图:原子式同心圆环与外围组件模块
组件像原子一样组合成更大的结构,状态沿着层级单向流动。

函数组件与 Hooks 规则

React 依靠调用顺序来识别每一个 Hook,因此 Hooks 只能在组件或自定义 Hook 的顶层调用,不能写进条件分支与循环。这条规则带来的连锁约束是:需要条件逻辑时,把条件放在 Hook 内部,或者把分支整体提取成不同组件,而不是在分支里调用 Hook。

副作用统一收敛在 useEffect 中,但要清楚它不是生命周期钩子,而是「同步外部系统」的机制。判断是否该用副作用,标准是:这段逻辑是否与 React 之外的系统交互(网络、订阅、DOM 直写、定时器)。仅仅是「某值变了要算另一个值」,应该用 useMemo 或直接计算。

function useDebounced(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);   // 清理:值变化时取消上一次
  }, [value, delay]);

  return debounced;
}

这个自定义 Hook 展示了副作用的完整形态:注册副作用 + 返回清理函数。清理函数不做,输入框快速输入时会留下大量未完成的定时器,最终把过期结果写进状态,表现出明显的「输入卡顿」。

状态放置与数据流设计

状态放置的第一原则是「尽可能低」。把筛选条件放在列表组件内部,把弹窗开关放在触发它的父组件上,能显著减少无关重渲染。当状态需要跨多层传递时,优先考虑组件组合(把子元素作为属性传入)而不是层层透传;确实需要跨模块共享时,再引入 Context 或外部状态库。

常见状态类型与去处
  • 局部界面态展开、悬停、输入内容,放在组件内部即可。
  • 服务端数据交给带有缓存与失效策略的数据请求库,不要手写全局变量。
  • 跨页面共享登录信息、主题等低频变化值用 Context,并拆分多个 Context 避免连带更新。

性能优化:memo、useMemo 与副作用边界

性能优化的顺序永远是:先测量,再定位,最后才改动。React 开发者工具的性能面板能记录一次提交里哪些组件重渲染、耗时多少。真实的瓶颈通常集中在三处:长列表未虚拟化、每次渲染都创建新对象导致的属性不稳定、以及高开销计算写在了渲染路径上。

const rows = useMemo(
  () => items.filter((it) => it.name.includes(keyword)),
  [items, keyword]
);

const onSelect = useCallback((id) => dispatch(select(id)), [dispatch]);

return <Table rows={rows} onSelect={onSelect} />;

useMemo 缓存计算结果,useCallback 稳定函数引用,二者配合 React.memo 才能让子组件真正跳过重渲染。凡尔登式的「到处加 memo」往往收益甚微,因为属性每天都在变,缓存本身还有成本。

并发特性与 Suspense

React 18 引入的并发渲染允许把一次更新标记为非紧急,让输入响应优先完成。useTransition 把「切换列表筛选」这类耗时更新放到后台,输入框保持即时反馈;useDeferredValue 则直接延时某个值,适合无法改造更新逻辑的场景。Suspense 配合懒加载分包,是首屏体积优化的标准手段。

需要注意的是并发特性不是免费的:它改变了副作用的执行时机,某些在渲染中依赖顺序的代码可能表现异常。项目刚引入时应先在低风险模块试用,配合严格的副作用清理习惯一起推进。