Xây dựng một tiêu đề động với Intersection Observer

theanh

Administrator
Nhân viên
Intersection Observer API là một API JavaScript cho phép chúng ta quan sát một phần tử và phát hiện khi phần tử đó đi qua một điểm đã chỉ định trong một vùng chứa cuộn — thường (nhưng không phải luôn luôn) là khung nhìn — kích hoạt hàm gọi lại.

Intersection Observer có thể được coi là có hiệu suất cao hơn so với việc lắng nghe các sự kiện cuộn trên luồng chính vì nó không đồng bộ và hàm gọi lại sẽ chỉ kích hoạt khi phần tử mà chúng ta đang quan sát đạt đến ngưỡng đã chỉ định, thay vì mỗi khi vị trí cuộn được cập nhật. Trong bài viết này, chúng ta sẽ xem xét một ví dụ về cách chúng ta có thể sử dụng Intersection Observer để xây dựng một thành phần tiêu đề cố định thay đổi khi giao với các phần khác nhau của trang web.

Cách sử dụng cơ bản​

Để sử dụng Intersection Observer, trước tiên chúng ta cần tạo một observer mới, lấy hai tham số: Một đối tượng có các tùy chọn của observer và hàm gọi lại mà chúng ta muốn thực thi bất cứ khi nào phần tử chúng ta đang quan sát (được gọi là mục tiêu observer) giao với gốc (bộ chứa cuộn, phải là tổ tiên của phần tử mục tiêu).
Mã:
const options = { root: document.querySelector('[data-scroll-root]'), rootMargin: '0px', threshold: 1.0}const callback = (entries, observer) => { entries.forEach((entry) => console.log(entry))}const observer = new IntersectionObserver(callback, options)
Khi chúng ta đã tạo observer, sau đó chúng ta cần hướng dẫn nó theo dõi một phần tử mục tiêu:
Mã:
const targetEl = document.querySelector('[data-target]')observer.observe(targetEl)
Bất kỳ giá trị tùy chọn nào cũng có thể bị bỏ qua, vì chúng sẽ trở về giá trị mặc định của chúng:
Mã:
const options = { rootMargin: '0px', threshold: 1.0}
Nếu không chỉ định root, thì nó sẽ được phân loại là chế độ xem của trình duyệt. Ví dụ mã ở trên hiển thị các giá trị mặc định cho cả rootMarginthreshold. Những điều này có thể khó hình dung, vì vậy cần giải thích:

rootMargin

Giá trị rootMargin hơi giống với việc thêm lề CSS vào phần tử gốc — và, giống như lề, có thể nhận nhiều giá trị, bao gồm cả giá trị âm. Phần tử mục tiêu sẽ được coi là giao nhau so với lề.



Điều đó có nghĩa là về mặt kỹ thuật, một phần tử có thể được phân loại là "giao nhau" ngay cả khi nó nằm ngoài tầm nhìn (nếu gốc cuộn của chúng ta là khung nhìn).



rootMargin mặc định là 0px, nhưng có thể lấy một chuỗi bao gồm nhiều giá trị, giống như sử dụng thuộc tính margin trong CSS.

threshold

threshold có thể bao gồm một giá trị đơn lẻ hoặc một mảng các giá trị từ 0 đến 1. Nó biểu thị tỷ lệ phần tử phải nằm trong giới hạn gốc để được coi là giao nhau. Sử dụng giá trị mặc định là 1, lệnh gọi lại sẽ kích hoạt khi 100% phần tử mục tiêu hiển thị trong gốc.



Không phải lúc nào cũng dễ hình dung khi nào một phần tử sẽ được phân loại là có thể nhìn thấy bằng các tùy chọn này. Tôi đã xây dựng một công cụ nhỏ để giúp làm quen với Intersection Observer.

Tạo Header​

Bây giờ chúng ta đã nắm được các nguyên tắc cơ bản, hãy bắt đầu xây dựng tiêu đề động của mình. Chúng ta sẽ bắt đầu với một trang web được chia thành các phần. Hình ảnh này hiển thị bố cục hoàn chỉnh của trang mà chúng ta sẽ xây dựng:



Tôi đã đưa bản demo vào cuối bài viết này, vì vậy bạn có thể thoải mái chuyển thẳng đến đó nếu bạn muốn giải mã. (Ngoài ra còn có kho lưu trữ Github.)

Mỗi phần có chiều cao tối thiểu là 100vh (mặc dù chúng có thể dài hơn, tùy thuộc vào nội dung). Tiêu đề của chúng tôi được cố định ở đầu trang và giữ nguyên vị trí khi người dùng cuộn (sử dụng position: fixed). Các phần có nền màu khác nhau và khi chúng gặp tiêu đề, màu của tiêu đề sẽ thay đổi để bổ sung cho màu của phần đó. Ngoài ra còn có một điểm đánh dấu để hiển thị phần hiện tại mà người dùng đang ở, điểm này sẽ trượt dọc khi phần tiếp theo xuất hiện.Để giúp chúng ta dễ dàng đi thẳng đến mã có liên quan, tôi đã thiết lập một bản demo tối giản với điểm bắt đầu của chúng ta (trước khi chúng ta bắt đầu sử dụng API Intersection Observer), trong trường hợp bạn muốn theo dõi.

Đánh dấu​

Chúng ta sẽ bắt đầu với HTML cho tiêu đề của mình. Đây sẽ là một tiêu đề khá đơn giản với liên kết trang chủ và điều hướng, không có gì đặc biệt cầu kỳ, nhưng chúng ta sẽ sử dụng một vài thuộc tính dữ liệu: data-header cho chính tiêu đề (để chúng ta có thể nhắm mục tiêu vào phần tử bằng JS) và ba liên kết neo với thuộc tính data-link, sẽ cuộn người dùng đến phần có liên quan khi nhấp vào:
Mã:
   [URL=#0]Trang chủ[/URL]  [LIST] 
[*]  [URL=#about-us]Giới thiệu về chúng tôi[/URL]  
[*]  [URL=#flavours]Hương vị[/URL]  
[*]  [URL=#get-in-touch]Liên hệ[/URL]  [/LIST]
Tiếp theo là HTML cho phần còn lại của trang, được chia thành các phần. Để ngắn gọn, tôi chỉ đưa vào các phần có liên quan đến bài viết, nhưng đánh dấu đầy đủ được đưa vào bản demo. Mỗi phần bao gồm một thuộc tính dữ liệu chỉ định tên của màu nền và một id tương ứng với một trong các liên kết neo trong tiêu đề:
Mã:
Chúng ta sẽ định vị tiêu đề bằng CSS sao cho tiêu đề cố định ở đầu trang khi người dùng cuộn:
Mã:
header { position: fixed; width: 100%;}
Chúng ta cũng sẽ cung cấp cho các phần chiều cao tối thiểu và căn giữa nội dung. (Mã này không cần thiết để Intersection Observer hoạt động, nó chỉ dành cho thiết kế.)
Mã:
section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center;}

Cảnh báo iframe​

Trong khi xây dựng bản demo Codepen này, tôi đã gặp phải một vấn đề khó hiểu khi mã Intersection Observer của tôi nên hoạt động hoàn hảo nhưng lại không kích hoạt lệnh gọi lại tại đúng điểm giao nhau mà thay vào đó lại kích hoạt khi phần tử mục tiêu giao nhau với cạnh khung nhìn. Sau một hồi suy nghĩ, tôi nhận ra rằng điều này là do trong Codepen, nội dung được tải trong một khung nhìn, được xử lý khác. (Xem phần tài liệu MDN về Cắt và hình chữ nhật giao nhau để biết đầy đủ chi tiết.)

Để giải quyết vấn đề này, trong bản demo, chúng ta có thể gói mã đánh dấu của mình trong một phần tử khác, phần tử này sẽ đóng vai trò là vùng chứa cuộn — gốc trong các tùy chọn IO của chúng ta — thay vì khung nhìn của trình duyệt, như chúng ta có thể mong đợi:
Mã:
Nếu bạn muốn xem cách sử dụng viewport làm gốc cho cùng một bản demo, thì điều này được bao gồm trong kho lưu trữ Github.

CSS​

Trong CSS, chúng ta sẽ định nghĩa một số thuộc tính tùy chỉnh cho các màu chúng ta đang sử dụng. Chúng ta cũng sẽ định nghĩa hai thuộc tính tùy chỉnh bổ sung cho văn bản tiêu đề và màu nền, và đặt một số giá trị ban đầu. (Chúng tôi sẽ cập nhật hai thuộc tính tùy chỉnh này cho các phần khác nhau sau.)
Mã:
:root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry);}
Chúng tôi sẽ sử dụng các thuộc tính tùy chỉnh này trong tiêu đề của mình:
Mã:
header { background-color: var(--headerBg); color: var(--headerText);}
Chúng tôi cũng sẽ thiết lập màu sắc cho các phần khác nhau của mình. Tôi đang sử dụng các thuộc tính dữ liệu làm bộ chọn, nhưng bạn cũng có thể dễ dàng sử dụng một lớp nếu bạn thích.
Mã:
[data-section="raspberry"] { background-color: var(--raspberry); color: var(--vanilla);}[data-section="mint"] { background-color: var(--mint); color: var(--chocolate);}[data-section="vanilla"] { background-color: var(--vanilla); color: var(--chocolate);}[data-section="chocolate"] { background-color: var(--chocolate); color: var(--vanilla);}
Chúng ta cũng có thể thiết lập một số kiểu cho tiêu đề khi từng phần được xem:
Mã:
/* Tiêu đề */[data-theme="raspberry"] { --headerText: var(--raspberry); --headerBg: var(--vanilla);}[data-theme="mint"] { --headerText: var(--mint); --headerBg: var(--chocolate);}[data-theme="chocolate"] { --headerText: var(--chocolate); --headerBg: var(--vanilla);}
Có một trường hợp mạnh hơn để sử dụng các thuộc tính dữ liệu ở đây vì chúng ta sẽ chuyển đổi thuộc tính data-theme của tiêu đề tại mỗi giao điểm.

Tạo Observer​

Bây giờ chúng ta đã thiết lập HTML và CSS cơ bản cho trang của mình, chúng ta có thể tạo một observer để theo dõi từng phần của mình xuất hiện. Chúng ta muốn kích hoạt lệnh gọi lại bất cứ khi nào một phần tiếp xúc với phần dưới cùng của tiêu đề khi chúng ta cuộn xuống trang. Điều này có nghĩa là chúng ta cần đặt một lề gốc âm tương ứng với chiều cao của tiêu đề.
Mã:
const header = document.querySelector('[data-header]')const sections = [...document.querySelectorAll('[data-section]')]const scrollRoot = document.querySelector('[data-scroller]')const options = { root: scrollRoot, rootMargin: `${header.offsetHeight * -1}px`, threshold: 0}
Chúng ta đang đặt ngưỡng là 0, vì chúng ta muốn nó kích hoạt nếu bất kỳ phần nào của phần này giao với lề gốc.

Trước hết, chúng ta sẽ tạo một lệnh gọi lại để thay đổi giá trị data-theme của tiêu đề. (Điều này đơn giản hơn việc thêm và xóa các lớp, đặc biệt là khi phần tử tiêu đề của chúng ta có thể đã áp dụng các lớp khác.)
Mã:
/* Hàm gọi lại sẽ kích hoạt khi giao nhau */const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) })}
Sau đó, chúng ta sẽ tạo trình quan sát để theo dõi các phần giao nhau:
Mã:
/* Tạo trình quan sát */const observer = new IntersectionObserver(onIntersect, options)/* Đặt trình quan sát của chúng ta để quan sát từng phần */sections.forEach((section) => { observer.observe(section)})
Bây giờ, chúng ta sẽ thấy màu tiêu đề được cập nhật khi từng phần giao với tiêu đề.

Xem Bút [Happy Face Ice Cream Parlour – Bước 2](https://codepen.io/smashingmag/pen/poPgpjZ) của Michelle Barker.
Xem Bút Happy Face Ice Cream Parlour – Bước 2 của Michelle Barker.
Tuy nhiên, bạn có thể nhận thấy rằng màu sắc không cập nhật chính xác khi chúng ta cuộn xuống. Trên thực tế, tiêu đề đang cập nhật với màu sắc của phần trước mỗi lần! Ngược lại, khi cuộn lên trên, nó hoạt động hoàn hảo. Chúng ta cần xác định hướng cuộn và thay đổi hành vi cho phù hợp.

Tìm hướng cuộn​

Chúng ta sẽ đặt một biến trong JS cho hướng cuộn, với giá trị ban đầu là 'up' và một biến khác cho vị trí cuộn đã biết cuối cùng (prevYPosition). Sau đó, trong lệnh gọi lại, nếu vị trí cuộn lớn hơn giá trị trước đó, chúng ta có thể đặt giá trị direction'down' hoặc 'up' nếu ngược lại.
Mã:
let direction = 'up'let prevYPosition = 0const setScrollDirection = () => { if (scrollRoot.scrollTop = > prevYPosition) { direction = 'down' } else { direction = 'up' } prevYPosition = scrollRoot.scrollTop}const onIntersect = (entries, observer) => { entries.forEach((entry) => { setScrollDirection() /* ... */ })}
Chúng ta cũng sẽ tạo một hàm mới để cập nhật màu tiêu đề, truyền vào phần mục tiêu dưới dạng đối số:
Mã:
const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme)}const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) })}
Cho đến nay, chúng ta không thấy có thay đổi nào đối với hành vi của tiêu đề. Nhưng bây giờ chúng ta đã biết hướng cuộn, chúng ta có thể truyền vào một mục tiêu khác cho hàm updateColors() của mình. Nếu hướng cuộn là hướng lên, chúng ta sẽ sử dụng mục tiêu mục nhập. Nếu nó ở dưới, chúng ta sẽ sử dụng phần tiếp theo (nếu có).
Mã:
const getTargetSection = (target) => { if (direction === 'up') return target if (target.nextElementSibling) { return target.nextElementSibling } else { return target }}const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() const target = getTargetSection(entry.target) updateColors(target) })}
Tuy nhiên, vẫn còn một vấn đề nữa: tiêu đề sẽ cập nhật không chỉ khi phần chạm vào tiêu đề mà còn khi phần tử tiếp theo xuất hiện ở cuối khung nhìn. Điều này là do người quan sát của chúng ta kích hoạt lệnh gọi lại hai lần: một lần khi phần tử đang vào và một lần nữa khi phần tử đang rời khỏi.

Để xác định xem tiêu đề có nên cập nhật hay không, chúng ta có thể sử dụng khóa isIntersecting từ đối tượng entry. Hãy tạo một hàm khác để trả về giá trị boolean cho biết màu tiêu đề có nên cập nhật hay không:
Mã:
const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false}
Chúng ta sẽ cập nhật hàm onIntersect() của mình cho phù hợp:
Mã:
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() /* Không làm gì nếu không cần cập nhật */ if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) })}
Bây giờ màu sắc của chúng ta sẽ cập nhật chính xác. Chúng ta có thể thiết lập một chuyển đổi CSS để hiệu ứng đẹp hơn một chút:
Mã:
header { transition: background-color 200ms, color 200ms;}
Xem Bút [Tiệm Kem Happy Face – Bước 3](https://codepen.io/smashingmag/pen/bGWEaEa) của Michelle Barker.
Xem Bút Tiệm Kem Happy Face – Bước 3 của Michelle Barker.

Thêm Hiệu ứng Động Đánh dấu​

Tiếp theo, chúng ta sẽ thêm một đánh dấu vào tiêu đề, đánh dấu này sẽ cập nhật vị trí của nó khi chúng ta cuộn đến các phần khác nhau. Chúng ta có thể sử dụng một phần tử giả cho việc này, vì vậy chúng ta không cần thêm bất cứ thứ gì vào HTML của mình. Chúng ta sẽ cung cấp cho nó một số kiểu CSS đơn giản để định vị nó ở góc trên bên trái của tiêu đề và cung cấp cho nó một màu nền. Chúng ta đang sử dụng currentColor cho việc này, vì nó sẽ lấy giá trị của màu văn bản tiêu đề:
Mã:
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor;}
Chúng ta có thể sử dụng một thuộc tính tùy chỉnh cho chiều rộng, với giá trị mặc định là 0. Chúng ta cũng sẽ sử dụng một thuộc tính tùy chỉnh cho giá trị translate x. Chúng ta sẽ thiết lập các giá trị cho những giá trị này trong hàm gọi lại khi người dùng cuộn.
Mã:
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; width: var(--markerWidth, 0); background-color: currentColor; transform: translate3d(var(--markerLeft, 0), 0, 0);}
Bây giờ chúng ta có thể viết một hàm sẽ cập nhật chiều rộng và vị trí của điểm đánh dấu tại điểm giao nhau:
Mã:
const updateMarker = (target) => { const id = target.id /* Không làm gì nếu không có ID mục tiêu */ if (!id) return /* Tìm liên kết điều hướng tương ứng hoặc sử dụng liên kết đầu tiên */ let link = headerLinks.find((el) => { return el.getAttribute('href') === `#${id}` }) link = link || headerLinks[0] /* Lấy các giá trị và thiết lập các thuộc tính tùy chỉnh */ const distanceFromLeft = link.getBoundingClientRect().left header.style.setProperty('--markerWidth', `${link.clientWidth}px`) header.style.setProperty('--markerLeft', `${distanceFromLeft}px`)}
Chúng ta có thể gọi hàm này cùng lúc cập nhật màu sắc:
Mã:
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) })}
Chúng ta cũng cần đặt vị trí ban đầu cho điểm đánh dấu, để nó không xuất hiện đột ngột. Khi tài liệu được tải, chúng ta sẽ gọi hàm updateMarker(), sử dụng phần đầu tiên làm mục tiêu:
Mã:
document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) }})
Cuối cùng, hãy thêm một chuyển tiếp CSS để điểm đánh dấu trượt qua tiêu đề từ liên kết này sang liên kết tiếp theo. Khi chúng ta đang chuyển đổi thuộc tính width, chúng ta có thể sử dụng will-change để cho phép trình duyệt thực hiện tối ưu hóa.
Mã:
header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width;}

Cuộn mượt​

Để hoàn thiện, thật tuyệt nếu khi người dùng nhấp vào liên kết, họ sẽ được cuộn mượt mà xuống trang, thay vì nhảy đến phần đó. Ngày nay, chúng ta có thể thực hiện ngay trong CSS của mình, không cần JS! Để có trải nghiệm dễ tiếp cận hơn, bạn nên tôn trọng sở thích chuyển động của người dùng bằng cách chỉ triển khai cuộn mượt mà nếu họ chưa chỉ định sở thích giảm chuyển động trong cài đặt hệ thống của mình:
Mã:
@media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; }}

Bản demo cuối cùng​

Kết hợp tất cả các bước trên lại với nhau sẽ cho ra bản demo hoàn chỉnh.

Xem Bút [Ví dụ về Quán kem Happy Face – Intersection Observer](https://codepen.io/smashingmag/pen/XWRXVXQ) của Michelle Barker.
Xem Bút Ví dụ về Quán kem Happy Face – Intersection Observer của Michelle Barker.

Hỗ trợ trình duyệt​

Intersection Observer được hỗ trợ rộng rãi trong các trình duyệt hiện đại. Khi cần thiết, nó có thể được polyfilled cho các trình duyệt cũ hơn — nhưng tôi thích áp dụng phương pháp cải tiến dần dần khi có thể. Trong trường hợp tiêu đề của chúng tôi, việc cung cấp một phiên bản đơn giản, không thay đổi cho các trình duyệt không hỗ trợ sẽ không gây ảnh hưởng nhiều đến trải nghiệm của người dùng.

Để phát hiện Intersection Observer có được hỗ trợ hay không, chúng tôi có thể sử dụng lệnh sau:
Mã:
if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { /* Mã để thực thi nếu IO được hỗ trợ */} else { /* Mã để thực thi nếu không được hỗ trợ */}

Tài nguyên​

Đọc thêm về Intersection Observer:
  • Tài liệu mở rộng, với một số ví dụ thực tế từ MDN
  • Intersection Observer công cụ trực quan hóa
  • Khả năng hiển thị phần tử thời gian với Intersection Observer API – một hướng dẫn khác từ MDN, hướng dẫn cách sử dụng IO để theo dõi khả năng hiển thị quảng cáo
  • Bài viết này của Denys Mishunov đề cập đến một số cách sử dụng khác cho IO, bao gồm cả tài sản tải chậm. Mặc dù hiện tại ít cần thiết hơn (nhờ thuộc tính loading), nhưng vẫn còn nhiều điều cần tìm hiểu ở đây.

Đọc thêm​

  • Tạo hoạt ảnh UI có thể truy cập
  • Kiểu điều khiển biểu mẫu nâng cao với Selectmenu và API neo
  • Tạo biểu mẫu nhiều bước hiệu quả để có trải nghiệm người dùng tốt hơn
  • Cuộc chiến giành luồng chính
 
Back
Bên trên