历史项目的 "小需求" 从来都是埋雷重灾区 ------ 上周随手给一个一年前的组件加了行依赖项,本以为是优化,结果周一被测试大哥的反馈暴击,一中午深陷 [闭包陷阱 + 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 的闭包陷阱
我当初加空依赖 [] 时,其实心里隐约知道闭包风险,但被三个 "自以为是的理由" 说服了:
- 我有 DictionaryItem 兜底,由于它是单例,所以即便 currentTRD 为 null,我的请求也只会发送一次;
- 如果切换页面整个组件也卸载了,重新进入再走逻辑即可;
我发现了问题,但我的钱只有这么些,时间紧,能用就行😘。
第二坑:EventLoop 的执行顺序冲突
当我意识到闭包问题,给 useImperativeHandle 加上 [currentTRD, tid] 依赖后,新的问题又出现了:Modal 场景下tableRef.current拿不到,reload方法执行失败。
这就涉及到两个关键因素:
Modal的 destroyOnClose 属性:关闭后组件卸载,重新打开时需要重新渲染,tableRef的赋值时机晚于reloadTable的调用;- 事件循环机制 :useGetWarehouseBeta 内部用
setTimeout(宏任务)处理回调,而loadTable的 500ms 延迟也是宏任务,两者执行顺序冲突,导致preRequestRef.current没及时更新,reload方法一直处于 pending 状态。
简单说:setState(打开 Modal)是类似微任务的异步操作,组件渲染完成的时机晚于 reloadTable 的调用,此时 tableRef.current 还没被赋值,自然执行不了 reload。
之前为什么原来能触发呢? 原来的 useImperativeHandle 是没有依赖项的,众所周知没有依赖项,那就不存在闭包问题,内部状态更新,ref 就跟着更新,外加上 loadTable 500ms 的延迟所有的 ref 都能正常获取。
shit on shit!!

最终解决
- 补全依赖项,解决闭包问题
- 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]); // 补全依赖项,解决闭包问题
后经测试大哥回归验证,两个页面的场景目前都表现正常, "问题完美解决🎉" ,如果后续这个组件还有新的坑,再分享!下课!