React TypeScript:如何为组件插槽实现类型安全?
解决方案:实现Slot属性的类型安全合并
核心问题分析
原代码的类型定义存在两个关键缺陷:
ComponentWithSlots直接合并默认root元素的props,无法根据用户传入的自定义slots.root动态更新属性类型slotProps绑定到固定的默认Slots类型,无法匹配用户实际传入的自定义Slot元素属性
重构类型定义
首先修正Slot和组件Props类型,让它们支持动态推断自定义Slot的属性:
import { ComponentPropsWithoutRef, ElementType, PropsWithChildren, ReactNode } from "react"; // 简化Slot类型,直接复用ElementType export type Slot = ElementType; // 重新定义带Slots的组件Props类型,支持动态推断 export type ComponentWithSlots< P, DefaultSlots extends Record<string, Slot> > = PropsWithChildren< P & { // 允许覆盖默认Slot的元素类型 slots?: Partial<{ [K in keyof DefaultSlots]: Slot }>; // 根据实际传入的Slot动态推导slotProps类型 slotProps?: Partial<{ [K in keyof DefaultSlots]: ComponentPropsWithoutRef< DefaultSlots[K] | (typeof slots)[K] >; }>; } > & Omit<ComponentPropsWithoutRef<DefaultSlots["root"]>, keyof P | "children">;
调整Avatar组件实现
修改Avatar组件,让它能正确接收自定义Slots并推导对应属性:
import Image from "next/image"; import { cva } from "class-variance-authority"; import { twMerge } from "tailwind-merge"; const Variants = cva( "relative inline-flex items-center justify-center bg-black/30 dark:bg-white/30 backdrop-blur-lg", { variants: { size: { xs: "w-6 h-6", sm: "w-8 h-8", md: "w-10 h-10", lg: "w-12 h-12", xl: "w-14 h-14", "2xl": "w-16 h-16", }, shape: { square: "rounded-none", rounded: "rounded-md", circular: "rounded-full", }, }, } ); export interface AvatarProps { src?: string; alt?: string; size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; shape?: "square" | "rounded" | "circular"; children?: ReactNode; } // 默认Slots定义 export type DefaultAvatarSlots = { root: "div"; image: "img"; }; export function Avatar<CustomSlots extends Partial<DefaultAvatarSlots> = {}>({ className, children, src, alt = "Avatar", size = "md", shape = "circular", slots, slotProps, ...rootProps }: ComponentWithSlots<AvatarProps, DefaultAvatarSlots & CustomSlots>) { // 合并默认Slot与用户自定义Slot const resolvedSlots = { root: slots?.root ?? "div", image: slots?.image ?? "img", } as const; const Root = resolvedSlots.root; const ImageComponent = resolvedSlots.image; // 按优先级合并className:组件默认样式 > 用户传入className > slotProps中的className const rootClassNames = twMerge( Variants({ size, shape }), className, slotProps?.root?.className ); const imageClassNames = twMerge( "h-full w-full object-cover", slotProps?.image?.className ); // 传递props时避免冲突,最后覆盖className确保生效 const filteredRootProps = { ...rootProps, ...slotProps?.root, className: rootClassNames, }; return ( <Root {...filteredRootProps}> {src ? ( <ImageComponent src={src} alt={alt} {...slotProps?.image} className={imageClassNames} /> ) : ( children )} </Root> ); } // 使用示例 export default function Home() { return ( <main className="flex min-h-screen flex-col items-center justify-center"> <Avatar src="/favicon.ico" alt="Logo" size="2xl" slots={{ image: Image }} slotProps={{ image: { fill: true, alt: "Custom Logo" } }} /> {/* 测试自定义root元素 */} <Avatar shape="square" slots={{ root: "section" }} className="border border-red-500" children={<span>Placeholder</span>} /> </main> ); }
关键改进点
- 动态类型推断:
ComponentWithSlots会根据用户传入的slots自动推导slotProps和root元素的属性类型 - className优先级控制:通过
twMerge确保样式按「组件默认 > 用户传入 > SlotProps」的优先级合并 - Props冲突规避:最后覆盖className,避免自定义样式被SlotProps覆盖
- Children类型安全:显式关联Children类型与root元素的允许值,避免传入不兼容的子节点
内容的提问来源于stack exchange,提问作者Moussa Harajli
相关产品推荐
相关产品推荐

