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