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

一个可能并不常见的问题:如果父组件中的逻辑依赖于子组件状态,那么能够通过 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 确实还是显得很古怪😂)。

相关推荐
老前端的功夫16 小时前
TypeScript 类型魔术:模板字面量类型的深层解密与工程实践
前端·javascript·ubuntu·架构·typescript·前端框架
Nan_Shu_61417 小时前
学习: Threejs (2)
前端·javascript·学习
G_G#17 小时前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界17 小时前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路17 小时前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug17 小时前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213817 小时前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中18 小时前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路18 小时前
GDAL 实现矢量合并
前端
hxjhnct18 小时前
React useContext的缺陷
前端·react.js·前端框架