首页
/ 深入 @shadcn/react 的 useRender:shadcn/ui 中多态渲染与 mergeProps 属性合并机制的实现剖析

深入 @shadcn/react 的 useRender:shadcn/ui 中多态渲染与 mergeProps 属性合并机制的实现剖析

2026-09-04 14:34:29作者:昌雅子Ethen

本文以 packages/react/src/use-render/README.md 为核心,拆解 shadcn/ui 仓库中内部多态渲染工具 useRendermergeProps 的完整实现:它如何让 @shadcn/react 的原语(primitives)在保持默认 DOM 标签的同时,支持消费者把组件“渲染成任意自定义元素”,并在此过程中正确合并 props、ref 与状态属性。读完本文,你能理解 render prop 模式在这套无样式组件库中的底层落地方式,并掌握在自己的 React 组件中复刻该模式的完整思路。

一、定位与来历:一个为 render prop 服务的内部 helper

README 对模块的定位非常明确:

Internal polymorphic-render helper (useRender, mergeProps) that powers the render prop on @shadcn/react primitives. 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-scrollerquestionnaire)类型系统的基石——例如 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 TStateRecord<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)
}

三个分支的语义:

  1. 不传 render:直接 React.createElement(defaultTagName, elementProps),渲染默认标签。这是最常见路径——大多数使用方根本不关心多态,只享受 props 合并带来的样式/状态挂载能力。
  2. render 是函数:调用 render(elementProps, state)。注意此时 ref 不会被自动挂到函数返回的元素上,函数形态把控制权完全交给使用方。
  3. 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 行) 承担,其映射优先级与规则如下:

  1. 自定义映射优先:若 stateAttributesMapping[key] 存在且返回了属性对象,则直接 Object.assign 进结果,跳过默认规则。这允许组件把状态翻译成非标准命名,例如互斥的 data-checked / data-unchecked 成对属性(见 QuestionnaireChoice 的实现);
  2. slot 特例:状态键为 slot 时映射到 data-slot,保留该约定俗成的语义;
  3. 默认规则:键名做 camelCase → kebab-case 转换并加 data- 前缀(如 isActivedata-is-active),值按类型处理:
    • 布尔 true → 空字符串(属性存在,即 data-active="");布尔 falseundefined(属性不渲染);
    • null / undefined → 不产生属性;
    • 其他值 → String(value)

useRender 内部,状态属性总是作为 mergeProps第一个 sourcemergeProps(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,
    }),
  },
})

stateQuestionnaireItemState(含 activedisabledinvalidmultiplerequiredstatus 等,见 types.ts 第 45–52 行)。其中 active 走自定义映射生成 data-active,其余键则按默认规则生成 data-disableddata-invalid 等。消费者因此可以直接用 CSS 选择器 [data-invalid] 做样式,无需 JS 参与。

stateAttributesMapping 的典型成对用法见 QuestionnaireChoicechecked 状态同时产出互斥的 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",
    }),
  },
})

这里体现了整套机制的叠加效果:内部逻辑(inerttabIndexonClick)先作为早 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"QuestionnaireProgressStatecurrent / first / last / total,最终呈现为 data-current 等属性),MessageScrollerButtonProps 绑定 "button"{ active, direction }。这意味着每个状态键都会自动获得一份 data-* 样式钩子,且 render 函数的第二参数 state 有完整类型——使用方在渲染函数里可以拿到与 DOM 上完全一致的状态快照。

八、构建与分发:一个只发 ESM 的内部模块

tsup.config.ts 可以看到该包的发布形态:入口只有 message-scroller/indexquestionnaire/index 两个文件(use-render 随消费方被打包进这两个入口,不单独发布);format: ["esm"]minify: truetreeshake: truedts: 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 行的机制提供了四个正交能力,且全部可在本仓库源码中逐行验证:

  1. 多态渲染render 同时接受元素与函数两种形态,元素形态下内部 props 与用户元素 props 安全合并、双 ref 并存;
  2. 可预测的属性合并className 拼接、style 展开、事件链式执行且可被 preventDefault 中断、undefined 永不覆盖;
  3. 状态即样式钩子:内部状态自动序列化为 data-* 属性,支持按键自定义映射(slotdata-slot、camelCase → kebab-case、布尔值的空字符串约定);
  4. ref 组合原语composeRefs 使内部管理与消费者透传各取所需。

同时,README 的 Attribution 一节也为这类“引入并本地化第三方实现”的做法提供了清晰的治理范本:注明来源与协议(Base UI,MIT)、说明保留副本的理由(原语零运行时依赖)、并给出未来切换官方 standalone 包的明确触发条件。对于任何希望让无样式组件支持 render prop 的 React 组件库,这份实现及其工程决策都值得直接参照。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388