在程式輔助捲動完成後觸發效果

透過程式輔助方式捲動至某個區段或元素時 (尤其是使用 behavior: 'smooth'),如果立即觸發後續動作 (例如閃爍醒目顯示狀態、啟動進入動畫或移動焦點),會導致效果在目標仍未顯示在螢幕上或捲動中時執行。

以往,開發人員必須使用 setTimeout() 猜測捲動時間長度 (在長捲動時太早觸發,在短捲動時太晚觸發),或在 Promise 中手動包裝 scrollend 事件監聽器,並設定備用計時器 (因為目標已在檢視畫面中且未捲動時,scrollend 不會觸發)。

Element (scrollIntoView()、scroll()、scrollTo()、scrollBy()) 和 Window (scroll()、scrollTo()、scrollBy()) 的程式輔助捲動方法會傳回 Promise,並在捲動作業完成時解析。如果目標已位於要求的捲動位置,且不需要捲動,則傳回的 Promise 會立即解析,不會停止。

導入作業

  1. 將處理常式標示為 async:針對觸發捲動的事件監聽器或導覽處理常式,使用 async 函式。
  2. 直接等待捲動方法:呼叫 await element.scrollIntoView({ behavior: 'smooth', ... }) (或 await element.scrollTo(...)、await window.scrollTo(...) 等)。執行作業會暫停,直到瀏覽器完成捲動動畫,且捲動位置固定為止。
  3. 套用捲動後效果:切換醒目顯示類別、啟動動畫,或在 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);
  });
}