如何用Typescript创建React多态组件?



我想创建一个多态按钮,实际上可以是一个按钮,一个锚或路由器链接。

例如:

<Button onClick={e => console.log("click", e)}>A button</Button>
<Button as="a" href="https://somewhere-external.com" rel="noopener noreferrer" target="_blank">
An anchor
</Button>
<Button as={Link} to="/some_page">
A Link
</Button>

我读过很多文章,比如这篇,但是我发现解决方案过于复杂,尤其是在支持forwardRef的时候。

我正在寻找一些简单的使用&很容易理解

编辑这是一个组件库,所以我想避免任何依赖于<Link>(由react-router或类似的库提供)。此外,我应该能够支持其他组件,如无头ui<Popover.Button>

我想到了一个像下面这样的解决方案,但是事件处理程序都是针对HTMLButtonElement类型的,这显然是错误的。

/* Types file */
export type PolymorphicProps<
OwnProps,
As extends ElementType,
DefaultElement extends ElementType
> = OwnProps &
(
| (Omit<ComponentProps<As>, "as"> & { as?: As })
| (Omit<ComponentProps<As>, "as"> & { as: As })
| (Omit<ComponentProps<DefaultElement>, "as"> & { as?: never })
)

/* Component file */
const defaultElement = "button"
type OwnProps = {}
type Props<As extends ElementType = typeof defaultElement> = PolymorphicProps<
OwnProps,
As,
typeof defaultElement
>
const Button = <As extends ElementType = typeof defaultElement>(
{ as, children, ...attrs }: Props<As>,
ref: ForwardedRef<ComponentProps<As>>
) => {
const Component = as || defaultElement
return (
<Component ref={ref} {...attrs}>
{children}
</Component>
)
}
export default forwardRef(Button) as typeof Button

我是这么想的:

type ValidElement<Props = any> = keyof Pick<HTMLElementTagNameMap, 'a' | 'button'> | ((props: Props) => ReactElement)
function PolyphormicButton <T extends ValidElement>({ as, ...props }: { as: T } & Omit<ComponentPropsWithoutRef<T>, 'as'>): ReactElement;
function PolyphormicButton ({ as, ...props }: { as?: undefined } & ComponentPropsWithoutRef <'button'>): ReactElement;
function PolyphormicButton <T extends ValidElement>({
as,
...props
}: { as?: T } & Omit<ComponentPropsWithoutRef<T>, "as">) {
const Component = as ?? "button"
return <Component {...props} />
}

我在这里做什么?

  • 声明ValidElement类型强制as类型为有效类型,在这种情况下:
    • HTMLElementTagNameMap
    • 中的值
    • 通用组件
  • (可选)声明函数重载以接受或不接受as参数,同时保留默认props
  • 声明用它的props渲染相应html元素的函数体

当然会使用typescript和tslint,而且只会查看元素自己的props。

用法:

const Home = () => {
const href = "whatever"
return (
<PolymorphicButton>just a button</PolymorphicButton>
<PolymorphicButton as="a" href={href}>an anchor</PolymorphicButton>
<PolymorphicButton as={Link} to={href}>a Link component</PolymorphicButton>
)
}

const Button = (props) => {
const { title, onClick, href, to } = props;
if (href) {
return (
<a href={href}>{title}</a>
)
}
return (
<Link to={to} onClick={onClick} >{title}</Link>
)

}

,我们可以这样称呼它为按钮

<Button 
title="Click me"
onClick={()=>{alert("this is button click")})
/>

为锚

<Button 
title="this is Anchor"
href="https://www.google.com"
/>

链接
<Button 
title="this is Link"
to-"/path/subpath"
/>

这就达到了你想要的所有类型都完好无损的效果,除了' back to 'button'元素';这应该是微不足道的实现。

import { ComponentProps, ComponentType, forwardRef } from "react";
type Tags = keyof JSX.IntrinsicElements;
type Props<T> = T extends Tags
? {
as: T;
} & JSX.IntrinsicElements[T]
: T extends ComponentType<any>
? {
as: T;
} & ComponentProps<T>
: never;
function ButtonInner<T>({ as, ...rest }: Props<T>, ref: React.ForwardedRef<T>) {
const Component = as;
return <Component ref={ref} {...rest} />;
}
const Button = (forwardRef(ButtonInner) as unknown) as <T>(
props: Props<T>
) => ReturnType<typeof ButtonInner>;
export default Button;

你可以在操场上试试:https://codesandbox.io/s/dry-silence-uybwmp?file=/src/App.tsx

我在App.tsx

中添加了一堆示例

当然,我有几个UI库发布到azure上的私有注册表为我的组织(使用TurboRepos)。这是一个在多个代码库中使用的ButtonAnchor混合组件,它真正地将buttonhtmlatattributes与anchorhtmlatattributes分离开来——但你可以使用额外的联合等来扩展逻辑,以实现所需的多态组件结果。

为了清晰起见,我将在文件中的代码之前包含一个定义

export namespace UI {
export module Helpers {
export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };

/* Provides a truly mutually exclusive type union */
export type XOR<T, U> = T | U extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U;
}
}
但是,为了简单起见,您可以将这两种类型从namspace .module中拉出。[类型]链接在这个ui组件库中使用。
export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
/* Provides a truly mutually exclusive type union */
export type XOR<T, U> = T | U extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U;

文件如下:

import type { FC, ButtonHTMLAttributes, AnchorHTMLAttributes, JSXElementConstructor } from "react";
import cn from "clsx";
import LoadingDots from "../LoadingDots";
import UI from "../../typedefs/namespace";
export type ButtonAnchorXOR = UI.Helpers.XOR<"a", "button">;
/**
* component types allowed by the (Button | Anchor) IntrinsicElements
*/
export type ButtonAnchorComponentType =
| "button"
| "a"
| JSXElementConstructor<
React.DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>
>   | JSXElementConstructor<
React.DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>
>;;
/**
* Base props of the (Button | Anchor) components.
*/
export interface ButtonAnchorProps<
C extends ButtonAnchorComponentType = ButtonAnchorXOR
> {
href?: string;
className?: string;
variant?: "primary" | "secondary" | "ghost" | "violet" | "black" | "white";
size?: "sm" | "md" | "lg";
active?: boolean;
Component?: C;
width?: string | number;
loading?: boolean;
}
/**
* The HTML props allowed by the (Button | Anchor) components.
* These props depend on the used component type (C = "a" | "button").
*/
export type ButtonAnchorHTMLType<
C extends ButtonAnchorComponentType = ButtonAnchorXOR
> = C extends "a"
? AnchorHTMLAttributes<HTMLAnchorElement>
: ButtonHTMLAttributes<HTMLButtonElement>;
export type ButtonAnchorFC<
C extends ButtonAnchorComponentType = ButtonAnchorXOR
> = FC<ButtonAnchorHTMLType<C> & ButtonAnchorProps<C>>;
export type ButtonType = <C extends ButtonAnchorComponentType = "button">(
...args: Parameters<ButtonAnchorFC<C>>
) => ReturnType<ButtonAnchorFC<C>>;
export type AnchorType = <C extends ButtonAnchorComponentType = "a">(
...args: Parameters<ButtonAnchorFC<C>>
) => ReturnType<ButtonAnchorFC<C>>;
export type ButtonAnchorConditional<
T extends ButtonAnchorXOR = ButtonAnchorXOR
> = T extends "a"
? AnchorType
: T extends "button"
? ButtonType
: UI.Helpers.XOR<ButtonType, AnchorType>;
const Button: ButtonAnchorFC<"button"> = props => {
const {
width,
active,
children,
variant = "primary",
Component = "button",
loading = false,
style = {},
disabled,
size = "md",
className,
...rest
} = props;
const variants = {
primary:
"text-background bg-success border-success-dark hover:bg-success/90 shadow-[0_5px_10px_rgb(0,68,255,0.12)]",
ghost: "text-success hover:bg-[rgba(0,68,255,0.06)]",
secondary:
"text-accents-5 bg-background border-accents-2 hover:border-foreground hover:text-foreground",
black:
"bg-foreground text-background border-foreground hover:bg-background hover:text-foreground",
white: "bg-background text-foreground border-background hover:bg-accents-1",
violet: "text-background bg-violet border-violet-dark hover:bg-[#7123be]"
};
const sizes = {
sm: "h-8 leading-3 text-sm px-1.5 py-3",
md: "h-10 leading-10 text-[15px]",
lg: "h-12 leading-12 text-[17px]"
};
const rootClassName = cn(
"relative inline-flex items-center justify-center cursor pointer no-underline px-3.5 rounded-md",
"font-medium outline-0 select-none align-middle whitespace-nowrap",
"transition-colors ease-in duration-200",
variant !== "ghost" && "border border-solid",
variants[variant],
sizes[size],
{ "cursor-not-allowed": loading },
className
);
return (
<Component
aria-pressed={active}
data-variant={variant}
className={rootClassName}
disabled={disabled}
style={{
width,
...style
}}
{...rest}>
{loading ? (
<i className='m-0 flex'>
<LoadingDots />
</i>
) : (
children
)}
</Component>
);
};
const Anchor: ButtonAnchorFC<"a"> = props => {
const {
width,
active,
children,
variant = "primary",
Component = "a",
loading = false,
style = {},
size = "md",
className,
...rest
} = props;
const variants = {
primary:
"text-background bg-success border-success-dark hover:bg-success/90 shadow-[0_5px_10px_rgb(0,68,255,0.12)]",
ghost: "text-success hover:bg-[rgba(0,68,255,0.06)]",
secondary:
"text-accents-5 bg-background border-accents-2 hover:border-foreground hover:text-foreground",
black:
"bg-foreground text-background border-foreground hover:bg-background hover:text-foreground",
white: "bg-background text-foreground border-background hover:bg-accents-1",
violet: "text-background bg-violet border-violet-dark hover:bg-[#7123be]"
};
const sizes = {
sm: "h-8 leading-3 text-sm px-1.5 py-3",
md: "h-10 leading-10 text-[15px]",
lg: "h-12 leading-12 text-[17px]"
};
const rootClassName = cn(
"relative inline-flex items-center justify-center cursor pointer no-underline px-3.5 rounded-md",
"font-medium outline-0 select-none align-middle whitespace-nowrap",
"transition-colors ease-in duration-200",
variant !== "ghost" && "border border-solid",
variants[variant],
sizes[size],
{ "cursor-not-allowed": loading },
className
);
return (
<Component
aria-pressed={active}
data-variant={variant}
className={rootClassName}
style={{
width,
...style
}}
{...rest}>
{loading ? (
<i className='m-0 flex'>
<LoadingDots />
</i>
) : (
children
)}
</Component>
);
};
const PolyMorphicComponent = <T extends ButtonAnchorXOR = ButtonAnchorXOR>({
props,
type
}: {
props?: ButtonAnchorConditional<T>
type: T;
}) => {
switch (type) {
case "a":
return <Anchor Component="a" {...props as AnchorType} />;
case "button":
return <Button Component='button' {...props as ButtonType} />;
default:
return <>{`The type property must be set to either "a" or "button"`}</>;
}
};
Anchor.displayName = "Anchor";
Button.displayName = "Button";
export default PolyMorphicComponent;

最新更新