🧑💻 寫在開頭
點贊 + 收藏 === 學會🤣🤣🤣
引言
靈魂拷問
你是不是也寫過這樣的代碼?
“這個動畫有點卡,加個 setTimeout 延時一下?” “這個狀態更新順序不對,給它個 100ms 緩衝?” “不知道什麼時候滾動結束?先延遲個 300ms 再説!”
在前端開發中,setTimeout 就像是一劑強效止痛藥。它能快速掩蓋邏輯上的時序衝突,讓代碼“看起來”跑通了。但請注意,它只是掩蓋了病情,並沒有治癒病灶。
需求描述
“用户點擊篩選按鈕時,頁面要先自動滾動(錨定)到頁面某一個位置,然後再展開篩選浮層。”
與下圖中淘寶閃購的效果類似:
為了優化體驗,頁面設計了“防滾動穿透”邏輯:
- 當浮層展開時: 調用
setPageScrollEnable(false)禁用頁面滾動。 - 當浮層關閉時: 調用
setPageScrollEnable(true)恢復頁面滾動。
預期的交互是這樣的:
- 用户點擊篩選
- 頁面先執行錨定滾動
- 滾動結束後,展開篩選浮層(同時禁用頁面滾動,防止穿透)
- 關閉浮層時,恢復頁面滾動
就是這個簡單的交互流,卻讓組內的同學掉進了 setTimeout 的陷阱。他試圖用“時間”來控制“順序”,結果引發了Bug:用户點擊“篩選”按鈕,頁面自動滾動定位。但如果用户手速快,點完馬上關掉,頁面就會突然“卡死”,怎麼滑都滑不動。。
今天我們就把這張流程圖攤開,看看這種“偷懶”的寫法是如何導致災難性 Bug 的。
問題分析
誤區:陷入延遲困境
為了實現交互行為,這位同學是這麼做的:
當用户點擊“篩選”按鈕後會發生什麼?組件的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”。