关于父子组件状态同步的碎碎念

一个可能并不常见的问题:如果父组件中的逻辑依赖于子组件状态,那么能够通过 ref 拿到正确的状态值吗?

最近写代码的时候遇到了这样一个场景:

  • 子组件是外部的封装组件。子组件通过 useEffect 设置初始化之后的组件状态,并将状态放置在 ref 上。举例而言,子组件可以是一个画布组件,内部维护了画布大小的状态;
  • 父组件是业务开发的组件。组件逻辑中依赖于子组件的内部状态做一些内容的展示 & 样式的调整。举例而言,父组件是画布的容器,需要根据画布大小调整自身的尺寸。

在这个场景里头,父组件是否能够通过 ref 来正确的取到子组件的内部状态呢?

省流:本文只介绍父子组件状态同步的场景及结论(一定程度上只是作为引子),具体的 React 源码级别解析请关注我的下一篇文章。

撸起袖子

想知道上面这个场景,最简单直白的方式就是做个例子:

父组件:

ts 复制代码
import { useEffect, useRef } from "react";
import { Child } from "./Child";

export function Parent() {
  console.log('Parent Render');

  const ref = useRef<{ info: any; log: () => void }>();

  // 获取 ref 内容
  useEffect(() => {
    console.log('Parent useEffect');
    console.log('Ref', ref.current);
    ref.current?.log();
    return () => {
      console.log('Unmounted');
    }
  }, []);

  // 错误的 ref 依赖
  useEffect(() => {
    ref.current?.log();
  }, [ref.current?.info]);

  return (
    <div>
      <p>Parent Node</p>
      <button onClick={() => {
        console.log('Click!');
        ref.current?.log();
      }}>Click</button>

      <Child ref={ref} />
    </div>
  );
}

子组件:

ts 复制代码
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";

export const Child = forwardRef(function InnerChild(props, ref) {
  console.log('Child Render', props);

  const [childInfo, setChildInfo] = useState({
    height: 0,
    width: 0,
  });

  // 在 useEffect 中更新内部状态
  useEffect(() => {
    // ...do something...
    console.log('Child useEffect');
    setChildInfo({
      height: 500,
      width: 400,
    })
  }, []);

  // 根据内部状态更新 ref
  useImperativeHandle(ref, () => {
    console.log('Child useImperativeHandle');
    return {
      info: childInfo,
      log: () => {
        console.log('This is a child node! Child info:', childInfo);
      }
    };
  }, [childInfo]);

  return <div><p>Child Node</p></div>;
});

初始化之后,点击父节对应的执行结果为:

根据例子的执行结果,我们可以给出结论:

  • 在初始化渲染时,虽然 useEffect 的顺序(无依赖的情况下)是子组件先于父组件,但是由于 useImperativeHandle 依赖于内部状态的更新,因此初始渲染中父组件的 useEffect 拿不到更新后的子组件 ref 结果;
  • 子组件 useEffect 触发了内部状态的更新,这一更新在后续也会引起包含依赖的 useImperativeHandle 的执行。因此,在最后的事件 / 异步逻辑中,父组件可以拿到更新后的子组件 ref;

另寻出路

通过 state & ref 的方式,父组件只能在后续异步逻辑里头拿到更新的子组件状态,由于 ref 并不会触发依赖的更新,因此父组件实际上没法拿到子组件中 ref 状态更新的时机。

那么,有什么办法能够让父组件正确的获取子组件状态吗?

由于父子组件之间的执行 mount 相关 hook 的顺序是子组件先于父组件,因此,我们可以去掉 state 的使用,直接在子组件的 mount 下更新 ref:

ts 复制代码
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";

export const Child = forwardRef(function InnerChild(props, ref) {
  console.log('Child Render', props);

  // 通过 useImperativeHandle 执行副作用
  useImperativeHandle(ref, () => {
    // do something
    console.log('Child useImperativeHandle');
    const childInfo = { width: 500, height: 400 };
    return {
      stateInfo: childInfo,
      log: () => {
        console.log('This is a child node! Child info:', childInfo);
      }
    };
  }, []);

  return <div><p>Child Node</p></div>;
});

如果把副作用的执行(原本在 useEffect 中的逻辑)挪到 useImperativeHandle 中来,那么我们实际上可以直接为 ref 设置更新后的结果。对应的执行效果为:

通过这样 hack 的方式,我们可以在 不需要使用 state & 副作用为同步逻辑 的情况下正确的让父组件拿到子组件信息。尤其需要注意的是,虽然 hook 执行的时机下,父子组件之前的 useEffect 顺序是确定的,但是一旦 effect 内部存在异步逻辑,那么 effect 最终执行完毕的顺序就会变得不确定。(同理,异步逻辑也常出现在 useEffect 的返回值中,任何情况下都应当避免直接将 async 函数直接交给 useEffect 执行。)

更好的处理方式是什么呢?

实际上 react 在自己的官网教程中提到了许多针对于实际使用场景的优化点。例如,在父子组件公用数据的情况下,react 建议将数据逻辑修改为 data 从父组件流向子组件(zh-hans.react.dev/learn/you-m...):

ts 复制代码
function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ 非常好:向子组件传递数据
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

如果子组件由于一些封装的逻辑(例如通过 useEffect 渲染图表,随后获取图表信息),无法将状态放到父组件上时,也可以通过子组件向父组件暴露一个事件接口,从而通知父组件更新的时机(zh-hans.react.dev/learn/you-m...):

ts 复制代码
function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }

  // ...
}

碎碎念

上述内容中写的例子,包括我目前手上开发的项目基本上都还是 react 18.x 版本的。等到在刚刚发布的 react 19 版本推广普及之后,我们就不用继续写 forwardRef 了:

ref as a prop

Starting in React 19, you can now access ref as a prop for function components:

New function components will no longer need forwardRef, and we will be publishing a codemod to automatically update your components to use the new ref prop. In future versions we will deprecate and remove forwardRef.

react.dev/blog/2024/1...

ts 复制代码
function MyInput({placeholder, ref}) {
  return <input placeholder={placeholder} ref={ref} />
}

//...
<MyInput ref={ref} />

我们可以直接从 props 中拿到 ref,使用起来更为灵活了,这大概也意味着我们能够对 ref 做更多混乱的骚操作了(笑。

另外提一嘴,在做 React 测试示例的时候,需要注意不使用 React StrictMode(react.dev/reference/r...)(点名表扬 vite 的 react 模板项目)。虽然在实际的开发中,StrickMode 能够帮助开发者更早的发现函数组件不纯导致的问题,但是 StrictMode 所触发的多次渲染会扰乱开发者对实际渲染顺序的观察(执行两次 mount 确实还是显得很古怪😂)。

相关推荐
拉不动的猪11 分钟前
electron的主进程与渲染进程之间的通信
前端·javascript·面试
软件技术NINI35 分钟前
html css 网页制作成品——HTML+CSS非遗文化扎染网页设计(5页)附源码
前端·css·html
fangcaojushi36 分钟前
npm常用的命令
前端·npm·node.js
阿丽塔~1 小时前
新手小白 react-useEffect 使用场景
前端·react.js·前端框架
鱼樱前端1 小时前
Rollup 在前端工程化中的核心应用解析-重新认识下Rollup
前端·javascript
m0_740154671 小时前
SpringMVC 请求和响应
java·服务器·前端
加减法原则1 小时前
探索 RAG(检索增强生成)
前端
禁止摆烂_才浅2 小时前
前端开发小技巧 - 【CSS】- 表单控件的 placeholder 如何控制换行显示?
前端·css·html
烂蜻蜓2 小时前
深度解读 C 语言运算符:编程运算的核心工具
java·c语言·前端
PsG喵喵2 小时前
用 Pinia 点燃 Vue 3 应用:状态管理革新之旅
前端·javascript·vue.js