跳至主要内容

[ReactDoc] useRef, Refs and the DOM

資料來源:

重要觀念

ref 主要有兩個用途:

  • 用來保存 DOM 節點,例如,用來控制元素的 focus, text selection 或 media 播放事件;或與第三方的 DOM 套件整合

  • 用來保存不需要觸發 re-render 的資料,例如 timeout IDs、DOM 等等。

在 React 中,每次更新都包含兩個階段

  • rendering:React 透過呼叫元件(functional component)來理解有哪些東西應該要呈現在畫面上
  • commit:實際將變更套用到 DOM 上

普遍來說,不要在 rendering 的時候嘗試讀取或寫入 ref.current

  • 在第一次 render 時,因為 DOM nodes 還沒被建立,因此 ref.current 會是 null,而元件在更新時,DOM nodes 也還沒被更新完成
  • React 會在 commit 階段才來設定 ref.current;在更新 DOM 之前,React 則是會先將受影響的 ref.current 設為 null
提示

不要在 rendering 的時候嘗試讀取或寫入 ref.current,如果有資料是需要在 rendering 是被使用,請使用 state。(除了有些時候會使用 if(!ref.current) ref.current = new Thing() 這個情況例外)。

基本使用:useRef

一般來說,因為 function component 中並沒有 this 實例,因此通常是無法使用 ref 的,例如:

// https://beta.reactjs.org/learn/manipulating-the-dom-with-refs#example-focusing-a-text-input

import { useRef } from 'react';

export default function Form() {
// STEP 1:使用 useRef 來建立 inputRef
const inputRef = useRef<HTMLInputElement>(null);

function handleClick() {
// STEP 3:即可在 inputRef.current 中取得並使用該 DOM element
inputRef.current?.focus();
}

return (
<>
{/* STEP 2:將 inputRef 帶入 DOM 的 ref 屬性中 */}
<input ref={inputRef} />
<button type="button" onClick={handleClick}>
Focus the input
</button>
</>
);
}

使用 ref Callback

keywords: ref callback, useCallback with ref

有些時候 ref 對象的數量或內容是動態的,沒辦法預先就定義好,這時候可以「將『函式』帶入 <div ref="" /> 的屬性中」,這種方式稱作 "ref callback"。

React 會透過此 ref callback 將 DOM node 設定到 useRef 的 ref 中,並在元件不存在時將此 ref 的值設為 null。如此,後續開發者便可以使用 array 或 Map 的方式將此 DOM 節點保存下來做後續使用。

基本使用

import { useRef, useCallback } from 'react';

const DemoCallbackRef = () => {
// STEP 1: 建立一個保存 node 的 ref
const nodeRef = useRef(null);

// STEP 2: 使用 callback + ref 取得 node
const setTextInput = useCallback((node) => {
console.log('[DemoCallbackRef] useCallback', { node });

// STEP 3: 將 node 設定給 nodeRef,便可將此 ref 保存下來
nodeRef.current = node;
}, []);

// STEP 5: 使用剛剛保存下來的 ref
const handleClick = useCallback(() => {
nodeRef.current.focus();
}, []);

return (
<div>
{/* STEP 4: 透過 ref 帶入 setTextInput */}
<input ref={setTextInput} type="text" />
<button type="button" onClick={handleClick}>
Focus the input
</button>
</div>
);
};

export default DemoCallbackRef;

實際範例

// Modified from https://beta.reactjs.org/learn/manipulating-the-dom-with-refs

import { useRef } from 'react';

type CatItem = {
id: number;
imageUrl: string;
};

const catList: CatItem[] = [];
for (let i = 0; i < 10; i++) {
catList.push({
id: i,
imageUrl: `https://placekitten.com/250/200?image=${i}`,
});
}

export default function CatFriends() {
// STEP 1:使用 ref 建立 itemsRef,預設值是 Map
const itemsRef = useRef<Map<number, HTMLLIElement>>(new Map());

// STEP 2:建立 ref callback
// 以 catId 為 key,HTMLLIElement 為 value 保存在 ref.current 這個 map 中
const setItemsRef = (catId: number) => (node: HTMLLIElement) => {
const map = itemsRef.current;
if (node) {
map.set(catId, node);
} else {
map.delete(catId);
}
};

const handleClick = (catId: number) => () => {
// STEP 4:以 catId 取得特定的 HTMLLIElement 後對其進行操作
const map = itemsRef.current;
const node = map.get(catId);
if (node) {
node.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
};

return (
<>
<nav>
<button type="button" onClick={handleClick(0)}>
Tom
</button>
<button type="button" onClick={handleClick(5)}>
Maru
</button>
<button type="button" onClick={handleClick(9)}>
Jellylorum
</button>
</nav>
<div>
<ul>
{/* STEP 3:透過 ref attribute 呼叫 ref callback */}
{catList.map((cat) => (
<li key={cat.id} ref={setItemsRef(cat.id)}>
<img src={cat.imageUrl} alt={`Cat #${cat.id}`} />
</li>
))}
</ul>
</div>
</>
);
}

useRef 的實作

Referencing Values with Refs: How does useRef work inside? @ React Docs beta

實際上,用 useState 可以很容易的實作出 useRef

const useRef = <T>(initialValue: T) => {
const [ref] = useState<{ current: T }>({
current: initialValue,
});

return ref;
};

什麼時候 React 會把 DOM Node 保存在 ref 中

When React attaches the refs @ React Docs beta

一般來說,你不應該在 rendering 的期間去存取 ref。在第一次 render 時,DOM 節點還沒被建立,所以 ref.current 會是 null,在後續的 re-rendering 期間,因為 DOM 節點還沒被更新,所以可能會沒辦法取用到最新的 DOM nodes。

React 會在 commit 階段把 DOM 節點保存在 ref.current 中,更確切來說,在更新 DOM 之前這個 ref.current 會是 null;在 DOM 更新之後,React 會立即將對應的 DOM 節點保存到 ref.current 中。

暴露 React 元件上的 DOM node:forwardRef

Accesing another component's DOM nodes @ React Docs beta

預設的情況下,如果你對 React 元件(而非 HTML DOM 元素)使用 ref 屬性只會得到 null,例如 <MyComponent ref={myRef} />,這時候 myRef.current 會是 null

這是因為 React 預設不會讓一個 React 元件去取得另一個 React 元件的 DOM nodes。這是 React 刻意的設計,因為一般的情況下都不會需要直接去操作這個 DOM 節點,如此才能避免根本不知道 DOM 是為什麼被改變。

如果真的需要透過 ref 取得 React 元件的 DOM 節點,則需要使用 forwardRef 這個 API(可參考 Forwarding Refs @ pjchender.dev):

// 將欲暴露 DOM node 的 React 元件用 forwardRef 包起來
const MyInput = forwardRef<HTMLInputElement>((props, ref) => {
return <input {...props} ref={ref} />;
});

// 其他 React 元件即可取得該元件的 DOM node
export default function App() {
const inputRef = useRef<HTMLInputElement>(null);

return (
<>
{/* 因為 MyInput 有用 forwardRef 包起來,所以這裡可以取得該元件暴露出來的 DOM node */}
<MyInput ref={inputRef} />
</>
);
}

限制元件 Ref 暴露出的方法:useImperativeHandle

開發者可以透過 forwardRef 來將特定元件的 DOM node 暴露給其他 React 元件使用,但有些時候,我們還想暴露該元件內部已經實作好的一些方法給外層的元件使用,這時候就需要使用 useImperativeHandle

/* eslint-disable react/display-name */
import { forwardRef, useImperativeHandle, useRef } from 'react';

type MyInputHandler = {
focus: () => void;
autoFill: (text?: string) => void;
};

const MyInput = forwardRef((props, ref) => {
const realInputRef = useRef<HTMLInputElement>(null);

useImperativeHandle(ref, () => ({
// 這裡是這個 ref 實際會暴露到外層的方法
focus() {
realInputRef.current?.focus();
},
autoFill(text = 'Hello World') {
if (realInputRef.current) {
realInputRef.current.value = text;
}
},
}));

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

export default function App() {
// inputRef 只能使用 useImperativeHandle 暴露出來的方法
// 這裡也就是 focus 和 autoFill
const inputRef = useRef<MyInputHandler>(null);

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

const handleAutoFill = () => {
inputRef.current?.autoFill('Auto fill value');
};

return (
<>
<MyInput ref={inputRef} />
<button type="button" onClick={handleClick}>
Focus the input
</button>
<button type="button" onClick={handleAutoFill}>
Auto fill
</button>
</>
);
}

常見例子

建立 dedounce 功能的按鈕

// Fix decouncing
// https://beta.reactjs.org/learn/referencing-values-with-refs#challenges
import { FC, ReactNode, useRef } from 'react';

interface Props {
onClick: () => void;
delayTime: number;
children: ReactNode;
}
const DebouncedButton: FC<Props> = ({ onClick, delayTime, children }) => {
const timeoutRef = useRef<NodeJS.Timeout>();

const handleClick = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

timeoutRef.current = setTimeout(() => {
onClick();
}, delayTime);
};

return (
<button type="button" onClick={handleClick}>
{children}
</button>
);
};

[Legacy] ref in class components

建立 Refs

Refs 可以使用 React.createRef() 來建立,並且透過在 React 的 DOM 節點上使用 ref 屬性來產生連結:

class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
render() {
return <div ref={this.myRef} />;
}
}

使用 Refs

可以透過 ref 中的 current 來取得該 DOM 節點(node)的參照:

const node = this.myRef.current;

根據 node 類型的不同,current 會得到不同的內容:

  • ref 使用在一般的 HTML 元素時,current 會是該 DOM 節點(DOM node)。
  • ref 使用在 React 的類別元件(class component)上時,current 會是該元件的實例。
  • 除非是要使用 React.forwardRef,否則你應該不會在 function component 上 ref 屬性。

生命週期:

  • React 會在元件**掛載(mount)時為 current 屬性設定內容,當它解除掛載(unmount)**時則把內容設為 null

  • ref 會在 componentDidMountcomponentDidUpdate 之前更新。

Callback Ref

React 一樣會在元件掛載(mounts)時呼叫此 callback ref(即,this.setTextInputRef);在元件卸載(unmount)時把此 ref 的值設為 null;並在 componentDidMount 或 componentDidUpdate 時確保 Ref 會被更新。

class CustomTextInput extends React.Component {
constructor(props) {
super(props);

// 利用 callback Ref 把取得的 element 保存在 textInputRef 的變數中
this.textInputRef = null;

// Callback Refs
this.setTextInputRef = (element) => {
this.textInputRef = element;
};

this.focusTextInput = () => {
if (this.textInputRef) this.textInputRef.focus();
};
}

componentDidMount() {
// autofocus the input on mount
this.focusTextInput();
}

render() {
return (
<div>
<input type="text" ref={this.setTextInputRef} />
</div>
);
}
}

你同樣可以利用這個方式來達到類似 forwardRef 的效果:

function CustomTextInput(props) {
return (
<div>
<input ref={props.inputRef} />
</div>
);
}

class Parent extends React.Component {
render() {
return <CustomTextInput inputRef={(el) => (this.inputElement = el)} />;
}
}

參考