目標:把“為什么慢、卡頓從哪來、該怎么寫”一次說清。本文先講事件傳播與主線程瓶頸,再給出四件法寶(防抖、節流、事件委托、被動監聽),最后用一套可復制的工具函數 + 清單收尾。
1)先理解“為什么會卡”:事件、傳播與主線程
1.1 事件傳播三階段
捕獲(capturing):從
window
→document
→ … → target,找目標元素。目標(at target):到達真正觸發的元素。
冒泡(bubbling):從 target → … →
document
→window
反向冒泡。
常用屬性:
el.addEventListener('click', handler, { capture: false }); // 冒泡階段
// e.target:事件最初觸發的元素
// e.currentTarget:當前正在運行回調的元素
// e.stopPropagation():阻止后續傳播
// e.preventDefault():阻止默認行為(僅在事件可取消時)
1.2 UI 線程瓶頸(滾動與觸摸最敏感)
滾動、觸摸等事件是高頻的:瀏覽器可能在一秒內觸發幾十到上百次回調。如果回調里:
改樣式導致強制同步布局(layout thrashing);
做了重活(復雜計算、DOM 大量操作、同步 XHR);
或者阻塞滾動(在可取消的滾動相關事件里做了
preventDefault
),
就會看到掉幀與卡頓。
核心策略:能少綁就少綁、能延后就延后(防抖/節流/rAF)、能復用就復用(委托)、能不阻塞就不阻塞(passive)。
2)防抖(debounce):過濾“高頻抖動”,保留“最后一次/第一次”
場景:搜索輸入聯想、窗口尺寸變化、表單校驗、復雜篩選。
思想:一段時間內多次觸發→只在最后(或第一次)執行。
2.1 可直接用的防抖函數(含 leading / trailing / maxWait)
function debounce(fn, wait = 200, { leading = false, trailing = true, maxWait } = {}) {let timer = null, lastCall = 0, lastInvoke = 0, result;const invoke = (ctx, args) => {lastInvoke = Date.now();result = fn.apply(ctx, args);};const debounced = function (...args) {const now = Date.now();const ctx = this;if (!lastCall && leading && !timer) {invoke(ctx, args);}lastCall = now;const remaining = wait - (now - lastCall);const timeSinceLastInvoke = now - lastInvoke;const shouldInvokeMax = maxWait !== undefined && timeSinceLastInvoke >= maxWait;clearTimeout(timer);timer = setTimeout(() => {timer = null;if (trailing && (!leading || (now - lastInvoke >= wait))) {invoke(ctx, args);}}, remaining > 0 ? remaining : 0);if (shouldInvokeMax) {clearTimeout(timer);timer = null;invoke(ctx, args);}return result;};debounced.cancel = () => { clearTimeout(timer); timer = null; lastCall = 0; };debounced.flush = () => { if (timer) { clearTimeout(timer); timer = null; invoke(this, []); } };return debounced;
}
用法示例(輸入搜索,尾觸發):
const onQuery = debounce((q) => fetchList(q), 300);
searchInput.addEventListener('input', e => onQuery(e.target.value));
常見坑:
同時
leading
與trailing
為true
時,注意一次“觸發周期”內最多執行兩次。防抖時間太長會造成感知延遲;交互型輸入建議 200–300ms 左右。
3)節流(throttle):控制執行頻率,平滑且可預測
場景:scroll
/ resize
/ mousemove
/ pointermove
等連續事件;滾動吸頂、進度計算、拖拽反饋。
思想:每隔固定間隔最多執行一次。
3.1 兩種常見實現
時間戳法:更“實時”,首觸發立即執行。
定時器法:更“平滑”,末尾補一次。
3.2 一個實戰可用的節流(支持 leading/trailing/cancel/flush)
function throttle(fn, wait = 100, { leading = true, trailing = true } = {}) {let lastCall = 0, timer = null, lastArgs, lastThis;const invoke = () => {lastCall = Date.now();timer = null;fn.apply(lastThis, lastArgs);lastArgs = lastThis = null;};const throttled = function (...args) {const now = Date.now();lastArgs = args; lastThis = this;if (!lastCall && leading === false) lastCall = now;const remaining = wait - (now - lastCall);if (remaining <= 0 || remaining > wait) {if (timer) { clearTimeout(timer); timer = null; }invoke();} else if (!timer && trailing !== false) {timer = setTimeout(invoke, remaining);}};throttled.cancel = () => { clearTimeout(timer); timer = null; lastCall = 0; lastArgs = lastThis = null; };throttled.flush = () => { if (timer) { clearTimeout(timer); invoke(); } };return throttled;
}
3.3 rAF 節流(渲染節拍對齊,適合動畫/滾動讀寫)
function rafThrottle(fn) {let ticking = false;return function (...args) {if (ticking) return;ticking = true;requestAnimationFrame(() => {fn.apply(this, args);ticking = false;});};
}
// 示例:滾動時讀一次 scrollTop,寫一次 transform(避免布局抖動)
window.addEventListener('scroll', rafThrottle(() => {const y = window.scrollY || document.documentElement.scrollTop;header.style.transform = `translateY(${Math.min(y, 80)}px)`;
}), { passive: true });
選擇建議:
僅渲染相關 →
rafThrottle
需要確定的時間頻率 →
throttle
僅在停止后處理 →
debounce
4)事件委托(Event Delegation):少綁監聽,動態內容更省心
思想:把子元素的監聽“上移”到父容器,在冒泡階段一個回調搞定所有子項。
收益:
海量列表只綁一個事件處理器;
動態插入/刪除子節點無需重綁;
更易做統一攔截/鑒權/打點。
4.1 一個可復用的 onDelegate 工具
function onDelegate(container, type, selector, handler, options) {const listener = (e) => {// 使用 closest 適配嵌套:匹配到最近的祖先const target = e.target.closest(selector);if (target && container.contains(target)) {handler.call(target, e, target); // this 指向匹配元素}};container.addEventListener(type, listener, options);return () => container.removeEventListener(type, listener, options); // 便于解綁
}
示例:列表點擊/鍵盤交互
const off = onDelegate(document.querySelector('#todo'), 'click', 'button.remove', (e, btn) => {const li = btn.closest('li');li?.remove();
});// 動態新增 li 無需額外綁定
4.2 委托的注意點
不是所有事件都冒泡:
focus/blur
不冒泡(可用focusin/focusout
),mouseenter/leave
不冒泡(用mouseover/out
+relatedTarget
)。e.stopPropagation()
會截斷冒泡,盡量在局部回調里少用或控制邊界。Shadow DOM 下要理解 composed path,委托到 shadow root 外需要事件是
composed: true
的。
5)Passive Event Listeners:不阻塞滾動的監聽
在觸發滾動相關事件(如 touchstart
/touchmove
/wheel
)時,瀏覽器需要知道你的監聽器會不會 preventDefault()
來阻止滾動。如果不確定,瀏覽器可能等待你的回調,從而產生卡頓。
被動監聽(passive: true
)告訴瀏覽器:我不會調用 preventDefault()
。這樣瀏覽器可以立刻滾動,顯著改善滾動流暢度。
// 正確:滾動/觸摸相關事件一般用 passive
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('touchmove', onTouchMove, { passive: true });
window.addEventListener('wheel', onWheel, { passive: true });
警告:被動監聽里調用 e.preventDefault()
會被忽略(并可能在控制臺收到提示)。
如果你必須阻止默認行為(例如自定義手勢),就不要把這個監聽設為 passive,或采用雙通道策略(僅在需要時單獨注冊非被動監聽)。
附:一次性、捕獲階段
el.addEventListener('click', onceHandler, { once: true });
el.addEventListener('click', capHandler, { capture: true });
6)組合拳:一個綜合示例(滾動 + 搜索 + 列表)
<header id="header">Header</header>
<input id="search" placeholder="輸入關鍵詞..."/>
<ul id="list"></ul>
// 1) 滾動:rAF 節流 + passive
const header = document.getElementById('header');
window.addEventListener('scroll', rafThrottle(() => {const y = window.scrollY || document.documentElement.scrollTop;header.style.opacity = Math.max(0, 1 - y / 300);
}), { passive: true });// 2) 輸入:防抖
const search = document.getElementById('search');
const query = debounce(async (kw) => {const data = await fetch(`/api/search?q=${encodeURIComponent(kw)}`).then(r => r.json());renderList(data);
}, 300);
search.addEventListener('input', e => query(e.target.value));// 3) 列表:事件委托(刪除 & 點贊)
const list = document.getElementById('list');
function renderList(items = []) {list.innerHTML = items.map(it => `<li data-id="${it.id}"><span class="title">${it.title}</span><button class="like">👍 ${it.likes}</button><button class="remove">刪除</button></li>`).join('');
}
const offRemove = onDelegate(list, 'click', 'button.remove', (e, btn) => {btn.closest('li')?.remove();
});
const offLike = onDelegate(list, 'click', 'button.like', (e, btn) => {const n = parseInt(btn.textContent.replace(/\D/g,'')) || 0;btn.textContent = `👍 ${n + 1}`;
});
7)性能與可維護性補充
讀寫分離:在同一個幀里,先讀所有布局值,再寫樣式,避免反復讀寫導致強制回流。
減少監聽數量:能委托就委托;不要給每個子項都綁監聽。
用觀測 API 替代輪詢/滾動監聽:
元素進入視口:
IntersectionObserver
元素尺寸變化:
ResizeObserver
Pointer Events:用
pointer*
合并鼠標與觸摸邏輯,代碼更少;配合getCoalescedEvents()
獲得更平滑的指針軌跡。易清理的監聽:使用
AbortController
一鍵解綁:const ac = new AbortController(); window.addEventListener('scroll', onScroll, { passive: true, signal: ac.signal }); // 需要時 ac.abort(); // 自動移除所有注冊在該 signal 上的監聽
8)不同事件的“推薦組合”速查
事件 | 建議 | 說明 |
---|---|---|
scroll | passive: true + rafThrottle | 滾動讀/寫渲染屬性時對齊幀率 |
touchstart/move | passive: true (若不阻止默認) | 需要自定義手勢且要 preventDefault 時改為非 passive |
wheel | passive: true (不阻止默認) | 要自定義滾動邏輯時禁用 passive |
resize | throttle 100–200ms | 計算布局較多時適度加大間隔 |
input | debounce 200–300ms | 搜索聯想等 |
mousemove | throttle 或 rafThrottle | 拖拽/繪圖更推薦 rAF |
列表 item 點擊 | 事件委托 | 動態增刪子項最省心 |
focus/blur | 用 focusin/focusout 代替做委托 | 這兩個才冒泡 |
9)常見坑與對策
在 passive 監聽里調用
preventDefault
→ 無效且會有警告。確認是否真的需要阻止默認;需要時把該監聽改為非 passive,僅作用于需要阻止的場景。委托 +
stopPropagation
沖突
→ 下層組件阻斷冒泡,會讓上層委托失效。團隊約定:組件層盡量少用stopPropagation
,或在容器委托前移到捕獲階段。高頻事件里讀寫混雜
→ 先讀后寫,或將寫入放requestAnimationFrame
,把讀操作緩存到局部變量。誤用
mouseenter/leave
做委托
→ 它們不冒泡。改用mouseover/out
+ 判斷relatedTarget
,或把監聽直接綁到目標元素。匿名函數難以解綁
→ 封裝返回off()
的注冊函數,或使用AbortController
統一收束。
10)可復制的“最小工具集”
// 1) debounce(上文已給全量版,可直接復用)
// 2) throttle(上文已給全量版)
// 3) rAF 節流
function rafThrottle(fn) {let ticking = false;return function (...args) {if (ticking) return;ticking = true;requestAnimationFrame(() => { fn.apply(this, args); ticking = false; });};
}
// 4) 事件委托
function onDelegate(container, type, selector, handler, options) {const listener = (e) => {const target = e.target.closest(selector);if (target && container.contains(target)) handler.call(target, e, target);};container.addEventListener(type, listener, options);return () => container.removeEventListener(type, listener, options);
}
// 5) 安全注冊(帶 AbortController)
function on(el, type, handler, { passive, capture, once, signal } = {}) {el.addEventListener(type, handler, { passive, capture, once, signal });return () => el.removeEventListener(type, handler, { capture });
}
結語
先理解事件傳播與主線程瓶頸,再對癥下藥:
高頻 → 節流/防抖/rAF;
海量節點 → 事件委托;
滾動/觸摸 → passive 默認化。
用一套可復用的工具函數和小清單,把“性能與流暢”變成默認選項,而不是事故后的補救。