问题
I'm trying to create a generic text input component for our application using react and typescript. I want it to be able to either be an input element or a textarea element based on a given prop. So it looks somewhat like this:
import {TextArea, Input} from 'ourComponentLibrary'
export const Component = forwardRef((props, ref) => {
const Element = props.type === 'textArea' ? TextArea : Input
return (
<Element ref={ref} />
)
})
This code works fine. However, when trying to incorporate types it becomes a little dicey. The ref type should be either HTMLInputElement
or HTMLTextAreaElement
based on the passed type
prop. In my head, it would look something like this:
interface Props {
...
}
export const Component = forwardRef<
HTMLInputElement | HTMLTextAreaElement,
Props
>((props, ref) => {
...
});
However I know this isn't exactly what I need. Hence, the error:
Type 'HTMLInputElement' is missing the following properties from type 'HTMLTextAreaElement': cols, rows, textLength, wrap
In summary, I want the types to align so that if the type
prop is textArea
then the ref
type should be HTMLTextAreaElement
, and if the type prop is input
then the ref type should be HTMLInputAreaElement
Any advice?
Thanks.
回答1:
While this by no means fixes the problem with React.forwardProps
, an alternative would be to work around it and instead utilize an innerRef
property. Then you can enforce types on the innerRef
property. Achieves the same result that you want, but with flexible typing, less overhead, and no instantiation.
Working demo:
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"));
回答2:
This is a tricky one, and the only viable way I can think to do this is with a higher order component and function overloading.
Basically, we have to create a function that will itself return one type of component or the other depending on what argument its passed.
// 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;
}
}
Then, instantiate the component type you want to render by calling the higher order component function MakeInput()
with the "type" of the component:
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>
);
}
Now, this may feel "unsatisfying" because this is roughly equivalent to doing a conditional check here to see what type of component to render based on type
, just abstracted away into a function. But, you can't render a magical <MyTextAreaOrInputComponent />
and get full type checking on both its props
and ref
attributes. And for that, you'll have to blame React itself, because the ref
prop, like key
and possibly some other props, are very very special and treated uniquely by React, which is exactly what necessitates React.forwardRef()
in the first place.
But if you think about it, in practical terms you're still getting the prop type checking that you are looking for, it's just that you add an extra step of calling MakeInput()
to determine the component type. So instead of writing this:
return <Component type="textArea" ref={textAreaRef} />
You're writing this:
const MyComponent = MakeInput("textArea");
return <MyComponent ref={textAreaRef} />
In both cases, you clearly must know the value of both type
and ref
at the time you are writing your code. The former case is impossible to get working (to my knowledge) because of the way React.forwardRef()
works. But the latter case is possible, and gives you the exact same level of type checking, just with the extra step.
https://codesandbox.io/s/nostalgic-pare-pqmfu?file=/src/App.tsx
Note: play around with the sandbox above and see how even though <Input/>
has an additional prop extraInputValue
compared to <TextArea/>
, the higher order component handles it gracefully. Also note that calling MakeInput()
with either valid string value to create a component results in the expected and proper prop type checking.
Edit: Another illustration of how a "magic bullet" component vs. using a HOC are functionally identical in terms of type checking, since in your scenario you know both the type
and what HTML element the ref
should represent at pre-compile-time, you could literally just do this IIFE which contains the same amount of information:
return <div>
{(function(){
const C = MakeInput("textArea");
return <C value={"Baz"} ref={textAreaRef} />
})()}
</div>;
来源:https://stackoverflow.com/questions/63963850/react-typescript-forwardref-types-for-an-element-which-returns-either-an-input-o