[捉虫日记] 给 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]); // 补全依赖项,解决闭包问题

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

相关推荐
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端
爱敲代码的小鱼10 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax