谷 阿里云開發(fā)者
2024年08月09日 08:30 浙江
阿里妹導(dǎo)讀
你真的用對了 useRef 嗎?在與 TypeScript 一起使用、以及撰寫組件庫的情況下,你的寫法能夠避開以下所有場景的坑嗎?
說到 useRef,相信你一定不會陌生:你可以用它來獲取 DOM 元素,也可以多次渲染之間保持引用不變……
然而,你真的用對了 useRef 嗎?在與 TypeScript 一起使用、以及撰寫組件庫的情況下,你的寫法能夠避開以下所有場景的坑嗎?
場景一:獲取 DOM 元素
以下幾種寫法,哪種是正確的?
function MyComponent() {
// 寫法 1
const ref=useRef();
// 寫法 2
const ref=useRef(undefined);
// 寫法 3
const ref=useRef(null);
// 通過 ref 計算 DOM 元素尺寸
// 這段代碼故意留了坑,坑在哪里?請看下文。
useLayoutEffect(()=> {
const rect=ref.current.getBoundingClientRect();
}, [ref.current]);
return <div ref={ref} />;
}
如果只看 JS,幾種寫法似乎并沒有差別,但如果你開啟了 TS 的類型提示,就能夠發(fā)現(xiàn)其中端倪:
function MyComponent() {
// ? 寫法 1
// 你會得到一個 MutableRefObject<HTMLDivElement | undefined>,
// 即 ref.current 類型是 HTMLDivElement | undefined,
// 這導(dǎo)致你每次獲取 DOM 元素都需要判斷是否為 undefined,很是麻煩。
const ref=useRef<HTMLDivElement>();
// ? 寫法 2.1
// 你可能想得到一個 MutableRefObject<HTMLDivElement>,但初始值傳入的
// undefined 并不是 HTMLDivElement,所以會 TS 報錯。
const ref=useRef<HTMLDivElement>(undefined);
// ? 寫法 2.2
// 等價于寫法 1,但需要多打一些字。
const ref=useRef<HTMLDivElement | undefined>(undefined);
// ? 寫法 3
// 你會得到一個 RefObject<HTMLDivElement>,其中
// ref.current 類型是 HTMLDivElement | null。
// 這個 ref 的 current 是不可從外部修改的,更符合使用場景下的語義,
// 也是 React 推薦的獲取 DOM 元素方式。
// 注意:如果 tsconfig 沒開 strictNullCheck,則不會匹配到這個定義,
// 因此請務(wù)必開啟 strictNullCheck。
const ref=useRef<HTMLDivElement>(null);
// 通過 ref 計算 DOM 元素尺寸
// 這段代碼故意留了坑,坑在哪里?請看下文。
useLayoutEffect(()=> {
const rect=ref.current.getBoundingClientRect();
}, [ref.current]);
return <div ref={ref} />;
}
Ref 還可以傳入一個函數(shù),會把被 ref 的對象應(yīng)用作為參數(shù)傳入,因此我們也可以這樣獲取 DOM 元素:
function MyComponent() {
const [divEl, setDivEl]=useState<HTMLDivElement | null>(null);
// 計算 DOM 元素尺寸
useEffect(()=> {
if (divEl) {
divEl.current.getBoundingClientRect();
}
}, [divEl]);
return <div ref={setDivEl} />;
}
場景二:DOM 元素與 useLayoutEffect
在場景一中,我們留了一個坑,你能看出以下代碼有什么問題嗎?
/* 錯誤案例,請勿照抄 */
function MyComponent({ visible }: { visible: boolean }) {
const ref=useRef<HTMLDivElement>(null);
useLayoutEffect(()=> {
const rect=ref.current.getBoundingClientRect();
// ...
}, [ref.current]);
return <>{visible && <div ref={ref}/>}</>;
}
這段代碼有兩個問題:?
1. useLayoutEffect 中沒有判空
按照場景一中的分析:
useRef<HTMLDivElement>(null) 返回的類型是RefObject<HTMLDivELement>,其ref.current 類型為HTMLDivELement | null。因此單從 TS 類型出發(fā),也應(yīng)該判斷ref.current 是否為空。
你也許會認(rèn)為,我都在 useLayoutEffect 里了,此時組件 DOM 已經(jīng)生成,因而理應(yīng)存在 ref.current,是否可以不用判斷呢?(或用 ! 強制設(shè)為非空)
上述使用場景中,確實可以這樣做,但如果div 是條件渲染的,則無法保證useLayoutEffect 時組件已被渲染,自然也就不一定存在ref.current。
2. useLayoutEffect deps 配置錯誤
這個問題涉及到useLayoutEffect 更本質(zhì)的使用目的。?
useLayoutEffect 的執(zhí)行時機是:
?VDOM 生成后(所有render 執(zhí)行完成);
?DOM 生成后(createElement 等 DOM 操作完成);
?最終提交渲染之前(同步任務(wù)返回前)。?
由于其執(zhí)行時機在 repaint 之前,此時對已生成的 DOM 進行更改,用戶不會看到「閃一下」。舉個例子,你可以計算元素的尺寸,如果太大則修改 CSS 使其自動換行,從而實現(xiàn)溢出檢測。?
另一個常見場景是在useLayoutEffect 中獲取原生組件,用來添加原生 listener、獲取底層HTMLMediaElement 實例來控制播放,或添加ResizeObserver、IntersectionObserver 等。
這里,由于div 是條件渲染的,我們顯然會希望useLayoutEffect 的操作在每次渲染出來之后都執(zhí)行一遍,因此我們會想把ref.current 寫進useLayoutEffect 的dependencies,但這是完全錯誤的。
讓我們盤一下MyComponent 的渲染過程:
1.visible 變化導(dǎo)致觸發(fā) render。
2.useRef 執(zhí)行,ref.current 還是上一次的值。
3.useLayoutEffect 執(zhí)行,對比 dependencies 發(fā)現(xiàn)沒有變化,跳過執(zhí)行。
4.渲染結(jié)果包含div。
5.由于<div ref={ref}>,React 使用新的 DOM 元素更新ref.current。
顯然,這里并沒有再次觸發(fā)useLayoutEffect,直到下一次渲染中才會發(fā)現(xiàn)ref.current 有變化,這背離了我們對于 useLayoutEffect 能讓用戶看不到「閃一下」的預(yù)期。
解決方案是,使用與條件渲染相同的條件作為useLayoutEffect 的 deps:
function MyComponent({ visible }: { visible: boolean }) {
const ref=useRef<HTMLDivElement>(null);
useLayoutEffect(()=> {
// 這里不必額外判斷 if (visible),因為只要這里有 ref.current 那就必然是 visible
if (ref.current) {
const rect=ref.current.getBoundingClientRect();
}
}, [/* ? */ visible]);
// 這樣,在 visible 變化時,就必然會在同一次渲染內(nèi)觸發(fā) useLayoutEffect
return <>{visible && <div ref={ref}/>}</>;
}
// 或者也可以將 <div> 抽取成一個獨立的組件,從而避免上述問題
最后,如果并非是要在 repaint 之前對 DOM 元素進行操作,更推薦的寫法是用函數(shù)寫法:
function MyComponent({ visible }: { visible: boolean }) {
// ? 無需使用 ref
const [video, setVideo]=useState<Video | null>(null);
const play=useCallback(()=> video?.play(), [video]);
// ? 使用普通 useEffect 即可
useEffect(()=> {
console.log(video.currentTime);
}, [video]);
return <>{visible && <video ref={setVideo}/>}</>;
}
場景三:組件中同時傳遞 & 獲取 Ref
——你實現(xiàn)了一個組件,想要將傳入的 ref 傳給組件中渲染的根元素,聽起來很簡單!
哦對了,出于某種原因,你的組件中也需要用到根組件的 ref,于是你寫出了這樣的代碼:
/* 錯誤案例,請勿照抄 */
const MyComponent=forwardRef(
function (
props: MyComponentProps,
// type ForwardedRef<T>=// | ((instance: T | null)=> void)
// | MutableRefObject<T | null>
// | null
// ? 這個工具類型覆蓋了傳 useRef 和傳 setState 的情況,是正確的寫法
ref: ForwardedRef<HTMLDivElement>
) {
useLayoutEffect(()=> {
const rect=ref.current.getBoundingClientRect();
// 使用 rect 進行計算
}, []);
return <div ref={ref}>{/* ... */}</div>;
}
});
等等,如果調(diào)用者沒傳ref 怎么辦?想到這里,你把代碼改成了這樣:
/* 錯誤案例,請勿照抄 */
const MyComponent=forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const localRef=useRef<HTMLDivElement>(null);
useLayoutEffect(()=> {
const rect=localRef.current.getBoundingClientRect();
// 使用 rect 進行計算
}, []);
return <div ref={(el: HTMLDivElement)=> {
localRef.current=el;
if (ref) {
ref.current=el;
}
}}>{/* ... */}</div>;
}
});
這樣的代碼顯然是會 TS 報錯的,因為ref 可能是個函數(shù),本來你只需要把它直接傳給<div> 就好了,因此你需要寫一堆代碼,處理多種可能的情況……
更好的解決方式是使用 react-merge-refs:
import { mergeRefs } from "react-merge-refs";
const MyComponent=forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const localRef=React.useRef<HTMLDivElement>(null);
useLayoutEffect(()=> {
const rect=localRef.current.getBoundingClientRect();
// 使用 rect 進行計算
}, []);
return <div ref={mergeRefs([localRef, ref])} />;
}
);
場景四:組件透出命令式操作
Form 和 Table 這種復(fù)雜的組件往往會在組件內(nèi)維護較多狀態(tài),不適合受控操作,當(dāng)調(diào)用者需要控制組件行為時,往往就會采取這樣的模式:
function MyPage() {
const ref=useRef<FormRef>(null);
return (
<div>
<Button onClick={()=> { ref.current.reset(); }}>重置表單</Button>
<Form actionRef={ref}>{/* ... */}</Form>
</div>
);
}
這種用法實際上脫胎于 class component 時代,人們使用 ref 來獲取 class 實例,通過調(diào)用實例方法來控制組件。
現(xiàn)在,你的超級復(fù)雜絕絕子組件也希望通過這種方式與調(diào)用者交互,于是你寫出了以下實現(xiàn):
/* 錯誤案例,請勿照抄 */
interface MySuperDuperComponentAction {
reset(): void;
}
const MySuperDuperComponent=forwardRef(
function (
props: MySuperDuperComponentProps,
ref: ForwardedRef<MySuperDuperComponentAction>
) {
const action=useMemo((): MySuperDuperComponentAction=> ({
reset() {
// ...
}
}), [/* ... */]);
if (ref) {
ref.current=action;
}
return <div/>;
}
);
然而 TS 不會容許這樣的代碼通過類型檢查,因為調(diào)用者可以函數(shù)作為 ref 來接收 action,這與獲取 DOM 元素時類似。?
正確的做法是,你應(yīng)該使用 React 提供的工具函數(shù)useImperativeHandle:
const MyComponent=forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<MyComponentRefType>
) {
// useImperativeHandle 這個工具函數(shù)會自動處理函數(shù) ref 和對象 ref 的情況,
// 后兩個參數(shù)基本等于 useMemo
useImperativeHandle(ref, ()=> ({
refresh: ()=> {
// ...
},
// ...
}), [/* deps */]);
// 命令式 + 下傳
// 如果你的組件內(nèi)部也會用到這個命令式對象,推薦的寫法是:
const actions=useMemo(()=> ({
refresh: ()=> {
// ...
},
}), [/* deps */]);
useImperativeHandle(ref, ()=> actions, [actions]);
return <div/>;
}
);
場景五:組件 TS 導(dǎo)出如何聲明 Ref 類型
如果內(nèi)部的組件類型正確,forwardRef 會自動檢測 ref 類型:
const MyComponent=forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<MyComponentRefType>
) {
return <div/>;
}
});
// 其結(jié)果類型為:
// const MyComponent: ForwardRefExoticComponent<
// PropsWithoutRef<MyComponentProps> & RefAttributes<MyComponentRefType>
// >
// 這里最后導(dǎo)出的 PropsWithoutRef<P> & RefAttributes<T> 就是用戶側(cè)最終可傳的類型,
// 其中 PropsWithoutRef 會無視你 component 中 props 的 ref。
這里有一個問題:你的組件導(dǎo)出的 Props 中需要包含 ref 嗎?由于forwardRef 會強行改掉你的 ref,這里有兩種方法:
1.在MyComponentProps 中寫上 ref,類型為MyComponentRefType,直接導(dǎo)出它作為最終的 Props;
2.用ComponentProps<typeof MyComponent> 取出最終的 Props。
然而,當(dāng)組件內(nèi)需要必須層層透傳 ref 的時候,如果把 ref 寫進 Props 里,就需要每層組件都使用 forwardRef,否則就會出現(xiàn)問題:
/* 錯誤案例,請勿照抄 */
interface OtherComponentProps {
ref?: Ref<OtherComponentActions>;
}
interface MyComponentProps extends OtherComponentProps {
myAdditionalProp: string;
}
// 這是錯誤的,props 里根本拿不到 ref!
function MyComponent({ myAdditionalProp, ...props }: MyComponentProps) {
console.log(myAdditionalProp);
return <OtherComponent {...props} />;
}
因此,更推薦的方案是不用 ref 這個名字,比如叫 actionRef 等等,這樣也可以毫無痛苦地寫進 props 并導(dǎo)出了。
Bonus: 與 Ref 相關(guān)的 TS 類型
?這些類型類似于 React 提供的類型接口,為了保證你的組件能夠兼容盡可能多的 React 版本,請務(wù)必使用最合適的類型。
之前學(xué)習(xí)過ref聲明響應(yīng)式對象,前幾天讀代碼遇到了發(fā)懵的地方,詳細(xì)學(xué)習(xí)了一下才發(fā)現(xiàn),用法還有很多,遂總結(jié)一下ref的用法備忘。
Vue3 中的 ref 是一種創(chuàng)建響應(yīng)式引用的方式,它在Vue生態(tài)系統(tǒng)中扮演著重要角色。以下是Vue3中ref屬性及其相關(guān)API的幾個關(guān)鍵點:
創(chuàng)建響應(yīng)式變量:使用 ref 函數(shù)可以創(chuàng)建一個響應(yīng)式的數(shù)據(jù)引用,返回的對象包含 .value 屬性,該屬性既可以讀取也可以寫入,并且是響應(yīng)式的。例如:
Javascript
1import { ref } from 'vue';
2
3const count=ref(0); // 創(chuàng)建一個響應(yīng)式引用,初始值為0
4console.log(count.value); // 輸出0
5count.value++; // 改變值,這將觸發(fā)視圖更新
在模板中使用 ref:在模板中,可以使用 v-ref 或簡寫 ref 來給 DOM 元素或組件添加引用標(biāo)識符。對于DOM元素:
<div ref="myDiv">Hello World</div>
然后在組件的 setup 函數(shù)內(nèi)或者生命周期鉤子如 onMounted 中通過 ref 訪問到該元素:
onMounted(()=> {
console.log(myDiv.value); // 這將輸出對應(yīng)的DOM元素
});
// 注意,在setup函數(shù)中使用需要解構(gòu)
setup() {
const myDiv=ref<HTMLElement | null>(null);
// ...
對于子組件,ref 則指向子組件的實例:
<MyChildComponent ref="childRef" />
動態(tài) refs:在動態(tài)渲染的組件或循環(huán)列表中,可以使用動態(tài) ref 名稱:
1<component v-for="(item, index) in items" :is="item.component" :key="index" :ref="`child${index}`" />
然后通過 getCurrentInstance() 獲取這些動態(tài) ref:
Javascript
1setup() {
2 const instance=getCurrentInstance();
3 const childrenRefs=computed(()=> {
4 return instance.refs;
5 });
6 // ...
7}
組件間通信:通過 ref 可以方便地在組件之間傳遞并操作狀態(tài),尤其適用于父子組件之間的通信。
(1)創(chuàng)建一個子組件 ChildComponent.vue:
<template>
<div>
<h2>{{ childMessage }}</h2>
<button @click="handleClick">點擊我</button>
</div>
</template>
<script>
import { ref, defineComponent } from 'vue';
export default defineComponent({
setup(props, { emit }) {
const childMessage=ref('Hello from Child');
const handleClick=()=> {
emit('child-clicked', 'Child component clicked!');
};
return {
childMessage,
handleClick,
};
},
});
</script>
(2)創(chuàng)建一個父組件 ParentComponent.vue,并使用 ref 屬性訪問子組件實例:
<!-- ParentComponent.vue --><template>
<div>
<h1>Parent Component</h1>
<ChildComponent ref="childRef" />
<button @click="callChildMethod">Call Child Method</button>
</div>
</template><script>
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';
export default {
components: {
ChildComponent,
},
setup() {
const childRef=ref(null);
function callChildMethod() {
childRef.value.showMessage();
}
return {
childRef,
callChildMethod,
};
},
};
</script>
在這個示例中,我們在父組件的模板中使用了 ref 屬性,并將其值設(shè)置為 “childRef”。然后,在組合式 API 的 setup 函數(shù)中,我們創(chuàng)建了一個名為 childRef 的響應(yīng)式引用,并將其初始值設(shè)置為 null。接著,我們定義了一個名為 callChildMethod 的方法,用于調(diào)用子組件的 showMessage 方法。當(dāng)用戶點擊按鈕時,callChildMethod 方法會被調(diào)用,從而觸發(fā)子組件的 showMessage 方法并在控制臺輸出一條消息。
import { reactive, toRef } from 'vue';
2
3const state=reactive({ count: 0 });
4const countRef=toRef(state, 'count'); // 提取出count屬性的響應(yīng)式引用
總之,Vue3 的 ref 功能增強了Vue的響應(yīng)式系統(tǒng),使得開發(fā)者能夠更靈活地處理組件的狀態(tài)及組件間交互,同時提供了對DOM元素的直接訪問能力。
人總是在接近幸福時倍感幸福,在幸福進行時卻患得患失。
用ref函數(shù)獲取組件中的標(biāo)簽元素,可以操作該標(biāo)簽身上的屬性,還可以通過ref來取到子組件的數(shù)據(jù),可以修改子組件的數(shù)據(jù)、調(diào)用子組件的方法等、非常方便. 適用于vue3.0版本語法,后續(xù)會講到vue3.2版本setup語法糖有所不同。
語法示例:
<input標(biāo)簽 type="text" ref="inputRef">
<子組件 ref="childRef" />
const inputRef=ref<HTMLElement|null>(null)
const childRef=ref<HTMLElement|null>(null)
父組件代碼:
<template>
<div style="font-size: 14px;">
<h2>測試ref獲取普通標(biāo)簽 讓輸入框自動獲取焦點</h2>
<input type="text" ref="inputRef">
<h2>測試ref獲取子組件</h2>
<Child ref="childRef" />
</div>
</template>
<script lang="ts">
// vue3.0版本語法
import { defineComponent, ref, onMounted } from 'vue'
import Child from './child.vue'
export default defineComponent({
components: {
Child
},
setup() {
const inputRef=ref<HTMLElement|null>(null)
const childRef=ref<HTMLElement|null>(null)
onMounted(()=> {
// ref獲取元素: 利用ref函數(shù)獲取組件中的標(biāo)簽元素
// 需求實現(xiàn)1: 讓輸入框自動獲取焦點
inputRef.value && inputRef.value.focus()
// ref獲取元素: 利用ref函數(shù)獲取組件中的標(biāo)簽元素
// 需求實現(xiàn)2: 查看子組件的數(shù)據(jù),修改子組件的某個值
console.log(childRef.value);
setTimeout(()=> {
childRef.value.text='3秒后修改子組件的text值'
}, 3000)
})
return {
inputRef,childRef
}
},
})
</script>
子組件代碼:
<template>
<div>
<h3>{{ text }}</h3>
</div>
</template>
<script lang="ts">
// vue3.0版本語法
import { ref, defineComponent } from "vue";
export default defineComponent({
name: "Child",
setup() {
const text=ref('我是子組件');
return {
text
};
},
});
</script>
*請認(rèn)真填寫需求信息,我們會在24小時內(nèi)與您取得聯(lián)系。