面试官说: "你怎么把 Radio 组件玩出花的,教教我! “
前言
面试官问我 radio 组件知道怎么实现吗?我把我的组件库 radio 网页丢给它,我说我这个 radio 组件什么样式都支持,你看这是传统样式:
![]()
你看,这是自定义样式,理论上什么样式都可以:
![]()
![]()
然后我嘿嘿一笑说:"我的 radio 组件本质上不涉及任何样式,只负责岁月静好,使用者负责貌美如花!"
最后面试完毕,面试官很满意,并问这个怎么实现的,我就写一篇文章来说说吧!
这是上面组件的网站地址
更多组件库教程,欢迎在 github 上给个 star,加群交流哦!
本文章及更多案例已经在官网上了,大家也可以去看 本文链接
可自定义的核心
正常的 radio 组件主要是通过 <input> 标签属性 type="radio" 实现的,浏览器都会显示一个默认的样式
![]()
所以我们要自定义样式,就不能使用原生的 radio样式,那么问题来了,你是不是只要实现了 radio 内在的逻辑,其实也就是实现了 radio 组件,有人会问?内在的逻辑是什么呢?
- 其一,选中态逻辑,就是多个元素,我们只要点击,就是选中态(checked)
- 其二,传入
disabled参数,那么这个元素就不能点击(或者cursor(也就是鼠标的状态)设置为not-allowed也就是不可选中) - 其三,原生
radio并不支持readyonly状态,但我们自定义组件应该实现,在表单中,readyonly是一种很常见的业务需求。所以传入readyonly参数,那么这个元素就不能改变状态,只能看。 - 最后最最重要的逻辑是多个
radio元素组合时,你只能选中其中一个,即单选的逻辑。
所以我们只要实现了上述逻辑,那就是跟原生的单选就没有区别了。
但问题来了,能不能既保持 radio 组件的原本的逻辑,还能支持自定义呢?也就是用户如果还是希望使用原生 radio 我们支持,自定义也支持呢?这样的话,语义性完好的基础上还能自由拓展,简直完美!
答案是肯定的,我们的组件就是这样的。
在介绍核心逻辑之前,我们先说一个小技巧。
radio 组件基本结构
![]()
首先先介绍一一个小技巧,如上图,一般 radio 包含了一个圆圈表示是否选中,圆圈的右边是文字,正常来说,我们点击圆圈才能选中,可这些组件库如何实现的点击圆圈右边的文字也能选中圆圈呢?这就涉及到 <label> 标签了。
如上图是 MDN 中的方式:
<div>
<input type="radio" id="huey" name="drone" value="huey" checked />
<label for="huey">Huey</label>
</div>
- 首先需要在
input上使用id属性 - 然后在
label组件上使用for属性,跟id的值一致即可实现点击label标签就能选中对应的radio
但这种方式比较繁琐,我们的组件使用的另一种方式达到同样的效果,就是 label 将 input 标签包裹就好:
<label>
<input type="radio" value="huey" />
huey
</label>
好了,接下来我们梳理一下状态的切换逻辑,这样我们实现起来就有一个蓝图:
- 首先点击
label,也就是绑定在label上的onClick事件 - 然后这个
onClick事件会触发input(radio) 上的onClick事件 -
input上的onClick事件会触发自身的onChange事件,也就是选中的value 值在onChange的时候可以设置为点击的input的值,- 这里有些新同学可能不知道
input这类表单元素,目的就是收集值,也就是可以传入value属性。
- 这里有些新同学可能不知道
整体逻辑很清晰,也很简单,但其中的坑不少,我们把坑介绍完,基本上你就可以实现一个自己的 radio 组件了
核心逻辑梳理
如上所述,我们第一步是给 label 标签绑定 onClick 事件。
const onLabelClick = function (e) {
// 只读或禁用时,阻止点击产生任何行为
if (disabled || readonly) {
e.preventDefault();
return;
}
rest?.onClick?.(e);
};
需要注意
- 当外界传入
disabled或者readonly参数的时候,我们直接return - 注意要使用
e.preventDefault();来组织默认事件,默认事件就是点击label触发input的onClick事件,从而阻止input上值被选中
然后需要给 input 绑定 onClick 事件
onClick={(e) => {
// 阻止 input 的点击事件冒泡,避免重复处理
e.stopPropagation();
}}
这里需要注意的是
- 调用
e.stopPropagation();防止用户在直接点击input(radio) 的时候,事件冒泡到label标签,从而多次触发label的onClick事件。
最后,就是在 input 绑定 onChange 事件
const [checked, setChecked] = useMergeValue(false, {
value: propsChecked,
defaultValue: mergeProps.defaultChecked,
});
const onChange = (e) => {
e.persist();
e.stopPropagation();
// 禁用或只读都不改变状态,不触发外部 onChange
if (disabled || readonly) return;
if (context.group) {
context?.onChangeValue?.(value, e);
} else if (!('checked' in props) && !checked) {
setChecked(true);
}
if (!checked) {
propsOnChange?.(true, e);
}
};
其中细节很多,我们简单说一下:
-
e.persist();在react17 之前需要(现在都已经 19 版本,是可以删掉这行代码的),大概介绍下它的作用
// React 16 及之前版本的问题,在 setTimeout 中无法获得正常的事件对象的值
const handleClick = (e) => {
console.log(e.type); // 正常访问
setTimeout(() => {
console.log(e.type); // null 或 undefined!
console.log(e.target); // null!
}, 1000);
};
-
e.stopPropagation();阻止事件冒泡- 如果 Radio 嵌套 Radio,点击里面的 Radio 就会让
onChnage事件冒泡到外层去,这不是我们希望的,所以隔离一下
- 如果 Radio 嵌套 Radio,点击里面的 Radio 就会让
-
if (disabled || readonly) return;检查是否是 disabled 或者 readonly 状态,这样的状态不能让它触发onChange事件
if (context.group) {
context?.onChangeValue?.(value, e);
}
这个我们暂且不讲,是要配合 Radio.Group 组件使用,这个 context 是使用 useContext api 获取的 Radio.Group 透传的数据。也就是状态最终会被 Radio.Group 接管。
} else if (!('checked' in props) && !checked) {
setChecked(true);
}
-
作用:独立使用时的状态管理
-
!('checked' in props):检查是否是非受控组件,这是识别是否是受控还是非受控组件的关键。- 没有传入
checkedprop → 组件自己管理状态
- 没有传入
这里非常非常细节,有人可能疑惑了,为什么要这么做,而不是用 checked === undefined 来判断,因为不传的值默认不是 undefined 吗?我们来解释一下
大家需要注意,假设你这样传入 checked 参数
<Radio checked={undefiend} />
-
'checked' in props得到的是true因为你还是传了undefined值
只有这样
<Radio />
-
'checked' in props得到的是false, 也就是什么也没传,代表是非受控组件、
} else if (!('checked' in props) && !checked) {
setChecked(true);
}
然后 setChecked(true); 这个很关键,有的人说,!('checked' in props) 不是代表非受控组件吗,非受控组件是组件自己控制值,怎么还有直接 setCheck 控制,这不是受控组件的控制的方式吗?
这里需要解释两点
-
首先,很多组件库,一般都会用受控的形式来模拟非受控,为什么呢?因为我们要确确实实拿到 Radio 组件的
checked(选中) 还是 非checked状态,如果都交给原生,我们获取很不方便 -
其次
useMergeValue是组件库很常用函数,它把受控和非受控组合起来,是个非常实用的函数,我们来介绍一下逻辑。相信你写组件库也一定会用到。
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { isUndefined } from '../utils';
import { usePrevious } from './use-previous';
export function useMergeValue<T>(
defaultStateValue: T,
props?: {
defaultValue?: T;
value?: T;
},
): [T, React.Dispatch<React.SetStateAction<T>>, T] {
const { defaultValue, value } = props || {};
const firstRenderRef = useRef(true);
const prevPropsValue = usePrevious(props?.value);
const [stateValue, setStateValue] = useState<T>(
!isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue,
);
// 受控转为非受控的时候,需要做转换处理
useEffect(() => {
if (firstRenderRef.current) {
firstRenderRef.current = false;
return;
}
if (value === undefined && prevPropsValue !== value) {
setStateValue(value);
}
}, [value]);
const mergedValue = isUndefined(value) ? stateValue : value;
return [mergedValue, setStateValue, stateValue];
}
这里简单解释一下,其实就是你传了 value 我就认为你是受控组件,然后 value 就透传出去, defaultValue 或者组件库想默认给个默认值, 我会用这个值其初始化 stateValue 然后传出去。并且 setStateValue 方法能改变其值。
setStateValue 其实在传入 value 的情况下,也没什么用,因为改变不了 value 的值。
如何将状态传递给子组件
我们可以使用 context api,将最终的 checked, disabled, readonly 状态让子组件使用 useContext 来获取。
<RadioContext.Provider value={{ checked, disabled, readonly }}>
<label {...rest} onClick={onLabelClick} aria-disabled={!!disabled} aria-readonly={!!readonly} aria-checked={!!checked}>
{/* 为什么没有 readonly 状态, 标准里本来也没有 */}
<input type="radio">
{children}
</label>
</RadioContext.Provider>
如何保持语义性
我们只需要将 input 组件依然接受之前我们的状态,就能保持原生 radio 组件的语义性,所以我们完善一下 input 组件,也就是把之前的状态传递过去即可:
<RadioContext.Provider value={{ checked, disabled, readonly }}>
<label {...rest} onClick={onLabelClick} aria-disabled={!!disabled} aria-readonly={!!readonly} aria-checked={!!checked}>
{/* 为什么没有 readonly 状态, 标准里本来也没有 */}
<input
ref={inputRef}
disabled={!!disabled}
value={value}
type="radio"
checked={!!checked}
onChange={onChange}
onClick={(e) => {
// 阻止 input 的点击事件冒泡,避免重复处理
e.stopPropagation();
}}
aria-readonly={!!readonly}
/>
{children}
</label>
</RadioContext.Provider>
Radio Group 逻辑
这里简单介绍一下如何使用 Radio Group 组件包裹上面我们完成的 Radio 组件。
核心逻辑为, 使用同样是 useContext api,我们命名为 RadioGroupContext 来把当前选中的 Radio 标签的 value 传递即可 :
<div role="radiogroup" {...rest}>
<RadioGroupContext.Provider
value={{
onChangeValue,
type,
value,
disabled,
readonly,
group: true,
name,
}}
>
{children}
</RadioGroupContext.Provider>
</div>
小结
文章把主要的核心逻辑梳理了一下,并没有过多解释每行代码。如果你想讨论关于如何实现自己组件库的的内容,欢迎加群一起讨论,组件还在不断拓展中,最终会对标大厂组件库。
其实对于一个前端来说,组件库算是囊括所有日常常见的前端技术了,无论是学习还是面试,都是绝佳的项目。