深入 @shadcn/react 的 useRender:shadcn/ui 中多态渲染与 mergeProps 属性合并机制的实现剖析
本文以 packages/react/src/use-render/README.md 为核心,拆解 shadcn/ui 仓库中内部多态渲染工具 useRender 与 mergeProps 的完整实现:它如何让 @shadcn/react 的原语(primitives)在保持默认 DOM 标签的同时,支持消费者把组件“渲染成任意自定义元素”,并在此过程中正确合并 props、ref 与状态属性。读完本文,你能理解 render prop 模式在这套无样式组件库中的底层落地方式,并掌握在自己的 React 组件中复刻该模式的完整思路。
一、定位与来历:一个为 render prop 服务的内部 helper
README 对模块的定位非常明确:
Internal polymorphic-render helper (
useRender,mergeProps) that powers therenderprop on@shadcn/reactprimitives. It lets a component render as a custom element while merging the primitive's props, refs, and state attributes onto it.
即:这是一个内部的多态渲染 helper,驱动 @shadcn/react 各原语上的 render prop。它允许一个组件渲染为自定义元素(custom element),同时把原语自身的 props、ref 和状态属性(state attributes)合并到这个自定义元素上。
README 的 Attribution 一节交代了它的来历与维护策略,这也是理解该模块设计动机的重要背景:
- 该代码改编自 Base UI(MUI 团队)的
useRender实现(对应其@base-ui/react/use-render模块),MIT 协议,版权属 MUI; - 仓库选择保留一份本地副本,目的是让
@shadcn/react的原语不对 Base UI 产生任何运行时依赖(no runtime dependency); - README 同时声明:如果 Base UI 将来把
useRender作为独立的、standalone 的包发布,仓库将切换到官方包并移除这份本地副本。源码首部的注释与这一声明互相印证:index.ts 第 1–3 行写着// A poor man's version of base-ui useRender与// TODO: Replace if base-ui useRender is published as a package.。
从源码结构看,这个“本地副本”并非直接照搬,而是一个精简实现(作者自称 “poor man's version”),整个模块只有一个文件,约 200 行。
二、公开导出与核心类型
use-render/index.ts 最终导出 3 个运行时符号和 1 个类型:
export { composeRefs, mergeProps, useRender, type UseRenderComponentProps }
其中 UseRenderComponentProps 是两个对外组件包(message-scroller、questionnaire)类型系统的基石——例如 MessageScrollerButtonProps 就定义为 UseRenderComponentProps<"button", MessageScrollerButtonRenderState> 与业务 prop 的交叉类型,从而使按钮既保留 <button> 的全部原生属性(含 ref),又获得强类型的 render prop。
UseRenderOptions:useRender 的入参
核心入参类型定义见 index.ts 第 30–39 行:
| 选项 | 类型 | 说明 |
|---|---|---|
defaultTagName |
TElement extends React.ElementType |
必填。未提供 render 时实际创建的元素标签(如 "form"、"fieldset"、"button") |
props |
React.ComponentPropsWithRef<TElement> |
可选。原语自身计算出的 props(内部逻辑、ARIA 属性、事件处理等) |
render |
RenderProp<TState> |
可选。React.ReactElement 或渲染函数,决定最终渲染成什么 |
state |
TState(Record<string, unknown>) |
可选。组件内部状态对象,会被映射为 data-* 属性,默认 {} |
stateAttributesMapping |
StateAttributesMapping<TState> |
可选。按状态键自定义 data-* 属性的生成方式 |
render 的类型是一个二选一联合(第 8–15 行):
type RenderFunction<TState extends RenderState> = (
props: Record<string, unknown>,
state: TState
) => React.ReactElement | null
type RenderProp<TState extends RenderState> =
| React.ReactElement
| RenderFunction<TState>
函数形态可以拿到“已合并好的 props + 原始 state”,适合需要条件渲染、组合多个元素等复杂场景;元素形态则直接提供要渲染的 JSX。
三、useRender 主流程:三个分支
useRender 函数本体(第 41–82 行) 的逻辑可以拆成“一次合并 + 三个分支”:
function useRender<TElement, TState>({
defaultTagName, props, render,
state = {} as TState, stateAttributesMapping,
}: UseRenderOptions<TElement, TState>) {
const elementProps = mergeProps<TElement>(
getStateAttributes(state, stateAttributesMapping),
props
)
if (!render) {
return React.createElement(defaultTagName, elementProps)
}
if (typeof render === "function") {
return render(elementProps, state)
}
if (!React.isValidElement(render)) {
return null
}
const renderProps = render.props as Record<string, unknown>
const propsWithRenderProps = mergeProps<TElement>(elementProps, renderProps)
const propsWithRef = {
...propsWithRenderProps,
ref: composeRefs(
elementProps.ref, renderProps.ref
),
}
return React.cloneElement(render, propsWithRef)
}
三个分支的语义:
- 不传
render:直接React.createElement(defaultTagName, elementProps),渲染默认标签。这是最常见路径——大多数使用方根本不关心多态,只享受 props 合并带来的样式/状态挂载能力。 render是函数:调用render(elementProps, state)。注意此时 ref 不会被自动挂到函数返回的元素上,函数形态把控制权完全交给使用方。render是 React 元素:通过React.cloneElement把合并后的 props 注入该元素。合并顺序是mergeProps(elementProps, renderProps)——render元素上已有的 props 作为更晚的 source 参与合并,普通键上消费者元素自己的 props 优先;而ref被特殊处理,通过composeRefs把“内部 ref”与“用户元素上的 ref”同时保留,谁都不会被覆盖。
此外还有一个防御性分支:如果 render 既不是函数也不是合法元素,直接返回 null,而不是抛出运行时错误。
四、mergeProps:四种特殊键的合并规则
mergeProps(第 84–135 行) 是接收任意多个 source、从左到右依次应用的浅合并函数,但并非简单的 {...a, ...b}。对每个键,它的规则是:
| 键/键类型 | 合并规则 |
|---|---|
value === undefined |
直接跳过,不会覆盖前一个 source 中已有的值 |
className |
用空格拼接:[current, value].filter(Boolean).join(" "),双方类名都保留 |
style |
对象展开:{ ...current, ...value },逐属性后者覆盖前者 |
ref |
调用 composeRefs(current, value) 组合成一个新的 ref callback |
事件处理器(/^on[A-Z]/ 匹配,且新旧值都是函数) |
调用 composeEventHandlers 组成“链式”处理器 |
| 其他所有键 | 后者直接覆盖前者(last wins) |
两个值得注意的实现细节:
undefined 不覆盖。 这是该合并语义的关键:props 中值为 undefined 的键(例如使用方传了 aria-label={undefined})不会抹掉内部已经设置好的属性,保证了“内部默认值 + 消费者覆盖”的层级关系稳定成立。
事件合并的优先级与中断机制。 composeEventHandlers 的实现见 第 174–186 行:
function composeEventHandlers(theirs: EventHandler, ours: EventHandler) {
return function handleEvent(event: React.SyntheticEvent) {
theirs(event)
if (!event.defaultPrevented) {
ours(event)
}
}
}
function isEventHandler(key: string) {
return /^on[A-Z]/.test(key)
}
在 mergeProps 的调用约定里,theirs 是新 source 的处理器,ours 是此前已合并累积的处理器:先执行新处理器;只有当它没有调用 event.preventDefault() 时,才继续执行内部处理器。这与 questionnaire 组件中的实际行为一致——例如 QuestionnaireNext 里业务方传入的 onClick 先执行,未阻止默认行为时组件才继续调用 context.goNext()。
五、状态到 data-* 属性的映射:getStateAttributes
无样式组件向消费者暴露样式钩子的方式,是把内部状态序列化为 data-* 属性。这一职责由 getStateAttributes(第 137–170 行) 承担,其映射优先级与规则如下:
- 自定义映射优先:若
stateAttributesMapping[key]存在且返回了属性对象,则直接Object.assign进结果,跳过默认规则。这允许组件把状态翻译成非标准命名,例如互斥的data-checked/data-unchecked成对属性(见 QuestionnaireChoice 的实现); slot特例:状态键为slot时映射到data-slot,保留该约定俗成的语义;- 默认规则:键名做 camelCase → kebab-case 转换并加
data-前缀(如isActive→data-is-active),值按类型处理:- 布尔
true→ 空字符串(属性存在,即data-active="");布尔false→undefined(属性不渲染); null/undefined→ 不产生属性;- 其他值 →
String(value)。
- 布尔
在 useRender 内部,状态属性总是作为 mergeProps 的第一个 source(mergeProps(getStateAttributes(...), props)),因此业务 props 可以在同名键上覆盖状态属性——这是一处明确的优先级设计。
六、composeRefs:让多个 ref 各得其所
composeRefs(第 188–206 行) 把任意多个 React.Ref<T> | undefined 过滤出有效项后,组合为一个 ref callback:
function composeRefs<T>(...refs: Array<React.Ref<T> | undefined>) {
const validRefs = refs.filter(Boolean)
if (validRefs.length === 0) {
return undefined
}
return (value) => {
for (const ref of validRefs) {
if (typeof ref === "function") {
ref(value)
} else if (ref) {
ref.current = value
}
}
}
}
它对函数 ref 逐一调用、对对象 ref 逐一写入 .current,从而保证“内部逻辑用的 ref”和“使用方透传的 ref prop”能同时指向同一 DOM 节点。这个能力在 useRender 的元素分支里用于合并内部 ref 与 render 元素上的 ref;也独立出现在不经 useRender 的组件中——例如 MessageScrollerViewport 手动把 setViewportElement 的结果与消费者 ref 通过 composeRefs(ref)?.(element) 一并挂载。
七、仓库中的真实用例:Questionnaire 与 MessageScroller
useRender 目前有两个消费方,二者都位于 packages/react 内(包名 @shadcn/react,当前版本 0.3.0,peer 依赖 react >= 19)。值得注意的是 package.json 的 exports 字段 只暴露了 ./message-scroller 与 ./questionnaire 两个子路径——use-render 本身不是对外发布的入口,它只服务于这两个原语包内部,这也印证了 README 中 “internal helper” 的定位。
用例 1:最简形态——无状态的纯元素替换
QuestionnaireTitle 展示了最小用法:
function QuestionnaireTitle({ render, ...props }: QuestionnaireTitleProps) {
useQuestionnaireItemContext("Questionnaire.Title")
return useRender({
defaultTagName: "legend",
props,
render,
})
}
不传 render 时渲染 <legend>;传 render={<span />} 或渲染函数时按前述分支处理。此时 UseRenderComponentProps<"legend"> 类型让消费者获得 <legend> 的全部原生属性提示。
用例 2:状态驱动 data-* 属性 + 自定义映射
QuestionnaireItem 渲染默认 fieldset,并把题目状态交给 useRender:
const element = useRender({
defaultTagName: "fieldset",
props: mergeProps<"fieldset">({ ...itemProps, children }, props),
state,
stateAttributesMapping: {
active: (isActive) => ({
"data-active": isActive ? "" : undefined,
}),
},
})
state 是 QuestionnaireItemState(含 active、disabled、invalid、multiple、required、status 等,见 types.ts 第 45–52 行)。其中 active 走自定义映射生成 data-active,其余键则按默认规则生成 data-disabled、data-invalid 等。消费者因此可以直接用 CSS 选择器 [data-invalid] 做样式,无需 JS 参与。
stateAttributesMapping 的典型成对用法见 QuestionnaireChoice:checked 状态同时产出互斥的 data-checked / data-unchecked,方便选择器精确命中任一态。
用例 3:render 元素 + state 的完整组合
MessageScrollerButton 是功能最完整的范例:一个滚动到会话顶端/尾部的控制按钮,默认渲染 <button>:
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type,
inert: !isActive,
tabIndex: isActive ? tabIndex : -1,
children: children ?? <span>Scroll to {direction}</span>,
onClick: handleClick,
},
props
),
render,
state: {
active: isActive,
direction,
},
stateAttributesMapping: {
active: (value) => ({
"data-active": value ? "true" : "false",
}),
},
})
这里体现了整套机制的叠加效果:内部逻辑(inert、tabIndex、onClick)先作为早 source 并入,消费者 props 作为晚 source 可覆盖普通键、拼接 className、组合 onClick(未 preventDefault 时才触发内部滚动,见 handleClick 实现);state 中的 direction 走默认规则生成 data-direction="start" | "end",active 走自定义映射生成字符串值 "true" / "false"(注意与布尔映射的空字符串写法不同,说明自定义映射可以把状态表达为任意字符串)。
用例 4:类型层复用——UseRenderComponentProps
两个包的公开类型都通过 UseRenderComponentProps<TElement, TState> 统一了“原生元素属性 + ref + render”三件套,例如 QuestionnaireProgressProps 绑定 "div" 与 QuestionnaireProgressState(current / first / last / total,最终呈现为 data-current 等属性),MessageScrollerButtonProps 绑定 "button" 与 { active, direction }。这意味着每个状态键都会自动获得一份 data-* 样式钩子,且 render 函数的第二参数 state 有完整类型——使用方在渲染函数里可以拿到与 DOM 上完全一致的状态快照。
八、构建与分发:一个只发 ESM 的内部模块
从 tsup.config.ts 可以看到该包的发布形态:入口只有 message-scroller/index 与 questionnaire/index 两个文件(use-render 随消费方被打包进这两个入口,不单独发布);format: ["esm"]、minify: true、treeshake: true、dts: true。配置中还有一个 useClientDirectivePlugin 插件,在构建结束后为发布的 dist 文件补写 "use client" 指令——因为打包器会从源码里剥掉该指令,而 RSC 应用需要它在已发布的模块上,才能让 import { ... } from "@shadcn/react/message-scroller" 被识别为客户端边界。由于 useRender 全程只做纯 React 元素计算(createElement / cloneElement / props 合并),没有任何 DOM 或浏览器 API 访问,它天然适合在这样的客户端组件闭包内运行。
九、小结:为什么值得在自家组件库复刻这套模式
回到 README 的核心命题:useRender + mergeProps 让 @shadcn/react 的原语“无样式但可替换”。梳理下来,这套仅约 200 行的机制提供了四个正交能力,且全部可在本仓库源码中逐行验证:
- 多态渲染:
render同时接受元素与函数两种形态,元素形态下内部 props 与用户元素 props 安全合并、双 ref 并存; - 可预测的属性合并:
className拼接、style展开、事件链式执行且可被preventDefault中断、undefined永不覆盖; - 状态即样式钩子:内部状态自动序列化为
data-*属性,支持按键自定义映射(slot→data-slot、camelCase → kebab-case、布尔值的空字符串约定); - ref 组合原语:
composeRefs使内部管理与消费者透传各取所需。
同时,README 的 Attribution 一节也为这类“引入并本地化第三方实现”的做法提供了清晰的治理范本:注明来源与协议(Base UI,MIT)、说明保留副本的理由(原语零运行时依赖)、并给出未来切换官方 standalone 包的明确触发条件。对于任何希望让无样式组件支持 render prop 的 React 组件库,这份实现及其工程决策都值得直接参照。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00