工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

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

    Authors
    • avatar
      Name
      Alex Yu

    上週在修改一個 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 的健康檢查告警,可能要等到使用者回報才會發現服務出問題。監控和告警真的是不可或缺的一環。

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

    參考資料