工程師與貓
Published on

Next.js IntersectionObserver 導致 ALB 健康檢查失敗問題

Authors
  • avatar
    Name
    Alex Yu
    Twitter

上週在修改一個 Next.js 專案的 custom hook 時,原本只是想調整一個使用 IntersectionObserver 的實作,結果部署到測試環境後發現 AWS 的 Application Load Balancer (ALB) 健康檢查持續失敗,服務被標記為 unhealthy。還好是在測試環境就發現了,沒有影響到正式環境。追查後才發現是因為在 server 端執行時 IntersectionObserver 不存在,導致 Node.js 拋出錯誤。這篇文章記錄這次除錯的過程和解決方案。

問題現象

部署新版本後,ALB 監控介面顯示目標群組中有 1 個 unhealthy 的實例,健康檢查持續失敗。

ALB Status
Load Balancer Unhealthy

檢查 Next.js server 的 log 後發現大量的錯誤訊息:

{"message":"ReferenceError: IntersectionObserver is not defined"}

這個錯誤導致 server 回傳錯誤響應給 Nginx,進而讓 ALB 的健康檢查認為服務異常。

問題根源

問題出在原本的 useIntersectionObserver hook 中,在模組載入時就直接初始化了 IntersectionObserver

// 有問題的程式碼
'use client'
import { RefObject, useEffect, useRef, useState } from 'react'

const useIntersectionObserver = (
  elementRef: RefObject<Element>,
  { threshold = 0, root = null, rootMargin = '0px' }: IntersectionObserverInit,
): boolean => {
  const [isIntersecting, setIntersecting] = useState<boolean>(false)

  const observerParams = { threshold, root, rootMargin }

  // ❌ 問題:在 useRef 初始化時直接 new IntersectionObserver
  const observer = useRef<IntersectionObserver>(
    new IntersectionObserver(
      ([entry]) => setIntersecting(entry.isIntersecting),
      observerParams,
    ),
  )

  useEffect(() => {
    const hasIOSupport = !!window.IntersectionObserver
    const currentObserver = observer.current
    const currentTarget = elementRef?.current

    if (!hasIOSupport || !currentTarget) return

    if (currentTarget) {
      currentObserver.observe(currentTarget)
    }

    return () => {
      if (currentTarget) {
        currentObserver.unobserve(currentTarget)
      }
    }
  }, [root, rootMargin, elementRef, threshold])

  return isIntersecting
}

為什麼會出錯?

這個問題最容易被誤解的地方是:明明已經在檔案頂部標記了 'use client',為什麼還會在 server 端執行?

關鍵在於理解 'use client' 並不代表程式碼完全不會在 server 端執行。它只是告訴 Next.js 這個元件需要在客戶端渲染,但模組的初始化程式碼仍然會在 server 端執行。

在我們的程式碼中:

const observer = useRef<IntersectionObserver>(
  new IntersectionObserver(...)  // ← 這行會在模組載入時就執行!
)

這個 new IntersectionObserver(...) 是作為 useRef 的初始值,它會在模組被 import 時就執行,而這個時機點在 Next.js 的 Server-Side Rendering (SSR) 過程中還在 server 端的 Node.js 環境。

相對的,useEffect 內的程式碼才是真正「只在客戶端執行」的地方,因為 useEffect 是在元件掛載到 DOM 之後才會被呼叫。

執行流程如下:

有問題的流程:

  1. Server 端:模組被載入 → useRef(new IntersectionObserver(...)) 執行 → ❌ 錯誤
  2. 因為 Node.js 環境沒有 IntersectionObserver,拋出 ReferenceError
  3. Server 回傳錯誤響應 → Nginx 接收錯誤 → ALB 健康檢查失敗

修正後的流程:

  1. Server 端:模組被載入 → useRef(null) 執行 → ✅ 安全
  2. 客戶端:元件掛載 → useEffect 執行 → new IntersectionObserver(...) 執行 → ✅ 成功

解決方案

解決這個問題的關鍵是延遲 IntersectionObserver 的初始化,確保它只在 useEffect 內被建立。以下是修復後的程式碼:

'use client'
import { RefObject, useEffect, useRef, useState } from 'react'

const useIntersectionObserver = (
  elementRef: RefObject<Element>,
  { threshold = 0, root = null, rootMargin = '0px' }: IntersectionObserverInit,
): boolean => {
  const [isIntersecting, setIntersecting] = useState<boolean>(false)

  // ✅ 修正:初始化為 null,不直接建立 IntersectionObserver
  const observer = useRef<IntersectionObserver | null>(null)

  useEffect(() => {
    // ✅ 修正:加上 typeof window 檢查
    const hasIOSupport =
      typeof window !== 'undefined' && !!window.IntersectionObserver
    const currentTarget = elementRef?.current

    if (!hasIOSupport || !currentTarget) return

    // ✅ 修正:在 useEffect 內才建立 IntersectionObserver
    if (!observer.current) {
      observer.current = new IntersectionObserver(
        ([entry]) => setIntersecting(entry.isIntersecting),
        { threshold, root, rootMargin },
      )
    }

    const currentObserver = observer.current
    currentObserver.observe(currentTarget)

    return () => {
      currentObserver.unobserve(currentTarget)
    }
  }, [root, rootMargin, elementRef, threshold])

  return isIntersecting
}

修改重點

這次修正主要有三個關鍵改動:

  1. useRef 初始值改為 null: 不在模組載入時就建立 IntersectionObserver
  2. 加上 typeof window !== 'undefined' 檢查: 確保在瀏覽器環境中
  3. useEffect 內延遲初始化: 只有在客戶端執行時才建立 observer

這樣就能確保 IntersectionObserver 只會在瀏覽器環境的 useEffect 中被建立,避免在 server 端執行時出錯。

後記

這次的問題讓我重新認識了 Next.js 中 'use client' 的真正意義。原本以為標記了 'use client' 就可以安心使用瀏覽器 API,沒想到模組初始化的時機點仍然在 server 端。

其實類似的問題不只會發生在 IntersectionObserver,其他瀏覽器專屬的 API(如 windowdocumentlocalStorage 等)都可能遇到同樣的狀況。關鍵在於理解程式碼的執行時機:

  • useRef 的初始值會在模組載入時執行(server + client 都會)
  • useEffect 內的程式碼只在元件掛載後執行(僅 client)

另外,這次問題雖然只是一個小小的 hook 修改,卻影響到整個服務的可用性。還好我們有完整的測試環境,在正式上線前就發現了這個問題,不然影響範圍就大了。這也再次驗證了測試環境和分階段部署的重要性。

最後,健康檢查機制在這次事件中扮演了關鍵角色,讓我們能快速發現異常並回溯問題。如果沒有 ALB 的健康檢查告警,可能要等到使用者回報才會發現服務出問題。監控和告警真的是不可或缺的一環。

希望這篇文章能幫助到遇到類似問題的開發者!

參考資料