React Refs:完整故事 可变数据存储 带有 Refs 的可视化计时器 DOM 元素引用 组件引用 类组件引用 单向流 将数据添加到 Ref 使用 useEffect 回调 Refs useState Refs 结论

2025-05-28

React Refs:完整的故事

可变数据存储

带 Refs 的可视计时器

DOM 元素引用

组件引用

类组件引用

单向流

将数据添加到参考

React Refs 中useEffect

回调引用

useState参考文献

结论

编程术语可能相当令人困惑。我第一次听说“React Refs”是在获取 DOM 节点引用的上下文中。然而,随着 hooks 的引入,useRef它扩展了“refs”的定义。

今天,我们将介绍 ref 的两个定义:

我们还将探索这两个定义各自的附加功能,例如组件 refs向 ref 添加更多属性,甚至探索与使用相关的常见代码陷阱useRef

由于本篇内容主要依赖于useRef钩子,因此我们将在所有示例中使用函数式组件。不过,我们也有一些 API,例如React.createRef实例变量,可以用来重新创建React.useRef类的功能。

可变数据存储

虽然useState是最常见的数据存储钩子,但它并非唯一的钩子。React 的useRef钩子功能与 不同useState,但它们都用于在渲染过程中持久化数据。

const ref = React.useRef();

ref.current = "Hello!";
Enter fullscreen mode Exit fullscreen mode

在此示例中,初始渲染后将ref.current包含"Hello!"。 返回的值useRef是一个包含单个键的对象:current

如果您运行以下代码:

const ref = React.useRef();

console.log(ref)
Enter fullscreen mode Exit fullscreen mode

你会发现{current: undefined}控制台打印了一条信息。这是所有 React Refs 的形状。如果你查看 Hooks 的 TypeScript 定义,你会看到类似这样的内容:

// React.d.ts

interface MutableRefObject {
    current: any;
}

function useRef(): MutableRefObject;
Enter fullscreen mode Exit fullscreen mode

为什么要useRef依赖于将数据存储在属性中current?这是因为这样你就可以利用 JavaScript 的“按引用传递”功能来避免渲染。

现在,您可能会认为useRef钩子的实现如下:

// This is NOT how it's implemented
function useRef(initial) {
  const [value, setValue] = useState(initial);
  const [ref, setRef] = useState({ current: initial });

  useEffect(() => {
    setRef({
      get current() {
        return value;
      },

      set current(next) {
        setValue(next);
      }
    });
  }, [value]);

  return ref;
}
Enter fullscreen mode Exit fullscreen mode

然而事实并非如此。引用 Dan Abramov 的话

...useRef工作原理更像这样:

function useRef(initialValue) {
  const [ref, ignored] = useState({ current: initialValue })
  return ref
}

由于这种实现,当你改变current值时,它不会导致重新渲染。

由于数据存储无需渲染,它对于存储需要引用但无需在屏幕上渲染的数据特别有用。计时器就是一个这样的例子:

  const dataRef = React.useRef();

  const clearTimer = () => {
    clearInterval(dataRef.current);
  };

  React.useEffect(() => {
    dataRef.current = setInterval(() => {
      console.log("I am here still");
    }, 500);

    return () => clearTimer();
  }, [dataRef]);
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

带 Refs 的可视计时器

虽然计时器有不渲染值的用途,但如果我们让计时器渲染状态值,会发生什么?

让我们继续之前的例子,但是在里面setInterval,我们更新useState包含数字的,以将一添加到其状态。

 const dataRef = React.useRef();

  const [timerVal, setTimerVal] = React.useState(0);

  const clearTimer = () => {
    clearInterval(dataRef.current);
  }

  React.useEffect(() => {
    dataRef.current = setInterval(() => {
      setTimerVal(timerVal + 1);
    }, 500)

    return () => clearInterval(dataRef.current);
  }, [dataRef])

  return (
      <p>{timerVal}</p>
  );
Enter fullscreen mode Exit fullscreen mode

现在,我们期望看到计时器在继续渲染时从1到(以及之后)更新。但是,如果我们在应用运行时查看它,我们会看到一些我们可能意想不到的行为:2

运行相关代码示例

这是因为传递给 的闭包已过期。这是使用 React Hooks 时常见的问题。虽然的 APIsetInterval中隐藏了一个简单的解决方案,但让我们使用 突变 和 来解决这个问题useStateuseRef

因为useRef依赖于通过引用传递并改变该引用,如果我们简单地引入第二个useRef并在每次渲染时对其进行变异以匹配值useState,我们就可以解决陈旧闭包的限制。

  const dataRef = React.useRef();

  const [timerVal, setTimerVal] = React.useState(0);
  const timerBackup = React.useRef();
  timerBackup.current = timerVal;

  const clearTimer = () => {
    clearInterval(dataRef.current);
  };

  React.useEffect(() => {
    dataRef.current = setInterval(() => {
      setTimerVal(timerBackup.current + 1);
    }, 500);

    return () => clearInterval(dataRef.current);
  }, [dataRef]);
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

  • 我不会在生产中用这种方式解决它。useState接受一个回调,您可以将其用作替代(更推荐)路线:
  const dataRef = React.useRef();

  const [timerVal, setTimerVal] = React.useState(0);

  const clearTimer = () => {
    clearInterval(dataRef.current);
  };

  React.useEffect(() => {
    dataRef.current = setInterval(() => {
      setTimerVal(tVal => tVal + 1);
    }, 500);

    return () => clearInterval(dataRef.current);
  }, [dataRef]);

我们只是用 auseRef来概括 refs 的一个重要属性:突变。

DOM 元素引用

在本文开头,我提到refs 不仅仅是一种可变数据存储方法,还是一种从 React 内部引用 DOM 节点的方法。跟踪 DOM 节点最简单的方法是useRef使用任意元素的ref属性将其存储在钩子中:

  const elRef = React.useRef();

  React.useEffect(() => {
    console.log(elRef);
  }, [elRef]);

  return (
    <div ref={elRef}/>
  )
Enter fullscreen mode Exit fullscreen mode

请记住,该ref属性由 React 在任何 HTML 元素上添加和处理。本例中使用了div,但这也适用于spans 和headers 以及更多,哦天哪。

在此示例中,如果我们查看console.log中的,我们会在 属性中useEffect找到一个HTMLDivElement实例current。打开以下 StackBlitz 并查看控制台值以确认:

运行相关代码示例

因为elRef.current现在是HTMLDivElement,这意味着我们现在可以访问整个Element.prototypeJavaScript API。因此,它elRef可以用于设置底层 HTML 节点的样式:

  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.style.background = 'lightblue';
  }, [elRef]);

  return (
    <div ref={elRef}/>
  )
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

替代语法

值得注意的是,该ref属性也接受一个函数。虽然我们以后会更多地讨论这一点,但请注意,此代码示例所做的与 完全相同ref={elRef}

  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.style.background = 'lightblue';
  }, [elRef]);

  return (
    <div ref={ref => elRef.current = ref}/>
  )
Enter fullscreen mode Exit fullscreen mode

组件引用

HTML 元素是 s 的绝佳用例ref。然而,在很多情况下,你需要为子组件渲染过程中的元素提供 ref。我们如何将 ref 从父组件传递给子组件?

通过将属性从父组件传递给子组件,你可以将 ref 传递给子组件。例如:

const Container = ({children, divRef}) => {
  return <div ref={divRef}/>
}

const App = () => {
  const elRef = React.useRef();

  React.useEffect(() => {
    if (!elRef.current) return;
   elRef.current.style.background = 'lightblue';
  }, [elRef])

  return (
    <Container divRef={elRef}/>
  );
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

你可能想知道为什么我没有将该属性命名refdivRef。这是因为 React 的一个限制。如果我们尝试将该属性的名称改为ref,就会出现一些意想不到的后果。

// This code does not function as intended
const Container = ({children, ref}) => {
  return <div ref={ref}/>
}

const App = () => {
  const elRef = React.useRef();

  React.useEffect(() => {
    if (!elRef.current) return;
    // If the early return was not present, this line would throw an error:
    // "Cannot read property 'style' of undefined"
   elRef.current.style.background = 'lightblue';
  }, [elRef])

  return (
    <Container ref={elRef}/>
  );
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

你会注意到, 的Container div样式没有背景lightblue。这是因为elRef.current从未设置为包含HTMLElement引用。因此,对于简单的引用转发,你不能使用ref属性名称。

如何让ref属性名称按照预期与功能组件一起工作?

您可以使用ref属性名称通过 API 转发 ref forwardRef。定义函数式组件时,与其像其他方式一样简单地将其定义为箭头函数,不如将组件赋值给 ,forwardRef并将箭头函数作为其第一个属性。这样,您就可以ref从内部箭头函数的第二个属性进行访问。

const Container = React.forwardRef((props, ref) => {
  return <div ref={ref}>{props.children}</div>
})

const App = () => {
  const elRef = React.useRef();

  React.useEffect(() => {
    console.log(elRef);
   elRef.current.style.background = 'lightblue';
  }, [elRef])

  return (
    <Container ref={elRef}/>
  );
Enter fullscreen mode Exit fullscreen mode

现在我们正在使用forwardRef,我们可以使用父组件上的属性名称再次ref访问。elRef

运行相关代码示例

类组件引用

虽然我提到我们将在本文的大部分内容中使用函数式组件和钩子,但我认为介绍类组件如何处理ref属性也很重要。以以下类组件为例:

class Container extends React.Component {
  render() {
    return <div>{this.props.children}</div>;
  }
}
Enter fullscreen mode Exit fullscreen mode

ref如果我们尝试传递一个属性,您认为会发生什么?

const App = () => {
  const compRef = React.useRef();

  React.useEffect(() => {
    console.log(compRef.current);
  });

  return (
    <Container ref={container}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

如果您愿意,您也可以将其写App为类组件:

class App extends React.Component {
  compRef = React.createRef();

  componentDidMount() {
    console.log(this.compRef.current);
  }

  render() {
    return (
      <Container ref={this.compRef}>
        <h1>Hello StackBlitz!</h1>
        <p>Start editing to see some magic happen :)</p>
      </Container>
    );
  }
}

运行相关代码示例

如果你看一下该console.log语句,你会注意到它打印了如下内容:

Container {props: {…}, context: {…}, refs: {…}, updater: {…}…}
context: Object
props: Object
refs: Object
state: null
updater: Object
_reactInternalInstance: Object
_reactInternals: FiberNode
__proto__: Container
Enter fullscreen mode Exit fullscreen mode

你会注意到它打印出了一个实例的值Container。实际上,如果我们运行以下代码,我们可以确认该ref.current值是该类的一个实例Container

console.log(container.current instanceof Container); // true
Enter fullscreen mode Exit fullscreen mode

但是,这个类什么?那些 props 是从哪里来的?好吧,如果你熟悉类继承,你就会知道它就是被扩展的属性React.Component。如果我们看一下这个React.Component的 TypeScript 定义,我们会发现它里面有一些非常熟悉的属性:

// This is an incomplete and inaccurate type definition shown for educational purposes - DO NOT USE IN PROD
class Component {
  render(): ReactNode;
  context: any;
  readonly props: Object;
  refs: any;
  state: Readonly<any>;
}
Enter fullscreen mode Exit fullscreen mode

不仅refsstatepropscontext与我们在 中看到的一致console.log,而且属于类的方法(如render)也存在:

console.log(this.container.current.render);
Enter fullscreen mode Exit fullscreen mode
ƒ render()
Enter fullscreen mode Exit fullscreen mode

自定义属性和方法

你不仅可以从类引用访问 React 组件的内置函数(例如renderprops),还可以访问附加到该类的数据。由于container.current是类的实例Container,因此当你添加自定义属性和方法时,它们也可以从引用中可见!

因此,如果你将类定义更改为如下所示:

class Container extends React.Component {
  welcomeMsg = "Hello"

  sayHello() {
    console.log("I am saying: ", this.welcomeMsg)
  }

  render() {
    return <div>{this.props.children}</div>;
  }
}
Enter fullscreen mode Exit fullscreen mode

然后您可以引用welcomeMsg属性和sayHello方法:

function App() {
  const container = React.useRef();

  React.useEffect(() => {
    console.log(container.current.welcomeMsg); // Hello
    container.current.sayHello(); // I am saying: Hello
  });

  return (
    <Container ref={container}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

单向流

虽然“通用定向流”的概念比我最初想在这篇文章中讨论的范围要广,但我认为理解为什么不应该使用上面概述的模式很重要。ref 如此有用的原因之一,也是它作为一个概念如此危险的原因之一:它破坏了单向数据流。

通常,在 React 应用程序中,您希望数据一次只传输一个方向。

从状态到视图、到动作、再回到状态的循环

让我们看一个遵循这种单向性的代码示例:

import React from "react";

class SimpleForm extends React.Component {
  render() {
    return (
      <div>
        <label>
          <div>Username</div>
          <input
            onChange={e => this.props.onChange(e.target.value)}
            value={this.props.value}
          />
        </label>
        <button onClick={this.props.onDone}>Submit</button>
      </div>
    );
  }
}

export default function App() {
  const [inputTxt, setInputTxt] = React.useState("");
  const [displayTxt, setDisplayTxt] = React.useState("");

  const onDone = () => {
    setDisplayTxt(inputTxt);
  };

  return (
    <div>
      <SimpleForm
        onDone={onDone}
        onChange={v => setInputTxt(v)}
        value={inputTxt}
      />
      <p>{displayTxt}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

在此示例中,由于onChange属性和value属性都被传递到SimpleForm组件中,因此您可以将所有相关数据保存在一个位置。您会注意到,组件内部没有任何实际逻辑SimpleForm。因此,该组件被称为“哑”组件。它用于样式和可组合性,但不用于逻辑本身。

一个合适的 React 组件应该是这样的。这种将状态从组件本身提升出来,并留下一个“哑”组件的模式源自 React 团队的指导。这种模式被称为“状态提升”

现在我们对要遵循的模式有了更好的理解,让我们来看看错误的做事方式。

打破建议的模式

与“提升状态”相反,我们将状态降回到SimpleForm组件中。然后,为了从 访问该数据App,我们可以使用ref属性从父级访问该数据。

import React from "react";

class SimpleForm extends React.Component {
  // State is now a part of the SimpleForm component
  state = {
    input: ""
  };

  onChange(e) {
    this.setState({
      input: e.target.value
    });
  }

  render() {
    return (
      <div>
        <label>
          <div>Username</div>
          <input onChange={this.onChange.bind(this)} value={this.state.input} />
        </label>
        <button onClick={this.props.onDone}>Submit</button>
      </div>
    );
  }
}

export default function App() {
  const simpleRef = React.useRef();
  const [displayTxt, setDisplayTxt] = React.useState("");

  const onDone = () => {
    // Reach into the Ref to access the state of the component instance
    setDisplayTxt(simpleRef.current.state.input);
  };

  return (
    <div>
      <SimpleForm 
        onDone={onDone} 
        ref={simpleRef} 
      />
      <p>{displayTxt}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

然而,问题在于,当你开始扩展时,你会发现管理这种双状态行为更加困难。甚至遵循应用程序逻辑也更加困难。让我们先来看看这两个组件的生命周期是如何直观呈现的。

首先我们先来看一下组件simpleRef,其中状态在SimpleForm组件中被“降低”了:

箭头指向 App 和 SimpleForm 之间的来回,以演示数据双向流动

在这个例子中,应用程序状态的流程如下:

  • App(以及它的子项,SimpleForm)渲染
  • 用户对存储在SimpleForm
  • 用户触发onDone操作,从而触发一个函数App
  • App onDone方法检查来自SimpleForm
  • 一旦数据返回到,它就会更改自己的数据,从而触发和 的重新App渲染AppSimpleForm

从上图和数据流的概述可以看出,您将数据分散在两个不同的位置。因此,修改此代码的思维模型可能会变得混乱且脱节。当onDone需要更改 中的状态时,此代码示例会变得更加复杂SimpleForm

现在,让我们将其与强制单向性所需的心理模型进行对比。

箭头指向从 App 到 SimpleForm 的单一圆形方向,以演示数据单向流动

  • App(以及它的子项,SimpleForm)渲染
  • 用户在 中进行更改,状态通过回调SimpleForm提升至App
  • 用户触发onDone操作,从而触发一个函数App
  • App onDone方法已经包含了它自己的组件中所需的所有数据,因此它只需重新渲染,AppSimpleForm无需任何额外的逻辑开销

如您所见,虽然这些方法之间的步骤数相似(并且在不太简单的示例中可能并非如此),但单向流程更加精简且更容易遵循。

这就是为什么 React 核心团队(以及整个社区)强烈建议您使用单向性,并且在不需要时正确地避免脱离该模式。

将数据添加到参考

如果你以前从未听说过useImperativeHandleHook,这就是原因所在。它允许你向转发/传递到组件的数据添加方法和属性ref。这样,你就可以直接在父组件中访问子组件的数据,而不必强制提升状态,因为这可能会破坏单向性。

让我们看一下可以使用来扩展的组件useImperativeHandle

import React from "react";
import "./style.css";

const Container = React.forwardRef(({children}, ref) => {
  return <div ref={ref} tabIndex="1">
    {children}
  </div>
})

export default function App() {
  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.focus();
  }, [elRef])

  return (
    <Container ref={elRef}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

正如您在嵌入式演示中所见,它将重点关注Container div应用程序渲染时的 。此示例未使用钩子,而是依赖于useImperativeHandle时机来useEffect定义refcurrent

假设我们想以Container div编程方式跟踪每次 获得焦点的时间。该怎么做呢?有很多方法可以实现该功能,但有一种不需要修改App(或其他Container消费者)的方法是使用useImperativeHandle

不仅useImperativeHandle允许向 ref 添加属性,还可以通过返回同名函数来提供本机 API 的替代实现。

import React from "react";
import "./style.css";

const Container = React.forwardRef(({children}, ref) => {
  const divRef = React.useRef();

  React.useImperativeHandle(ref, () => ({
    focus: () => {
      divRef.current.focus();
      console.log("I have now focused");
    }
  }))

  return <div ref={divRef} tabIndex="1">
    {children}
  </div>
})

export default function App() {
  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.focus();
  }, [elRef])

  return (
    <Container ref={elRef}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

如果您查看控制台,您会发现运行console.log时已经运行focus()

正如您可以的那样,useImperativeHandle可以与之结合使用,forwardRef以最大限度地提高组件 API 的自然外观。

但是,请注意,如果您希望使用自己的 API 来补充原生 API,则只有第二个参数返回的属性和方法才会被设置为 ref。这意味着如果您现在运行:

  React.useEffect(() => {
    elRef.current.style.background = 'lightblue';
  }, [elRef])
Enter fullscreen mode Exit fullscreen mode

在 中App,您将遇到一个错误,因为style不再定义elRef.current

话虽如此,你并不局限于原生 API 的名称。你认为这个代码示例在其他App组件中可能会做什么?

  React.useEffect(() => {
    elRef.current.konami();
  }, [elRef])
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

当你的焦点位于Container元素上时,尝试使用箭头键输入“Konami 代码”。完成后会有什么反应?

React Refs 中useEffect

我必须坦白:我一直在骗你。并非恶意,但我在之前的示例中反复使用了不该在生产环境中使用的代码。这是因为,如果不费吹灰之力,教授这些东西可能会很棘手。

有问题的代码是什么?

React.useEffect(() => {
  elRef.current.anything.here.is.bad();
}, [elRef])
Enter fullscreen mode Exit fullscreen mode

什么?

没错!你不应该把它放在elRef.current任何东西里面useEffect(除非你真的 真的 知道自己在做什么)。

为什么?

在我们全面回答这个问题之前,让我们先看看它是如何useEffect工作的。

假设我们有一个如下所示的简单组件:

const App = () => {
  const [num, setNum] = React.useState(0);

  React.useEffect(() => {
    console.log("Num has ran");
  }, [num])

  return (
    // ...
  )
}
Enter fullscreen mode Exit fullscreen mode

你可能以为,当num更新时,依赖项数组会“监听” 的变化num,并且当数据更新时,它会触发副作用。这种想法实际上是“useEffect 会主动监听数据更新,并在数据更改时运行副作用”。这种思维模式是不准确的,并且在实际使用中可能会很危险ref。甚至我直到开始写这篇文章时才意识到这是错误的!

在非 ref(useState/props)依赖数组跟踪下,这种推理通常不会在代码库中引入错误,但是当ref添加 s 时,由于误解,它会引发一系列麻烦。

useEffect 实际工作方式更加被动。在渲染期间,useEffect会检查依赖项数组中的值。如果任何值的内存地址发生变化(这意味着对象突变会被忽略),它就会运行副作用。这可能看起来与之前概述的理解类似,但这是“推”与“拉”的区别。useEffect它不会监听任何内容,也不会触发渲染,而是渲染触发useEffect对值的监听和比较。这意味着如果没有渲染,useEffect即使数组中的内存地址发生了变化,也无法运行副作用。

为什么在使用 s 时会出现这种情况ref?嗯,有两件事需要记住:

  • Refs 依赖于对象变异而不是重新分配
  • 当 aref发生变异时,它不会触发重新渲染

  • useEffect仅在重新渲染时检查数组

  • Ref 的当前属性集不会触发重新渲染(记住它实际上useRef是如何实现的

了解了这一点,让我们再看一次令人反感的例子:

export default function App() {
  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.style.background = "lightblue";
  }, [elRef]);

  return (
    <div ref={elRef}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

这段代码的行为正如我们最初预期的那样,这并不是因为我们做对了,而是由于 ReactuseEffect钩子的时间特性。

因为useEffect发生在第一次渲染之后elRef,所以在新值被赋值之前就已经赋值了elRef.current.style。但是,如果我们以某种方式打破了这种时间预期,就会看到不同的行为。

如果在初始渲染之后div进行渲染,您认为会发生什么?

export default function App() {
  const elRef = React.useRef();
  const [shouldRender, setRender] = React.useState(false);

  React.useEffect(() => {
    if (!elRef.current) return;
    elRef.current.style.background = 'lightblue';
  }, [elRef.current])

  React.useEffect(() => {
    setTimeout(() => {
      setRender(true);
    }, 100);
  }, []);

  return !shouldRender ? null : ( 
    <div ref={elRef}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

糟糕!背景不再存在了'lightblue'!因为我们延迟了 的渲染div所以初始渲染时不会赋值。之后,一旦elRef渲染完成它会修改.current的属性elRef来赋值 ref。由于修改不会触发重新渲染(并且useEffect只会在渲染期间运行),所以useEffect它没有机会“比较”值的差异,从而导致副作用。

困惑了吗?没关系!我一开始也是。我做了个游乐场,帮我们这些动觉学习者!

  const [minus, setMinus] = React.useState(0);
  const ref = React.useRef(0);

  const addState = () => {
    setMinus(minus + 1);
  };

  const addRef = () => {
    ref.current = ref.current + 1;
  };

  React.useEffect(() => {
    console.log(`ref.current:`, ref.current);
  }, [ref.current]);

  React.useEffect(() => {
    console.log(`minus:`, minus);
  }, [minus]);
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

打开控制台并记下console.log更改相应值时运行的内容!

如何使用这个例子?好问题!

首先,点击useState标题下方的按钮。你会注意到,每次点击按钮都会立即触发重新渲染,UI 中显示的值也会立即更新。因此,它使得useEffectnum依赖依赖)能够将先前的值与当前值进行比较(如果它们不匹配),并运行console.log副作用。

现在,一旦触发了useState“添加”按钮,就对 按钮执行相同的操作useRef。您可以根据需要多次点击它,但(单独)它永远不会触发重新渲染。由于useRef突变不会重新渲染 DOM,因此两者都无法useEffect比较值,因此都useEffect不会运行。但是, 中的值.current 正在更新 - 它们只是没有显示在 UI 中(因为组件没有重新渲染)。一旦触发重新渲染(useState再次按下“添加”按钮),它将更新 UI 以匹配 的内部内存值.current

简而言之- 尝试按useState两次“加”键。屏幕上的值为 2。然后,尝试按useRef三次“加”键。屏幕上的值为 0。useState再次按下 按钮,瞧 - 两个值都变成了 3!

核心团队的评论

由于ref在 a 中跟踪 a 会产生意想不到的影响useEffect,因此核心团队明确建议避免这样做。

Dan Abramov 在 GitHub 上说道:

正如我之前提到的,如果你把 [ref.current] 放在依赖项中,你很可能会犯一个错误。refs 指的是那些其更改不需要触发重新渲染的值。

如果您想在 ref 改变时重新运行效果,您可能需要回调 ref。

...两次:

当你尝试添加ref.current依赖项时,通常需要一个回调引用

Twitter 上也有人提到:

我认为你需要回调 ref 来实现这一点。你无法让组件神奇地对 ref 的变化做出反应,因为 ref 可以深入底层,并且拥有与所属组件无关的生命周期。

这些都是很好的观点...但是 Dan 所说的“回调引用”是什么意思呢?

回调引用

在本文开头,我们提到了另一种分配 ref 的方法。而不是:

<div ref={elRef}>
Enter fullscreen mode Exit fullscreen mode

这是有效的(并且稍微冗长一些):

<div ref={node => elRef.current = node}>
Enter fullscreen mode Exit fullscreen mode

这是因为ref可以接受回调函数。这些函数会使用元素节点本身进行调用。这意味着,如果你愿意,你可以内联.style我们在本文中多次使用的赋值语句:

<div ref={node => node.style.background = "lightblue"}>
Enter fullscreen mode Exit fullscreen mode

但是,你可能会想,如果它接受一个函数,我们可以传递一个之前在组件中声明的回调函数。没错!

  const elRefCB = React.useCallback(node => {
    if (node !== null) {
      node.style.background = "lightblue";
    }
  }, []);

  return !shouldRender ? null : (
    <div ref={elRefCB}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </div>
  );
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

但是嘿!等一下!即使shouldRender时间不匹配仍然存在,背景仍然会被应用!为什么useEffect时间不匹配不会导致我们之前遇到的 bug?

嗯,那是因为我们在这个例子中完全消除了 的使用useEffect!因为回调函数只运行一次ref,所以我们可以确定.current 存在,因此,我们可以在回调中分配属性值等等!

但我还需要把它传递ref给代码库的其他部分!我不能传递函数本身;那只是一个函数,而不是引用!

确实如此。不过,你可以将这两种行为结合起来,创建一个回调,并将其数据存储在 中useRef(以便稍后使用该引用)。

  const elRef = React.useRef();

  console.log("I am rendering");

  const elRefCB = React.useCallback(node => {
    if (node !== null) {
      node.style.background = "lightblue";
      elRef.current = node;
    }
  }, []);

  React.useEffect(() => {
    console.log(elRef.current);
  }, [elRef, shouldRender]);
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

useState参考文献

有时,仅使用useRef和回调引用是不够的。在极少数情况下,每当 中获取新值时,都需要重新渲染.current.。问题在于 的固有特性会.current阻止重新渲染。我们如何解决这个问题?.current将 替换为 ,即可完全避免useRef这种情况useState

您可以使用回调引用来分配给useState钩子,从而相对简单地完成此操作。

  const [elRef, setElRef] = React.useState();

  console.log('I am rendering');

  const elRefCB = React.useCallback(node => {
    if (node !== null) {
      setElRef(node);
    }
  }, []);

  React.useEffect(() => {
    console.log(elRef);
  }, [elRef])
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

现在ref更新会导致重新渲染,您现在可以安全地使用refinuseEffect的依赖数组。

 const [elNode, setElNode] = React.useState();

  const elRefCB = React.useCallback(node => {
    if (node !== null) {
      setElNode(node);
    }
  }, []);

  React.useEffect(() => {
    if (!elNode) return;
    elNode.style.background = 'lightblue';
  }, [elNode])
Enter fullscreen mode Exit fullscreen mode

运行相关代码示例

然而,这会以性能为代价。由于触发了重新渲染,因此它本身会比不触发重新渲染时慢。不过,这样做也有其合理之处。你只需要注意你的决策以及代码对它们的使用。

结论

与大多数工程工作一样,了解 API 的局限性、优势和变通方案可以提高性能,减少生产环境中的错误,并使代码组织更加便捷。既然您已经了解了 ref 的全部内容,您将如何运用这些知识呢?我们期待您的反馈!请在下方留言,或加入我们的 Discord 社区

文章来源:https://dev.to/this-is-learning/react-refs-the-complete-story-16km
PREV
React 与 Signals:十年之后
NEXT
微前端:我的经验教训