React 回調 Refs:它們是什麼以及如何使用

在開發過程中,我們經常需要直接與 DOM 元素互動。在這種情況下,React 提供了一種機制,稱為 refs,它允許在元素渲染後訪問這些元素。最常見的是通過 useRef 使用標準對象 refs(我們就這麼稱呼它們),但還有另一種稱為 callback refs 的方法。這種方法提供了額外的靈活性和對元素生命週期的控制,使我們能夠在元素附加或從 DOM 中移除的精確時刻執行某些特定操作。

在這篇文章中,我想解釋什麼是 callback refs 及其如何運作,討論您可能遇到的陷阱,並展示它們的使用範例。

什麼是 Callback Refs 及其如何運作?

與對象 refs 相比,callback refs 使您對 ref 附加擁有更細緻的控制。我們來看看它們在實踐中的運作方式:

  1. 掛載。當一個元素掛載到 DOM 中時,React 會調用 ref 函數並傳入 DOM 元素本身。這使您能夠在元素出現在頁面上後立即對其執行操作。
  2. 卸載。當一個元素卸載時,React 會調用 ref 函數並傳入 null。這讓您有機會清理或取消與該元素相關的任何操作。

範例:跟蹤掛載和卸載

TypeScript

 

import React, { useCallback, useState } from 'react';

function MountUnmountTracker() {
  const [isVisible, setIsVisible] = useState(false);

  const handleRef = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Element mounted:', node);
    } else {
      console.log('Element unmounted');
    }
  }, []);

  return (
    <div>
      <button onClick={() => setIsVisible((prev) => !prev)}>
        {isVisible ? 'Hide' : 'Show'} element
      </button>
      {isVisible && <div ref={handleRef}>Tracked element</div>}
    </div>
  );
}

export default MountUnmountTracker;

每次切換元素的可見性時,都會呼叫handleRef函數,傳入節點或null,讓我們能夠追蹤元素附加或分離的時刻。

常見問題和解決方案

問題:重複回調引用調用

在使用回調引用時經常出現的問題是在每次重新渲染組件時重複創建引用函數。因此,React認為這是一個新的引用,用null(清理它)調用舊的引用,然後初始化新的引用 — 即使我們的元素或組件實際上並未改變。這可能導致意想不到的副作用。

問題示例

考慮一個具有用於切換div可見性的按鈕和用於強制重新渲染組件的另一個按鈕的Basic組件,其中包含一個回調引用:

TypeScript

 

import React, { useState, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = (node: HTMLDivElement | null) => {
    console.log('div', node);
  };

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

每次單擊重新渲染按鈕時,組件都會重新渲染,創建一個新的refCallback函數。結果,React調用舊的refCallback(null),然後調用新的refCallback(node),即使我們帶有引用的元素未發生變化。在控制台中,您會看到依次是div null,然後是div [node]。顯然,我們通常希望避免這樣的不必要調用。

解決方案:使用useCallback對回調引用進行記憶化

避免這種情況非常簡單:只需使用useCallback對函數進行記憶化。這樣,該函數在重新渲染時保持不變,除非其依賴發生變化。

TypeScript

 

import React, { useState, useCallback, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = useCallback((node: HTMLDivElement | null) => {
    console.log('div', node);
  }, []);

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

現在,refCallback僅在初始渲染時創建一次。它在後續重新渲染時不會觸發額外的調用,從而防止不必要的回調並提高性能。

回調 Ref、useLayoutEffect 和 useEffect 的執行順序

在我們討論如何在代碼中使用回調 Ref 來解決特定問題之前,讓我們了解回調 Ref 如何與 useEffectuseLayoutEffect 鉤子互動,以便您可以適當地組織資源初始化和清理。

執行順序

  1. 回調 Ref – 在渲染 DOM 元素後立即調用,執行效果鉤子之前
  2. useLayoutEffect – 在所有 DOM 變異之後運行,但在瀏覽器繪製之前運行
  3. useEffect – 在組件完成對屏幕的渲染後運行
TypeScript

 

import React, { useEffect, useLayoutEffect, useCallback } from 'react';

function WhenCalled() {
  const refCallback = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Callback ref called for div:', node);
    } else {
      console.log('Callback ref detached div');
    }
  }, []);

  useLayoutEffect(() => {
    console.log('useLayoutEffect called');
  }, []);

  useEffect(() => {
    console.log('useEffect called');
  }, []);

  return (
    <div>
      <div ref={refCallback}>Element to watch</div>
    </div>
  );
}

export default WhenCalled;

控制台輸出

  1. 為 div 調用回調 Ref: [div 元素]
  2. 調用 useLayoutEffect
  3. 調用 useEffect

這個順序告訴我們,回調 Ref 在像 useLayoutEffectuseEffect 這樣的鉤子之前被觸發,這在編寫邏輯時是必須牢記的。

回調 Ref 在代碼中解決了哪些問題?

首先,讓我們重現通常使用常規對象 Ref 遇到的問題,以便然後用回調 Ref 來解決它。

TypeScript

 

import { useCallback, useEffect, useRef, useState } from 'react';

interface ResizeObserverOptions {
  elemRef: React.RefObject<HTMLElement>;
  onResize: ResizeObserverCallback;
}

function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
  useEffect(() => {
    const element = elemRef.current;

    if (!element) {
      return;
    }

    const resizeObserver = new ResizeObserver(onResize);

    resizeObserver.observe(element);

    return () => {
      resizeObserver.unobserve(element);
    };
  }, [onResize, elemRef]);
}

export function UsageDom() {
  const [bool, setBool] = useState(false);
  const elemRef = useRef<HTMLDivElement>(null);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  useResizeObserver({ elemRef, onResize: handleResize });

  const renderTestText = () => {
    if (bool) {
      return <p ref={elemRef}>Test text</p>;
    }

    return <div ref={elemRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

我們不會在這裡深入每個細節。簡而言之,我們通過 ResizeObserver 來跟踪我們的 divp 元素的大小。最初,一切運行正常:在掛載時,我們可以獲取元素的大小,並且調整大小的變化也在控制台中報告。

真正的麻煩開始於我們切換狀態以更換我們正在觀察的元素。當我們改變狀態並因此替換被跟蹤的元素時,我們的 ResizeObserver 不再正常工作。它依然在監視第一個元素,而該元素已經從 DOM 中移除!即使切換回原始元素也沒有幫助,因為對新元素的訂閱從未正確附加。

注意:以下解決方案更具代表性,適合在庫的上下文中編寫,需要通用代碼。在實際項目中,您可能通過標誌、效果等的組合來解決這個問題。但在庫代碼中,您並不知道具體的組件或其狀態。這正是回調引用可以幫助我們的場景。

TypeScript

 

import { useCallback, useRef, useState } from 'react';

function useResizeObserver(onResize: ResizeObserverCallback) {
  const roRef = useRef<ResizeObserver | null>(null);

  const attachResizeObserver = useCallback(
    (element: HTMLElement) => {
      const resizeObserver = new ResizeObserver(onResize);
      resizeObserver.observe(element);
      roRef.current = resizeObserver;
    },
    [onResize]
  );

  const detachResizeObserver = useCallback(() => {
    roRef.current?.disconnect();
  }, []);

  const refCb = useCallback(
    (element: HTMLElement | null) => {
      if (element) {
        attachResizeObserver(element);
      } else {
        detachResizeObserver();
      }
    },
    [attachResizeObserver, detachResizeObserver]
  );

  return refCb;
}

export default function App() {
  const [bool, setBool] = useState(false);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  const resizeRef = useResizeObserver(handleResize);

  const renderTestText = () => {
    if (bool) {
      return <p ref={resizeRef}>Test text</p>;
    }

    return <div ref={resizeRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

如您所見,我們重寫了我們的 useResizeObserver 鉤子以使用回調引用。我們只需傳遞一個(記憶化的)回調,該回調應在調整大小時觸發,無論我們切換元素多少次,我們的調整大小回調仍然有效。這是因為觀察者在我們希望的確切時間附加到新元素並從舊元素分離,這得益於回調引用。

這裡的主要好處是使用我們的 hook 的開發者不再需要擔心在底層添加/移除觀察者的邏輯 — 我們已經將這些邏輯封裝在 hook 內部。開發者只需將回調傳遞給我們的 hook,並將其返回的 ref 附加到他們的元素上。

將多個 refs 合併為一個

這裡有另一個場景,回調 refs 可以派上用場:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={ref} />;
});

export function UsageWithoutCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

在上面的代碼中,我們有一個簡單的輸入組件,我們在其上設置了一個 ref,通過 forwardRef 從 props 中獲取它。但是如果我們還需要外部的 ref 來做一些像聚焦的事情,我們該如何在 Input 組件內使用 inputRef?也許我們想在輸入組件內部做些其他事情,例如 getBoundingClientRect。用我們的內部 ref 替換 props ref 意味著從外部聚焦將不再有效。那么,我們該如何合併這兩個 refs 呢?

這時回調 refs 再次提供了幫助:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

type RefItem<T> =
  | ((element: T | null) => void)
  | React.MutableRefObject<T | null>
  | null
  | undefined;

function useCombinedRef<T>(...refs: RefItem<T>[]) {
  const refCb = useCallback((element: T | null) => {
    refs.forEach((ref) => {
      if (!ref) {
        return;
      }

      if (typeof ref === 'function') {
        ref(element);
      } else {
        ref.current = element;
      }
    });
  }, refs);

  return refCb;
}

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);
  const combinedInputRef = useCombinedRef(ref, inputRef);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={combinedInputRef} />;
});

export function UsageWithCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

解釋

我們實現了一個 useCombinedRef hook,開發者可以傳遞標準 refs、回調 refs,以及可選的 nullundefined。這個 hook 本身只是一個 useCallback,它遍歷 refs 陣列。如果參數是 null,我們將忽略它,但如果 ref 是一個函數,我們會用元素調用它;如果它是一個標準的 ref 對象,我們會將 ref.current 設置為該元素。

這樣,我們將多個引用合併為一個。在上面的例子中,Input 組件內的 getBoundingClientRect 和外部焦點調用都能正常工作。

React 19 中關於回調引用的變更是什麼?

自動清理

React 現在會自動處理 回調引用的清理,當元素卸載時,這使得資源管理變得更簡單。以下是 React 團隊在其文檔中顯示的範例:

TypeScript

 

 {
    // 創建引用

    // 新增:返回一個清理函數以重置
    // 當元素從 DOM 中移除時的引用。
    return () => {
      // 引用清理
    };
  }}
/>

您可以在 官方博客文章 中閱讀更多細節,其中提到不久後通過 null 來清理引用可能會被棄用,留下單一標準化的方式來清理引用。

在普通引用與回調引用之間的選擇

  • 當您只需要簡單訪問 DOM 元素或希望在渲染之間保留某些值而不需要附加的附加或分離操作時,使用標準引用 (useRef)。
  • 當您需要更精細地控制元素的生命周期、當您正在編寫通用代碼(例如,在您自己的庫或包中)或當您需要一起管理多個引用時,使用回調引用。

結論

在React中,回調參考是一個強大的工具,為開發人員在處理DOM元素時提供了額外的靈活性和控制。在大多數情況下,通過useRef使用標準物件參考就足夠應對日常任務,但回調參考可以幫助處理像上面討論的更複雜的情況。

Source:
https://dzone.com/articles/react-callback-refs-guide