面试官说: "你怎么把 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 中的方式:

html 复制代码
  <div>
    <input type="radio" id="huey" name="drone" value="huey" checked />
    <label for="huey">Huey</label>
  </div>
  • 首先需要在 input 上使用 id 属性
  • 然后在 label 组件上使用 for属性,跟 id 的值一致即可实现点击 label 标签就能选中对应的 radio

但这种方式比较繁琐,我们的组件使用的另一种方式达到同样的效果,就是 labelinput 标签包裹就好:

html 复制代码
<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 事件。

javascript 复制代码
  const onLabelClick = function (e) {
    // 只读或禁用时,阻止点击产生任何行为
    if (disabled || readonly) {
      e.preventDefault();
      return;
    }
    rest?.onClick?.(e);
  };

需要注意

  • 当外界传入 disabled 或者 readonly 参数的时候,我们直接 return
  • 注意要使用 e.preventDefault(); 来组织默认事件,默认事件就是点击 label 触发 inputonClick 事件,从而阻止input 上值被选中

然后需要给 input 绑定 onClick 事件

javascript 复制代码
onClick={(e) => {
   // 阻止 input 的点击事件冒泡,避免重复处理
   e.stopPropagation();
}}

这里需要注意的是

  • 调用 e.stopPropagation(); 防止用户在直接点击 input(radio) 的时候,事件冒泡到 label 标签,从而多次触发 labelonClick 事件。

最后,就是在 input 绑定 onChange 事件

javascript 复制代码
 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();react 17 之前需要(现在都已经 19 版本,是可以删掉这行代码的),大概介绍下它的作用
javascript 复制代码
// 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 事件冒泡到外层去,这不是我们希望的,所以隔离一下
  • if (disabled || readonly) return; 检查是否是 disabled 或者 readonly 状态,这样的状态不能让它触发 onChange 事件
javascript 复制代码
if (context.group) {
  context?.onChangeValue?.(value, e);
}

这个我们暂且不讲,是要配合 Radio.Group 组件使用,这个 context 是使用 useContext api 获取的 Radio.Group 透传的数据。也就是状态最终会被 Radio.Group 接管。

javascript 复制代码
} else if (!('checked' in props) && !checked) {
  setChecked(true);
}
  • 作用:独立使用时的状态管理

  • !('checked' in props):检查是否是非受控组件,这是识别是否是受控还是非受控组件的关键。

    • 没有传入 checked prop → 组件自己管理状态

这里非常非常细节,有人可能疑惑了,为什么要这么做,而不是用 checked === undefined 来判断,因为不传的值默认不是 undefined 吗?我们来解释一下

大家需要注意,假设你这样传入 checked 参数

ini 复制代码
<Radio checked={undefiend} />
  • 'checked' in props 得到的是 true 因为你还是传了 undefined

只有这样

xml 复制代码
<Radio />
  • 'checked' in props 得到的是 false, 也就是什么也没传,代表是非受控组件、
javascript 复制代码
} else if (!('checked' in props) && !checked) {
  setChecked(true);
}

然后 setChecked(true); 这个很关键,有的人说,!('checked' in props) 不是代表非受控组件吗,非受控组件是组件自己控制值,怎么还有直接 setCheck 控制,这不是受控组件的控制的方式吗?

这里需要解释两点

  • 首先,很多组件库,一般都会用受控的形式来模拟非受控,为什么呢?因为我们要确确实实拿到 Radio 组件的 checked(选中) 还是 非 checked 状态,如果都交给原生,我们获取很不方便

  • 其次 useMergeValue 是组件库很常用函数,它把受控和非受控组合起来,是个非常实用的函数,我们来介绍一下逻辑。相信你写组件库也一定会用到。

javascript 复制代码
'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 来获取。

javascript 复制代码
    <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 组件,也就是把之前的状态传递过去即可:

javascript 复制代码
    <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 传递即可 :

javascript 复制代码
    <div role="radiogroup" {...rest}>
      <RadioGroupContext.Provider
        value={{
          onChangeValue,
          type,
          value,
          disabled,
          readonly,
          group: true,
          name,
        }}
      >
        {children}
      </RadioGroupContext.Provider>
    </div>

小结

文章把主要的核心逻辑梳理了一下,并没有过多解释每行代码。如果你想讨论关于如何实现自己组件库的的内容,欢迎加群一起讨论,组件还在不断拓展中,最终会对标大厂组件库。

其实对于一个前端来说,组件库算是囊括所有日常常见的前端技术了,无论是学习还是面试,都是绝佳的项目。

相关推荐
~无忧花开~4 小时前
JavaScript实现PDF本地预览技巧
开发语言·前端·javascript
yumgpkpm4 小时前
数据可视化AI、BI工具,开源适配 Cloudera CMP 7.3(或类 CDP 的 CMP 7.13 平台,如华为鲲鹏 ARM 版)值得推荐?
人工智能·hive·hadoop·信息可视化·kafka·开源·hbase
小时前端5 小时前
“能说说事件循环吗?”—— 我从候选人回答中看到的浏览器与Node.js核心差异
前端·面试·浏览器
IT_陈寒5 小时前
Vite 5.0实战:10个你可能不知道的性能优化技巧与插件生态深度解析
前端·人工智能·后端
SAP庖丁解码5 小时前
【SAP Web Dispatcher负载均衡】
运维·前端·负载均衡
天蓝色的鱼鱼5 小时前
Ant Design 6.0 正式发布:前端开发者的福音与革新
前端·react.js·ant design
HIT_Weston5 小时前
38、【Ubuntu】【远程开发】拉出内网 Web 服务:构建静态网页(一)
linux·前端·ubuntu
零一科技6 小时前
Vue3拓展:自定义权限指令
前端·vue.js
字节跳动开源6 小时前
AIBrix v0.5.0 正式发布:实现批量API支持、KVCache v1连接器升级,全面提升P/D架构协同效能
开源·github·资讯
im_AMBER6 小时前
AI井字棋项目开发笔记
前端·笔记·学习·算法