谷 阿里云开发者
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小时内与您取得联系。