博客 / 詳情

返回

拆解一個由 setTimeout 引發的“頁面假死”懸案

🧑‍💻 寫在開頭

點贊 + 收藏 === 學會🤣🤣🤣

引言

靈魂拷問

你是不是也寫過這樣的代碼?

“這個動畫有點卡,加個 setTimeout 延時一下?” “這個狀態更新順序不對,給它個 100ms 緩衝?” “不知道什麼時候滾動結束?先延遲個 300ms 再説!”

在前端開發中,setTimeout 就像是一劑強效止痛藥。它能快速掩蓋邏輯上的時序衝突,讓代碼“看起來”跑通了。但請注意,它只是掩蓋了病情,並沒有治癒病灶。

需求描述

“用户點擊篩選按鈕時,頁面要先自動滾動(錨定)到頁面某一個位置,然後再展開篩選浮層。”

與下圖中淘寶閃購的效果類似:

ScreenShot_2026-02-02_094336_499

 

為了優化體驗,頁面設計了“防滾動穿透”邏輯:

  • 當浮層展開時: 調用 setPageScrollEnable(false) 禁用頁面滾動。
  • 當浮層關閉時: 調用 setPageScrollEnable(true) 恢復頁面滾動。

預期的交互是這樣的:

  1. 用户點擊篩選
  2. 頁面先執行錨定滾動
  3. 滾動結束後,展開篩選浮層(同時禁用頁面滾動,防止穿透)
  4. 關閉浮層時,恢復頁面滾動

就是這個簡單的交互流,卻讓組內的同學掉進了 setTimeout 的陷阱。他試圖用“時間”來控制“順序”,結果引發了Bug:用户點擊“篩選”按鈕,頁面自動滾動定位。但如果用户手速快,點完馬上關掉,頁面就會突然“卡死”,怎麼滑都滑不動。

今天我們就把這張流程圖攤開,看看這種“偷懶”的寫法是如何導致災難性 Bug 的。

問題分析

誤區:陷入延遲困境

為了實現交互行為,這位同學是這麼做的:

ScreenShot_2026-02-02_094346_243

當用户點擊“篩選”按鈕後會發生什麼?組件的onClick事件裏面是怎麼處理的?頁面是怎麼接收到用户點擊以及浮層狀態更改的通知的?

  • 觸發: 用户點擊組件內的“篩選”按鈕。

  • 通知: 組件觸發 Callback,通知頁面“我要展開了”。

  • 響應(頁面端): 頁面收到通知,利用 requestAnimationFrame 執行錨定滾動,將視口定位到指定區域。

  • 響應(組件端): 組件更新內部狀態 isShowLayer,開始執行 250ms 的展開動畫。

  • 聯動: 頁面通過 Hooks 監聽到組件狀態變為“展開”,於是執行 setPageScrollLocked(true) 禁用滾動,防止穿透。

組件偽代碼:

// 組件代碼
const { onClickCallBack } = props
const [isShowLayer, setIsShowLayer] = useState(false)

const onClickFilter = () => {
    // 1. 執行回調
    onClickCallBack()
    
    // 2. 更新狀態
    setIsShowLayer(!isShowLayer)
}


return <>
    {/* 篩選按鈕 */}
    <FilterBtn onClick={onClickFilter} />
    {/* 篩選浮層 */}
    { isShowLayer ?  <FilterLayer /> : null }
</>
頁面偽代碼:
// 頁面代碼
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]);

修復方案 2:基於 Promise 的執行順序 (架構優化)

更徹底的解法是摒棄猜測時間的邏輯,將“錨定滾動”封裝為 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;
  }
};

警示

不要認為 setTimeout 能解決一切問題。

  • 嚴格管理執行順序: 異步操作(如頁面滾動、接口請求)必須通過 Promise事件回調 來確保邏輯的串行執行,絕不要靠猜時間。
  • 必須清理定時器: 在處理涉及頁面全局狀態(如滾動鎖定)的邏輯時,務必關注組件的生命週期。濫用定時器而忽略 clearTimeout 或生命週期清理,極易引發難以復現的“幽靈 Bug”。

如果對您有所幫助,歡迎您點個關注,我會定時更新技術文檔,大家一起討論學習,一起進步。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.