React/Typescript 元素的 forwardRef 类型 returns input 或 textArea
React/Typescript forwardRef types for an element which returns either an input or textArea
我正在尝试使用 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
属性,ref 类型应该是 HTMLInputElement
或 HTMLTextAreaElement
。在我看来,它看起来像这样:
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
,如果类型prop 是 input
那么 ref 类型应该是 HTMLInputAreaElement
有什么建议吗?
谢谢。
这是一个棘手的问题,我认为唯一可行的方法是使用 higher order component and function overloading。
基本上,我们必须创建一个函数,它本身将 return 一种类型的组件或另一种类型的组件,具体取决于它传递的参数。
// 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
呈现哪种类型的组件,只是抽象为一个函数。但是,您 不能 渲染一个神奇的 <MyTextAreaOrInputComponent />
并在 both 其 props
和 ref
属性。为此,你将不得不责怪 React 本身,因为 ref
道具,就像 key
和可能的其他一些道具一样,非常非常特殊,并且被 React 独特地对待,这正是需要 React.forwardRef()
排在首位。
但是如果你考虑一下,实际上你仍然得到了你正在寻找的道具类型检查,只是你添加了一个额外的步骤来调用 MakeInput()
来确定组件类型.所以不要这样写:
return <Component type="textArea" ref={textAreaRef} />
你正在写这个:
const MyComponent = MakeInput("textArea");
return <MyComponent ref={textAreaRef} />
在这两种情况下,您显然 必须 知道 both type
和 ref
在编写代码的时间。由于 React.forwardRef()
的工作方式,前一种情况不可能起作用(据我所知)。但后一种情况 是 可能的,并且为您提供完全相同级别的类型检查,只是需要额外的步骤。
https://codesandbox.io/s/nostalgic-pare-pqmfu?file=/src/App.tsx
注意:尝试一下上面的沙箱,看看即使 <Input/>
与 <TextArea/>
相比有一个额外的属性 extraInputValue
,但高阶组件如何优雅地处理它。另请注意,使用任一有效字符串值调用 MakeInput()
以创建组件会导致进行预期和正确的 prop 类型检查。
编辑:关于“魔法子弹”组件与使用 HOC 如何在类型检查方面功能相同的另一个例子,因为在您的场景中您知道 type
和什么 HTML 元素 ref
应该表示在 pre-compile-time,你可以从字面上看只做这个包含相同数量信息的 IIFE:
return <div>
{(function(){
const C = MakeInput("textArea");
return <C value={"Baz"} ref={textAreaRef} />
})()}
</div>;
虽然这决不能解决 React.forwardProps
的问题,但另一种方法是解决它,而不是使用 innerRef
属性。然后你可以在 innerRef
属性 上强制类型。实现您想要的相同结果,但具有灵活的类型、更少的开销并且没有实例化。
工作演示:
components/Label/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}:
</label>
);
export default Label;
components/Fields/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"));
我知道我回答这个问题真的很晚,但这就是我解决这个问题的方法。也许有一天这会对其他人有所帮助。
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>,
) {
我正在尝试使用 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
属性,ref 类型应该是 HTMLInputElement
或 HTMLTextAreaElement
。在我看来,它看起来像这样:
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
,如果类型prop 是 input
那么 ref 类型应该是 HTMLInputAreaElement
有什么建议吗?
谢谢。
这是一个棘手的问题,我认为唯一可行的方法是使用 higher order component and function overloading。
基本上,我们必须创建一个函数,它本身将 return 一种类型的组件或另一种类型的组件,具体取决于它传递的参数。
// 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
呈现哪种类型的组件,只是抽象为一个函数。但是,您 不能 渲染一个神奇的 <MyTextAreaOrInputComponent />
并在 both 其 props
和 ref
属性。为此,你将不得不责怪 React 本身,因为 ref
道具,就像 key
和可能的其他一些道具一样,非常非常特殊,并且被 React 独特地对待,这正是需要 React.forwardRef()
排在首位。
但是如果你考虑一下,实际上你仍然得到了你正在寻找的道具类型检查,只是你添加了一个额外的步骤来调用 MakeInput()
来确定组件类型.所以不要这样写:
return <Component type="textArea" ref={textAreaRef} />
你正在写这个:
const MyComponent = MakeInput("textArea");
return <MyComponent ref={textAreaRef} />
在这两种情况下,您显然 必须 知道 both type
和 ref
在编写代码的时间。由于 React.forwardRef()
的工作方式,前一种情况不可能起作用(据我所知)。但后一种情况 是 可能的,并且为您提供完全相同级别的类型检查,只是需要额外的步骤。
https://codesandbox.io/s/nostalgic-pare-pqmfu?file=/src/App.tsx
注意:尝试一下上面的沙箱,看看即使 <Input/>
与 <TextArea/>
相比有一个额外的属性 extraInputValue
,但高阶组件如何优雅地处理它。另请注意,使用任一有效字符串值调用 MakeInput()
以创建组件会导致进行预期和正确的 prop 类型检查。
编辑:关于“魔法子弹”组件与使用 HOC 如何在类型检查方面功能相同的另一个例子,因为在您的场景中您知道 type
和什么 HTML 元素 ref
应该表示在 pre-compile-time,你可以从字面上看只做这个包含相同数量信息的 IIFE:
return <div>
{(function(){
const C = MakeInput("textArea");
return <C value={"Baz"} ref={textAreaRef} />
})()}
</div>;
虽然这决不能解决 React.forwardProps
的问题,但另一种方法是解决它,而不是使用 innerRef
属性。然后你可以在 innerRef
属性 上强制类型。实现您想要的相同结果,但具有灵活的类型、更少的开销并且没有实例化。
工作演示:
components/Label/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}:
</label>
);
export default Label;
components/Fields/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"));
我知道我回答这个问题真的很晚,但这就是我解决这个问题的方法。也许有一天这会对其他人有所帮助。
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>,
) {