Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537
谷 阿里云開發者
2024年08月09日 08:30 浙江
阿里妹導讀
你真的用對了 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 的類型提示,就能夠發現其中端倪:
function MyComponent() {
// ? 寫法 1
// 你會得到一個 MutableRefObject<HTMLDivElement | undefined>,
// 即 ref.current 類型是 HTMLDivElement | undefined,
// 這導致你每次獲取 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,則不會匹配到這個定義,
// 因此請務必開啟 strictNullCheck。
const ref=useRef<HTMLDivElement>(null);
// 通過 ref 計算 DOM 元素尺寸
// 這段代碼故意留了坑,坑在哪里?請看下文。
useLayoutEffect(()=> {
const rect=ref.current.getBoundingClientRect();
}, [ref.current]);
return <div ref={ref} />;
}
Ref 還可以傳入一個函數,會把被 ref 的對象應用作為參數傳入,因此我們也可以這樣獲取 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 類型出發,也應該判斷ref.current 是否為空。
你也許會認為,我都在 useLayoutEffect 里了,此時組件 DOM 已經生成,因而理應存在 ref.current,是否可以不用判斷呢?(或用 ! 強制設為非空)
上述使用場景中,確實可以這樣做,但如果div 是條件渲染的,則無法保證useLayoutEffect 時組件已被渲染,自然也就不一定存在ref.current。
2. useLayoutEffect deps 配置錯誤
這個問題涉及到useLayoutEffect 更本質的使用目的。?
useLayoutEffect 的執行時機是:
?VDOM 生成后(所有render 執行完成);
?DOM 生成后(createElement 等 DOM 操作完成);
?最終提交渲染之前(同步任務返回前)。?
由于其執行時機在 repaint 之前,此時對已生成的 DOM 進行更改,用戶不會看到「閃一下」。舉個例子,你可以計算元素的尺寸,如果太大則修改 CSS 使其自動換行,從而實現溢出檢測。?
另一個常見場景是在useLayoutEffect 中獲取原生組件,用來添加原生 listener、獲取底層HTMLMediaElement 實例來控制播放,或添加ResizeObserver、IntersectionObserver 等。
這里,由于div 是條件渲染的,我們顯然會希望useLayoutEffect 的操作在每次渲染出來之后都執行一遍,因此我們會想把ref.current 寫進useLayoutEffect 的dependencies,但這是完全錯誤的。
讓我們盤一下MyComponent 的渲染過程:
1.visible 變化導致觸發 render。
2.useRef 執行,ref.current 還是上一次的值。
3.useLayoutEffect 執行,對比 dependencies 發現沒有變化,跳過執行。
4.渲染結果包含div。
5.由于<div ref={ref}>,React 使用新的 DOM 元素更新ref.current。
顯然,這里并沒有再次觸發useLayoutEffect,直到下一次渲染中才會發現ref.current 有變化,這背離了我們對于 useLayoutEffect 能讓用戶看不到「閃一下」的預期。
解決方案是,使用與條件渲染相同的條件作為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 變化時,就必然會在同一次渲染內觸發 useLayoutEffect
return <>{visible && <div ref={ref}/>}</>;
}
// 或者也可以將 <div> 抽取成一個獨立的組件,從而避免上述問題
最后,如果并非是要在 repaint 之前對 DOM 元素進行操作,更推薦的寫法是用函數寫法:
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
——你實現了一個組件,想要將傳入的 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>;
}
});
等等,如果調用者沒傳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 可能是個函數,本來你只需要把它直接傳給<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 這種復雜的組件往往會在組件內維護較多狀態,不適合受控操作,當調用者需要控制組件行為時,往往就會采取這樣的模式:
function MyPage() {
const ref=useRef<FormRef>(null);
return (
<div>
<Button onClick={()=> { ref.current.reset(); }}>重置表單</Button>
<Form actionRef={ref}>{/* ... */}</Form>
</div>
);
}
這種用法實際上脫胎于 class component 時代,人們使用 ref 來獲取 class 實例,通過調用實例方法來控制組件。
現在,你的超級復雜絕絕子組件也希望通過這種方式與調用者交互,于是你寫出了以下實現:
/* 錯誤案例,請勿照抄 */
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 不會容許這樣的代碼通過類型檢查,因為調用者可以函數作為 ref 來接收 action,這與獲取 DOM 元素時類似。?
正確的做法是,你應該使用 React 提供的工具函數useImperativeHandle:
const MyComponent=forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<MyComponentRefType>
) {
// useImperativeHandle 這個工具函數會自動處理函數 ref 和對象 ref 的情況,
// 后兩個參數基本等于 useMemo
useImperativeHandle(ref, ()=> ({
refresh: ()=> {
// ...
},
// ...
}), [/* deps */]);
// 命令式 + 下傳
// 如果你的組件內部也會用到這個命令式對象,推薦的寫法是:
const actions=useMemo(()=> ({
refresh: ()=> {
// ...
},
}), [/* deps */]);
useImperativeHandle(ref, ()=> actions, [actions]);
return <div/>;
}
);
場景五:組件 TS 導出如何聲明 Ref 類型
如果內部的組件類型正確,forwardRef 會自動檢測 ref 類型:
const MyComponent=forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<MyComponentRefType>
) {
return <div/>;
}
});
// 其結果類型為:
// const MyComponent: ForwardRefExoticComponent<
// PropsWithoutRef<MyComponentProps> & RefAttributes<MyComponentRefType>
// >
// 這里最后導出的 PropsWithoutRef<P> & RefAttributes<T> 就是用戶側最終可傳的類型,
// 其中 PropsWithoutRef 會無視你 component 中 props 的 ref。
這里有一個問題:你的組件導出的 Props 中需要包含 ref 嗎?由于forwardRef 會強行改掉你的 ref,這里有兩種方法:
1.在MyComponentProps 中寫上 ref,類型為MyComponentRefType,直接導出它作為最終的 Props;
2.用ComponentProps<typeof MyComponent> 取出最終的 Props。
然而,當組件內需要必須層層透傳 ref 的時候,如果把 ref 寫進 Props 里,就需要每層組件都使用 forwardRef,否則就會出現問題:
/* 錯誤案例,請勿照抄 */
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 并導出了。
Bonus: 與 Ref 相關的 TS 類型
?這些類型類似于 React 提供的類型接口,為了保證你的組件能夠兼容盡可能多的 React 版本,請務必使用最合適的類型。
之前學習過ref聲明響應式對象,前幾天讀代碼遇到了發懵的地方,詳細學習了一下才發現,用法還有很多,遂總結一下ref的用法備忘。
Vue3 中的 ref 是一種創建響應式引用的方式,它在Vue生態系統中扮演著重要角色。以下是Vue3中ref屬性及其相關API的幾個關鍵點:
創建響應式變量:使用 ref 函數可以創建一個響應式的數據引用,返回的對象包含 .value 屬性,該屬性既可以讀取也可以寫入,并且是響應式的。例如:
Javascript
1import { ref } from 'vue';
2
3const count=ref(0); // 創建一個響應式引用,初始值為0
4console.log(count.value); // 輸出0
5count.value++; // 改變值,這將觸發視圖更新
在模板中使用 ref:在模板中,可以使用 v-ref 或簡寫 ref 來給 DOM 元素或組件添加引用標識符。對于DOM元素:
<div ref="myDiv">Hello World</div>
然后在組件的 setup 函數內或者生命周期鉤子如 onMounted 中通過 ref 訪問到該元素:
onMounted(()=> {
console.log(myDiv.value); // 這將輸出對應的DOM元素
});
// 注意,在setup函數中使用需要解構
setup() {
const myDiv=ref<HTMLElement | null>(null);
// ...
對于子組件,ref 則指向子組件的實例:
<MyChildComponent ref="childRef" />
動態 refs:在動態渲染的組件或循環列表中,可以使用動態 ref 名稱:
1<component v-for="(item, index) in items" :is="item.component" :key="index" :ref="`child${index}`" />
然后通過 getCurrentInstance() 獲取這些動態 ref:
Javascript
1setup() {
2 const instance=getCurrentInstance();
3 const childrenRefs=computed(()=> {
4 return instance.refs;
5 });
6 // ...
7}
組件間通信:通過 ref 可以方便地在組件之間傳遞并操作狀態,尤其適用于父子組件之間的通信。
(1)創建一個子組件 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)創建一個父組件 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 屬性,并將其值設置為 “childRef”。然后,在組合式 API 的 setup 函數中,我們創建了一個名為 childRef 的響應式引用,并將其初始值設置為 null。接著,我們定義了一個名為 callChildMethod 的方法,用于調用子組件的 showMessage 方法。當用戶點擊按鈕時,callChildMethod 方法會被調用,從而觸發子組件的 showMessage 方法并在控制臺輸出一條消息。
import { reactive, toRef } from 'vue';
2
3const state=reactive({ count: 0 });
4const countRef=toRef(state, 'count'); // 提取出count屬性的響應式引用
總之,Vue3 的 ref 功能增強了Vue的響應式系統,使得開發者能夠更靈活地處理組件的狀態及組件間交互,同時提供了對DOM元素的直接訪問能力。
人總是在接近幸福時倍感幸福,在幸福進行時卻患得患失。
用ref函數獲取組件中的標簽元素,可以操作該標簽身上的屬性,還可以通過ref來取到子組件的數據,可以修改子組件的數據、調用子組件的方法等、非常方便. 適用于vue3.0版本語法,后續會講到vue3.2版本setup語法糖有所不同。
語法示例:
<input標簽 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獲取普通標簽 讓輸入框自動獲取焦點</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函數獲取組件中的標簽元素
// 需求實現1: 讓輸入框自動獲取焦點
inputRef.value && inputRef.value.focus()
// ref獲取元素: 利用ref函數獲取組件中的標簽元素
// 需求實現2: 查看子組件的數據,修改子組件的某個值
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>
*請認真填寫需求信息,我們會在24小時內與您取得聯系。