Radix UI 是一个主打灵活性和可扩展性的组件库,如果你厌倦了传统组件库那种预设大量样式、难以覆盖的痛苦,那么 Radix UI 可能正是你要找的工具。在本文中,我将带你了解 Radix UI 是如何通过无头组件(Headless Components)和无样式组件(Unstyled Components)的设计理念,为开发者提供更强大的自定义能力。
Radix 的核心是 Primitives 仓库,也就是它的无头组件库。这些组件只提供交互逻辑,没有任何样式,开发者可以自由地基于自己的设计系统添加样式。相比之下,那些预设了大量样式的组件库(如 Ant Design、MUI)虽然让开发更快上手,但在需要高度定制时却经常让人抓狂。
本文还会探讨 Radix 在细节设计上的一些亮点,比如:
- 可插拔子组件设计:像搭积木一样拼接出你需要的 UI;
- Context 作用域隔离:优雅解决 Context 冲突;
- Primitive 组件代理模式:让原生组件更智能、更灵活。
1. 前置概念
- 无头组件库(Headless Component Library)
无头组件库只提供组件的逻辑和功能,不包含任何特定样式或 UI 设计,开发者可以根据自己的设计系统为组件自定义样式。
- 无样式组件(Unstyled Components)
无头组件库提供的组件称为 unstyled components,与之相对的概念是 pre-styled components,比如 Element-UI、Antd、MUI。
之所以推出 unstyled components 是因为 pre-styled components 往往都带有预设的样式(对应企业的风格),虽然适合快速开发,但是难以自定义样式,大量的 CSSVar 和样式覆盖起来很困难。而 unstyled components 完全不提供样式,就可以用自己的 design token 去设计组件样式了。
- Design Tokens
可以理解为是一组 CSS 变量的集合,基本组成单元是 token。一个 token 就是一个键值对,键是遵循某种命名方式的语义化的名称(如 negative-border-color-default),值就是键对应的数据(如 RGBA 颜色、百分比、像素值等)。
Example:bg-white: background-color: rgb(255 255 255);
Token system/Design system 基本上就是 token 的集合,可能包含设计中的各种元素(颜色、尺寸、布局...)。
如果你曾经写过 Tailwind CSS,写的类名其实就是 token:
2. 总体概览
Radix UI 是一个多仓库多包的项目,分为四个仓库,每个仓库内部是 monorepo:
- Themes:pre-styled-components 组件库,基于 Primitives 搭建
- Primitives:headless 组件库,我们平常提到 Radix 基本就是说这个库
- 优点:用户可以自定义主题而不需要通过 css 变量覆盖/样式覆盖的方式来修改主题,使用自己的 design system 来进行定制,通用性更好
- 缺点:组件只包含逻辑不含样式,用户使用体验和用原生 HTML 元素的体验差不多,需要配合 design system 使用,可能要写大量的 css,对用户的要求较高
- Icons:和下面的 Colors 都是基建性质的库,功能和设计都很简单,没什么可看的
- Colors:Radix 自己的 Design Tokens 中的颜色相关的 token 开源出来,单开了一个仓库
本文着重看 Primitives 和 Themes,Icons 和 Colors 学不到什么,不看。
3. Primitives:unstyled-components
3.1. 如何使用
以 Dialog 为例,使用上的体验是组合式的,由多个组件组成一个复合组件,导入组件并把它们拼在一起使用,使用起来会更灵活:
import * as Dialog from "@radix-ui/react-dialog";
export default () => (
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
没有对比就没有伤害,不妨看看 Antd 的对话框:
const App = () => {
const [open, setOpen] = useState(false);
const showModal = () => {};
const handleOk = () => {};
const handleCancel = () => {};
return (
<>
<Button type="primary" onClick={showModal}>
Open Modal with customized footer
</Button>
<Modal
open={open}
title="Title"
onOk={handleOk}
onCancel={handleCancel}
footer={[
<Button key="back">
Return
</Button>,
<Button key="submit">
Submit
</Button>,
<Button key="link">
Search on Google
</Button>,
]}
>
<p>Some contents...</p>
</Modal>
</>
);
};
你可以看到 antd 使用上充满了大量的配置项,一方面理解起来不直观,用户可能需要看文档 + 写 demo 尝试才知道 props 的作用,props 一多直接歇菜;另一方面自定义的 title 和 footer 都需要传入 ReactNode,如果想在配置项提供的能力之外自定义一些事情是很难的(比如更复杂的 DOM 结构,或是 mask 样式和行为的自定义)。
3.2. 可插拔子组件设计
Radix 最大的亮点之一就是可组合 & 可插拔的子组件设计,使用上是相对轻量级的。
在组件实现上,Dialog 的根组件 Dialog.Root 并不渲染 DOM,而是作为容器层派发 props,实际上是一个 ContextProvider:
// packages/react/dialog/src/Dialog.tsx
const Dialog: React.FC<DialogProps> = (props: ScopedProps<DialogProps>) => {
const {
__scopeDialog,
children,
open: openProp,
defaultOpen,
onOpenChange,
modal = true,
} = props;
const triggerRef = React.useRef<HTMLButtonElement>(null);
const contentRef = React.useRef<DialogContentElement>(null);
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
return (
<DialogProvider
scope={__scopeDialog}
triggerRef={triggerRef}
contentRef={contentRef}
contentId={useId()}
titleId={useId()}
descriptionId={useId()}
open={open}
onOpenChange={setOpen}
onOpenToggle={React.useCallback(() => setOpen((prevOpen) => !prevOpen), [setOpen])}
modal={modal}
>
{children}
</DialogProvider>
);
};
Dialog.Root 把 props(如 open、onOpenChange)、无障碍相关 id 和 ref 通过 context 透传,子组件去消费 contextProvider 提供的参数,根据参数处理 UI 展示和事件的逻辑。例如 Dialog.Trigger:
const DialogTrigger = React.forwardRef<DialogTriggerElement, DialogTriggerProps>(
(props: ScopedProps<DialogTriggerProps>, forwardedRef) => {
const { __scopeDialog, ...triggerProps } = props;
const context = useDialogContext(TRIGGER_NAME, __scopeDialog); // 消费 context
const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef);
return (
<Primitive.button
type="button"
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
data-state={getState(context.open)}
{...triggerProps}
ref={composedTriggerRef}
onClick={composeEventHandlers(props.onClick, context.onOpenToggle)}
/>
);
}
);
3.3. Context 作用域隔离设计
可插拔子组件的设计固然灵活,但是会带来 Context 冲突的问题,例子。
在这个例子中,DropdownMenu 和 ContextMenu 底层都使用了 Menu 组件,而 Menu 组件的 Context 是在模块作用域(Module Scope)级别定义的。因此,当在 ContextMenu.Root 中使用 DropdownMenu 组件时,DropdownMenu.Root 传入的 context 会被覆盖。下面是一个很简单的模拟:
// MenuContext.jsx
import React, { createContext, useContext } from 'react'
const MenuContext = createContext('')
export const MenuProvider = ({ value, children }) => (
<MenuContext.Provider value={value}>{children}</MenuContext.Provider>
)
export const useMenuContext = () => {
return useContext(MenuContext)
}
// Menu.tsx
import React from 'react'
import { MenuProvider, useMenuContext } from './MenuContext'
export const Root = ({ children, value }) => <MenuProvider value={value}>{children}</MenuProvider>
export const Content = () => {
const menuType = useMenuContext()
return <div>{menuType}</div>
}
// DropdownMenu.jsx
import React from 'react'
import * as Menu from './Menu'
export const Content = () => {
return <Menu.Content></Menu.Content>
}
export const Root = ({ children }) => <Menu.Root value="Dropdown Menu">{children}</Menu.Root>
// ContextMenu.jsx
import React from 'react'
import * as Menu from './Menu'
export const Content = () => {
return <Menu.Content></Menu.Content>
}
export const Root = ({ children }) => <Menu.Root value="Context Menu">{children}</Menu.Root>
// App.jsx
import React from 'react'
import * as ContextMenu from './ContextMenu'
import * as DropdownMenu from './DropdownMenu'
export const App = () => (
<ContextMenu.Root>
<DropdownMenu.Root>
{/* 问题:这里 ContextMenu.Content 会显示 "Dropdown",而不是 "Context" */}
<DropdownMenu.Content />
<ContextMenu.Content />
</DropdownMenu.Root>
</ContextMenu.Root>
)
在这个例子中,ContextMenu.Root 和 DropdownMenu.Root 会向子组件透传 menuType 的信息,但是我们会发现 ContextMenu.Content 错误地接收到了 DropdownMenu.Root 注入的类型信息。
问题的根本在于 Context 是模块作用域下创建的,这导致不同的 Menu.Root 实例共享同一个 context,从而引发 context 覆盖的问题。
为了解决这一问题,Radix 引入了具有作用域的 Context,确保 DropdownMenu 和 ContextMenu 拥有相互独立的 context。
本身的原理并不复杂,但是代码看起来比较晦涩,这里只对原理进行讲解,模拟代码放在这部分最后,有兴趣可以看看。
原理:
- 通过 createContextScope 函数创建 createContext 和 createScope
- 每次调用 createContext 时得到的 context 应该是相互独立的,因此 使用闭包变量 contextsArray,每次 createContext 都会在其中放入一个 context,并记录对应的 index。从而能让 Provider 和 useContext 引用到对应的 context
- Provider 和 useContext 都新增了 scope 属性,scope 就是 context 的数组,这是为了区分同一个 createContext 创建出的context(例如上面的 Menu 的 context,就是通过调用一次 createContext 得到的,但是我们需要区分 ContextMenu 和 DropdownMenu 两个场景,因此需要 scope)。由于 ContextMenu 和 DropdownMenu 传入的 scope 对应 index 上的 context 不同,因此得到的 context 也不同,从而将 context 隔离开来
- 最后是 scope 的实现,我们这里需要保证 ContextMenu 和 DropdownMenu 分别调用 createScope 后得到的 scope 不同。这里不太好理解,我们可以用例子来理解:
- 当未调用 createScope 时,假设第一次调用 createContext 时 defaultContext 为 undefined,此时 contextsArray = [undefined]
- 此时 ContextMenu 调用 createScope,得到 scope [React.createContext(undefined)]
- 接下来 DropdownMenu 调用 createScope,也得到 scope [React.createContext(undefined)],但是两次 React.createContext 调用返回的是不同的 context(只是初值相同,都为 undefined),因此 DropdownMenu 和 ContextMenu 的 scope 不同,从而实现了 scope 作用域
更简洁的实现方式是不使用 scope,直接在场景中创建 context 并传递给 Menu。但这种方法缺乏通用性,因为每个组件都需要定义自己的 context 属性(如 menuContext、dialogContext 等)。
相比之下,scope 的可扩展性更强。在当前简化的实现中,scope 只是一个 context 数组,但存在一个局限性:父组件传递的 scope 只能被单个组件使用。例如,Menu 组件通过 createContextScope 得到的 createScope 创建的 scope 仅适用于自身,其他组件是无法使用的,因为 context 数据类型和 index 都无法匹配。
为了解决这个问题,可以进一步扩展 scope 的实现,将其类型改为 { [scopeName]: Context[] }。这样,传递的 scope 可被所有组件共享,各组件只需从中提取自己对应的 Context 数组即可。
代码模拟实现:
// MenuContext.tsx
import React, { ReactNode } from 'react'
type ScopeContexts<C = any> = React.Context<C>[]
export const createContextScope = () => {
let contextsArray: any[] = []
function createContext<ContextValueType extends object | null>(
defaultContext?: ContextValueType
) {
const BaseContext = React.createContext(defaultContext)
const index = contextsArray.length
contextsArray = [...contextsArray, defaultContext]
const Provider = ({
children,
value,
scope
}: {
children: ReactNode
value: object
scope?: ScopeContexts<ContextValueType>
}) => {
const Context = scope?.[index] || BaseContext
return (
<Context.Provider value={{ ...value } as ContextValueType}>{children}</Context.Provider>
)
}
const useContext = (scope?: ScopeContexts<ContextValueType | undefined>) => {
const Context = scope?.[index] || BaseContext
return React.useContext(Context)
}
return [Provider, useContext] as const
}
const createScope = () => {
const scopeContexts = contextsArray.map((ctx) => {
return React.createContext(ctx)
})
return function useScope() {
return scopeContexts
}
}
return [createContext, createScope] as const
}
// Menu.tsx
import React, { ReactNode } from 'react'
import { createContextScope } from './MenuContext'
const [createMenuContext, createMenuScope] = createContextScope()
const [MenuContextProvider, useMenuContext] = createMenuContext<{ menuType: string }>()
export const Root = ({
children,
value,
scope
}: {
children: ReactNode
value: { menuType: string }
scope?: React.Context<any>[]
}) => (
<MenuContextProvider scope={scope} value={{ ...value }}>
{children}
</MenuContextProvider>
)
export const Content = ({ scope }: { scope?: React.Context<any>[] }) => {
const { menuType } = useMenuContext(scope)!
return <div>{menuType}</div>
}
export { createMenuScope }
// ContextMenu.tsx
import React, { ReactNode } from 'react'
import * as Menu from './Menu'
import { createMenuScope } from './Menu'
const useMenuScope = createMenuScope()
export const Content = () => {
const menuScope = useMenuScope()
return <Menu.Content scope={menuScope}></Menu.Content>
}
export const Root = ({ children }: { children: ReactNode }) => {
const menuScope = useMenuScope()
return (
<Menu.Root scope={menuScope} value={{ menuType: 'Context Menu' }}>
{children}
</Menu.Root>
)
}
// DropdownMenu.tsx
import React, { ReactNode } from 'react'
import * as Menu from './Menu'
import { createMenuScope } from './Menu'
const useMenuScope = createMenuScope()
export const Content = () => {
const menuScope = useMenuScope()
return <Menu.Content scope={menuScope}></Menu.Content>
}
export const Root = ({ children }: { children: ReactNode }) => {
const menuScope = useMenuScope()
return (
<Menu.Root scope={menuScope} value={{ menuType: 'Dropdown Menu' }}>
{children}
</Menu.Root>
)
}
3.4. Primitive 组件设计
Radix 在使用原生组件如 div、button 时,都会使用 Primitive 组件。例如 Trigger 组件实际上就使用到了 Primitive.button:
这实际上是一种代理模式的体现。通过 Primitive 包装,所有原生元素获得了额外能力:
// 1. asChild 属性支持
// Radix 官方文档:当asChild设置为true时,Radix 将不会渲染默认的 DOM 元素,
// 而是克隆该部分的子元素并向其传递使其正常运行所需的 props 和行为。
<Primitive.div asChild>
<CustomComponent/> // 原生div被替换为CustomComponent,但保留props和ref
</Primitive.div>
// 2. ref 转发
<Primitive.div ref={myRef}/> // 自动处理ref转发
这种统一的能力扩展机制是值得学习的。
在具体实现上,实际上就是通过 forwardRef 转发 ref(React 19 以后就不用 forwardRef 了 hhh),asChild 属性依赖于内部的 Slot 组件对 props 和 ref 进行转发。
const NODES = [
'a',
'button',
'div',
'form',
'h2',
'h3',
'img',
'input',
'label',
'li',
'nav',
'ol',
'p',
'span',
'svg',
'ul',
] as const;
const Primitive = NODES.reduce((primitive, node) => {
const Node = React.forwardRef((props: PrimitivePropsWithRef<typeof node>, forwardedRef: any) => {
const { asChild, ...primitiveProps } = props;
const Comp: any = asChild ? Slot : node;
if (typeof window !== 'undefined') {
(window as any)[Symbol.for('radix-ui')] = true;
}
return <Comp {...primitiveProps} ref={forwardedRef} />;
});
Node.displayName = `Primitive.${node}`;
return { ...primitive, [node]: Node };
}, {} as Primitives);
用 Slot 组件还可以实现自己的 asChild API:Radix Slot
Slot 组件的主要原理就是把 Slot 接收的 props 和 ref 与 children 的做合并,之后克隆该部分的子元素并向其传递使其正常运行所需的 props 和 ref:
const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;
if (React.isValidElement(children)) {
const childrenRef = getElementRef(children);
return React.cloneElement(children, {
...mergeProps(slotProps, children.props as AnyProps),
ref: forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef,
});
}
// 无效情况:children 大于 1 个或没有 children
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});
// composeRefs 的简单模拟
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
// 遍历所有ref并设置值
refs.map((ref) => {
if (typeof ref === 'function') {
ref(node); // 函数形式ref
} else if (ref != null) {
ref.current = node; // 对象形式ref
}
});
};
}
4. Themes:pre-styled components
4.1. 主题配置设计
Themes 使用 Theme 组件给全局注入主题配置,就像这样:
<Theme
accentColor="mint" // 主题色
grayColor="gray"
panelBackground="solid" // 背景不透明
scaling="100%" // 缩放
radius="full" // 圆角
appearance="dark" // 暗色模式
>
<MyApp />
</Theme>
实现原理是在 div 上添加类名和 dataset,并使用 css 规则匹配对应的 css var,通过切类名和 dataset 来实现切换 css 变量值,从而切换样式:
const ThemeImpl = React.forwardRef<ThemeImplElement, ThemeImplProps>((props, forwardedRef) => {
const context = React.useContext(ThemeContext);
const {
asChild,
isRoot,
hasBackground: hasBackgroundProp,
//
appearance = context?.appearance ?? themePropDefs.appearance.default,
accentColor = context?.accentColor ?? themePropDefs.accentColor.default,
grayColor = context?.resolvedGrayColor ?? themePropDefs.grayColor.default,
panelBackground = context?.panelBackground ?? themePropDefs.panelBackground.default,
radius = context?.radius ?? themePropDefs.radius.default,
scaling = context?.scaling ?? themePropDefs.scaling.default,
//
onAppearanceChange = noop,
onAccentColorChange = noop,
onGrayColorChange = noop,
onPanelBackgroundChange = noop,
onRadiusChange = noop,
onScalingChange = noop,
//
...themeProps
} = props;
const Comp = asChild ? Slot : 'div';
const resolvedGrayColor = grayColor === 'auto' ? getMatchingGrayColor(accentColor) : grayColor;
const isExplicitAppearance = props.appearance === 'light' || props.appearance === 'dark';
const hasBackground =
hasBackgroundProp === undefined ? isRoot || isExplicitAppearance : hasBackgroundProp;
return (
<ThemeContext.Provider
value={...省略}
>
<Comp
// 主题色、圆角半径等主题使用 dataset
data-is-root-theme={isRoot ? 'true' : 'false'}
data-accent-color={accentColor}
data-gray-color={resolvedGrayColor}
// for nested `Theme` background
data-has-background={hasBackground ? 'true' : 'false'}
data-panel-background={panelBackground}
data-radius={radius}
data-scaling={scaling}
ref={forwardedRef}
{...themeProps}
// light/dark mode 使用类名
className={classNames(
'radix-themes',
{
light: appearance === 'light',
dark: appearance === 'dark',
},
themeProps.className
)}
/>
</ThemeContext.Provider>
);
});
Themes 依赖的样式位于 tokens 目录下,里面存储了 Themes 使用的 Design Tokens:
举几个 css 规则的例子:
/* 亮色暗色模式 */
:where(.radix-themes) {
--color-background: white;
--color-overlay: var(--black-a6);
--color-panel-solid: white;
--color-panel-translucent: rgba(255, 255, 255, 0.7);
--color-surface: rgba(255, 255, 255, 0.85);
}
:is(.dark, .dark-theme),
:is(.dark, .dark-theme) :where(.radix-themes:not(.light, .light-theme)) {
--color-background: var(--gray-1);
--color-overlay: var(--black-a8);
--color-panel-solid: var(--gray-2);
--color-panel-translucent: var(--gray-a2);
--color-surface: rgba(0, 0, 0, 0.25);
}
/* 主题色 */
[data-accent-color='amber'] {
--accent-1: var(--amber-1);
--accent-2: var(--amber-2);
--accent-3: var(--amber-3);
--accent-4: var(--amber-4);
--accent-5: var(--amber-5);
--accent-6: var(--amber-6);
--accent-7: var(--amber-7);
--accent-8: var(--amber-8);
--accent-9: var(--amber-9);
--accent-10: var(--amber-10);
--accent-11: var(--amber-11);
--accent-12: var(--amber-12);
...
}
[data-accent-color='blue'] { ... }
[data-accent-color='bronze'] { ... }
...
如果要做样式覆盖,有两种方式:
- 小规模覆盖:用 className 和 style 做覆盖,可以用来覆盖单个组件的样式
- 大规模覆盖:覆盖 design tokens 中的 css 变量值,可以用来修改所有组件的样式,例如覆盖颜色主题