原文链接: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-merge 和 Class 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 等),但下游库和开发者全面接纳仍需时日。
在此期间,开发者仍需理解两种模式的工作原理。