[ReactDoc] useRef, Refs and the DOM
資料來源:
- Refs and the Dom @ React Docs
- Referencing Values with Refs @ React Docs beta
- Manipulating the DOM with Refs @ React Docs beta
重要觀念
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
會在 componentDidMount 或 componentDidUpdate 之前更新。
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)} />;
}
}
參考
- Refs and the DOM @ React Doc