【翻译】React Slots/作为子组件组合模式

3 阅读6分钟

原文链接:boda.sh/blog/react-…

作者:boda

在 React 中,我们已见过多种用于创建高级可复用组件的组合模式,例如高阶组件(HOC)as 属性

本文将介绍近期流行的 asChild 属性及 Radix UI 推广的 Slot 模式,同时探讨 React 的 forwardRef 等 API 与 clsx 等库如何协同工作,最后展望 Base UI 及其render属性的未来发展。

asChild 属性

假设你想将 Button 组件渲染为 Anchor a 元素:

<Button>click here</Button>
// output: <button>click here</button>

<Button asChild>
  <a href="/about">About</a>
</Button>
// output: <a href="/about">About</a>

<Button asChild={false}>
  <a href="/about">About</a>
</Button>
// output: <button><a href="/about">About</a></button>

为什么不直接创建一个独立的锚点组件呢?例如一个Link组件?上述示例较为简化,但在更复杂的场景中,我们可能需要继承Button组件的属性(样式、分析数据等),同时仅希望扩展其渲染方式。

asChild 属性的一个简单 React 实现方案如下:

import * as React from "react";

type ButtonProps = React.ComponentProps<'button'> & {
  asChild?: boolean
}

const Button = ({ asChild, children, ...props }: ButtonProps) => {
  if (asChild) {
    // Clone the child element and merge props into it
    return React.cloneElement(children as React.ReactElement, props)
  }

  // Default: render as button
  return <button {...props}>{children}</button>
}

附注:React中的children元素可以是任何内容!静态内容、函数、多个元素,甚至更多……

<Button>Click me</Button>
// children = "Click me"

<Button><span>Hello</span></Button>
// children = <span>Hello</span>

<Button>
  {(data) => <div>{data.name}</div>}
</Button>
// children = (data) => <div>{data.name}</div>

<Button>
  <Icon />
  <span>Click</span>
</Button>
// children = [<Icon />, <span>Click</span>]

如果我们将组件的children元素替换为其他元素,当前的实现将无法正常工作:

<Button asChild>
  <a>Click</a>
  <span>Extra</span>
</Button>
// ❌ Error! cloneElement expects a single ReactElement, not an array

<Button asChild>
  Just text
</Button>
// ❌ Error! Can't clone a string

其他属性呢?例如冲突的className

<Button asChild className="text-red">
  <a href="/about" className="text-sm">
    About
  </a>
</Button>
// ❌ output: <a href="/about" class="text-red">About</a>

父元素的className覆盖了子锚元素的text-sm样式。这是因为cloneElement(children, props)函数本质上执行的是:

cloneElement(
  <a href="/about" className="text-sm">About</a>,
  { className: "text-red" }
)

由于父级 className 属性出现在后,它覆盖了之前的冲突属性。

我们可以拆分冲突的 className 字符串并手动合并,或借助 clsx 库自动处理:

ⓘ注

熟悉该技术栈的读者可能还了解 tailwind-mergeClass Variance Authority 等库。本文不作深入探讨,但若您希望后续撰写相关库的专题文章,欢迎告知!

import { clsx } from 'clsx';

// within the Button component
return React.cloneElement(children as React.ReactElement, {
  ...props,
  className: clsx(children.props.className, props.className),
})
// output: <a href="/about" class="text-sm text-red">click me</a>

请注意类名的顺序!子元素的类名优先,然后才是父元素的类名。

根据CSS特异性规则,这意味着当存在冲突类名时(例如父元素使用text-red而子元素使用text-blue),后者/父元素的类名将优先生效。

若需让子元素的属性覆盖父元素,可通过在cloneElement中重新排序属性与类名实现快速修复:

return cloneElement(children as React.ReactElement, {
  ...props,
  ...children.props,
  className: clsx(props.className, children.props.className),
});
// output: <a href="/about" class="text-red text-sm">About</a>

子组件的属性是否应覆盖父组件的属性?反之亦然?这是组件设计层面的决策。正如我们稍后将在Radix UI中看到的那样,他们选择让子组件的属性覆盖父组件。但你可能会遇到采取相反做法的库。无论哪种方式,关键是要明确告知用户(通过警告或文档说明)哪些属性具有更高优先级。

还有其他未涉及的使用场景,例如:

  • 我们不转发引用(refs)
const buttonRef = React.useRef<HTMLButtonElement>(null)
const anchorRef = React.useRef<HTMLAnchorElement>(null)

<Button asChild ref={buttonRef}>
  <a ref={anchorRef} href="/">Link</a>
</Button>
// ❌ both refs would not work!
  • 子事件处理程序可能导致父级的重要事件处理程序被静默处理
<Button asChild onClick={() => analyticsTracking()}>
  <a onClick={() => console.log("child")}>click</a>
</Button>
// ❌ parent onClick would not fire
  • 除了 className 之外,我们是否考虑过其他属性?比如 style 属性?
  • 其他边界情况……

我们可以扩展实现方案,或者参考 Radix UI 的 Slot 组件——他们已经为我们考虑并实现了大部分边界情况。

Radix UI 的 Slot

首先,安装 @radix-ui/react-slot

pnpm add @radix-ui/react-slot

提醒:这是我们目前实现的效果:

const Button = ({ asChild, children, ...props }: ButtonProps) => {
  if (asChild) {
    return React.cloneElement(children as React.ReactElement, {
      ...props,
      ...children.props,
      className: clsx(props.className, children.props.className),
    })
  }

  return <button {...props} />
}

要使用Radix UI的Slot组件实现此功能:

import { Slot } from '@radix-ui/react-slot'

const Button = ({ asChild, ...props }: ButtonProps) => {
  const Comp = asChild ? Slot : 'button'
  return <Comp {...props} />
}

借助 Radix UI 的 Slot 组件,我们还处理了以下情况:

  • 事件处理程序
<Button asChild onClick={() => analyticsTracking()}>
  <a onClick={() => console.log("child")}>click</a>
</Button>
// we can handle both onClick events

如果父组件拥有 onClick 事件,但我们只想触发子组件的事件该怎么办?通常我们可以尝试使用 event.stopPropagation(),但由于 Radix Slot 会合并事件处理程序,停止事件传播的方法将失效。解决方法是将父组件的逻辑包裹在条件语句中,并检查 defaultPrevented 属性:

<Button
  asChild
  onClick={(event) => {
    if (!event.defaultPrevented) {
      console.log('button clicked')
    }
  }}
>
  <a
    href="/about"
    onClick={(event) => {
      event.preventDefault()
      console.log('anchor clicked')
    }}
  >
    About
  </a>
</Button>
  • 自动合并道具,包括其他道具如style
<Button asChild style={{ padding: '10px' }}>
  <a href="/about" style={{ border: '3px solid purple' }}>
    About
  </a>
</Button>
// the style prop also merged together
  • 多个组件作为子元素使用 Slot.Slottable
const Button = ({ asChild, children, leftElement, rightElement, ...props }) => {
  const Comp = asChild ? Slot.Root : "button";
  return (
    <Comp {...props}>
    {leftElement}
    <Slot.Slottable>{children}</Slot.Slottable>
    {rightElement}
    </Comp>
  );
}

React 18 与 forwardRef

让我们回到自定义实现,探讨如何合并 ref

此前我们提到,以下示例无法正常工作:

const buttonRef = React.useRef<HTMLButtonElement>(null)
const anchorRef = React.useRef<HTMLAnchorElement>(null)

<Button asChild ref={buttonRef}>
  <a ref={anchorRef} href="/">Link</a>
</Button>
// ❌ both refs would not work!

我们可以扩展自定义实现并将引用合并:

// Simple utility to merge refs
function composeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
  return (node: T | null) => {
    refs.forEach((ref) => {
      // ref could be a callback function when used in useCallback
      if (typeof ref === 'function') {
        ref(node)
      } else if (ref != null) {
        ref.current = node
      }
    })
  }
}

const Button = React.forwardRef(({ asChild, children, ...props }: ButtonProps, forwardedRef) => {
  if (asChild) {
    const child = children as React.ReactElement
    return React.cloneElement(child, {
      ...props,
      ...child.props,
      ref: forwardedRef ? composeRefs(forwardedRef, child.ref) : child.ref,
      className: clsx(props.className, child.props.className),
    })
  }

  return <button ref={forwardedRef} {...props} />
})

在应用程序中测试它:

function App() {
  const buttonRef = React.useRef<HTMLButtonElement>(null)
  const anchorRef = React.useRef<HTMLAnchorElement>(null)

  React.useEffect(() => {
    console.log('buttonRef:', buttonRef.current)
    console.log('anchorRef:', anchorRef.current)
  }, [])

  return (
    <Button asChild ref={buttonRef}>
      <a href="#" ref={anchorRef}>
        About
      </a>
    </Button>
  )
}

请注意我们在 useEffect 中记录 ref.current 的方式。如果我们更早记录该ref(例如使用 console.log(forwardRef)),它会输出 {current: null},因为此时 React 仍处于渲染阶段,尚未更新 DOM 并分配引用。

这段代码应在控制台输出以下内容:

buttonRef: <a href="#">​About​</a>anchorRef: <a href="#">​About​</a>

如果我们将 asChild={false},则会输出:

buttonRef: <button><a href="#">​About​</a></button>
anchorRef: <a href="#">​About​</a>

我们也可以使用Radix UI的Slot组件实现相同效果:

// Radix UI with React 18
const Button = React.forwardRef(({ asChild, ...props }: ButtonProps, ref) => {
  const Comp = asChild ? Slot : 'button'
  return <Comp {...props} ref={ref} />
})

从 React v19 开始,forwardRef 函数已被弃用。对于函数组件,我们可以将 ref 作为 props 进行访问,因此请按以下方式更新 Radix Slot 代码以传递 ref

// Radix UI with React 19
const Button = ({ asChild, ref, ...props }: ButtonProps) => {
  const Comp = asChild ? Slot : 'button'
  return <Comp {...props} ref={ref} />
}

<Button asChild ref={buttonRef} className="text-red">
  <a href="#" ref={anchorRef} className="text-sm">
    About
  </a>
</Button>

未来:基础 UI

展望未来,Radix UI 的作者们已着手开发基础 UI base-ui.com

在基础 UI 中,Slot 模式将被渲染属性render属性和 useRender 钩子所取代:

import { useRender } from '@base-ui/react/use-render'

interface ButtonProps extends useRender.ComponentProps<'button'> {}

const Button = ({ render, ...props }: ButtonProps) => {
  return useRender({
    defaultTagName: 'button',
    render,
    props,
  })
}

<Button
  className="text-red"
  render={<a href="#" className="text-sm" ref={anchorRef} />}
>
  About
</Button>
// output: <a href="#" class="text-sm text-red">About</a>
// buttonRef: <a href="#" class="text-sm text-red">About</a>
// anchorRef: <a href="#" class="text-sm text-red">About</a>

新的render属性实际上是React社区早有先例的as属性模式:bsky.app/profile/haz…

属性合并的魔力会自动为我们处理,但若需要进一步定制,Base UI也提供了mergeProps函数供我们使用:

import { mergeProps } from '@base-ui/react/merge-props'

const Button = ({ render, ...props }: ButtonProps) => {
  return useRender({
    defaultTagName: 'button',
    render,
    // either here
    props: mergeProps<'button'>({ className: 'underline' }, props),
  })
}

<Button
  className="text-red"
  render={(props) => (
    <a
      href="#"
      {...mergeProps<'a'>(props, {
        className: 'text-sm',
        // or here
        ref: composeRefs(props.ref, anchorRef),
      })}
    />
  )}
>
  About
</Button>
// output: <a href="#" class="text-sm text-red underline">About</a>
// buttonRef: <a href="#" class="text-sm text-red underline">About</a>
// anchorRef: <a href="#" class="text-sm text-red underline">About</a>

总体而言,Radix UI 的方法更为隐式,因为我们需要理解以下几点:

  • asChild 属性实际上触发了另一个 Slot 组件
  • Slot 组件的合并逻辑被抽象化处理

而 Base UI 的方案则更显性,因为:

  • render 属性的命名更具语义(明确表示渲染内容)
  • 底层机制采用了现代 React 钩子 useRender
  • useRender.ComponentProps 类型工具可能提供更优的类型推断
  • mergeProps 显式允许我们控制合并行为

这反映了 React 社区持续向更显式、基于钩子的 API 演进,而非沿用组件封装模式。

尽管 asChild 模式已在生态中形成某种标准(如 Radix、shadcn/ui 等),但下游库和开发者全面接纳仍需时日。

在此期间,开发者仍需理解两种模式的工作原理。