问题描述
一个很小的图片预览功能,在列表里,鼠标浮上去显示 popover,鼠标划走消失,demo 示意图如下:
js
<div className='cursor-a' onMouseEnter={handlePopoverOpen} onBlur={handlePopoverClose} onMouseLeave={handlePopoverClose}>
....
// popover 开关
const open = Boolean(anchorEl);
const handlePopoverOpen = (event) => {
setAnchorEl(event.currentTarget);
};
但是在线上灰度环境测试时,偶尔会出现如下的问题:
图床用的是公司自己的对象存储,每次 hover 打开 popover 后,调用后台 API 获取对象存储的访问 url,然后放到img 的 src 中,这个 url 有 3 秒的时效,超过就401。但是鼠标划上去的动作只有几毫秒,按理说不会存在过期的问题呀。
问题排查
首先怀疑是后端问题[坏笑],会不会是后台获取图床 url 时留有延时,导致压力测试下某一次请求延迟时间超过了 3 s?
打开控制台,找到出现 401 的那次请求,看请求后台的接口:
找到对应的base64编码,解码看一下过期时间:
解析一下看看:
再看看这次 401 请求的地址(src的地址):
解析一下过期时间:
一个 11 秒,一个 14 秒,啊这... 冤枉后端老哥了。是前端请求图片时的 src 用了过去老的 url 了。
重新看了一下,前端组件初次加载没有问题,多次反复加载图片弹窗就会出现 401 的出现。
然后,在弹窗中打印显示这个图片的 url,理论上应该是在初始化加载时是 undefined 的,通过 API 获取后 setInfo
,通过 info 里的信息获取 url 给到 img 标签。这里初步排查发现是弹窗中图片的 url 在弹窗销毁时,数据没有清理,导致再次挂载弹窗组件时,在接口请求之前还是会用原来的 url (这里的字段叫 MediaFile) 来请求一次。
美滋滋的处理一下:
js
const handlePopoverClose = () => {
setAnchorEl(null);
// 清理数据
setInfo({
...info,
MediaFile: ''
})
};
于是满怀信心的开始自测,刚开始确实没有出现 401 过期的问题,但是当鼠标不停划过表格各行的对应位置时,还是出现了 401 的问题.... 看来问题不止这一点。
只能继续排查。
期间为了尝试减少其他 props 变动造成的影响,在图片显示的时候,控制单一变量 info.MediaFile, 做了如下缓存:
js
const ImageShow = useMemo(() => {
if (info.MediaFile) {
return <img
alt="preview"
style={{ maxWidth: '80%', height: 'auto', display: 'block', borderRadius: '15px', marginBottom: '4px' }}
src={`${info.MediaFile}`}
onError={console.error}
/>
}
return null;
}, [info.MediaFile]);
发现还是有问题。看来还是 MediaFile 自身的问题。
最后发现是异步请求的问题😂,下面是 API 获取预览结果的代码示意:
js
useEffect(() => {
if (open && TemplateId) {
setLoading(true);
setTimeout(() => {
QueryMMSTemplate({ TemplateIds: [TemplateId], AccountId })
.then((res) => {
if (res && res.Data && res.Data.length && open) {
console.log('这个时候可能页面已经关闭了')
setInfo(res.Data[0] || {});
} else {
setInfo({});
}
setLoading(false);
})
.catch(() => {
setInfo({});
setLoading(false);
});
});
}
}, [open, TemplateId, AccountId]);
异步请求导致返回结果设置 info 不可控,如果请求回来的慢了,刚刚清理数据的 setInfo 就会被覆盖掉,导致 MediaFile 没有被清理掉。但是之前也考虑了这个问题,加了这么一行:
js
if (res && res.Data && res.Data.length && open) {}
现在发现,这个里边是个闭包,open 可能一直是 true,即使在接口请求过程中鼠标划走....
于是做一下优化,使用外部 ref:
js
const openRef = useRef();
...
const handlePopoverOpen = (event) => {
openRef.current = true;
setAnchorEl(event.currentTarget);
};
const handlePopoverClose = () => {
openRef.current = false; // 加上这样一行
setAnchorEl(null);
setInfo({
...info,
MediaFile: ''
})
};
在 API 请求返回的判断里改写判断条件:
js
if (res && res.Data && res.Data.length && openRef.current)
此时再进测试环境测试,发现原来 401 的问题解决了!url 保证每次是最新的即可。
体验优化
在故障解决后,要回归测试,保证原功能没问题的前提下解决新的故障。
测试发现,虽然原故障解决了,但是这个解决方案又引入了新的问题:在关闭 popover 动画执行前,图片url 被手动清空,导致弹窗中图片会突然消失一下,视觉上会造成突变。这里给出两种解决方案:
- 无图片或者图片加载时提供模糊展示
js
filter: `blur(${(!img && !imgUrl) ? '5px' : 0})`
- 用一个占位图片表示
还有个问题,如何API节流,用户不停的在列表上划来划去,API 预览接口一直在调用,造成网络资源浪费。 :
js
setLoading(true)
setTimeout(() => {
if (openRef.current) {
QueryMMSTemplate({ TemplateIds: [TemplateId], AccountId })
...
}
}, 200);
这里在请求 API 时添加加载冷静期,并在 loading 时放上加载动画:
js
{loading ? (
<CircularProgress />
) : (
<Review forPopover value={info.Text} ... imgUrl={info.MediaFile} />
)}
复盘
该问题的出现,在于写作时没有注意数据清理,导致老的数据干扰了正常数据。
该问题的避免,一方面是通过代码审查和单元测试提高代码质量,一方面是靠 QA 的充分测试。但是测试的再充分,还是百密一疏。还好该问题的出现是隐式的报错,不会干扰用户的使用。
此外,还要有一套规范的纠错机制,总结一下流程: