<译> How to use React Ref

翻译系列第四篇,怎样使用React Ref原文地址

在React中使用ref和真正理解它是两码事。说实话时至今日我也不确定十分完全理解它,因为它不像state或者effect一样常用而且它的API经常改变。在这个教程中,我会一步一步给你介绍React中的ref

Refs Hook

在过去React refs和DOM紧密相关,但自从React引入了React Hooks之后就不再如此。Ref的意思就是引用(reference),所以它可以是任何事物的引用(DOM节点、JS 值等等)。因此在深入了解它的HTML节点用法之前, 我们先退一步探索不带DOM的React ref。以下面的组件为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Counter() {
const [count, setCount] = React.useState(0);

function onClick() {
const newCount = count + 1;

setCount(newCount);
}

return (
<div>
<p>{count}</p>

<button type="button" onClick={onClick}>
Increase
</button>
</div>
);
}

React提供了useRef钩子作为使用函数式组件时的状态API。useRef返回一个可变对象,该对象在react组件的生命周期内保持不变。具体而言,返回的对象有一个当前属性,它可以为我们保存任何可修改的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function Counter() {
const hasClickedButton = React.useRef(false);

const [count, setCount] = React.useState(0);

function onClick() {
const newCount = count + 1;

setCount(newCount);

hasClickedButton.current = true;
}

console.log('Has clicked button? ' + hasClickedButton.current);

return (
<div>
<p>{count}</p>

<button type="button" onClick={onClick}>
Increase
</button>
</div>
);
}

ref的当前属性会使用我们为useRef钩子提供的参数初始化(这里是false),无论何时我们都可以为ref的当前属性赋一个新值。在上面的例子中我们只是追踪按钮是否被点击。

将ref设为新值并不会触发组件的重新渲染。虽然上一个示例中的状态更新函数(这里是setCount)会更新组件的状态并使组件重新渲染,但仅为ref的当前属性切换布尔值根本不会触发重新渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function Counter() {
const hasClickedButton = React.useRef(false);

const [count, setCount] = React.useState(0);

function onClick() {
// const newCount = count + 1;

// setCount(newCount);

hasClickedButton.current = true;
}

// Does only run for the first render.
// Component does not render again, because no state is set anymore.
// Only the ref's current property is set, which does not trigger a re-render.
console.log('Has clicked button? ' + hasClickedButton.current);

return (
<div>
<p>{count}</p>

<button type="button" onClick={onClick}>
Increase
</button>
</div>
);
}

Okay,我们使用useRef创建了一个存在于组件整个生命周期的可变对象,但是它不会在我们改变它时触发重新渲染——那是state的用途,那么这里ref的用途是什么?

Ref作为实例变量

当我们需要追踪某个状态但是不触发重新渲染机制ref在函数式组件中作为一个实例变量。例如我们可以追踪组件是第一次渲染还是重新渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function ComponentWithRefInstanceVariable() {
const [count, setCount] = React.useState(0);

function onClick() {
setCount(count + 1);
}

const isFirstRender = React.useRef(true);

React.useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
}
});

return (
<div>
<p>{count}</p>

<button type="button" onClick={onClick}>
Increase
</button>

{/*
Only works because setCount triggers a re-render.
Just changing the ref's current value doesn't trigger a re-render.
*/}
<p>{isFirstRender.current ? 'First render.' : 'Re-render.'}</p>
</div>
);
}

在本例中,我们将ref的当前属性初始化为true,因为我们假设组件在第一次初始化时从第一次呈现开始。然而当我们使用useEffect钩子——不需要第一次或任何一次渲染作为第二参数——在组件第一次渲染之后去更新ref的值。设置ref的当前属性为false不会触发重新渲染。

现在我们可以创建一个useEffect钩子,它只在每个组件更新时而不是初始化时运行它的逻辑。这当然是每个React开发人员在某些时候都需要的特性,但React的useEffect钩子没有提供。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function ComponentWithRefInstanceVariable() {
const [count, setCount] = React.useState(0);

function onClick() {
setCount(count + 1);
}

const isFirstRender = React.useRef(true);

React.useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
} else {
console.log(
`
I am a useEffect hook's logic
which runs for a component's
re-render.
`
);
}
});

return (
<div>
<p>{count}</p>

<button type="button" onClick={onClick}>
Increase
</button>
</div>
);
}

使用refs为React组件部署实例变量并没有被广泛使用,也不经常需要,然而我在我的办公室看到过一些React开发人员,他们在我的课堂上学习了这个特性后,肯定知道他们需要一个useRef实例变量来处理他们的特定情况。

经验法则:当你需要跟踪你的React组件中不应该触发组件重新呈现的状态时,你可以使用React的useRef钩子为它创建一个实例变量

DOM REFS

让我们看看React ref的特长: DOM。大多数时候,当你必须与HTML元素交互的时候你会使用ref。React本质是声明性的,但有时你仍然需要从HTML元素读取值、与HTML元素交互甚至是向HTML写入值。对于这些不常见的情况,必须用ref以命令式的方式,而非声明式的方式与DOM交互。这个例子展示了ref与DOM交互的最流行的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function App() {
return (
<ComponentWithDomApi
label="Label"
value="Value"
isFocus
/>
);
}

function ComponentWithDomApi({ label, value, isFocus }) {
const ref = React.useRef(); // (1)

React.useEffect(() => {
if (isFocus) {
ref.current.focus(); // (3)
}
}, [isFocus]);

return (
<label>
{/* (2) */}
{label}: <input type="text" value={value} ref={ref} />
</label>
);
}

像之前一样,我们用useRef钩子创建ref对象,这种情况下我们不会给他任何初始值,因为这将在下一步完成——当我们把ref对象作为HTML属性提供给HTML元素的时候。React自动为我们将这个HTML元素的DOM节点分配给ref对象。最后我们可以使用DOM节点与它的API交互。

前面的示例向我们展示了如何在React中与DOM API交互,接下来将学习如何使用ref从DOM节点中读取值。下面的示例从元素中读取大小以在浏览器中作为标题显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function ComponentWithRefRead() {
const [text, setText] = React.useState('Some text ...');

function handleOnChange(event) {
setText(event.target.value);
}

const ref = React.useRef();

React.useEffect(() => {
const { width } = ref.current.getBoundingClientRect();

document.title = `Width:${width}`;
}, []);

return (
<div>
<input type="text" value={text} onChange={handleOnChange} />
<div>
<span ref={ref}>{text}</span>
</div>
</div>
);
}

与前面一样,我们使用React的useRef钩子初始化ref对象,在JSX中使用它将ref的当前属性分配给DOM节点,最后通过useEffect钩子读取组件第一次渲染的元素宽度。你应该能够在你的浏览器的标签看到你的元素的宽度作为标题。

不过,读取DOM节点的大小只在初始渲染时才会发生。如果你想在每次状态改变导致HTML元素的大小变化时读取它,你可以将状态作为依赖变量提供给useEffect。每当状态(这里是文本)发生变化时,元素的新大小将从HTML元素中读取并写入文档的title属性中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function ComponentWithRefRead() {
const [text, setText] = React.useState('Some text ...');

function handleOnChange(event) {
setText(event.target.value);
}

const ref = React.useRef();

React.useEffect(() => {
const { width } = ref.current.getBoundingClientRect();

document.title = `Width:${width}`;
}, [text]);

return (
<div>
<input type="text" value={text} onChange={handleOnChange} />
<div>
<span ref={ref}>{text}</span>
</div>
</div>
);
}

两个例子都使用了useEffect钩子来处理ref对象。我们可以通过使用回调ref来避免这种情况。

回调Ref

对于前面的例子,一个更好的方法是使用所谓的回调ref。有了回调ref,你就不必再使用useEffect和useRef钩子了,因为回调ref允许你在每次渲染时访问DOM节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function ComponentWithRefRead() {
const [text, setText] = React.useState('Some text ...');

function handleOnChange(event) {
setText(event.target.value);
}

const ref = (node) => {
if (!node) return;

const { width } = node.getBoundingClientRect();

document.title = `Width:${width}`;
};

return (
<div>
<input type="text" value={text} onChange={handleOnChange} />
<div>
<span ref={ref}>{text}</span>
</div>
</div>
);
}

一个回调ref只不过是一个可以用于JSX中的HTML元素的ref属性的函数。这个函数可以访问DOM节点,当在HTML元素的ref属性上使用它时就会触发它。本质上,它所做的与之前的副作用(side-effect)相同,但这次回调ref本身通知我们它已被附加到HTML元素。

在使用useRef + useEffect组合之前,可以在useEffect的钩子依赖项数组的帮助下运行你的副作用一段时间。你可以通过使用useCallback钩子增强回调ref来达到同样的效果,使它只在组件的第一次渲染时运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function ComponentWithRefRead() {
const [text, setText] = React.useState('Some text ...');

function handleOnChange(event) {
setText(event.target.value);
}

const ref = React.useCallback((node) => {
if (!node) return;

const { width } = node.getBoundingClientRect();

document.title = `Width:${width}`;
}, []);

return (
<div>
<input type="text" value={text} onChange={handleOnChange} />
<div>
<span ref={ref}>{text}</span>
</div>
</div>
);
}

这里还可以更具体地使用useCallback钩子的依赖项数组。例如,只有在状态(这里是文本)发生改变时,才执行回调ref的回调函数,当然,这是为了组件的第一次呈现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function ComponentWithRefRead() {
const [text, setText] = React.useState('Some text ...');

function handleOnChange(event) {
setText(event.target.value);
}

const ref = React.useCallback((node) => {
if (!node) return;

const { width } = node.getBoundingClientRect();

document.title = `Width:${width}`;
}, [text]);

return (
<div>
<input type="text" value={text} onChange={handleOnChange} />
<div>
<span ref={ref}>{text}</span>
</div>
</div>
);
}

然而,然后我们会再次以相同的行为结束,就像我们之前没有使用useCallback钩子,每一次渲染都会调用回调ref。

Ref的读写操作

到目前为止,我们只在读取操作(例如读取一个DOM节点的大小)时使用了DOM ref,它也可以修改引用的DOM节点(写操作)。下一个示例向我们展示了如何使用ref来应用样式,而无需为其管理任何额外的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function ComponentWithRefReadWrite() {
const [text, setText] = React.useState('Some text ...');

function handleOnChange(event) {
setText(event.target.value);
}

const ref = (node) => {
if (!node) return;

const { width } = node.getBoundingClientRect();

if (width >= 150) {
node.style.color = 'red';
} else {
node.style.color = 'blue';
}
};

return (
<div>
<input type="text" value={text} onChange={handleOnChange} />
<div>
<span ref={ref}>{text}</span>
</div>
</div>
);
}

可以对这个引用的DOM节点上的任何属性执行此操作。需要注意的是由于React是声明性的,因此通常不应该这样使用。相反,你可以使用useState钩子来设置一个布尔值,无论你想要将文本颜色设置为红色还是蓝色。但是出于性能原因,有时直接操作DOM对于避免重新渲染会非常有帮助。

为了了解它,我们也可以在React组件中以这种方式管理状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function ComponentWithImperativeRefState() {
const ref = React.useRef();

React.useEffect(() => {
ref.current.textContent = 0;
}, []);

function handleClick() {
ref.current.textContent = Number(ref.current.textContent) + 1;
}

return (
<div>
<div>
<span ref={ref} />
</div>

<button type="button" onClick={handleClick}>
Increase
</button>
</div>
);
}

但我们不建议你掉进这个兔子洞里……本质上,它只是向您展示了如何操作React中的ref属性中的元素。但是,为什么我们不再使用普通的JavaScript呢?因此,React的ref主要用于读操作。

这篇介绍应该已经向你展示了如何通过useRef钩子或回调ref来使用ref引用DOM节点和实例变量。为了完整性起见,我还想提一下React的顶级API createRef(),它相当于React类组件的useRef()。也有一些被称为string ref的ref也不被推荐在React中使用。