useRef 使用详解与常见陷阱避坑指南

UX智营 框架 阅读 1,887
赞 22 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个需求,要做一个带复杂交互的表单页,里面包含动态增删字段、实时校验、以及一个能拖拽排序的列表。一开始我下意识想用 React 的状态管理(比如 useState)来搞一切,但写到一半就发现不对劲——频繁更新状态导致整个表单区域重渲染,尤其在低端安卓机上,输入框打字都卡成PPT。

useRef 使用详解与常见陷阱避坑指南

后来一拍大腿,想起 useRef 这个老伙计。它不触发重渲染,又能持久保存数据,特别适合存那些“不需要驱动 UI 更新”的东西。比如 DOM 引用、定时器 ID、或者像这次的——表单内部的临时数据快照。于是决定把非 UI 驱动的数据逻辑抽出来,用 ref 管起来。

核心代码就这几行

最开始的实现其实挺简单。我用 useRef 存了一个对象,里面放了当前所有字段的值和校验状态:

const formRef = useRef({
  fields: {},
  isValid: true,
  // 其他临时状态
});

然后在字段 onChange 时直接修改 ref.current,而不是 setState。这样输入框就不会因为状态更新而闪动或卡顿。关键点在于:**只有当用户真正需要看到错误提示(比如提交失败)时,才把 ref 里的状态同步到 React state 里触发 UI 更新**。

比如提交按钮的处理逻辑:

const handleSubmit = () => {
  // 先校验 ref 里的数据
  const { isValid, errors } = validate(formRef.current.fields);
  if (!isValid) {
    // 只有校验失败才触发重渲染,显示错误
    setFormErrors(errors);
    setIsValid(false);
    return;
  }
  // 提交...
};

这套逻辑跑起来后,输入流畅度立马提升,亲测有效。

最大的坑:性能问题

但好景不长,测试同学反馈说,在字段特别多(50+)的时候,拖拽排序依然卡。我一开始以为是 drag-and-drop 库的问题,折腾了半天没解决。后来用 Performance 面板录了一下,发现每次拖拽时,虽然没 setState,但组件还是在 render —— 因为父组件的状态变了(比如 loading 状态),导致整个表单区域 re-render。

问题来了:ref 本身不会触发重渲染,但如果组件因为其他原因 re-render,里面的函数(比如事件处理器)会重新创建,而这些函数又依赖了 ref.current 的值。虽然 ref.current 是最新的,但函数闭包里的引用可能不是?不,ref.current 始终是最新的,但问题在于——**事件处理器里如果直接读取 ref.current,没问题;但如果把这些处理器传给子组件,而子组件做了 memo,那可能因为 props 引用变化导致子组件无效重渲染**。

举个例子:

// 错误示范:每次父组件 render,onFieldChange 都是新函数
const onFieldChange = (name, value) => {
  formRef.current.fields[name] = value;
};

return <FieldList onChange={onFieldChange} />;

即使 FieldList 用了 React.memo,只要父组件 re-render,onChange prop 就变,FieldList 也会跟着 re-render。字段一多,拖拽时每一帧都重渲染几十个子组件,不卡才怪。

折腾了半天发现:useCallback 才是关键

解决方案其实很经典:用 useCallback 包裹事件处理器,确保引用稳定。但这里有个细节:useCallback 的依赖项怎么写?

一开始我傻乎乎地写成 useCallback(fn, []),结果发现 formRef.current 在回调里永远是初始值。后来才反应过来:**ref 本身不需要放进依赖数组,因为 ref.current 是可变的,useCallback 闭包里直接读就行**。正确的写法是:

const onFieldChange = useCallback((name, value) => {
  formRef.current.fields[name] = value;
  // 注意:这里不需要依赖 formRef,因为 .current 是实时的
}, []);

这样,onFieldChange 的引用就固定了,FieldList 即使被 memo 住也不会无效重渲染。拖拽卡顿问题迎刃而解。

不过这里我踩过好几次坑:有一次不小心在回调里用了某个 state,忘了加依赖,导致逻辑错乱。所以现在写 useCallback 会特别小心,先问自己:这个函数真的不需要任何依赖吗?

另一个隐蔽问题:ref 的初始化时机

还有一次,我在 useEffect 里初始化 ref 的数据,但初始化逻辑依赖了 props。比如从 URL 参数里读取默认值:

useEffect(() => {
  formRef.current.fields = parseQueryParams(props.location.search);
}, [props.location.search]);

结果发现,如果用户快速操作(比如页面加载后立刻点提交),ref 里的数据还是空的。因为 useEffect 是异步的,而用户操作可能发生在首次 render 之后、effect 执行之前。

后来改成在 render 期间直接初始化(利用 useRef 的惰性初始化特性):

const formRef = useRef(null);

if (formRef.current === null) {
  formRef.current = {
    fields: parseQueryParams(props.location.search),
    isValid: true,
  };
}

这样保证了在任何同步操作前,ref 已经有值。虽然有点 dirty,但简单有效。

回顾与反思

总的来说,用 useRef 解决了表单性能问题,效果立竿见影。特别是在高频更新的场景下,避免不必要的 state 更新真的能省下不少性能开销。

做得好的地方:

  • 把非 UI 驱动的数据剥离到 ref,减少重渲染
  • 配合 useCallback 稳定事件处理器引用,避免子组件无效更新
  • 用惰性初始化确保 ref 数据及时可用

还能优化的点:

  • ref 里的数据结构有点乱,后期维护成本高。其实可以考虑用 useReducer + ref 结合,或者干脆上 Zustand 这种轻量 store
  • 错误提示的同步逻辑还是有点手动,如果有更自动的机制就好了(比如监听 ref 变化并选择性 setState)
  • 最后留了个小问题:在极端情况下(比如快速连续提交),偶尔会漏掉一次校验。但概率很低,影响不大,就没深究

这个方案不是最优的,但胜在简单直接,两天就改完上线,业务方也没再提性能问题。

结尾

以上是我最近在项目中用 useRef 的实战经验,踩了不少坑,也摸索出一些实用技巧。如果你也在做高性能表单或者类似高频交互的组件,不妨试试这种思路。

当然,ref 也不是万能的,滥用会导致数据流混乱,调试困难。我的原则是:**只有当数据变化不需要立即反映到 UI 上时,才考虑用 ref**。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如你怎么处理 ref 和 state 的同步问题?

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论