拓展性如此强大的 Radio,有必要了解一下!高拓展性 radio 实现思路

前言
为什么说我们的 Radio 组件拓展性高呢?
从本质上讲,Radio 组件的核心就是单选逻辑。一旦实现了单选的核心机制,UI 表现形式就变得相对次要了。单选的本质无非就是:
-
定义选中和未选中状态的 UI 表现
-
确保同一时间只能有一个选项被选中
这就是单选逻辑的简单精髓。
在我们的 @t-headlessui/react 组件库中,Radio 组件(在库中命名为 Radio.Root)与 Radio.Group 已经完美实现了上述逻辑,两者配合使用即可轻松构建单选功能。
本文不会逐行解析具体代码实现(对此感兴趣的同学欢迎进群交流,如果需要包装简历,我也可以手把手指导,甚至协助撰写简历内容),而是重点介绍如何实现 UI 自定义的核心逻辑。
可自定义性的核心原理
传统的 Radio 组件主要通过 <input type="radio"> 标签实现,浏览器会为其渲染默认的样式:

核心洞察
要实现真正的样式自定义,我们就必须突破原生 Radio 的样式限制。但问题来了:只要实现了 Radio 的内在逻辑,本质上就实现了 Radio 组件。
那么,什么是 Radio 的内在逻辑呢?
四大核心逻辑:
-
选中状态逻辑,传入点击选项时切换选中状态(checked)
-
禁用状态逻辑,传入
disabled参数时,元素不可交互- 视觉上表现为不可点击状态(如 cursor: not-allowed)
-
只读状态逻辑:注意原生
Radio不支持readonly状态,但业务场景中极为常见, 所以我们的组件增加了这个状态 -
单选分组逻辑:个 radio 组合时,确保同一时间只能选中其中一个
更理想的解决方案
能否在保持 radio 组件原有逻辑的基础上,同时支持完全自定义的 UI?
也就是说:
-
用户仍可选择使用原生 radio(保持语义完整性)
-
也支持完全自定义的
UI表现
答案是肯定的! 我们的组件正是基于这一理念设计的。
在介绍核心逻辑之前,我们先说一个小技巧。
radio 组件基本结构

观察上图可以发现,标准的 radio 组件通常包含两个部分:
- 左侧的圆圈图标(表示选中状态)
- 右侧的描述文字
在实际使用中,我们期望不仅点击圆圈可以选中,点击右侧文字也应该能触发选中。这个功能是如何实现的呢?关键在于 <label> 标签的巧妙运用。
方法一:MDN 推荐方式
1<div>
2<input type="radio" id="huey" name="drone" value="huey" checked />
3<label for="huey">Huey</label>
4</div>实现要点:
- 在
input上定义唯一的id属性 - 在
label上设置对应的for属性 - 两者值匹配时,点击
label即可选中关联的radio
缺点: 需要维护 input 上的 id 属性与 label 上的 for 属性对应关系,较为繁琐。
方法二:嵌套包裹方式
1<label>
2<input type="radio" value="huey" />
3Huey
4</label>优势:
- 结构更简洁,无需维护
id映射 label自动与内部的input建立关联- 点击
label内的任何区域(包括文字)都能触发radio选中
了解完基本的结构后,我们开始先说一下触发 radio 选中的机制的完整流程。
状态切换的完整流程
基于上述结构,我们来梳理完整的状态切换逻辑,为后续实现提供清晰的蓝图:
-
用户交互起点
- 用户点击
label区域(包括图标或文字) - 触发绑定在
label上的onClick事件
- 用户点击
-
事件冒泡传递
label的点击事件会自动触发内部input的onClick事件- 这是浏览器默认的事件冒泡机制
-
状态变更核心
input的onClick最终触发自身的onChange事件- 在
onChange中将选中的值设置为当前input的value
补充说明:input 等表单元素的核心作用就是收集用户输入值,通过 value 属性传递给表单提交或状态管理。
可视化流程:
1用户点击 Label
2↓
3触发 label.onClick
4↓
5冒泡至 input.onClick
6↓
7触发 input.onChange
8↓
9更新选中状态 (value)虽然整体逻辑看似清晰简单,但实际开发中隐藏着不少陷阱。我们将逐一剖析这些常见问题,一旦理解并规避了这些坑,您就能轻松实现一个健壮可靠的 radio 组件。
三个核心技术点
基于前述分析,我们构建三层事件处理机制来完善 Radio 组件的交互逻辑。
第一个: Label 点击拦截
1const onLabelClick = function (e) {
2// 只读或禁用时,阻止点击产生任何行为
3if (disabled || readonly) {
4 e.preventDefault();
5 return;
6}
7rest?.onClick?.(e);
8};关键要点:
- 状态拦截:当组件处于
disabled或readonly状态时,直接返回 - 事件阻止:调用
e.preventDefault()阻止浏览器默认行为,防止触发input的选中状态变更
因为 label 点击会触发 <input type="radio"> 的点击事件,所以我们需要在 label 点击事件中判断是否是 disabled 或 readonly 状态,如果是,就阻止点击事件的冒泡。
第二个: Input 点击隔离
1onClick={(e) => {
2// 阻止 input 的点击事件冒泡,避免重复处理
3e.stopPropagation();
4}}设计意图:
有时候可能会只点击 <input type="radio"> 而不点击 <label>,这时候我们需要在 input 点击事件中阻止事件冒泡,避免触发 label 的点击事件。
第三个: Change 事件核心逻辑
1const [checked, setChecked] = useMergeValue(false, {
2value: propsChecked,
3defaultValue: mergeProps.defaultChecked,
4});
5
6const onChange = (e) => {
7e.persist();
8e.stopPropagation();
9
10// 禁用或只读都不改变状态,不触发外部 onChange
11if (disabled || readonly) return;
12
13if (context.group) {
14 context?.onChangeValue?.(value, e);
15} else if (!('checked' in props) && !checked) {
16 setChecked(true);
17}
18if (!checked) {
19 propsOnChange?.(true, e);
20}
21};以下是一些坑点介绍:
1e.persist(); // React 17+ 可安全移除历史背景:React 16 及之前版本使用事件池机制,异步代码中无法访问事件对象。现代 React 已移除此机制。
- 事件冒泡控制
1e.stopPropagation();防止 Radio 嵌套时,内层事件冒泡影响外层 Radio 组件的 onChange 事件,确保事件处理的独立性。
1if (disabled || readonly) return;检查是否是 disabled 或者 readonly 状态,这样的状态不能让它触发 onChange 事件
1if (context.group) {
2context?.onChangeValue?.(value, e);
3}上面这段代码我们暂且不讲,是要配合 Radio.Group 组件使用,这个 context 是使用 useContext api 获取的 Radio.Group 透传的数据。也就是状态最终会被 Radio.Group 接管。
也就是 Group 来通知到底是谁被选中了。
1} else if (!('checked' in props) && !checked) {
2setChecked(true);
3}-
!('checked' in props):检测是否未传入 checked 属性
-
有的同学会问,为什么要用
'checked' in props, 这与checked === undefined有什么区别?请体会一下代码:
1// 情况一:显式传递 undefined(受控模式)
2<Radio checked={undefined} />
3'checked' in props → true // 正确识别为受控
4
5// 情况二:未传递属性(非受控模式)
6<Radio />
7'checked' in props → false // 正确识别为非受控而上述代码 'checked' in props 逻辑改为 checked === undefined 是无法很多做到区分受控和非受控的。
这里需要解释两点
-
首先,很多组件库,一般都会用受控的形式来模拟非受控,为什么呢?因为我们要确确实实拿到 Radio 组件的
checked(选中) 还是 非checked状态,如果都交给原生,我们获取很不方便 -
其次
useMergeValue是组件库很常用函数,它把受控和非受控组合起来,是个非常实用的函数,我们来介绍一下逻辑。相信你写组件库也一定会用到。
以下是 useMergeValue 函数的逻辑:
1export function useMergeValue<T>(
2defaultStateValue: T,
3props?: {
4 defaultValue?: T;
5 value?: T;
6},
7) {
8const { defaultValue, value } = props || {};
9
10const [stateValue, setStateValue] = useState<T>(
11 !isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue,
12);
13
14// 处理受控转非受控的场景
15useEffect(() => {
16 // 实现细节...
17}, [value]);
18
19const mergedValue = isUndefined(value) ? stateValue : value;
20return [mergedValue, setStateValue, stateValue];
21}这里简单解释一下,其实就是你传了 value 我就认为你是受控组件,然后 value 就透传出去, defaultValue 或者组件库想默认给个默认值, 我会用这个值其初始化 stateValue 然后传出去。并且 setStateValue 方法能改变其值。
setStateValue 其实在传入 value 的情况下,也没什么用,因为改变不了 value 的值。
如何将状态传递给子组件
我们可以使用 context api,将最终的 checked, disabled, readonly 状态让子组件使用 useContext 来获取。
1<RadioContext.Provider value={{ checked, disabled, readonly }}>
2 <label {...rest} onClick={onLabelClick} aria-disabled={!!disabled} aria-readonly={!!readonly} aria-checked={!!checked}>
3 {/* 为什么没有 readonly 状态, 标准里本来也没有 */}
4 <input type="radio">
5 {children}
6 </label>
7</RadioContext.Provider>如何保持语义性
我们只需要将 input 组件依然接受之前我们的状态,就能保持原生 radio 组件的语义性,所以我们完善一下 input 组件,也就是把之前的状态传递过去即可:
1<RadioContext.Provider value={{ checked, disabled, readonly }}>
2 <label {...rest} onClick={onLabelClick} aria-disabled={!!disabled} aria-readonly={!!readonly} aria-checked={!!checked}>
3 {/* 为什么没有 readonly 状态, 标准里本来也没有 */}
4 <input
5 ref={inputRef}
6 disabled={!!disabled}
7 value={value}
8 type="radio"
9 checked={!!checked}
10 onChange={onChange}
11 onClick={(e) => {
12 // 阻止 input 的点击事件冒泡,避免重复处理
13 e.stopPropagation();
14 }}
15 aria-readonly={!!readonly}
16 />
17 {children}
18 </label>
19 </RadioContext.Provider>Radio Group 逻辑
这里简单介绍一下如何使用 Radio Group 组件包裹上面我们完成的 Radio 组件。
核心逻辑为, 使用同样是 useContext api,我们命名为 RadioGroupContext 来把当前选中的 Radio 标签的 value 传递即可 :
1<div role="radiogroup" {...rest}>
2 <RadioGroupContext.Provider
3 value={{
4 onChangeValue,
5 type,
6 value,
7 disabled,
8 readonly,
9 group: true,
10 name,
11 }}
12 >
13 {children}
14 </RadioGroupContext.Provider>
15 </div>小结
文章把主要的核心逻辑梳理了一下,并没有过多解释每行代码。如果你想讨论关于如何实现自己组件库的的内容,欢迎加群一起讨论,组件还在不断拓展中,最终会对标大厂组件库。
欢迎加入简历亮点交流群
如果什么项目能覆盖常见的开发中的核心技术,那莫过于这套 从 0 到 1 打造的 企业级 前端组件库的项目了。
如果还在发愁建立有什么亮点项目能让面试官眼前一亮,让你在其它面试者中 脱颖而出,那这个项目就是你需要的。
可以根据你的需要,定制化 地帮你我来帮你写出符合你需求的项目。这个项目水平如何,效果可见,源码可见,无需多言。
掌握这套体系,你将全面提升前端架构能力与工程思维,让你的简历多一个能打的 「专家级项目」。
附录
安装 radio
1npm i @t-headless-ui/react
2yarn i @t-headless-ui/react
3pnpm i @t-headless-ui/react引入和使用 radio
1import { Radio } from '@t-headless-ui/react';
2// use radio
3<Radio.Root>
4 Radio
5</Radio.Root>
6
7// use radio group
8<Radio.Group defaultValue="1">
9 <Radio.Root value="1">Option 1</Radio.Root>
10 <Radio.Root value="2">Option 2</Radio.Root>
11</Radio.Group>