使用 useImperativeHandle 修改子组件的 state

April 10, 2021

前言

在 React 的开发理念中,一切皆由组件渲染,而组件数据分为两大来源:state 和 props.

state 指组件自身内部的数据,props 指父组件传递给子组件的数据。

严格来说,props 也属于 state,因为总有一个最顶级父组件持有 state,当传递至子组件时,也就变成了 "props".

一般情况下,子组件的 state 只在该组件内部被使用,但如果父组件需要改变子组件的 state,又该如何是好?

如何修改子组件的 state

举个 🌰 例子:

子组件负责渲染 label 和 input,并处理受控组件的文字输入。

tsx
interface IChild {
onChange: (val: string) => void;
}
const Child: FC<IChild> = ({ onChange, children }) => {
const [val, setVal] = useState("");
const handleInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { value } = ev.target;
setVal(value);
onChange(value);
};
return (
<label>
{children}:&nbsp;&nbsp;
<input value={val} onChange={handleInputChange} />
</label>
);
};
tsx
interface IChild {
onChange: (val: string) => void;
}
const Child: FC<IChild> = ({ onChange, children }) => {
const [val, setVal] = useState("");
const handleInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { value } = ev.target;
setVal(value);
onChange(value);
};
return (
<label>
{children}:&nbsp;&nbsp;
<input value={val} onChange={handleInputChange} />
</label>
);
};

父组件负责渲染 button,拿到 "name" 后向服务端发送请求。

tsx
export default function Parent() {
const [name, SetName] = useState("");
const handleChange = (val: string) => {
SetName(val);
};
const handleSend = () => {
console.log(`your name: ${name} will be sent`);
// fakePost(someUrl, name)
SetName("");
// next we how to empty Child's input field
};
return (
<div className="App">
<Child onChange={handleChange}>
<span>Please Enter your name</span>
</Child>
&nbsp;&nbsp;
<button onClick={handleSend}>send</button>
</div>
);
}
tsx
export default function Parent() {
const [name, SetName] = useState("");
const handleChange = (val: string) => {
SetName(val);
};
const handleSend = () => {
console.log(`your name: ${name} will be sent`);
// fakePost(someUrl, name)
SetName("");
// next we how to empty Child's input field
};
return (
<div className="App">
<Child onChange={handleChange}>
<span>Please Enter your name</span>
</Child>
&nbsp;&nbsp;
<button onClick={handleSend}>send</button>
</div>
);
}

注意,当请求被发送后,父组件需要清空输入框的值。

由于子组件中的 input 是受控组件,父组件必须要改变子组件内部 state,即 name 的值。

lift state up(状态提升)

如果你使用过 Redux 等全局状态管理工具,会很自然想到 Context 的概念,将子组件的 state 提升至父组件中的 state(Context)进行管理,父组件再将 state 作为 props 传递至子组件

这么做的目的是只在父组件维护一份 state,父子组件实现 state 共享。

tsx
interface IChild {
val: string;
setVal: (newVal: string) => void;
onChange: (val: string) => void;
}
const Child: FC<IChild> = ({ onChange, children, val, setVal }) => {
const handleInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { value } = ev.target;
setVal(value);
onChange(value);
};
return (
<label>
{children}:&nbsp;&nbsp;
<input value={val} onChange={handleInputChange} />
</label>
);
};
tsx
interface IChild {
val: string;
setVal: (newVal: string) => void;
onChange: (val: string) => void;
}
const Child: FC<IChild> = ({ onChange, children, val, setVal }) => {
const handleInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { value } = ev.target;
setVal(value);
onChange(value);
};
return (
<label>
{children}:&nbsp;&nbsp;
<input value={val} onChange={handleInputChange} />
</label>
);
};
tsx
export default function Parent() {
const [name, SetName] = useState("");
const [val, setVal] = useState("");
const handleChange = (val: string) => {
SetName(val);
};
const handleSend = () => {
console.log(`your name: ${name} will be sent`);
// fakePost(someUrl, name)
SetName("");
// empty Child's input field
setVal("");
};
return (
<div className="App">
<Child onChange={handleChange} val={val} setVal={setVal}>
<span>Please Enter your name</span>
</Child>
&nbsp;&nbsp;
<button onClick={handleSend}>send</button>
</div>
);
}
tsx
export default function Parent() {
const [name, SetName] = useState("");
const [val, setVal] = useState("");
const handleChange = (val: string) => {
SetName(val);
};
const handleSend = () => {
console.log(`your name: ${name} will be sent`);
// fakePost(someUrl, name)
SetName("");
// empty Child's input field
setVal("");
};
return (
<div className="App">
<Child onChange={handleChange} val={val} setVal={setVal}>
<span>Please Enter your name</span>
</Child>
&nbsp;&nbsp;
<button onClick={handleSend}>send</button>
</div>
);
}

在线例子:CodeSandbox - lift state up

状态提升虽然实现简单、易理解,但也存在弊端:

  • <Child /> 组件和父组件强耦合
  • setNamesetVal 两个 useState 显得冗余

useImperativeHandle(命令式获取)

在这种情形下,我比较倾向于使用 React Hooks 中的 useImperativeHandle 方法,它的函数签名如下:

tsx
useImperativeHandle(ref, createHandle, [deps]);
tsx
useImperativeHandle(ref, createHandle, [deps]);

官网上是这么描述的,useImperativeHandle 可以让你在使用 ref 时 自定义暴露给父组件的实例值

上面那句话比较绕口,通俗来说,ref 在 React 中实际上是一个可变对象(ref.current),他除了可以传递给原生的 HTML 标签以访问 DOM,还可以搭配 forwardRef 传递给子组件,子组件转发 ref 到其内部的 HTML 标签,从而将 DOM 对象赋值给 ref.current.

有了 useImperativeHandle 的存在,ref.current 可以不仅仅被赋值为一个 DOM 对象,还可以是一个普通(字面量)对象,对象里的方法,可以访问子组件的任意变量(上下文)。

于是父组件就能通过 ref.current.customMethod() 的方式修改子组件的 state.

虽然官方提倡在大多数情况下,应当避免使用 ref 这样的命令式代码,但此情此景,非它莫属。

具体看下面的代码示例:

tsx
import React, {
useState,
useImperativeHandle,
forwardRef,
useRef,
} from "react";
interface IChild {
onChange: (val: string) => void;
children: React.ReactNode;
}
interface IRef {
emptyField(): void;
}
const Child = forwardRef<IRef, IChild>(({ onChange, children }, ref) => {
const [val, setVal] = useState("");
useImperativeHandle(
ref,
() => ({
emptyField() {
setVal("");
},
}),
[]
);
const handleInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { value } = ev.target;
setVal(value);
onChange(value);
};
return (
<label>
{children}:&nbsp;&nbsp;
<input value={val} onChange={handleInputChange} />
</label>
);
});
export default function Parent() {
const [name, SetName] = useState("");
const childRef = useRef<IRef>(null);
const handleChange = (val: string) => {
SetName(val);
};
const handleSend = () => {
console.log(`your name: ${name} will be sent`);
// fakePost(someUrl, name)
SetName("");
// empty Child's input field
childRef.current?.emptyField();
};
return (
<div className="App">
<Child onChange={handleChange} ref={childRef}>
<span>Please Enter your name</span>
</Child>
&nbsp;&nbsp;
<button onClick={handleSend}>send</button>
</div>
);
}
tsx
import React, {
useState,
useImperativeHandle,
forwardRef,
useRef,
} from "react";
interface IChild {
onChange: (val: string) => void;
children: React.ReactNode;
}
interface IRef {
emptyField(): void;
}
const Child = forwardRef<IRef, IChild>(({ onChange, children }, ref) => {
const [val, setVal] = useState("");
useImperativeHandle(
ref,
() => ({
emptyField() {
setVal("");
},
}),
[]
);
const handleInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { value } = ev.target;
setVal(value);
onChange(value);
};
return (
<label>
{children}:&nbsp;&nbsp;
<input value={val} onChange={handleInputChange} />
</label>
);
});
export default function Parent() {
const [name, SetName] = useState("");
const childRef = useRef<IRef>(null);
const handleChange = (val: string) => {
SetName(val);
};
const handleSend = () => {
console.log(`your name: ${name} will be sent`);
// fakePost(someUrl, name)
SetName("");
// empty Child's input field
childRef.current?.emptyField();
};
return (
<div className="App">
<Child onChange={handleChange} ref={childRef}>
<span>Please Enter your name</span>
</Child>
&nbsp;&nbsp;
<button onClick={handleSend}>send</button>
</div>
);
}

在线例子:CodeSandbox - useImperativeHandle


B2D1 (包邦东)

Written by B2D1 (包邦东)