【问题标题】:React/Typescript forwardRef types for an element which returns either an input or textArea返回输入或 textArea 的元素的 React/Typescript forwardRef 类型
【发布时间】:2021-01-05 20:54:03
【问题描述】:

我正在尝试使用 react 和 typescript 为我们的应用程序创建一个通用的文本输入组件。我希望它能够成为基于给定道具的输入元素或文本区域元素。所以看起来有点像这样:

import {TextArea, Input} from 'ourComponentLibrary'

export const Component = forwardRef((props, ref) => {
  const Element = props.type === 'textArea' ? TextArea : Input

  return (
    <Element ref={ref} />
  )
})

此代码运行良好。但是,当尝试合并类型时,它变得有点冒险。根据传递的 type 属性,引用类型应该是 HTMLInputElementHTMLTextAreaElement。在我的脑海中,它看起来像这样:

interface Props {
  ...
}

export const Component = forwardRef<
  HTMLInputElement | HTMLTextAreaElement,
  Props
>((props, ref) => {
  ...
});

但是我知道这不是我所需要的。因此,错误: Type 'HTMLInputElement' is missing the following properties from type 'HTMLTextAreaElement': cols, rows, textLength, wrap

总而言之,我希望类型对齐,以便如果type 属性为textArea,则ref 类型应为HTMLTextAreaElement,如果类型属性为input,则引用类型应是HTMLInputAreaElement

有什么建议吗?

谢谢。

【问题讨论】:

  • 在我进一步调查之前快速提问。通过对 textarea 和 input 使用单个组件,您实际寻求的最终结果是什么?对我来说,似乎将它们分开到自己的组件是更好的方法
  • 您基本上是在尝试在编译时进行运行时类型检查,这是不可能的。 Component 未确定类型,因此您在运行时检查组件内的道具。当您确切知道它将是什么类型时,您只能将类型强制转换为 HTMLInputElementHTMLTextAreaElement

标签: reactjs typescript react-ref react-forwardref


【解决方案1】:

虽然这决不能解决React.forwardProps 的问题,但另一种方法是解决它,而是使用innerRef 属性。然后,您可以在 innerRef 属性上强制执行类型。实现您想要的相同结果,但键入灵活、开销更少且无需实例化。

工作演示:


组件/标签/index.tsx

import * as React from "react";
import { FC, LabelProps } from "~types";

/*
  Field label for form elements

  @param {string} name - form field name
  @param {string} label - form field label 
  @returns {JSX.Element}
*/
const Label: FC<LabelProps> = ({ name, label }) => (
  <label className="label" htmlFor={name}>
    {label}&#58;
  </label>
);

export default Label;

组件/字段/index.tsx

import * as React from "react";
import Label from "../Label";
import { FC, InputProps, TextAreaProps } from "~types";

/*
  Field elements for a form that are conditionally rendered by a fieldType
  of "input" or "textarea".

  @param {Object} props - properties for an input or textarea
  @returns {JSX.Element | null} 
*/
const Field: FC<InputProps | TextAreaProps> = (props) => {
  switch (props.fieldType) {
    case "input":
      return (
        <>
          <Label name={props.name} label={props.label} />
          <input
            ref={props.innerRef}
            name={props.name}
            className={props.className}
            placeholder={props.placeholder}
            type={props.type}
            value={props.value}
            onChange={props.onChange}
          />
        </>
      );
    case "textarea":
      return (
        <>
          <Label name={props.name} label={props.label} />
          <textarea
            ref={props.innerRef}
            name={props.name}
            className={props.className}
            placeholder={props.placeholder}
            rows={props.rows}
            cols={props.cols}
            value={props.value}
            onChange={props.onChange}
          />
        </>
      );
    default:
      return null;
  }
};

export default Field;

components/Form/index.tsx

import * as React from "react";
import Field from "../Fields";
import { FormEvent, FC, EventTargetNameValue } from "~types";

const initialState = {
  email: "",
  name: "",
  background: ""
};

const Form: FC = () => {
  const [state, setState] = React.useState(initialState);
  const emailRef = React.useRef<HTMLInputElement>(null);
  const nameRef = React.useRef<HTMLInputElement>(null);
  const bgRef = React.useRef<HTMLTextAreaElement>(null);

  const handleChange = React.useCallback(
    ({ target: { name, value } }: EventTargetNameValue) => {
      setState((s) => ({ ...s, [name]: value }));
    },
    []
  );

  const handleReset = React.useCallback(() => {
    setState(initialState);
  }, []);

  const handleSubmit = React.useCallback(
    (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();

      const alertMessage = Object.values(state).some((v) => !v)
        ? "Must fill out all form fields before submitting!"
        : JSON.stringify(state, null, 4);

      alert(alertMessage);
    },
    [state]
  );

  return (
    <form className="uk-form" onSubmit={handleSubmit}>
      <Field
        innerRef={emailRef}
        label="Email"
        className="uk-input"
        fieldType="input"
        type="email"
        name="email"
        onChange={handleChange}
        placeholder="Enter email..."
        value={state.email}
      />
      <Field
        innerRef={nameRef}
        label="Name"
        className="uk-input"
        fieldType="input"
        type="text"
        name="name"
        onChange={handleChange}
        placeholder="Enter name..."
        value={state.name}
      />
      <Field
        innerRef={bgRef}
        label="Background"
        className="uk-textarea"
        fieldType="textarea"
        rows={5}
        name="background"
        onChange={handleChange}
        placeholder="Enter background..."
        value={state.background}
      />
      <button
        className="uk-button uk-button-danger"
        type="button"
        onClick={handleReset}
      >
        Reset
      </button>
      <button
        style={{ float: "right" }}
        className="uk-button uk-button-primary"
        type="submit"
      >
        Submit
      </button>
    </form>
  );
};

export default Form;

types/index.ts

import type {
  FC,
  ChangeEvent,
  RefObject as Ref,
  FormEvent,
  ReactText
} from "react";

// custom utility types that can be reused
type ClassName = { className?: string };
type InnerRef<T> = { innerRef?: Ref<T> };
type OnChange<T> = { onChange: (event: ChangeEvent<T>) => void };
type Placeholder = { placeholder?: string };
type Value<T> = { value: T };

// defines a destructured event in a callback
export type EventTargetNameValue = {
  target: {
    name: string;
    value: string;
  };
};

/*
  Utility interface that constructs typings based upon passed in arguments

  @param {HTMLElement} E - type of HTML Element that is being rendered
  @param {string} F - the fieldType to be rendered ("input" or "textarea")
  @param {string} V - the type of value the field expects to be (string, number, etc)
*/
interface FieldProps<E, F, V>
  extends LabelProps,
    ClassName,
    Placeholder,
    OnChange<E>,
    InnerRef<E>,
    Value<V> {
  fieldType: F;
}

// defines props for a "Label" component
export interface LabelProps {
  name: string;
  label: string;
}

// defines props for an "input" element by extending the FieldProps interface
export interface InputProps
  extends FieldProps<HTMLInputElement, "input", ReactText> {
  type: "text" | "number" | "email" | "phone";
}

// defines props for an "textarea" element by extending the FieldProps interface
export interface TextAreaProps
  extends FieldProps<HTMLTextAreaElement, "textarea", string> {
  cols?: number;
  rows?: number;
}

// exporting React types for reusability
export type { ChangeEvent, FC, FormEvent };

index.tsx

import * as React from "react";
import { render } from "react-dom";
import Form from "./components/Form";
import "uikit/dist/css/uikit.min.css";
import "./index.css";

render(<Form />, document.getElementById("root"));

【讨论】:

  • 嗯,我认为这是行不通的,forwardRef() 是传递参考给孩子的必要条件。这就引出了一个问题,如果这种更直接的方法可行,那么使用forwardRef() 到底有什么意义?顺便说一句,很好的例子,但是除了工作代码演示之外,最好对正在发生的事情进行更多解释。 :)
  • innerRef 解决方法已经存在了一段时间(在 16.3 之前 forwardProps 不可用),但它本质上是一样的。我的假设是 forwardRef 的建立是为了提供一种将 ref 传递给孩子的标准化限制模式。为避免混淆,它确保refprops 明显分开。此外,有些人可能会发现 innerRef 是一个模棱两可的属性,因为它不是将 ref 附加到元素的官方方式。更新了上面的注释,如果还有什么不清楚的,请告诉我。
  • 获得赏金!感谢您的解决方案@jered,但这感觉更惯用。谢谢大家!
【解决方案2】:

我知道我回答这个问题真的很晚了,但这就是我解决这个问题的方法。也许有一天这会对其他人有所帮助。

type InputElement = 'input' | 'textarea'

export type InputProps<E extends InputElement> = {
    multiline: E extends 'textarea' ? true : false
    /* rest of props */
}

const Component = React.forwardRef(function Component<E extends InputElement>(
    props: InputProps<E>,
    ref: React.Ref<HTMLElementTagNameMap[E] | null>,
) {

【讨论】:

  • 嗨,Keith,感谢您回答这个问题!在回答时,包含关于代码如何以及为什么工作的解释会很有帮助。请随时edit your answer 提供更多详细信息。
【解决方案3】:

这是一个棘手的问题,我能想到的唯一可行方法是使用 higher order componentfunction overloading

基本上,我们必须创建一个函数,该函数本身将根据传递的参数返回一种或另一种类型的组件。

// Overload signature #1
function MakeInput(
  type: "textArea"
): React.ForwardRefExoticComponent<
  TextAreaProps & React.RefAttributes<HTMLTextAreaElement>
>;
// Overload signature #2
function MakeInput(
  type: "input"
): React.ForwardRefExoticComponent<
  InputProps & React.RefAttributes<HTMLInputElement>
>;
// Function declaration
function MakeInput(type: "textArea" | "input") {
  if (type === "textArea") {
    const ret = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
      (props, ref) => {
        return <TextArea {...props} ref={ref} />;
      }
    );
    return ret;
  } else {
    const ret = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
      return <Input {...props} ref={ref} />;
    });
    return ret;
  }
}

然后,用组件的“类型”调用高阶组件函数MakeInput(),实例化你要渲染的组件类型:

export default function App() {
  const textAreaRef = React.useRef<HTMLTextAreaElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);

  const MyTextArea = MakeInput("textArea");
  const MyInput = MakeInput("input");

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <MyTextArea value={"Foo"} ref={textAreaRef} />
      <MyInput value={"Bar"} ref={inputRef} />
    </div>
  );
}

现在,这可能会让人感到“不满意”,因为这大致相当于在此处进行条件检查,以查看要根据 type 呈现的组件类型,只是抽象成一个函数。但是,您不能渲染一个神奇的&lt;MyTextAreaOrInputComponent /&gt; 并对其propsref 属性进行两个 的完整类型检查。为此,您将不得不责怪 React 本身,因为 ref 道具,如 key 和可能的其他一些道具,非常非常特殊,并且被 React 独特地对待,这正是 React.forwardRef() 在第一名。

但是如果你仔细想想,实际上你仍然在检查你正在寻找的 prop 类型,只是你添加了一个额外的步骤,调用 MakeInput() 来确定组件类型。所以不要写这个:

return <Component type="textArea" ref={textAreaRef} />

你正在写这个:

const MyComponent = MakeInput("textArea");

return <MyComponent ref={textAreaRef} />

在这两种情况下,您在编写代码时显然必须知道typeref 的值both。由于React.forwardRef() 的工作方式,前一种情况无法正常工作(据我所知)。但是后一种情况可能的,并且为您提供完全相同级别的类型检查,只是需要额外的步骤。

https://codesandbox.io/s/nostalgic-pare-pqmfu?file=/src/App.tsx

注意:玩弄上面的沙箱,看看虽然&lt;Input/&gt;&lt;TextArea/&gt; 相比,extraInputValue 有一个额外的道具,但高阶组件如何优雅地处理它。另请注意,使用有效字符串值调用 MakeInput() 来创建组件会导致预期和正确的道具类型检查。

编辑:另一个说明“魔术子弹”组件与使用 HOC 在类型检查方面的功能相同,因为在您的场景中,您知道 typeref 应该代表什么 HTML 元素在预编译时,您实际上可以只执行包含相同数量信息的 IIFE:

  return <div>
      {(function(){
        const C = MakeInput("textArea");
        return <C value={"Baz"} ref={textAreaRef} />
      })()}
  </div>;

【讨论】:

    猜你喜欢
    • 2022-12-31
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-01-10
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多