•
阅读时长 2 分钟

ref 应用场景:读取异步回调的值

目录

ref 常见的用法是拿到输入框、调用 focus。它还可以保存一个跨渲染存在的值,但它和 state 的用法不同:修改 ref.current 不会让页面重新渲染。

异步回调里很容易看出这个区别。点下按钮时 count 是 0,延迟回调执行前又点了一次加一,回调读到的 count 会是什么?

创建一个空目录,安装 React 和打包工具。示例不用 JSX,省去 Babel 配置,组件逻辑和 JSX 的写法是一样的。

npm init -y
npm install --save-exact react@18.3.1 react-dom@18.3.1
npm install --save-dev --save-exact webpack@5.99.9

创建 build.cjs:

const webpack = require("webpack");
const path = require("path");
webpack(
  {
    mode: "development",
    entry: path.resolve("main.js"),
    output: { path: path.resolve("dist"), filename: "main.js" },
    devtool: "source-map",
  },
  (error, stats) => {
    if (error || stats.hasErrors()) {
      console.error(error || stats.toString("errors-only"));
      process.exitCode = 1;
    }
  },
);

创建 index.html:

<!doctype html>
<html lang="zh-CN">
  <meta charset="utf-8" /><meta name="viewport" content="width=device-width" />
  <title>React 实验</title>
  <div id="root"></div>
  <script src="./dist/main.js"></script>
</html>

把下面的代码存为 main.js,运行 node build.cjs,然后用浏览器打开 index.html。这里用开发构建和根节点 StrictMode,观察次数时要把这两个条件算进去。

import React, {
  useState,
  useRef,
  useEffect,
  useMemo,
  useContext,
  memo,
  StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
const e = React.createElement;
window.lab = { effects: [], contexts: { stable: 0, unstable: 0 }, measurements: [] };
function Refs() {
  const [count, setCount] = useState(0);
  const latest = useRef(count);
  const input = useRef(null);
  const [log, setLog] = useState("");
  useEffect(() => {
    latest.current = count;
  }, [count]);
  return e(
    "section",
    null,
    e("h2", null, "ref 与异步回调"),
    e("input", { ref: input, "aria-label": "ref input" }),
    e("button", { id: "focus", onClick: () => input.current.focus() }, "聚焦"),
    e("button", { id: "increment", onClick: () => setCount((v) => v + 1) }, "加一"),
    e(
      "button",
      {
        id: "delayed",
        onClick: () =>
          setTimeout(
            () => setLog("snapshot=" + count + ", latest=" + latest.current),
            200,
          ),
      },
      "延迟读取",
    ),
    e("p", { id: "count" }, count),
    e("p", { id: "ref-log" }, log),
  );
}

createRoot(document.querySelector("#root")).render(e(StrictMode, null, e(Refs)));

点聚焦,焦点移到输入框。点延迟读取,随后在 200ms 内点加一,页面会显示 snapshot=0, latest=1。

count 来自创建回调的那次渲染,所以保留了当时的值。latest 是稳定的 ref 对象,读取 latest.current 时拿到已经同步进去的新值。这两个结果都合理,关键是回调需要哪一种语义。

如果是在提交订单,点击时确认的金额可能就应该保留快照;如果是持续运行的订阅回调,可能需要最新配置。不能看到“旧值”就一律换成 ref。

ref 也有同步时机

这里在 effect 中更新 latest.current,表示提交以后再同步。它不是任意时刻都和 state 完全一致;刚触发更新、提交尚未完成时,ref 可能还没有更新。

示例的延迟任务只是显示一行结果。如果换成请求或长期定时器,要补上取消和卸载清理。需要页面随着值改变而更新的内容,仍然放 state。把所有状态改成 ref,只会让界面不知道什么时候更新。

还要避免在渲染过程中随意写 ref.current。渲染可能被重复执行或放弃,渲染期间的修改不适合承担外部同步。使用规则参考 React useRef 文档。