[捉虫日记] 给 useImperativeHandle 加个空依赖,竟让我 debug n 小时

历史项目的 "小需求" 从来都是埋雷重灾区 ------ 上周随手给一个一年前的组件加了行依赖项,本以为是优化,结果周一被测试大哥的反馈暴击,一中午深陷 [闭包陷阱 + EventLoop] 迷局,差点没扛住。

需求背景

先看涉及到这次变更的流程和相关作用:

  • ManagementCompnent 封装了ProTable,通过 ref 对外暴露reloadTable等方法;
  • 被两个页面复用:management 页面直接引入,system_management 页面通过Modal包裹引入;
  • 依赖 useGetWarehouseBeta 这个数据字典 Hook,必须等字典数据返回后,ProTable才能正常请求列表(否则会渲染异常)。详见另一篇文章 [开发随笔] 前端处理数据字典问题的踩坑与填坑

此外,原来的 useImperativeHandle 没有显式依赖项,随着 management 页面的需求调整,给它也加上了空依赖,自测页面正常,于是提交部署。

问题爆发:Modal 里面的表格突然"罢工"

周一正在思考如何重构基础组件,测试大哥突然丢了一个截图:另外一个(system_management)打开后,表格没数据,接口也没发! ,只能点击才能触发请求!

我直接大小眼,同一个组件,直接引入的页面正常,Modal 包裹的就挂了?这不扯呢

先看关键代码(一年前的代码,简直不忍直视🤦‍♂️)

虽然是自己写的,真的是当时为了赶需求排期瞎写了🤣。什么最佳实践,还能比业务上线重要,还不写注释

Management.tsx 核心逻辑

tsx 复制代码
import { isNil } from 'lodash';
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { ProTable, ActionType } from '@ant-design/pro-table';
import {
  DictionaryItem,
  useDictionaryItem as useGetWarehouseBeta,
} from '@siroi/react-utils';

const Management = forwardRef((props, ref) => {
  const tableRef = useRef<ActionType>(); // ProTable的ref
  const { tid } = props; // 业务唯一标识

  // 预请求ref:用于字典数据加载完成后触发回调
  const preRequestRef = useRef<{
    res?: (value: unknown) => void;
    callback?: () => Promise<void>;
  }>({ res: () => {}, callback: async () => {} });

  // 数据字典Hook:缓存5分钟,依赖preRequestRef传递回调
  const [tenantRoleData, currentTRD] = useGetWarehouseBeta(
    'tenant_role',
    getTenantRoleList,
    300, // 缓存5min
    preRequestRef.current,
  );

  // 对外暴露的ref方法(我加了空依赖[]的地方)
  useImperativeHandle(ref, () => {
    return {
      originRef: tableRef.current,
      reloadTable: () => {
        // 字典数据未加载完成:缓存回调,等待字典加载后执行
        if (isNil(currentTRD)) {
          preRequestRef.current.callback = async (...res) => {
            return tableRef.current?.reload?.();
          };
          return new Promise((res) => {
            preRequestRef.current.res = res;
          });
        } else {
          // 字典已存在:直接触发表格刷新
          tableRef.current?.reload?.();
        }
      },
      // 其他暴露方法...
    };
  }, []); // 罪魁祸首:空依赖项

  return <ProTable actionRef={tableRef} {...props} />;
});

system_management.tsx

tsx 复制代码
import { useState, useRef } from 'react';
import { Modal } from 'antd';
import Management from './Management';

const SystemManagement = () => {
  const [showModal, setShowModal] = useState<boolean>(false);
  const originRef = useRef<InstanceType<typeof Management>>(null);

  // 异步操作:500ms后调用表格刷新
  const loadTable = (): Promise<void> => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve();
      }, 500);
    });
  };

  // 打开Modal并触发刷新
  const handleClick = () => {
    setShowModal(true);
    loadTable().then(() => {
      originRef.current?.reloadTable?.(); // 这里没触发表格请求
    });
  };

  return (
    <>
      <button onClick={handleClick}>打开表格</button>
      <Modal open={showModal} destroyOnClose>
        <Management ref={originRef} />
      </Modal>
    </>
  );
};

根因拆解

第一坑:useImperativeHandle 的闭包陷阱

我当初加空依赖 [] 时,其实心里隐约知道闭包风险,但被三个 "自以为是的理由" 说服了:

  1. 我有 DictionaryItem 兜底,由于它是单例,所以即便 currentTRD 为 null,我的请求也只会发送一次;
  2. 如果切换页面整个组件也卸载了,重新进入再走逻辑即可;
  3. 我发现了问题,但我的钱只有这么些,时间紧,能用就行😘

第二坑:EventLoop 的执行顺序冲突

当我意识到闭包问题,给 useImperativeHandle 加上 [currentTRD, tid] 依赖后,新的问题又出现了:Modal 场景下tableRef.current拿不到,reload方法执行失败。

这就涉及到两个关键因素:

  • ModaldestroyOnClose 属性:关闭后组件卸载,重新打开时需要重新渲染,tableRef的赋值时机晚于reloadTable的调用;
  • 事件循环机制 :useGetWarehouseBeta 内部用 setTimeout(宏任务)处理回调,而loadTable的 500ms 延迟也是宏任务,两者执行顺序冲突,导致preRequestRef.current没及时更新,reload方法一直处于 pending 状态。

简单说:setState(打开 Modal)是类似微任务的异步操作,组件渲染完成的时机晚于 reloadTable 的调用,此时 tableRef.current 还没被赋值,自然执行不了 reload。

之前为什么原来能触发呢? 原来的 useImperativeHandle 是没有依赖项的,众所周知没有依赖项,那就不存在闭包问题,内部状态更新,ref 就跟着更新,外加上 loadTable 500ms 的延迟所有的 ref 都能正常获取。

shit on shit!!

最终解决

  1. 补全依赖项,解决闭包问题
  2. 0ms 延迟宏任务,保证 tableRef 可访问 利用 EventLoop 的特性:setTimeout(fn, 0) 会把回调推入下一轮宏任务队列,此时 React 已经完成组件渲染,tableRef.current 已经被正确赋值,就能正常执行 reload 方法.

最终优化后的代码:

tsx 复制代码
useImperativeHandle(ref, () => {
  return {
    originRef: tableRef.current,
    reloadTable: () => {
      if (isNil(currentTRD)) {
        // 字典未加载:缓存回调,等待字典加载后执行
        preRequestRef.current.callback = async (...res) => {
          return tableRef.current?.reload?.();
        };
        return new Promise((res) => {
          preRequestRef.current.res = res;
        });
      } else {
        // 字典已加载:用0ms延迟保证tableRef能拿到最新实例
        setTimeout(() => {
          tableRef.current?.reload?.();
        }, 0);
      }
    },
    // 其他暴露方法...
  };
}, [currentTRD, tid]); // 补全依赖项,解决闭包问题

后经测试大哥回归验证,两个页面的场景目前都表现正常, "问题完美解决🎉" ,如果后续这个组件还有新的坑,再分享!下课!

相关推荐
雯0609~3 小时前
uni-app:防止重复提交
前端·javascript·uni-app
2501_918126913 小时前
用html5写一个国际象棋
前端·javascript·css
遇见~未来3 小时前
前端原生能力速查笔记(HTML + 浏览器 API 实战篇)
前端
博客zhu虎康3 小时前
Vue全局挂载Element消息组件技巧
前端·javascript·vue.js
LaoZhangAI3 小时前
Gemini图像生成宽高比教程:10种比例完整配置指南【2025】
前端·后端
尼罗河女娲3 小时前
【测试开发】为什么 UI 自动化总是看起来不稳定?为什么需要引入SessionDirty flag?
开发语言·前端·javascript
JQ_Zhang3 小时前
手把手教你封装一个高性能、多功能的 React 锚点导航组件 (Anchor)
前端
叫我詹躲躲3 小时前
如何实现流式输出?一篇文章手把手教你
前端·javascript
soda_yo3 小时前
隐式类型转换:哈基米 == 猫 ? true :false
前端·javascript·面试