เพื่อนๆ เคยต้องทำ UI ที่จะแสดง animation เมื่อเราเลื่อนถึงวัตถุนั้นหรือไม่ หรือว่าต้องทำ lazy loading กับ รูปภาพ ที่จะโหลดก็ต่อเมื่อผู้เข้าเว็บไซต์เลื่อนถึง และ การทำ Infinite Scrolling ที่จะทำการ ค้นหาข้อมูลใหม่เมื่อเลื่อนหน้าจอจนสุดไหมครับ
ปัญหาเหล่านี้เป็นความยากลำบากของเหล่า Frontend Developer มาช้านานและวิธีการแก้ปัญหานั้น อาจจะส่งผลต่อการทำงานของหน้าเว็บไซต์และทำให้การใช้งานของ user นั้นติดขัด ส่งผลให้ในปัจจุบัน การมาถึงของ intersection observer api จึงเข้ามาแก้ปัญหาในจุดนี้
Table of Content
- Compatibility
What is Intersection Observer API
Intersection Observer API เป็น API ที่ถูกเพิ่มเข้าบน เบราวเซอร์ต่าง ๆ เพื่อทำหน้าที่ในการสังเกต ว่าวัตถุดังกล่าวที่ได้รับการตั้งค่าไว้ เลื่อนเข้ามาในบริเวณ browser viewport แล้วหรือยัง หรือ หากจับสังเกตได้ว่าเกิดเหตุการณ์ข้างต้นขึ้น หรือ เกิดการเรียกใช้งาน ตัว API จะทำการ เรียกใช้งานฟังก์ชัน เพื่อดำเนินคำสั่งตามที่เราเขียนไว้นั้นเอง

โดย API สามารถปรับแต่งตาม options ต่าง ๆ ซึ่งเราจะอธิบายในพาร์ทต่อไป
Why Do We Need Intersection Observer API & What Problems Does It Solve?
สาเหตุที่มีการสร้าง Intersection Observer API คือการที่เราพยายาม ทำ lazy loading, infinite scrolling, การคิดกำไรจากการเลื่อนผ่าน ads บนเว็บไซต์ , และ การทำ animation on scroll
ในสมัยก่อนนั้นเวลาเราจะทำสิ่งเหล่านี้ เราต้องไปใช้ onScroll event และ listen มันตลอดว่า หาก ผู้ใช้งานเลื่อนจนถึงจุดที่เราตั้งค่า ไว้เนี้ย ค่อย trigger เหตุการณ์ต่าง ๆ เช่น โชว์ รูปภาพ, เล่น animation, query ข้อมูลใหม่ หรือ คำนวณเงินจากการมองเห็นโฆษณา
ซึ่ง การทำดังกล่าวสร้างปัญหาอย่างนึงก็คือ ทำให้ การแสดงผลช้า และอาจจะทำให้เว็บไซต์ของเราช้าตามไปด้วยเช่นกันเนื่องจากว่า มันไม่ใช่สิ่งที่มีอยู่ดั้งเดิมบน เบราวเซอร์
แม้กระทั่ง พวก library ที่จะ โชว์ ad ก็เช่นกัน เวลาขึ้นมาพร้อมกันหลายๆ อัน ด้านใน ก็จะเรียก ฟังก์ชั่นที่คอย ตรวจเช็คค่า ว่ามีการเลื่อนถึงแล้วหรือยัง คุณลองนึกภาพว่า มีซัก 10 อันดูสิ แค่นี้ก็ทำให้ผู้ใช้งานรู้สึกเหนื่อยหนายและท้อแท้ต่อการใช้เว็บไซต์ของคุณแล้ว
การมาของ Intersection Observer API ช่วยให้ เว็บไซต์ของคุณไม่จำเป็นจะต้อง แบ่ง ความสามารถ เพื่อค่อยติดตามพฤติกรรมข้างต้นอีกแล้ว และสามารถนำ ไปใช้ทำอย่างอื่นให้มีประสิทธิภาพมากขึ้น
อย่างไรก็ตาม Intersection Observer API ไม่สามารถระบุได้ในระดับ pixel หรือ บอกว่าให้ดู ณ จุดใด จุดหนึ่ง ตัวมันสามารถเช็คได้เพียงแค่ว่า มีการเลื่อนมาพบวัตถุนี้แล้วประมาณ กี่ % เท่านั้น
🔧 Deep Dive: Intersection Observer API Options & Mechanics
หลังจาก ที่เราได้รู้ที่มาที่ไป ว่ากว่าจะมาเป็น Intersection Observer API เราต้องผ่านอะไรมาบ้าง เรามาเรียนรู้และทำความเข้าใจหลักการทำงานของ API กันดีกว่าครับ
/**
* สร้าง Observer สำหรับติดตามการปรากฏของ Elements บนหน้าจอ
*
* @param {function} callback - ฟังก์ชันที่จะทำงานเมื่อ element ปรากฏ/หายไป
* @param {object} options - ตั้งค่าการทำงาน
* root: พื้นที่ที่จะติดตาม (null = viewport)
* threshold: % ความเห็น [0-1]
* rootMargin: ระยะขอบเพิ่มเติม
*
*
*/
const observer = new IntersectionObserver(callback, options);
โดยการเรียกใช้งาน Intersection Observer API เราต้องเรียกผ่าน constructor และ pass arguments สองตัวได้แก่ callback และ options
const options = {
root: document.querySelector("#scrollArea"),
rootMargin: "0px", // จำเป็นต้องเป็น หน่วย pixel หรือ %
threshold: 1.0,
};
เรามาเริ่มทำความเข้าใจเกี่ยวกับ options กันก่อน ภายใน options นั้นจะประกอบไปด้วย 3 attributes ได้แก่
root , rootMargin และ threshold
root คือ ที่ๆเราจะกำหนดว่า จะใช้ element อะไรเป็น viewport สำหรับเช็คการมองเห็นของ element ที่เราเลือกสังเกต โดยจำเป็นเป็นต้องเป็น ancestor element โดยปกติแล้วจมีค่า default เป็น browser viewport
rootMargin คือ string ที่เราสามารถกำหนด เหมือนเวลาตั้งค่า margin ตั้งค่าเป็น px หรือ % ก็ได้ ใน CSS โดยเราจะใส่ค่าก็ต่อเมื่อเราต้องการเพิ่มหรือลดพื้นที่ในการ เช็คการสังเกตการ โดยปกติ จะมีค่าเป็น 0
threshold คือค่าที่มีไว้เพื่อระบุว่าเมื่อไหร่จะ trigger callback function โดย เราสามารถใส่เป็นค่าตัวเลขเดียว หรือใส่เป็น Array of number ก็ได้เช่นกัน ซึ่งมันจะคิดว่าต้องถึง element
หลังจากที่เราทำความเข้าใจกับ options แล้ว เรามาดู ที่ตัว callback function กันบ้างดีกว่า
ภายใน callback function นั้น เราจะได้ array of object IntersectionObserverEntry และภายในก็จะประกอบไปด้วย method และ properties ต่าง ๆ เช่น
boundingClientRect – method ที่จะคืนค่า กรอบสี่เหลี่ยม ที่เป็น immutable ตามลักษณะของ DOMRectReadOnly โดยคำนวณจากหน้าจอ Viewport และ คิด ค่า border-width และ ค่า padding หากเป็น standard box โดย ค่าที่ return จะประกอบไปด้วย left, top, right, bottom, x, y, width, and height ใช้ในการอธิบายตำแหน่งและขนาดของกล่องสี่เหลี่ยม

intersectionRatio – ค่าที่สามารถใช้บอกว่า element ที่เราทำการ observer อยู่นั้น ปรากฎอยู่บน กรอบของ root element เท่าไหร่ ก็คือ ค่าของ intersectionRect ต่อ ค่า ของ boundingClientRect
intersectionRect – ให้ค่า เหมือนกัน กับ boundingClientRect แต่จะเป็นค่าของ ตัว element ที่เราไป observer อยู่
isIntersecting – ค่า boolean ที่จะบอกว่าเราว่า ขณะนี้ element ที่เรา observer อยู่ ปรากฎบน root rect หรือยัง
rootBounds – ค่า DomRectReadOnly ของ root โดยหากมีการ กำหนด rootMargin ก็จะนำมาคำวณด้วยเช่นกัน
target – ค่า Element ที่เพิ่งมีการ observe ไป เช่น สมมติว่า เราเลื่อนผ่าน object หนึ่ง ค่า root ของเรามันเปลี่ยนไป และไป observe โดน element ดังกล่าว หากเราเลื่อนเข้าไปเจอ ค่าtarget ก็จะเป็นค่านั้น หรือเราเลื่อนออก จาก element นั้นก็จะได้ค่านั้นเช่นกัน
time – เวลาที่ จะบอก ว่า intersect กับ intersection root โดย สัมพันธ์กับ ค่า เวลาใน Intersection Observer
Use Cases
หลังจากที่เราทำความเข้าใจในหลักการแล้วว่า อะไร คือ Intersection Observer API เรามาดู use case scenario ในการทำงานจริงกันดีกว่า โดยเบื้องต้นผมจะโชว์ตัวอย่างเป็น
- infinite scroll
- animate on scroll
- lazy loading
โดยตัวอย่าง ที่จะโชว์นั้นจะเป็น ตัวอย่าง จาก side-project ที่ผมทำครับ
Infinite Scrolling
ตัวอย่างแรกจะเป็นการ ดึงข้อมูลจาก OPENSOURCE API อย่าง The Rick and Morty API ครับ

เรามาเริ่มที่ไฟล์แรกกันดีกว่า ครับนั้นคือ ไฟล์ index.tsx ที่เราใช้ render หน้านี้
import { fetcher } from "../utils/fetcher";
import { Character, PaginatedResponse } from "../types/types";
import useSWR from "swr";
import { useCallback, useEffect, useRef, useState } from "react";
import { useIntersection } from "../hook/useIntersection.ts";
import { CharactersPage } from "../components/CharactersPage.tsx";
export default function Index() {
const loadMoreSection = useRef<HTMLDivElement>(null);
const [allCharacters, setAllCharacters] = useState<Character[]>([]);
const [characterPage, setCharacterPage] = useState<number>(1);
const { data, isLoading } = useSWR<PaginatedResponse<Character>>(
characterPage
? `https://rickandmortyapi.com/api/character?page=${characterPage}`
: null,
{
fetcher: fetcher,
revalidateOnFocus: false, // Prevent revalidation on window focus
revalidateIfStale: false, // Prevent automatic revalidation
}
);
const hanldeLoadMore = useCallback(() => {
if (data?.info.next && !isLoading) {
setCharacterPage((prev) => prev + 1);
}
}, [data?.info.next, isLoading]);
const { observer } = useIntersection(hanldeLoadMore);
useEffect(() => {
if (data?.results) {
setAllCharacters((prev) => [...prev, ...data.results]);
}
}, [data]);
useEffect(() => {
const currentRef = loadMoreSection.current;
if (currentRef && observer) {
observer.observe(currentRef);
return () => {
observer.unobserve(currentRef);
};
}
}, [observer, loadMoreSection]);
if (!data && !isLoading) return "Loading...";
return (
<main className="container mx-auto relative ">
<h1 className="text-red-400 text-3xl text-center py-4">
Welcome to the Rick C-137 Directory app
</h1>
<CharactersPage data={allCharacters} />
<div ref={loadMoreSection}>Load More...</div>
</main>
);
}
จาก โค้ดข้างต้นนะครับ ผมได้ทำการ fetch ข้อมูลใหม่โดยใช้ library SWR มา จากนั้นก็ทำการ เช็คเงื่อนไขตัวแปรที่ได้คืนมาจาก useSWR อย่าง loading และ data ว่ามีข้อมูลหรือป่าว ถ้าไม่มี ให้แสดงหน้า Loading แต่ถ้ามีก็ให้แสดง การ์ด ข้อมูล Rick & Morty
ถึงเวลาพระเอกของเรานั้นคือ custom hook อย่าง useIntersection ที่จะรับฟังก์ชั่น handleLoadMore เข้าไป เพื่อเปลี่ยนค่าตัวแปร page เมื่อเราเลื่อนจนถึงด้านล่างสุดครับ
โดยผมจะทำการเรียก useEffect เพื่อเรียกใช้ ตัวแปรที่ชื่อว่า observer เพื่อให้ตัวแปรนี้ทำการ observe Element ที่ชื่อว่า loadMoreSection หรือ เลิก observe เมื่อเราออกจากหน้านี้
import { useCallback, useEffect, useRef, useState } from "react";
interface IntersectionOptions {
threshold?: number;
rootMargin?: string;
root?: Element | null;
}
export const useIntersection = (
handleLoadMore: () => void,
options: IntersectionOptions = {},
) => {
const [visible, setVisible] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const handleIntersection = useCallback( // ด้วยการใช้ useCallback จะทำให้สร้างฟังก์ชั่นนี้ขึ้นมาใหม่ เมื่อตัวแปรที่เรากำหนดมีการเปลี่ยนเท่านั้น
(entries: IntersectionObserverEntry[]) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setVisible(true);
handleLoadMore();
}
}
},
[handleLoadMore],
);
useEffect(
() => {
const {
threshold = 1.0,
rootMargin = "30px",
root = null,
} = options;
observerRef.current = new IntersectionObserver(handleIntersection, {
threshold,
rootMargin,
root,
});
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
},
[handleIntersection, options],
);
const observer = observerRef.current;
return {
observer,
visible,
};
};
ในส่วนของ useIntersection.ts custom hook นี้จะรับตัวแปร เข้ามา ได้แก่ ฟังก์ชั่น handleLoadMore และ IntersectionObserverOption
สำหรับโค้ดในไฟล์นี้จะมีหลักอยู่สองพาร์ท ก็คือส่วนที่เช็คว่า โค้ดนั้นถึงจุดที่ intersect แล้วหรือยัง กับ การ init ตัวแปร IntersectionObserver ตัวใหม่
ในส่วนของ การเช็คว่า intersect แล้วหรือยังนั้น จากที่เราได้อธิบายไปข้างต้นแล้วว่า ในตัวแปรประเภท IntersectionObserverEntry จะมี isIntersecting อยู่ หรือ หาก มันถึงจุดที่ intersect กันแล้วเราก็จะให้เรียก ฟังก์ชั่น handleLoadMore() เพื่อให้ เรียก ข้อมูลชุดใหม่จาก API
ในส่วนของการ สร้าง Instance ของ Intersection Observer นั้น เรานำไปไว้ใน useEffect เพราะว่า เราต้องการให้ มัน recreate เฉพาะเมื่อมีมีการเปลี่ยนแปลงของ handleIntersection หรือก็คือ ให้ init เฉพาะตอนที่ มีการเลื่อนจนถึงล่างสุดและต้องโหลดหน้าใหม่ ให้เปลี่ยนตัว observe ตัวใหม่นั้นเอง
ส่วนเหตุผลที่บางครั้งการทำ Infinite Scrolling นั้นดีกว่าการทำ Pagination หรือปุ่ม Loadmore นั้นเพราะว่ามันช่วยเพิ่ม User Experience ในการใช้งาน ลองจินตนาการถึงเวลาเล่น Facebook แล้วเรา ไถฟีด จนล่างสุดดูสิ ถ้าเราต้องมาคอยกดปุ่มให้เปลี่ยนหน้า หรือ กดปุ่มเพื่อให้โหลดมันก็จะเสียเวลาในการดูคอนเทนท์ของผู้ใช้งานนั้นเอง
ในกรณีข้างต้นนี้เราจะอธิบายเพิ่มเติบเกี่ยวกับ IntersectionObserver options อย่าง threshold , rootMargin, และ Root
เริ่มจาก RootMargin ถ้าหากเรา ตั้งค่า ให้เป็น 30px หรือมากกว่านั้น จะทำให้แม้ว่าเราจะยังไม่ทันเลื่อนจนถึง จุดที่จะ intersect กับ element ที่ observe ก็ตามมันจะทำการ trigger handleLoadMore ทันที ส่งผลให้เริ่มการ fetch ข้อมูลใหม่แม้ว่าผู้ใช้จะยังไม่ทันเลื่อนจนล่างสุดก็ตาม

จะเห็นว่าเรายังไม่ได้เลื่อนจนชนขอบล่าง ตัว Intersection Observer ก็ทำการโหลด ข้อมูลใหม่รอไว้เรียบร้อยแล้ว
ถัดไปสำหรับ threshold เป็นการ บอกว่าต้องผ่านไปแล้ว คิดเป็นเท่าไหร่ ของ Element นั้นจาก 0-1 จึงจะให้ทำการบอกว่านี้คือการ intersect แล้ว
<div ref={loadMoreSection} className="h-40 w-full bg-red-400">
Load More...
</div>
ผมได้ทำการปรับ ขนาด ของ loadMoreSection เพิ่มเป็น height : 160px และ ให้มีสีแดง จาก โค้ด option ที่เรากำหนดไว้เราบอกว่า Element จะต้องถูก observe ทั้งหมด จึงจะมองว่า intersect

จากภาพเราจะมองเห็นว่าผมยังเลื่อนไม่ถึงสุดขอบจอ หรือ ก็คือ เรายังไม่ได้ เห็น element ครบ 100% มันจึงยังไม่ trigger ให้ fetch ข้อมูลใหม่
ในส่วนของ root นั้น ในตัวอย่างนี้จะ ไม่สามารถโชว์ความแตกต่างได้แต่ถ้าให้ผมอธิบาย ยกตัวอย่างเช่น กล่อง แชท เวลามีคน live ใน ยูทูปก็ได้ครับ เวลาที่เรา เลื่อนภายใน กล่องแชท มันจะ trigger fetch แชทใหม่เรื่อยๆ ในขณะเดียวกัน แม้ว่าเราเลื่อน ดู comment ของ วีดิโอ มันก็จะไม่ trigger อะไรในกล่องนั้นเลย
อย่างไรก็ดี จากโค้ดข้างต้นยังมีข้อบกพร่องคือบางครั้งการที่เรา fetch ข้อมูลใหม่ โดย อาศัยมองแค่ว่า ข้อมูลเปลี่ยนกับ เช็คว่ากำลังโหลด ข้อมูลอยู่ไหม อาจจะทำให้ เกิดบัคได้ เพราะ โดยปกติแล้วเวลาโหลดข้อมูลใหม่ โดยใช้ SWR นั้น จะทำการเปลี่ยนค่า data เป็น null ซึ่งอาจทำให้ การ render นั้นผิดพลาดและส่งผลให้ ไปแสดงค่าเริ่มต้นที่บนสุดแทน
เพราะฉะนั้นเราจึงจำเป็นต้องใช้ ตัวแปรที่จะไม่เปลี่ยนค่า เมื่อมีการ render ใหม่อย่าง useRef เพื่อให้จดจำค่าล่าสุดว่า อยู่ตรงไหนของ scrollbar นั้นเอง
const scrollPositionRef = useRef(0);
const saveScrollPosition = useCallback(() => {
scrollPositionRef.current = window.scrollY;
}, []);
const restoreScrollPosition = useCallback(() => {
if (scrollPositionRef.current) {
window.scrollTo(0, scrollPositionRef.current);
requestAnimationFrame(() => {
if (window.scrollY === scrollPositionRef.current) {
scrollPositionRef.current = 0;
}
});
}
}, []);
const isLoadingRef = useRef(false);
const handleLoadMore = useCallback(() => {
if (data?.info.next && !isLoading && !isLoadingRef.current) {
isLoadingRef.current = true;
saveScrollPosition();
setCharacterPage((prev) => prev + 1);
}
}, [data?.info.next, isLoading, saveScrollPosition]);
const { observer } = useIntersection(handleLoadMore);
useEffect(() => {
if (data?.results) {
setAllCharacters((prev) => {
const newCharacters = data.results.filter(
(newChar) =>
!prev.some((existingChar) => existingChar.id === newChar.id)
);
return [...prev, ...newCharacters];
});
isLoadingRef.current = false;
restoreScrollPosition();
}
}, [data, restoreScrollPosition]);
โดย ทุกครั้งที่เราจะโหลดข้อมูลใหม่ เราจะให้ เรียกใช้งานฟังก์ชั่น saveScrollPosition เพื่อทำการจำ ตำแหน่งสุดท้ายเอาไว้ และ เมื่อมีการ เรียกข้อมูลใหม่ useEffect ก็จะ trigger และทำการเรียกใช้ restoreScrollPosition เพื่อ ให้scroll ไปที่ตำแหน่งนั้นหาก เกิดข้อผิดพลาดในการแสดงผลนั้นเอง
Animating on Scroll
ในตัวอย่างถัดมาจะเป็นการแสดงการแสดง animation บางอย่างเมื่อเราเลื่อน ไป intersect กับ object นั้นๆ

<script lang="ts">
import { onMount } from 'svelte';
export let text = '';
export let delay = 200;
export let once = false;
let isTextRevealed: HTMLElement;
onMount(() => {
const observer = new IntersectionObserver((entries) => {
for (let entry of entries) {
console.log(entry.target);
if (once) {
if (entry.isIntersecting) {
entry.target.classList.add('reveal');
}
} else entry.target.classList.toggle('reveal', entry.isIntersecting);
}
});
observer.observe(isTextRevealed);
return () => {
observer.unobserve(isTextRevealed);
};
});
</script>
<div bind:this={isTextRevealed} class="relative inline-block">
<p id="text" class="opacity-0" style="animation-delay: {delay}ms">
{@html text}
</p>
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div
id="cover"
class="absolute inset-0 bg-current transform-gpu -translate-x-[101%]"
style="animation-delay: {delay}ms"
/>
</div>
</div>
ก็คือจากตัวอย่างนี้ ผมจะเรียกใช้ component ที่ชื่อว่า TextReveal.svelte ซึ่งเมื่อ Browser Viewport ของเราเลื่อนไปจนถึงจุดที่ Intersect กับ Component นี้ก็ให้มัน แสดงตัวอักษรนั้นออกมาเหมือนในวีดิโอข้างต้น
Image Lazy Loading

import { useCallback, useEffect, useRef, useState } from "react";
import { Character } from "../types/types";
import { useIntersection } from "../hook/useIntersection";
interface ICharacterCard {
character: Character;
}
export const CharacterCard: React.FC<ICharacterCard> = ({ character }) => {
const baseUrl = "<https://placehold.co/300x300>";
const imageSection = useRef<HTMLImageElement | null>(null);
const [imageSrc, setImageSrc] = useState<string>(baseUrl); // This is a gray placeholder
const handleLoadImage = useCallback(() => {
setImageSrc(character.image ?? "");
}, [character.image]);
const { observer } = useIntersection(handleLoadImage);
useEffect(() => {
const currentRef = imageSection.current;
if (currentRef && observer) {
observer.observe(currentRef);
return () => {
observer.unobserve(currentRef);
};
}
}, [observer, imageSrc]);
return (
<div className=" bg-slate-400 max-w-xl w-full text-white rounded-xl shadow-2xl">
<div className="flex gap-4">
<img
ref={imageSection}
src={imageSrc}
className="rounded-l-lg"
loading="lazy"
alt={character.name ?? "some unnamed chracter"}
/>
<div className="p-4">
<h3 className="text-2xl font-bold mb-4">{character.name}</h3>
<p>Status: {character.status}</p>
<p>Origin: {character.origin?.name}</p>
<p>Species: {character.species}</p>
<p className="text-slate-900 font-xl">Last Known Area</p>
<p>{character.location?.name}</p>
<p>Gender: {character.gender}</p>
</div>
</div>
</div>
);
};
เราจะทำคล้ายกัน ตัว Infinite Scroll เลยนั้นคือเมื่อเราเลื่อนถึงตำแหน่งที่ภาพนั้นอยู่เราจะค่อยให้มันโหลดรูป ขึ้นมาจากตอนแรกที่ใช้ template รูปมาก่อนนั้นเอง
Compatibility
ในปัจจุบัน สามารถใช้ได้บนทุก เบราว์เซอร์แล้วแต่จะมี บาง method ที่ยังไม่สามารถเรียกใช้งานได้ ทุกท่านสามารถเข้าไปดูโดยคลิกที่ลิงค์นี้เลย Browser compatibility
References
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

Leave a comment