การทริกเกอร์เอฟเฟกต์หลังจากการเลื่อนแบบเป็นโปรแกรมเสร็จสมบูรณ์

ฟีเจอร์บนเว็บ: Scroll method promises scrollIntoView()

เมื่อเลื่อนไปยังส่วนหรือองค์ประกอบแบบเป็นโปรแกรม (โดยเฉพาะเมื่อใช้ behavior: 'smooth') การเรียกใช้การดำเนินการติดตามผลทันที เช่น การกะพริบสถานะไฮไลต์ การเริ่มภาพเคลื่อนไหวขาเข้า หรือการย้ายโฟกัส จะทำให้เอฟเฟกต์ทำงานขณะที่เป้าหมายยังอยู่นอกหน้าจอหรืออยู่ระหว่างการเลื่อน

ก่อนหน้านี้ นักพัฒนาแอปต้องเดาระยะเวลาการเลื่อนด้วย setTimeout() (ซึ่งเริ่มทำงานเร็วเกินไปในการเลื่อนแบบยาวและช้าเกินไปในการเลื่อนแบบสั้น) หรือรวม Listener เหตุการณ์ scrollend ไว้ใน Promise ด้วยตนเองพร้อมตัวจับเวลาสำรอง (เนื่องจาก scrollend จะไม่เริ่มทำงานเมื่อเป้าหมายอยู่ในมุมมองอยู่แล้วและไม่มีการเลื่อนเกิดขึ้น)

เมธอดการเลื่อนแบบเป็นโปรแกรมใน Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) และ Window (scroll(), scrollTo(), scrollBy()) จะแสดงผล Promise ซึ่งจะได้รับการแก้ไขเมื่อการดำเนินการเลื่อนเสร็จสมบูรณ์ หากเป้าหมายอยู่ที่ตำแหน่งการเลื่อนที่ขออยู่แล้วและไม่จำเป็นต้องเลื่อน Promise ที่แสดงผลจะได้รับการแก้ไขทันทีโดยไม่ค้าง

การใช้งาน

  1. ทำเครื่องหมายเครื่องจัดการเป็น async: ใช้ฟังก์ชัน async สำหรับ Listener เหตุการณ์หรือเครื่องจัดการการนำทางที่ทริกเกอร์การเลื่อน
  2. รอเมธอดการเลื่อนโดยตรง: เรียกใช้ await element.scrollIntoView({ behavior: 'smooth', ... }) (หรือ await element.scrollTo(...), await window.scrollTo(...) ฯลฯ) การดำเนินการจะหยุดชั่วคราวจนกว่าเบราว์เซอร์จะเลื่อนภาพเคลื่อนไหวเสร็จและตำแหน่งการเลื่อนคงที่
  3. ใช้เอฟเฟกต์หลังการเลื่อน: สลับคลาสไฮไลต์ เริ่มภาพเคลื่อนไหว หรือย้ายโฟกัสหลังจากนิพจน์ await ทำงานเสร็จสมบูรณ์

การเลื่อนที่ถูกขัดจังหวะและการจัดการโฟกัส

  • การจัดการการเลื่อนอย่างรวดเร็ว/ถูกขัดจังหวะ: หากการเลื่อนใหม่เริ่มขึ้นในคอนเทนเนอร์การเลื่อนเดียวกันก่อนที่การเลื่อนอย่างราบรื่นที่ดำเนินอยู่จะเสร็จสิ้น เบราว์เซอร์จะยกเลิกการเลื่อนอย่างราบรื่นที่ดำเนินอยู่และแก้ไข (ไม่ปฏิเสธ) สัญญาการเลื่อนที่รอดำเนินการทั้งหมดในคอนเทนเนอร์นั้น หากต้องการระงับเอฟเฟกต์หลังการเลื่อนสำหรับการเลื่อนที่ถูกแทนที่เมื่อผู้ใช้คลิกอย่างรวดเร็ว ให้ติดตามรหัสการดำเนินการที่เพิ่มขึ้นและแสดงผลก่อนกำหนดหลังจาก 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');
}
  • การโฟกัสเป้าหมายหลังจากเลื่อน: เมื่อย้ายโฟกัสของแป้นพิมพ์ไปยังองค์ประกอบเป้าหมาย (หรือส่วนหัวภายในองค์ประกอบที่มี tabindex="-1") หลังจาก await targetElement.scrollIntoView(...) ให้ส่ง { preventScroll: true } ไปยัง focus() เพื่อไม่ให้การเรียกโฟกัสเริ่มการเลื่อนที่แข่งขันกันเป็นครั้งที่ 2
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

ข้อจำกัดและแนวทางปฏิบัติแนะนำ

  • บังคับ: รอให้ Promise แสดงผลโดยตรงจาก scrollIntoView(), scroll(), scrollTo() หรือ scrollBy() แทนที่จะคาดเดาระยะเวลาการเลื่อนด้วย setTimeout()
  • อย่ารวม scrollend ไว้ใน new Promise() ด้วยตนเองเป็นการติดตั้งใช้งานหลัก เนื่องจาก scrollend จะไม่เริ่มทำงานเมื่อเป้าหมายอยู่ในมุมมองอยู่แล้วและไม่มีการเลื่อนหน้าจอ ในขณะที่เมธอดการเลื่อนจะแก้ไขโดยอัตโนมัติในกรณีดังกล่าว
  • สิ่งที่ควรทำ: ส่ง { preventScroll: true } เมื่อเรียกใช้ .focus() หลังจากรอเมธอดการเลื่อน เพื่อไม่ให้เบราว์เซอร์ข้ามหรือเลื่อนเป็นครั้งที่ 2
  • ควรทำ: ป้องกันเอฟเฟกต์หลังการเลื่อนด้วยโทเค็นคำขอ หากการโต้ตอบของผู้ใช้อย่างรวดเร็วสามารถทริกเกอร์การเลื่อนอย่างราบรื่นที่ทับซ้อนกันในคอนเทนเนอร์เดียวกันได้ เนื่องจากการขัดจังหวะการเลื่อนอย่างราบรื่นจะแก้ไข (แทนที่จะปฏิเสธ) สัญญา (Promise) ก่อนหน้า
  • ข้อบกพร่องที่ทราบของ Chrome เกี่ยวกับ Root Scroller: Chrome มีข้อบกพร่องที่ Promise ซึ่งแสดงผลโดย scrollIntoView() จะได้รับการแก้ไขเร็วเกินไปเมื่อเป็น Root Scroller ของเอกสารที่กำลังเลื่อน การรอ scrollIntoView() ในคอนเทนเนอร์การเลื่อนที่ฝังอยู่จะทำงานได้ตามที่คาดไว้ อย่ารวมเนื้อหาไว้ในคอนเทนเนอร์แบบเลื่อน เช่น div ที่มี overflow-y: auto เพื่อแก้ปัญหาข้อบกพร่องนี้

กลยุทธ์สำรอง

ในเบราว์เซอร์ที่ไม่รองรับ Scroll Method Promise, scrollIntoView(), scroll(), scrollTo() และ scrollBy() จะแสดงผล undefined แทน Promise

  • การเพิ่มประสิทธิภาพแบบต่อเนื่อง (ลักษณะการทำงานเริ่มต้นของ await): เนื่องจาก await undefined เป็น JavaScript ที่ถูกต้องซึ่งจะแก้ไขทันทีในไมโครทาสก์ถัดไป การเขียน await element.scrollIntoView({ behavior: 'smooth' }) จะไม่ส่งข้อผิดพลาดในเบราว์เซอร์ที่ไม่รองรับ แต่จะลดระดับลงอย่างราบรื่นโดยเรียกใช้โค้ดหลังการเลื่อนทันทีที่เริ่มเลื่อน สำหรับภาพที่สวยงามแต่ไม่สำคัญ เช่น ไฮไลต์ชั่วคราว วิธีนี้มักจะเพียงพอ
  • ทางเลือกที่เชื่อถือได้ (scrollend + หมดเวลา): หากเป้าหมาย Baseline ไม่รองรับ 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);
  });
}