拆解一个由 setTimeout 引发的“页面假死”悬案

引言

灵魂拷问

你是不是也写过这样的代码?

"这个动画有点卡,加个 setTimeout 延时一下?" "这个状态更新顺序不对,给它个 100ms 缓冲?" "不知道什么时候滚动结束?先延迟个 300ms 再说!"

在前端开发中,setTimeout 就像是一剂强效止痛药。它能快速掩盖逻辑上的时序冲突,让代码"看起来"跑通了。但请注意,它只是掩盖了病情,并没有治愈病灶。

需求描述

"用户点击筛选按钮时,页面要先自动滚动(锚定)到页面某一个位置,然后再展开筛选浮层。"

与下图中淘宝闪购的效果类似:

为了优化体验,页面设计了"防滚动穿透"逻辑:

  • 当浮层展开时: 调用 setPageScrollEnable(false) 禁用页面滚动。
  • 当浮层关闭时: 调用 setPageScrollEnable(true) 恢复页面滚动。

预期的交互是这样的:

  1. 用户点击筛选
  2. 页面先执行锚定滚动
  3. 滚动结束后,展开筛选浮层(同时禁用页面滚动,防止穿透)
  4. 关闭浮层时,恢复页面滚动

就是这个简单的交互流,却让组内的同学掉进了 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"。
相关推荐
渔_2 小时前
【已解决】uni-textarea 无法绑定 v-model / 数据不回显?换原生 textarea 一招搞定!
前端
小胖霞2 小时前
vite+ts+monorepo从0搭建vue3组件库(二):项目搭建
前端·vue.js·前端工程化
JS_GGbond2 小时前
Vue中级冒险:3-4周成为组件沟通大师 🚀
前端·vue.js
登山者2 小时前
npm发布报错急救手册:快速解决2FA与令牌问题
前端·npm
小小善后师2 小时前
按钮太多了?基于ResizeObserver优雅显示
前端
HIT_Weston2 小时前
57、【Ubuntu】【Gitlab】拉出内网 Web 服务:Gitlab 配置审视(一)
前端·ubuntu·gitlab
用户6600676685392 小时前
模板字符串 + map:用现代 JavaScript 高效构建动态 HTML
前端·javascript
AY呀2 小时前
《玩转Vue3响应式:手把手实现TodoList,掌握核心指令》
前端·javascript·vue.js
哆啦A梦15882 小时前
商城后台管理系统 07 商品列表-分页实现
前端·javascript·vue.js