北纬的北 發表於 2026-2-2 09:46:00

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

<h1 data-id="heading-0">🧑‍💻 写在开头</h1>
<p>点赞 + 收藏 === 学会🤣🤣🤣</p>
<div>
<div>
<h2 data-id="heading-0">引言</h2>
<h3 data-id="heading-1">灵魂拷问</h3>
<p>你是不是也写过这样的代码?</p>
<p>“这个动画有点卡,加个 setTimeout 延时一下?” “这个状态更新顺序不对,给它个 100ms 缓冲?” “不知道什么时候滚动结束?先延迟个 300ms 再说!”</p>
<p>在前端开发中,setTimeout 就像是一剂强效止痛药。它能快速掩盖逻辑上的时序冲突,让代码“看起来”跑通了。但请注意,它只是掩盖了病情,并没有治愈病灶。</p>
<h3 data-id="heading-2">需求描述</h3>
<p>“用户点击筛选按钮时,页面要先自动滚动(锚定)到页面某一个位置,然后再展开筛选浮层。”</p>
<p>与下图中淘宝闪购的效果类似:</p>
</div>
<p><img src="https://img2024.cnblogs.com/blog/2149129/202602/2149129-20260202094449087-290373119.png" alt="ScreenShot_2026-02-02_094336_499" loading="lazy"></p>
<p>&nbsp;</p>
<div>
<div>
<p>为了优化体验,页面设计了“防滚动穿透”逻辑:</p>
<ul>
<li><strong>当浮层展开时:</strong> 调用 <code>setPageScrollEnable(false)</code> <strong>禁用</strong>页面滚动。</li>
<li><strong>当浮层关闭时:</strong> 调用 <code>setPageScrollEnable(true)</code> <strong>恢复</strong>页面滚动。</li>
</ul>
<p>预期的交互是这样的:</p>
<ol>
<li>用户<strong>点击</strong>筛选</li>
<li>页面先执行<strong>锚定滚动</strong></li>
<li>滚动结束后,展开筛选浮层(同时禁用页面滚动,防止穿透)</li>
<li>关闭浮层时,恢复页面滚动</li>
</ol>
<p>就是这个简单的交互流,却让组内的同学掉进了 <code>setTimeout</code> 的陷阱。他试图用“时间”来控制“顺序”,结果引发了Bug:用户点击“筛选”按钮,页面自动滚动定位。但如果用户手速快,点完马上关掉,<strong>页面就会突然“卡死”,怎么滑都滑不动。</strong>。</p>
<p>今天我们就把这张流程图摊开,看看这种“偷懒”的写法是如何导致灾难性 Bug 的。</p>
<h2 data-id="heading-3">问题分析</h2>
<h3 data-id="heading-4">误区:陷入延迟困境</h3>
<p>为了实现交互行为,这位同学是这么做的:</p>
</div>
<p><img src="https://img2024.cnblogs.com/blog/2149129/202602/2149129-20260202094500415-925838178.png" alt="ScreenShot_2026-02-02_094346_243" loading="lazy"></p>
<div>
<div>
<p>当用户点击“筛选”按钮后会发生什么?组件的onClick事件里面是怎么处理的?页面是怎么接收到用户点击以及浮层状态更改的通知的?</p>
<ul>
<li>
<p><strong>触发:</strong> 用户点击组件内的“筛选”按钮。</p>
</li>
<li>
<p><strong>通知:</strong> 组件触发 Callback,通知页面“我要展开了”。</p>
</li>
<li>
<p><strong>响应(页面端):</strong> 页面收到通知,利用 <code>requestAnimationFrame</code> 执行<strong>锚定滚动</strong>,将视口定位到指定区域。</p>
</li>
<li>
<p><strong>响应(组件端):</strong> 组件更新内部状态 <code>isShowLayer</code>,开始执行 250ms 的展开动画。</p>
</li>
<li>
<p><strong>联动:</strong> 页面通过 Hooks 监听到组件状态变为“展开”,于是执行 <code>setPageScrollLocked(true)</code> <strong>禁用滚动</strong>,防止穿透。</p>
</li>
</ul>
<p>组件伪代码:</p>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">// 组件代码
const { onClickCallBack } = props
const = useState(false)

const onClickFilter = () =&gt; {
    // 1. 执行回调
    onClickCallBack()
   
    // 2. 更新状态
    setIsShowLayer(!isShowLayer)
}


return &lt;&gt;
    {/* 筛选按钮 */}
    &lt;FilterBtn onClick={onClickFilter} /&gt;
    {/* 筛选浮层 */}
    { isShowLayer ?&lt;FilterLayer /&gt; : null }
&lt;/&gt;</pre>
</div>
页面伪代码:<br>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">// 页面代码
const { isShowLayer } = useFilterComponent()
// 控制页面滚动的自定义hooks
const { setPageScrollEnable } = usePageScroll()
// 控制页面滚动到指定模块的自定义hooks
const { setScrollPageToModule } = useScrollToModule()

const onClickCallBack = () =&gt; {
    // requestAnimationFrame控制页面滚动到FilterBar的位置
    requestAnimationFrame(setScrollPageToModule(FilterBar))
}

useEffect(() =&gt; {
    // 划重点!!! 延迟禁止滚动,确保动画效果完成
    const delayTime = isMini ? 2000 : 300
    if (isShowLayer) { // 展开浮层
      setTimeout(() =&gt; {
            setPageScrollEnable(false) // 禁止滚动
      }, delayTime)
    } else { // 关闭浮层
      setPageScrollEnable(true) // 恢复滚动
    }
}, )</pre>
</div>
<div>
<div>
<h3 data-id="heading-5">题拆解:页面为什么会“死”?</h3>
<blockquote>
<p>看似完美的闭环,实则脆弱不堪</p>
</blockquote>
<p>导致页面卡死或无法滚动的根源,在于<strong>状态变更(State)与视觉呈现(UI)的严重不同步</strong>,而开发同学试图用 <code>setTimeout</code> 来掩盖这种裂痕</p>
<h4 data-id="heading-6">原因一:用“猜时间”代替“逻辑顺序”</h4>
<p>代码中为了<strong>等待页面滚动结束以及浮层动画展开</strong>,硬编码了一个 <code>2000ms</code>(小程序) 的延时。</p>
<p>滚动的耗时取决于手机性能和滚动距离。如果滚动只用了 0.5 秒,用户要白白等 1.5 秒;如果滚动卡顿用了 3 秒,2 秒时浮层强制弹出,画面就会冲突。</p>
<p>页面的页面锚定和禁用页面滚动这两个状态的顺序是割裂的,页面锚定和浮层展开后的禁用页面滚动,明明是一个强依赖的交互流,却完全靠setTimeout在猜测。</p>
<p><strong>为什么小程序会设置一个2000ms的延时?我们知道requestAnimationFrame的时机我们是无法控制的,受限于小程序的性能,所以草率地设置了一个2000ms的延时!离谱plus!</strong></p>
<h4 data-id="heading-7">原因二:只管生,不管“埋”(内存溢出与副作用)</h4>
<p>代码中设置了浮层展开后 <code>2000ms(小程序)</code> 后锁定页面滚动,但<strong>没有清除定时器</strong>。</p>
<p>当用户在 2000ms 内快速关闭了组件,组件虽然销毁了,但定时器依然在内存中倒数。时间一到,定时器“诈尸”,执行锁定滚动的代码。此时浮层已关,用户看着正常的页面,手指却怎么划都划不动。</p>
<h2 data-id="heading-8">解决方案</h2>
<p>必须遵循两个原则: <strong>“及时清理副作用”</strong> 和 <strong>“基于事件而非时间”</strong></p>
<h3 data-id="heading-9">修复方案 1:必须清理定时器 (最快修复)</h3>
<p>凡是在 <code>useEffect</code> 中使用 <code>setTimeout</code>,务必在清理函数(cleanup function)中清除它。这能确保当状态变化(用户关闭)时,之前的待执行任务被取消。</p>
</div>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">const timerRef = useRef(null);

useEffect(() =&gt; {
    // 1. 每次 effect 执行前,先清理上一次可能存在的定时器
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }

    if (isShowLayer) {
      const delayTime = isMini ? 2000 : 300;
      
      timerRef.current = setTimeout(() =&gt; {
            setPageScrollEnable(false);
            
            timerRef.current = null;
      }, delayTime);
      
    } else {
      setPageScrollEnable(true);
    }

    return () =&gt; {
      if (timerRef.current) {
            clearTimeout(timerRef.current);
            timerRef.current = null;
      }
    };
}, );</pre>
</div>
<h3 data-id="heading-10">修复方案 2:基于 Promise 的执行顺序 (架构优化)</h3>
<p>更彻底的解法是摒弃猜测时间的逻辑,将“锚定滚动”封装为 Promise。只有当滚动真正结束后,才更新状态并锁屏。该方法重构工作较大,暂时放弃...</p>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">const scrollToModule = () =&gt; {
return new Promise((resolve) =&gt; {
    // 1. 调用滚动 API
    nativeScrollTo({ // nativeScrollTo也是封装的,根据实际端侧实现效果
      target: '#filter-bar',
      success: () =&gt; {
      // 2. 只有真正滚完了,才 resolve
      // 小程序里甚至可以用 IntersectionObserver 来辅助判断是否到位
      resolve(true);
      },
      fail: () =&gt; resolve(false) // 容错处理
    });
});
};

const onClickCallBack = async () =&gt; {
if (isLocked.current) return;
isLocked.current = true;

try {
    // 滚动锚定
    await scrollToModule();
    // 只有滚动完成,才执行下一步
    filterComponentRef.current.open();
    // 禁用页面滚动
    setPageScrollEnable(false);
} catch (e) {
    console.error(e);
} finally {
    isLocked.current = false;
}
};</pre>
</div>
<div>
<div>
<h2 data-id="heading-11">警示</h2>
<p><strong>不要认为 setTimeout 能解决一切问题。</strong></p>
<ul>
<li><strong>严格管理执行顺序:</strong> 异步操作(如页面滚动、接口请求)必须通过 <strong>Promise</strong> 或 <strong>事件回调</strong> 来确保逻辑的串行执行,绝不要靠猜时间。</li>
<li><strong>必须清理定时器:</strong> 在处理涉及页面全局状态(如滚动锁定)的逻辑时,务必关注组件的生命周期。滥用定时器而忽略 <code>clearTimeout</code> 或生命周期清理,极易引发难以复现的“幽灵 Bug”。</li>
</ul>
</div>
</div>
<div>
<h3 id="tid-D8HBxE">如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。</h3>
</div>
<p><em><img src="https://img2024.cnblogs.com/blog/2149129/202501/2149129-20250122165814748-630765389.png" alt="" loading="lazy"></em></p>
</div>
</div><br><br>
来源:https://www.cnblogs.com/smileZAZ/p/19562588
頁: [1]
查看完整版本: 拆解一个由 setTimeout 引发的“页面假死”悬案