透過程式輔助方式捲動至某個區段或元素時 (尤其是使用 behavior: 'smooth'),如果立即觸發後續動作 (例如閃爍醒目顯示狀態、啟動進入動畫或移動焦點),會導致效果在目標仍未顯示在螢幕上或捲動中時執行。
以往,開發人員必須使用 setTimeout() 猜測捲動時間長度 (在長捲動時太早觸發,在短捲動時太晚觸發),或在 Promise 中手動包裝 scrollend 事件監聽器,並設定備用計時器 (因為目標已在檢視畫面中且未捲動時,scrollend 不會觸發)。
Element (scrollIntoView()、scroll()、scrollTo()、scrollBy()) 和 Window (scroll()、scrollTo()、scrollBy()) 的程式輔助捲動方法會傳回 Promise,並在捲動作業完成時解析。如果目標已位於要求的捲動位置,且不需要捲動,則傳回的 Promise 會立即解析,不會停止。
導入作業
- 將處理常式標示為
async:針對觸發捲動的事件監聽器或導覽處理常式,使用async函式。 - 直接等待捲動方法:呼叫
await element.scrollIntoView({ behavior: 'smooth', ... })(或await element.scrollTo(...)、await window.scrollTo(...)等)。執行作業會暫停,直到瀏覽器完成捲動動畫,且捲動位置固定為止。 - 套用捲動後效果:切換醒目顯示類別、啟動動畫,或在
await運算式完成後移動焦點。
中斷捲動和焦點管理
- 處理快速/中斷的捲動:如果同一個捲動容器在進行中的順暢捲動完成前,開始新的捲動,瀏覽器會中止進行中的順暢捲動,並解析 (不會拒絕) 該容器上所有待處理的捲動承諾。如果使用者快速點擊,您需要抑制已取代捲動的捲動後效果,請追蹤遞增作業 ID,並在啟動新捲動後,於
await之後提早返回:
let currentScrollId = 0;
const sections = document.querySelectorAll('section');
async function scrollToAndHighlight(targetElement) {
const scrollId = ++currentScrollId;
sections.forEach((sec) => sec.classList.remove('highlight'));
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Abort follow-up effect if another scroll was triggered while awaiting
if (scrollId !== currentScrollId) return;
targetElement.classList.add('highlight');
}
- 捲動後聚焦目標:在
await targetElement.scrollIntoView(...)之後,將鍵盤焦點移至目標元素 (或其中含有tabindex="-1"的標題) 時,請將{ preventScroll: true }傳遞至focus(),這樣焦點呼叫就不會啟動第二次競爭捲動:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });
限制與最佳做法
- 必要:請等待
scrollIntoView()、scroll()、scrollTo()或scrollBy()直接傳回Promise,而非使用setTimeout()猜測捲動時間長度。 - 請勿將
scrollend包裝在手動new Promise()中做為主要導入方式,因為當目標已在檢視畫面中且沒有捲動距離時,scrollend不會觸發,而捲動方法承諾在這種情況下會自動解析。 - 建議做法:在等待捲動方法後呼叫
.focus()時,傳遞{ preventScroll: true },這樣瀏覽器就不會再次跳轉或捲動。 - 建議做法:如果快速的使用者互動可能會在同一個容器上觸發重疊的順暢捲動,請使用要求權杖保護捲動後效果,因為中斷順暢捲動會解析 (而非拒絕) 先前的 Promise。
- 根捲動器的已知 Chrome 錯誤:Chrome 有一個錯誤,當文件的根捲動器捲動時,
scrollIntoView()傳回的Promise會過早解析。在巢狀捲動容器上等待scrollIntoView()的運作方式符合預期。請勿將內容包裝在捲動容器中 (例如div搭配overflow-y: auto),做為這個錯誤的解決方法。
備用策略
在不支援捲動方法 Promise 的瀏覽器中,scrollIntoView()、scroll()、scrollTo() 和 scrollBy() 會傳回 undefined,而非 Promise。
- 漸進增強 (預設
await行為):由於await undefined是有效的 JavaScript,會在下一個微任務中立即解析,因此在不支援的瀏覽器中編寫await element.scrollIntoView({ behavior: 'smooth' })不會擲回錯誤。而是會在捲動開始時立即執行捲動後程式碼,以流暢的方式降級。對於非關鍵的視覺效果 (例如暫時醒目顯示),這通常就足夠了。 - 可靠的備用方案 (
scrollend+ 逾時):如果基準目標不支援scroll-promises,且捲動後動作必須等到舊版瀏覽器捲動完成後才能執行,請檢查傳回值是否為Promise(result instanceof Promise)。如果是undefined,請等待捲動容器上的scrollend事件,並設定短暫的備用逾時時間,以免元素已顯示在畫面中,且未發生捲動:
function scrollIntoViewAsync(element, options, scroller = window) {
const result = element.scrollIntoView(options);
// Feature detect: in supporting browsers, scrollIntoView() returns a Promise
if (result instanceof Promise) {
return result;
}
// Fallback for browsers where scrollIntoView() returns undefined
return new Promise((resolve) => {
let timeoutId;
const finish = () => {
clearTimeout(timeoutId);
scroller.removeEventListener('scrollend', finish);
scroller.removeEventListener('scroll', onScroll);
resolve();
};
const onScroll = () => {
// Restart the idle timer on every scroll event. If scrollend never fires
// (browsers without scrollend support), this resolves once scrolling goes idle.
clearTimeout(timeoutId);
timeoutId = setTimeout(finish, 100);
};
scroller.addEventListener('scrollend', finish, { once: true });
scroller.addEventListener('scroll', onScroll, { passive: true });
// If no scroll event fires within the idle window (e.g., already in view), resolve.
// 100ms is an example value; it only needs to exceed the gap between scroll events.
timeoutId = setTimeout(finish, 100);
});
}