使用 useImperativeHandle 修改子组件的 state

April 10, 2021

前言

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

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

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

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

如何修改子组件的 state

举个 🌰 例子:

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

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" 后向服务端发送请求。

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 共享。

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>
  );
};
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 方法,它的函数签名如下:

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 这样的命令式代码,但此情此景,非它莫属。

具体看下面的代码示例:

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


Written by B2D1(包邦东)