- Published on
React组件如何设计?
- Authors

- Name
- Li WenKang
- https://x.com/liwenkang_space
四点建议:
- 一开始不要过度考虑组件的扩展性。如果在一开始就把各种情况都考虑到,会导致组件核心功能不清晰,为了实现各个 props,可能会导致代码存在很多的逻辑判断
- 如果为了高扩展性设计,可以考虑更多使用 props.children 插槽的方式,让外界更自由的使用。而组件只关心自己的核心功能。如果是为了让用户即插即用,可能确实需要支持更多的 props。
- 关注传入 props 的命名,类型,是否必填(使用 Typescript 强定义)
- 关注组件中的状态管理,state => common parent state => props.children => context
一个 Button 组件的示例
功能:包含文字描述和图标,支持 Link(React Router) 和 a 标签 注意点
- 能够接受任意 props,例如 onKeyDown 和 aria-describedby
- 能够呈现为 button 、带有 href 属性的 a 或带有 to 属性的 Link
- 确保根元素具有其所需的所有 props,并且没有不支持的 props
- 不会让 TypeScript 崩溃 我们来提供一个初始化示例
function Button() {}
const App = () => {
return (
<>
{/* Easy defaults */}
<Button />
<Button>Click me</Button>
{/* complex component */}
<Button
color="primary"
icon="arrow-right"
className="btn-large"
onClick={() => {
console.log('clicked')
}}
onMouseDown={() => {
console.log('mouse down')
}}
/>
{/* wrong prop type should be caught by the compiler */}
<Button fakeProp="fakePropValue" />
</>
)
}
export default App
在第一版的实现中我是这么做的
const Link = ({ to }: { to: string }) => {
return <a href={to}>Click me</a>
}
interface ButtonInjectedProps {
className?: string
children?: React.ReactNode
}
interface ButtonProps
extends React.HTMLAttributes<HTMLButtonElement & HTMLAnchorElement & HTMLDivElement> {
color?: string
icon?: string
className?: string
onClick?: () => void
renderContainer?: (props: ButtonInjectedProps) => React.ReactNode
children?: React.ReactNode
}
const getClassName = (color: string, className: string) => {
return `btn btn-${color ?? 'primary'} ${className ?? ''}`
}
function Button({
color = 'primary',
icon,
className = '',
onClick,
renderContainer,
children,
...props
}: ButtonProps & React.HTMLAttributes<HTMLButtonElement>): React.ReactNode {
// Enforce that color is always a defined string for getClassName
const safeColor = color ?? 'primary'
return renderContainer ? (
renderContainer({ className, children, ...props } as ButtonInjectedProps &
React.HTMLAttributes<HTMLButtonElement & HTMLAnchorElement & HTMLDivElement>)
) : (
<>
{icon && <div className={`icon icon-${icon}`}>{icon}</div>}
<button className={getClassName(safeColor, className)} onClick={onClick} {...props}>
{children}
</button>
</>
)
}
const App = () => {
return (
<>
{/* Easy defaults */}
<Button />
<Button>Click me</Button>
{/* complex component */}
<Button
color="primary"
icon="arrow-right"
className="btn-large"
onClick={() => {
console.log('clicked')
}}
onMouseDown={() => {
console.log('mouse down')
}}
/>
{/* Renders a Link, enforces `to` prop set */}
<Button renderContainer={(props) => <Link {...props} to="/" />} />
{/* Renders an anchor, accepts `href` prop */}
<Button renderContainer={(props) => <a {...props} href="/" />} />
{/* Renders a button with `aria-describedby` */}
<Button renderContainer={(props) => <button {...props} aria-describedby="tooltip-1" />} />
{/* wrong prop type should be caught by the compiler */}
<Button fakeProp="fakePropValue" />
</>
)
}
export default App
但是还有几个小问题需要解决一下
React.HTMLAttributes<HTMLButtonElement & HTMLAnchorElement & HTMLDivElement>使用交叉类型 &来合并多个不同的 HTML 元素类型(如 HTMLButtonElement、HTMLAnchorElement)的主要问题在于,它会试图创建一个同时包含所有这些元素属性的“超级”类型。这会导致几个问题:
- 属性冲突与类型不兼容:不同的 HTML 元素虽然有大量共用属性,但也存在许多独有的属性,甚至同名的属性可能要求不同的类型。例如,
<a>标签有 href 属性,而<button>有 disabled属性。当它们被合并时,您的组件理论上会同时拥有这些属性,这听起来很好,但实际上,当您将 ...rest属性传递给一个具体的 DOM 元素(如<button>)时,href属性对按钮是无效的,可能会被 React 忽略或导致控制台警告。更重要的是,如果不同元素对同一个属性的类型定义存在不兼容(虽然不常见),交叉类型会尝试合并它们,可能导致该属性的类型变为 never,从而无法使用。 - 语义混乱:一个组件应该具有明确的语义。它应该清晰地对应一个主要的 HTML 元素。将一个组件的属性同时定义为按钮、链接和 div 的特性,会使组件的用途和预期行为变得模糊不清,不利于代码的维护和理解。 我们可以考虑使用 as 属性,实现多态组件
- 应该精确过滤和传递属性
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react'
const Link = ({ to }: { to: string }) => {
return <a href={to}>{to}</a>
}
const ButtonIcon = ({ icon }: { icon: string }) => {
return <div className={`icon icon-${icon}`}>{icon}</div>
}
type BaseButtonProps = {
color?: string
icon?: string
className?: string
children?: ReactNode
}
// 多态Props:通过 `as` 属性决定渲染为何种元素/组件
type PolymorphicButtonProps<T extends ElementType> = BaseButtonProps & {
as?: T
to?: string // 专属于 Link 的 prop
href?: string // 专属于 a 标签的 prop
type?: string // 专属于 button 的 prop
} & Omit<ComponentPropsWithoutRef<T>, keyof BaseButtonProps | 'to' | 'href'>
// 组件的函数签名
function Button<T extends ElementType = 'button'>({
as,
color = 'primary',
icon,
className = '',
children,
to,
href,
type,
...restProps
}: PolymorphicButtonProps<T>) {
const Component = as || 'button' // 默认渲染为 button 元素
const finalClassName = `btn btn-${color} ${className}`.trim()
// 根据 `as` 的值和传入的属性,安全地构建需要传递的 props
const baseProps: any = {
className: finalClassName,
...restProps,
}
if (Component === 'a') {
return (
<Component {...baseProps} href={href}>
{icon && <ButtonIcon icon={icon} />}
{children}
</Component>
)
} else if (Component === Link) {
// 需要正确定义 Link 类型
return (
<Component {...baseProps} to={to as string}>
{icon && <ButtonIcon icon={icon} />}
{children}
</Component>
)
} else {
// 处理 button 和其他自定义组件
const componentProps = {
...baseProps,
...(Component === 'button' && { type: type || 'button' }), // 仅为 button 添加 type
}
return (
<Component {...componentProps}>
{icon && <ButtonIcon icon={icon} />}
{children}
</Component>
)
}
}
const App = () => {
return (
<>
{/* Easy defaults */}
<Button />
<Button>Click me</Button>
{/* complex component */}
<Button
color="primary"
icon="arrow-right"
className="btn-large"
onClick={() => {
console.log('clicked')
}}
onMouseDown={() => {
console.log('mouse down')
}}
/>
{/* Renders a Link, enforces `to` prop set */}
<Button as={Link} to="/" />
{/* Renders an anchor, accepts `href` prop */}
<Button as="a" href="/" />
{/* Renders a button with `aria-describedby` */}
<Button as="button" aria-describedby="tooltip-1" />
{/* wrong prop type should be caught by the compiler */}
<Button fakeProp="fakePropValue" />
</>
)
}
export default App
如果你有更好的设计思路,欢迎和我交流!