组件库设计(一):Radix UI

1,730 阅读11分钟

Radix UI 是一个主打灵活性和可扩展性的组件库,如果你厌倦了传统组件库那种预设大量样式、难以覆盖的痛苦,那么 Radix UI 可能正是你要找的工具。在本文中,我将带你了解 Radix UI 是如何通过无头组件(Headless Components)和无样式组件(Unstyled Components)的设计理念,为开发者提供更强大的自定义能力。

Radix 的核心是 Primitives 仓库,也就是它的无头组件库。这些组件只提供交互逻辑,没有任何样式,开发者可以自由地基于自己的设计系统添加样式。相比之下,那些预设了大量样式的组件库(如 Ant Design、MUI)虽然让开发更快上手,但在需要高度定制时却经常让人抓狂。

本文还会探讨 Radix 在细节设计上的一些亮点,比如:

  • 可插拔子组件设计:像搭积木一样拼接出你需要的 UI;
  • Context 作用域隔离:优雅解决 Context 冲突;
  • Primitive 组件代理模式:让原生组件更智能、更灵活。

1. 前置概念

  1. 无头组件库(Headless Component Library)

无头组件库只提供组件的逻辑和功能,不包含任何特定样式或 UI 设计,开发者可以根据自己的设计系统为组件自定义样式。

  1. 无样式组件(Unstyled Components)

无头组件库提供的组件称为 unstyled components,与之相对的概念是 pre-styled components,比如 Element-UI、Antd、MUI。

之所以推出 unstyled components 是因为 pre-styled components 往往都带有预设的样式(对应企业的风格),虽然适合快速开发,但是难以自定义样式,大量的 CSSVar 和样式覆盖起来很困难。而 unstyled components 完全不提供样式,就可以用自己的 design token 去设计组件样式了。

  1. 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。

本身的原理并不复杂,但是代码看起来比较晦涩,这里只对原理进行讲解,模拟代码放在这部分最后,有兴趣可以看看。

原理:

  1. 通过 createContextScope 函数创建 createContext 和 createScope
  2. 每次调用 createContext 时得到的 context 应该是相互独立的,因此 使用闭包变量 contextsArray,每次 createContext 都会在其中放入一个 context,并记录对应的 index。从而能让 Provider 和 useContext 引用到对应的 context
  3. Provider 和 useContext 都新增了 scope 属性,scope 就是 context 的数组,这是为了区分同一个 createContext 创建出的context(例如上面的 Menu 的 context,就是通过调用一次 createContext 得到的,但是我们需要区分 ContextMenu 和 DropdownMenu 两个场景,因此需要 scope)。由于 ContextMenu 和 DropdownMenu 传入的 scope 对应 index 上的 context 不同,因此得到的 context 也不同,从而将 context 隔离开来
  4. 最后是 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'] { ... }
...

如果要做样式覆盖,有两种方式:

  1. 小规模覆盖:用 className 和 style 做覆盖,可以用来覆盖单个组件的样式
  2. 大规模覆盖:覆盖 design tokens 中的 css 变量值,可以用来修改所有组件的样式,例如覆盖颜色主题