最近做AI应用相关的新功能有这样一个业务场景:
- 用户输入特定信息生成指标
- 根据生成的指标结合用户再次输入的特定信息生成报告
- 生成报告的过程很久,快的时候大概要7~8分钟,慢的时候要10多分钟
(注:指标和报告的生成都是通过AI算法)
当时做的第一个版本如图,在页面右侧有一个历史栏,可以看到已经生成的指标和报告数据。但是正在生成的数据是不会显示在右侧历史栏里的(这也是后续优化的一个点)。

这就会导致如果一个报告正在生成,用户想实时追踪这个报告的状态的话就只能留在当前页面,和冰冷的提示语大眼对小眼。
如果这时候你切换到其他页面或者其他已经生成的报告,在页面上就点不回来了,因为还在生成中的报告是不会显示在历史数据中的,这样给用户体验就非常不好。

而且,由于算法的问题这个接口很耗时会超时报错,实际上报告可能在超时报错之后才真正生成出来,但这时候停留在当前页面的用户就永远拿不到这条数据了。这里就比较严重了,也很影响测试!
于是产品拉上我和后端大哥开了个小会,确定要在报告生成时就显示在历史数据里以及一些其他需求。加了需求之后我们开发讨论修改逻辑为:生成报告时先调用一个接口返回一个 id,但没有内容,为了实现产品加的需求。之后新写一个接口 getReportDetail 采用轮询调用来查报告是否生成,停留在当前页面就进行轮询,切换到其他页面就停止轮询。
这样子就解决了痛点,因为报告会在后台自己生成,等到生成后他会更新在历史记录中,哪怕历史记录中的数据是旧的,在切换到当前页面时,前端判断如果这个报告正在生成,那就会立即调用一次 getReportDetail,来进行双重保险。
部分代码如下:
js
// 轮询方法封装
const startPolling = async (currentReportId: string, originalUpdateTime: string) => {
let intervalId: NodeJS.Timeout | null = null;
const waitTime = 3 * 60 * 1000;
const pollImmediately = async () => {
try {
await poll();
} catch (error) {
handlePollingError(intervalId);
}
};
const poll = async () => {
try {
const currentNewRiskId = sessionStorage.getItem('currentNewRiskId') || riskId;
const isSameReport = currentNewRiskId === currentReportId;
// 如果不是同一个报告,终止轮询
if (!isSameReport) {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
return;
}
const detail = await getReportDetail(currentReportId);
const isReady =
detail.report &&
new Date(detail.updateTime).getTime() >
new Date(originalUpdateTime).getTime() + waitTime;
if (isReady) {
clearInterval(intervalId!);
// 重新生成完成后从数组中移除ID
removeRefreshingId(currentReportId);
batchUpdate(() => {
updateRiskState(detail);
setEditTime(detail.updateTime);
setUpdateReportName(!updateReportName);
});
setGeneratingState(false);
setReportError(false);
}
} catch (error) {
handlePollingError(intervalId);
}
};
pollImmediately();
intervalId = setInterval(poll, 60000);
// 存储轮询ID到ref
pollingIntervalRef.current = intervalId;
return intervalId;
};
调用轮询方法的部分:
由于报告还有重新生成的逻辑,所以分为了已有报告和没有报告两个分支

需要注意的地方还有:在切换另一个还在生成的报告的时候要清除上一个报告的轮询,以及组件卸载的时候停止轮询。

在代码中封装了一些公共方法,比如像上述的两个报告分支都会更新的数据就可以放到里面,然后更新数据的代码可以放到 batchUpdate 里。
js
// 状态更新封装
const updateRiskState = (detail: IReportDetail) => {
setSelectedIndicatorId(detail.templateId ?? '');
setRiskRecordContent(detail.report ?? '');
setReportId(detail.id ?? '');
setRiskLevel(detail.riskLevel ?? '');
setCreateName(detail.name ?? '');
};
batchUpdate(() => {
updateRiskState(riskRecord);
setIndexName(riskRecord.templateName);
setMiningName(riskRecord.areaName);
setText(riskRecord.descriptionInput);
setEditTime(riskRecord.updateTime ?? riskRecord.createTime);
setShared(riskRecord.shared === 1);
setRecordUserId(riskRecord.userId);
});
这里可以顺便展开介绍下 batchUpadate,
batchUpadate 是 React 的关键性能优化策略 ,它将多个状态更新操作合并为单个更新操作,从而显著减少不必要的渲染次数 。 如果上面代码不使用 batchUpdate ,这将导致多次独立的渲染过程,造成界面闪烁和性能损耗。
实现原理
js
const batchUpdate = (updater: () => void) => {
ReactDOM.unstable_batchedUpdates(updater);
};
这里的核心是调用了React内部的unstable_batchedUpdates
方法,它会:
- 将包裹的回调函数放入更新队列
- 合并所有状态变更
- 最终执行一次渲染过程
潜在陷阱与解决方案
问题1:回调中访问过期的状态
javascript
batchUpdate(() => {
setCount(count + 1); // 使用闭包值
setTotal(total + count); // 可能访问过期值
});
// 解决方案:使用函数式更新
batchUpdate(() => {
setCount(prev => prev + 1);
setTotal(prev => prev + count);
});
问题2:滥用批处理导致UI响应延迟
javascript
// 错误做法:将耗时操作放入批处理
batchUpdate(() => {
processHugeData(); // 阻塞UI
setResults(data);
});
// 正确:仅状态更新用批处理
processHugeDataAsync().then(result => {
batchUpdate(() => setResults(result));
});