引言
灵魂拷问
你是不是也写过这样的代码?
"这个动画有点卡,加个 setTimeout 延时一下?" "这个状态更新顺序不对,给它个 100ms 缓冲?" "不知道什么时候滚动结束?先延迟个 300ms 再说!"
在前端开发中,setTimeout 就像是一剂强效止痛药。它能快速掩盖逻辑上的时序冲突,让代码"看起来"跑通了。但请注意,它只是掩盖了病情,并没有治愈病灶。
需求描述
"用户点击筛选按钮时,页面要先自动滚动(锚定)到页面某一个位置,然后再展开筛选浮层。"
与下图中淘宝闪购的效果类似:

为了优化体验,页面设计了"防滚动穿透"逻辑:
- 当浮层展开时: 调用
setPageScrollEnable(false)禁用页面滚动。 - 当浮层关闭时: 调用
setPageScrollEnable(true)恢复页面滚动。
预期的交互是这样的:
- 用户点击筛选
- 页面先执行锚定滚动
- 滚动结束后,展开筛选浮层(同时禁用页面滚动,防止穿透)
- 关闭浮层时,恢复页面滚动
就是这个简单的交互流,却让组内的同学掉进了 setTimeout 的陷阱。他试图用"时间"来控制"顺序",结果引发了Bug:用户点击"筛选"按钮,页面自动滚动定位。但如果用户手速快,点完马上关掉,页面就会突然"卡死",怎么滑都滑不动。。
今天我们就把这张流程图摊开,看看这种"偷懒"的写法是如何导致灾难性 Bug 的。
问题分析
误区:陷入延迟困境
为了实现交互行为,这位同学是这么做的:

当用户点击"筛选"按钮后会发生什么?组件的onClick事件里面是怎么处理的?页面是怎么接收到用户点击以及浮层状态更改的通知的?
-
触发: 用户点击组件内的"筛选"按钮。
-
通知: 组件触发 Callback,通知页面"我要展开了"。
-
响应(页面端): 页面收到通知,利用
requestAnimationFrame执行锚定滚动,将视口定位到指定区域。 -
响应(组件端): 组件更新内部状态
isShowLayer,开始执行 250ms 的展开动画。 -
联动: 页面通过 Hooks 监听到组件状态变为"展开",于是执行
setPageScrollLocked(true)禁用滚动,防止穿透。
组件伪代码:
js
// 组件代码
const { onClickCallBack } = props
const [isShowLayer, setIsShowLayer] = useState(false)
const onClickFilter = () => {
// 1. 执行回调
onClickCallBack()
// 2. 更新状态
setIsShowLayer(!isShowLayer)
}
return <>
{/* 筛选按钮 */}
<FilterBtn onClick={onClickFilter} />
{/* 筛选浮层 */}
{ isShowLayer ? <FilterLayer /> : null }
</>
页面伪代码:
js
// 页面代码
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)中清除它。这能确保当状态变化(用户关闭)时,之前的待执行任务被取消。
js
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]);
修复方案 2:基于 Promise 的执行顺序 (架构优化)
更彻底的解法是摒弃猜测时间的逻辑,将"锚定滚动"封装为 Promise。只有当滚动真正结束后,才更新状态并锁屏。该方法重构工作较大,暂时放弃...
js
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;
}
};
警示
不要认为 setTimeout 能解决一切问题。
- 严格管理执行顺序: 异步操作(如页面滚动、接口请求)必须通过 Promise 或 事件回调 来确保逻辑的串行执行,绝不要靠猜时间。
- 必须清理定时器: 在处理涉及页面全局状态(如滚动锁定)的逻辑时,务必关注组件的生命周期。滥用定时器而忽略
clearTimeout或生命周期清理,极易引发难以复现的"幽灵 Bug"。