为了优化体验,页面设计了“防滚动穿透”逻辑:
- 当浮层展开时: 调用
setPageScrollEnable(false) 禁用页面滚动。
- 当浮层关闭时: 调用
setPageScrollEnable(true) 恢复页面滚动。
预期的交互是这样的:
- 用户点击筛选
- 页面先执行锚定滚动
- 滚动结束后,展开筛选浮层(同时禁用页面滚动,防止穿透)
- 关闭浮层时,恢复页面滚动
就是这个简单的交互流,却让组内的同学掉进了 setTimeout 的陷阱。他试图用“时间”来控制“顺序”,结果引发了Bug:用户点击“筛选”按钮,页面自动滚动定位。但如果用户手速快,点完马上关掉,页面就会突然“卡死”,怎么滑都滑不动。。
今天我们就把这张流程图摊开,看看这种“偷懒”的写法是如何导致灾难性 Bug 的。
问题分析
误区:陷入延迟困境
为了实现交互行为,这位同学是这么做的:
// 页面代码
const { isShowLayer } = useFilterComponent()
// 控制页面滚动的自定义hooks
const { setPageScrollEnable } = usePageScroll()
// 控制页面滚动到指定模块的自定义hooks
const { setScrollPageToModule } = useScrollToModule()
const onClickCallBack = () => {
// requestAnimationFrame控制页面滚动到FilterBar的位置
requestAnimationFrame(setScrollPageToModule(FilterBar))
}
useEffect(() => {
// 划重点!!! 延迟禁止滚动,确保动画效果完成
const delayTime = isMini ? 2000 : 300
if (isShowLayer) { // 展开浮层
setTimeout(() => {
setPageScrollEnable(false) // 禁止滚动
}, delayTime)
} else { // 关闭浮层
setPageScrollEnable(true) // 恢复滚动
}
}, [isShowLayer])
题拆解:页面为什么会“死”?
看似完美的闭环,实则脆弱不堪
导致页面卡死或无法滚动的根源,在于状态变更(State)与视觉呈现(UI)的严重不同步,而开发同学试图用 setTimeout 来掩盖这种裂痕
原因一:用“猜时间”代替“逻辑顺序”
代码中为了等待页面滚动结束以及浮层动画展开,硬编码了一个 2000ms(小程序) 的延时。
滚动的耗时取决于手机性能和滚动距离。如果滚动只用了 0.5 秒,用户要白白等 1.5 秒;如果滚动卡顿用了 3 秒,2 秒时浮层强制弹出,画面就会冲突。
页面的页面锚定和禁用页面滚动这两个状态的顺序是割裂的,页面锚定和浮层展开后的禁用页面滚动,明明是一个强依赖的交互流,却完全靠setTimeout在猜测。
为什么小程序会设置一个2000ms的延时?我们知道requestAnimationFrame的时机我们是无法控制的,受限于小程序的性能,所以草率地设置了一个2000ms的延时!离谱plus!
原因二:只管生,不管“埋”(内存溢出与副作用)
代码中设置了浮层展开后 2000ms(小程序) 后锁定页面滚动,但没有清除定时器。
当用户在 2000ms 内快速关闭了组件,组件虽然销毁了,但定时器依然在内存中倒数。时间一到,定时器“诈尸”,执行锁定滚动的代码。此时浮层已关,用户看着正常的页面,手指却怎么划都划不动。
解决方案
必须遵循两个原则: “及时清理副作用” 和 “基于事件而非时间”
修复方案 1:必须清理定时器 (最快修复)
凡是在 useEffect 中使用 setTimeout,务必在清理函数(cleanup function)中清除它。这能确保当状态变化(用户关闭)时,之前的待执行任务被取消。
const timerRef = useRef(null);
useEffect(() => {
// 1. 每次 effect 执行前,先清理上一次可能存在的定时器
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (isShowLayer) {
const delayTime = isMini ? 2000 : 300;
timerRef.current = setTimeout(() => {
setPageScrollEnable(false);
timerRef.current = null;
}, delayTime);
} else {
setPageScrollEnable(true);
}
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [isShowLayer]);
更彻底的解法是摒弃猜测时间的逻辑,将“锚定滚动”封装为 Promise。只有当滚动真正结束后,才更新状态并锁屏。该方法重构工作较大,暂时放弃...
const scrollToModule = () => {
return new Promise((resolve) => {
// 1. 调用滚动 API
nativeScrollTo({ // nativeScrollTo也是封装的,根据实际端侧实现效果
target: '#filter-bar',
success: () => {
// 2. 只有真正滚完了,才 resolve
// 小程序里甚至可以用 IntersectionObserver 来辅助判断是否到位
resolve(true);
},
fail: () => resolve(false) // 容错处理
});
});
};
const onClickCallBack = async () => {
if (isLocked.current) return;
isLocked.current = true;
try {
// 滚动锚定
await scrollToModule();
// 只有滚动完成,才执行下一步
filterComponentRef.current.open();
// 禁用页面滚动
setPageScrollEnable(false);
} catch (e) {
console.error(e);
} finally {
isLocked.current = false;
}
};