React中很厉害的一个地方在于,它抽象了复杂的处理真实DOM过程。在React中,我们不再需要手动访问真实的DOM,或者手动为元素添加类,或者为浏览器兼容性头疼,而只用专注于写组件和提升用户体验。但是呢,也有极少数情况下,我们需要访问真实的DOM。
为了实现这个目的,我们需要Refs。这次没有什么神秘复杂的东西,咱们就来实现一个带有输入字段简单验证功能的精美表单吧。在此过程中,我们将会学到:
- 为什么我们还需要访问DOM元素。
- 什么是Ref,Ref与state(状态)之间有什么不同/
- 如何使用Ref来访问UI元素。
- 什么是
forwardRef
,如何使用它,以及如何避免使用它。 - 为什么我们仍然需要指令式API,如通在有
useImperativeHandle
或没有useImperativeHandle
的情况下实现指令式API
在React访问DOM
假设,我为我正在组织的一个论坛实现一个登录表单。我希望来会人员在我提供他们详细信息前,先提供他们的名字、邮箱和Twitter号。其中,名字和邮箱号是必填的。但是,我不希望必填字段为空时,仅仅是出现红色的边框。我希望这个表单的交互可以更加酷炫。所以,我期望的校验交互是晃动空白的输入框。
React为我们提供了强大的特性,但并没有提供所有能力。像"手动关注一个元素",或者"摇动一个元素"这些功能,并不在React的核心包里面。我们得重拾生疏了的原生 JavaScript API 使用技能。为此,我们需要能够访问实际的 DOM 元素。
在非React的世界,我需要这样:
js
const element = document.getElementById('bla');
然后,我们聚焦这个元素:
js
element.focus();
或者,滑动到此:
js
element.scrollIntoView();
或者,我们可以进行其他操作:
- 手动聚焦一个已经渲染的元素,比如表单中的input
- 当显示类似弹出框的元素时,检测组件外部的点击事件。
- 在某个元素出现在屏幕上之后,手动滚动到该元素处。
- 计算屏幕上各组件的尺寸和边界,以便正确定位类似提示工具(tooltip)之类的元素。
虽然我们仍然可以js原生API来操作元素,但是React仍然为我们提供了更加轻便的、无需传递id、无需关心DOM结构的方法:Refs。
什么是Ref
Ref是一个可变对象,在React重新渲染时,React会保留它。还记得吗,我们在组件内声明的东西会在重新渲染时再次创造。
js
const Component = () => {
// "data" object will be new with every re-render
const data = { id: 'test' };
}
组件本质上就是函数,所以组件内部的一切基本上都是该函数的局部变量。引用(Refs)使我们能够绕过这一限制。
为了创建一个Ref,我们可以使用useRef
钩子,并为这个钩子传递一个初始值:
js
const Component = () => {
const ref = useRef({ id: 'test' });
}
我们可以通过current属性来访问ref的当前值:
js
const Component = () => {
// pass intital value here
const ref = useRef({ id: 'test' });
useEffect(() => {
// access it here
console.log(ref.current);
});
}
这个初始值被缓存了,重新渲染器在比对ref.current
时,其索引始终是相同的。如果我们用useMemo来缓存一个对象,也是一样的。
Ref被创立后,我们可以在useEffect
活事件处理器中为之传递任何值。它不过就是一个对象罢了:
js
const Component = () => {
useEffect(() => {
// assign url as an id, when it changes
ref.current = { id: url };
}, [url])
}
这一切看起来很像状态,对吧?既然如此,为什么我们要四处使用状态,而很少使用Ref?
Ref和state的不同
让我们先实现表单的基本功能:
js
const Form = () => {
return (
<>
<input type="text" />
<button onClick={submit}>submit</button>
</>
);
};
现在,为了实现提交功能,我们需要提取输入的内容。在React中,我们只要添加一个onChang回调函数给input即可:
js
const Form = () => {
const [value, setValue] = useState();
const onChange = (e) => {
setValue(e.target.value);
};
const submit = () => {
// send to the backend here
console.log(value);
}
return (
<>
<input type="text" />
<button onClick={submit}>submit</button>
</>
);
};
但是我之前说过,我们存在Ref中的索引在重新渲染前后始终是相同的。而且,任何值都可以赋给Ref。那么,如果我把输入的内容存到Ref,而不是存到状态(state),会发生什么?
js
const Form = () => {
const ref = useRef();
const onChange = (e) => {
// save it to ref instead of state
ref.current = e.target.value;
};
const submit = () => {
// get it from ref instead of state
console.log(ref.current);
}
return (
<>
<input type="text" />
<button onClick={submit}>submit</button>
</>
);
};
这段代码看起来和使用状态差不多,也能跑起来。
代码示例: advanced-react.com/examples/09...
那么,这两者的不同到底在哪里?为什么在实践中很少看到有人这么写?我来说明相关原因。
Ref的更新并不会触发重新渲染
它们两者最明显的区别在于,Ref的更新并不会触发重新渲染。如果你在这两个表单里面添加console.log
,你会发现:在使用了sate的Form
的表单里,每一次输入内容都会触发重新渲染,而在使用了Ref的Form
的表单则没有重新渲染。
js
useEffect(() => {
console.log('Form component re-renders');
});
表面上看,这是好事。我们都在探索如何通过减少重新渲染来提升性能。而不触发重新渲染的Ref更新,不正是提升应用性能最好的解方吗?
然而并不是这样的。我们知道,重新渲染是React生命周期中非常关键的一个环节。它是React实现信息更新的环节。比如说,如果我想在表单上统计输入内容的长度,我们就不能使用Ref了。
js
const Form = () => {
const ref = useRef();
const numberOfLetters = res.current?.length ?? 0;
return (
<>
<input type="text" onChange={onChange} />
{/* Not going to work */}
Characters count: {numberOfLetters}
<button onClick={submit}>submit</button>
</>
)
}
Ref更新并不会触发重新渲染,所以numberOfLetters
的返回值永远是0。
实际上,这比 0 的情况更有意思。如果某个与该输入完全无关的因素导致表单(Form)组件重新渲染,那么该值会突然更新为最新的值。要记得,引用(Ref)会在多次重新渲染之间存储那个值呀。如果我给这个表单添加一个类似简单模态对话框之类的东西,那么对话框的打开操作就会成为触发组件自我更新以及字母数量发生变化的诱因。
js
const Form = () => {
// state for the dialog
const [isOpen, setIsOpen] = useState(false);
const ref = useRef();
const numberOfLetters = ref.current?.length ?? 0;
return (
<>
<input type="text" onChange={onChange} />
{/* This will not change when you type in the field */}
{/* Only when you open/close the dialog */}
Characters count: {numberOfLetters}
<button onClick={submit}>submit</button>
{/* Adding dialog here */}
<button onClick={() => setIsOpen(true)}>
Open dialog
</button>
{isOpen ? <ModalDialog onClose={() => setIsOpen(false)} /> : null}
</>
);
};