从零到一:打造企业级 Vue3 Radio 单选框组件

241 阅读8分钟

🚀 从零到一:打造企业级 Vue3 Radio 单选框组件

深度解析如何使用 Vue3 + TypeScript + Composition API 构建一个功能完整、可复用的 Radio 组件库

📋 目录

🎯 项目概述

本文将详细介绍如何从零开始构建一个企业级的 Radio 单选框组件,该组件具备以下特性:

  • 完整的双向绑定:支持 v-model 语法糖
  • 组合式使用:支持单独使用和 RadioGroup 组合使用
  • 丰富的状态管理:禁用、聚焦、选中等状态
  • 多尺寸支持:small、medium、large 三种尺寸
  • 样式定制:支持边框样式、暗色主题等
  • TypeScript 支持:完整的类型定义和类型安全
  • 无障碍访问:符合 ARIA 标准

🛠 技术栈选择

核心技术

{
  "vue": "^3.3.0",
  "typescript": "^5.0.0",
  "sass": "^1.60.0"
}

为什么选择这些技术?

  • Vue 3:提供 Composition API,更好的 TypeScript 支持
  • TypeScript:类型安全,提升开发体验和代码质量
  • SCSS:强大的 CSS 预处理器,支持嵌套、变量、混入等特性

🏗 架构设计

文件结构

radio/
├── index.ts                 # 组件入口文件
└── src/
    ├── radio.vue            # Radio 单选框组件
    ├── radio-group.vue      # RadioGroup 组合组件
    ├── radio.ts             # Radio 类型定义
    ├── radio-group.ts       # RadioGroup 类型定义
    ├── use-radio.ts         # Radio 组合式函数
    ├── constants.ts         # 常量定义
    └── radio.scss           # 样式文件

设计模式

  1. 组合式函数模式:将复杂逻辑抽离到 use-radio.ts
  2. 依赖注入模式:通过 provide/inject 实现父子组件通信
  3. 插件模式:支持全局注册和按需引入

🔧 核心功能实现

第一步:定义类型系统

首先创建完整的 TypeScript 类型定义:

// 事件定义
export const radioEmits = {
  "update:modelValue": (value: string | number | boolean) => value,
  change: (value: string | number | boolean) => value,
};

// Props 接口
export interface RadioProps {
  modelValue?: string | number | boolean;
  label?: string | number | boolean;
  value?: string | number | boolean;
  disabled?: boolean;
  size?: "small" | "medium" | "large";
  border?: boolean;
}

export type RadioEmits = typeof radioEmits;
import { radioEmits } from "./radio";

export interface RadioGroupProps {
  modelValue: string | number | boolean;
  size?: "small" | "medium" | "large";
  disabled?: boolean;
  name?: string;
}

export type RadioGroupEmits = typeof radioEmits;
export const radioGroupEmits = radioEmits;

第二步:建立组件通信机制

使用 Vue 3 的依赖注入系统实现父子组件通信:

import type { InjectionKey } from "vue";
import type { RadioGroupProps } from "./radio-group";

export const name = Symbol("RadioGroup");

export interface RadioGroupContext extends RadioGroupProps {
  changeEvent: (val: string | number | boolean) => any;
}

export const RadioGroupKey: InjectionKey<RadioGroupContext> =
  Symbol("RadioGroupKey");

第三步:核心逻辑抽象

创建组合式函数,封装 Radio 组件的核心逻辑:

import { computed, inject, ref, type SetupContext } from "vue";
import type { RadioEmits, RadioProps } from "./radio";
import { RadioGroupKey } from "./constants";

export const useRadio = (
  props: RadioProps,
  emit: SetupContext<RadioEmits>["emit"]
) => {
  // 状态管理
  const focus = ref(false);
  const radioRef = ref<HTMLInputElement>();
  
  // 依赖注入:获取父组件 RadioGroup 的上下文
  const radioGroup = inject(RadioGroupKey, undefined);
  
  // 计算属性:判断是否在 RadioGroup 中
  const isGroup = computed(() => {
    return radioGroup !== undefined;
  });
  
  // 计算属性:获取实际值
  const actualValue = computed(() => {
    if (props.value) {
      return props.value;
    } else {
      return props.label;
    }
  });
  
  // 双向绑定的核心逻辑
  const modelValue = computed({
    get() {
      return isGroup.value ? radioGroup!.modelValue : props.modelValue;
    },
    set(val) {
      if (isGroup.value) {
        // 在 RadioGroup 中,通过父组件的 changeEvent 更新
        radioGroup!.changeEvent(actualValue.value);
      } else {
        // 独立使用时,直接触发 update:modelValue 事件
        emit && emit("update:modelValue", actualValue.value);
      }
    },
  });

  return {
    modelValue,
    actualValue,
    size: props.size,
    disabled: props.disabled,
    border: props.border,
    radioGroup,
    isGroup,
    radioRef,
    focus,
  };
};

第四步:Radio 组件实现

<template>
  <label class="el-radio" :class="klasses">
    <input
      ref="radioRef"
      type="radio"
      :name="radioGroup?.name"
      :value="actualValue"
      :checked="modelValue === actualValue"
      :disabled="disabled"
      @click.stop
      @change="changeHandler"
      @focus="focus = true"
      @blur="focus = false"
    />
    <span class="el-radio-label"><slot /></span>
  </label>
</template>

<script setup lang="ts">
import { computed, nextTick } from "vue";
import type { RadioProps } from "./radio";
import { radioEmits } from "./radio";
import { useRadio } from "./use-radio";

// 组件名称定义
defineOptions({
  name: "ELRadio",
});

// Props 和 Emits 定义
const props = defineProps<RadioProps>();
const emit = defineEmits(radioEmits);

// 使用组合式函数
const {
  radioGroup,
  disabled,
  modelValue,
  actualValue,
  radioRef,
  size,
  border,
  focus,
} = useRadio(props, emit);

// 动态类名计算
const klasses = computed(() => {
  return {
    "is-disabled": disabled,
    [`is-${size}`]: size,
    "is-checked": modelValue.value === actualValue.value,
    "is-border": border,
    "is-focus": focus.value,
  };
});

// 变更事件处理
const changeHandler = () => {
  modelValue.value = actualValue.value;
  nextTick(() => {
    emit("change", actualValue.value);
  });
};
</script>

<style lang="scss" scoped>
@import "./radio.scss";
</style>

第五步:RadioGroup 组件实现

<template>
  <div class="el-radio-group" ref="radioGroupRef">
    <slot></slot>
  </div>
</template>

<script setup lang="ts">
import { ref, nextTick, provide, reactive, toRefs } from "vue";
import type { RadioGroupProps } from "./radio-group";
import { radioGroupEmits } from "./radio-group";
import { RadioGroupKey } from "./constants";

defineOptions({
  name: "ELRadioGroup",
});

// Props 默认值设置
const props = withDefaults(defineProps<RadioGroupProps>(), {
  size: "medium",
  disabled: false,
  name: "radio-group",
});

const emit = defineEmits(radioGroupEmits);
const radioGroupRef = ref();

// 子组件变更事件处理
const changeEvent = (value: string | number | boolean) => {
  emit("update:modelValue", value);
  nextTick(() => {
    emit("change", value);
  });
};

// 向子组件提供上下文
provide(
  RadioGroupKey,
  reactive({
    ...toRefs(props),
    changeEvent,
  })
);
</script>

<style lang="scss" scoped>
@import './radio.scss';
</style>

🎨 样式系统设计

SCSS 架构设计

// Radio 组件样式
.el-radio {
  position: relative;
  display: inline-flex;
  align-items: center;
  margin-right: 30px;
  font-size: 14px;
  font-weight: 500;
  color: #606266;
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
  outline: none;

  // 基础输入框样式
  input[type="radio"] {
    position: absolute;
    opacity: 0;
    outline: none;
    z-index: -1;
    
    // 自定义单选框外观
    &::before {
      content: '';
      position: absolute;
      top: 50%;
      left: 0;
      transform: translateY(-50%);
      width: 14px;
      height: 14px;
      border: 1px solid #dcdfe6;
      border-radius: 50%;
      background-color: #fff;
      transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
      box-sizing: border-box;
    }

    // 选中状态的内圆点
    &::after {
      content: '';
      position: absolute;
      top: 50%;
      left: 4px;
      transform: translateY(-50%) scale(0);
      width: 6px;
      height: 6px;
      border-radius: 50%;
      background-color: #fff;
      transition: transform 0.15s ease-in;
    }

    // 选中状态
    &:checked {
      &::before {
        background-color: #409eff;
        border-color: #409eff;
      }

      &::after {
        transform: translateY(-50%) scale(1);
      }
    }

    // 聚焦状态
    &:focus {
      &::before {
        border-color: #409eff;
        box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
      }
    }

    // 禁用状态
    &:disabled {
      &::before {
        background-color: #f5f7fa;
        border-color: #e4e7ed;
        cursor: not-allowed;
      }

      &:checked::before {
        background-color: #f2f6fc;
        border-color: #dcdfe6;
      }

      &:checked::after {
        background-color: #c0c4cc;
      }
    }
  }

  // 标签文字
  .el-radio-label {
    padding-left: 8px;
    font-size: inherit;
    line-height: 1;
  }

  // 状态修饰符
  &.is-disabled {
    color: #c0c4cc;
    cursor: not-allowed;

    .el-radio-label {
      color: #c0c4cc;
    }
  }

  &.is-checked {
    color: #409eff;

    .el-radio-label {
      color: #409eff;
    }
  }

  // 尺寸变体
  &.is-large {
    font-size: 16px;

    input[type="radio"] {
      &::before {
        width: 16px;
        height: 16px;
      }

      &::after {
        left: 5px;
        width: 6px;
        height: 6px;
      }
    }

    .el-radio-label {
      padding-left: 10px;
    }
  }

  &.is-small {
    font-size: 12px;

    input[type="radio"] {
      &::before {
        width: 12px;
        height: 12px;
      }

      &::after {
        left: 3px;
        width: 6px;
        height: 6px;
      }
    }

    .el-radio-label {
      padding-left: 6px;
    }
  }

  // 边框样式
  &.is-border {
    padding: 9px 15px 9px 10px;
    border: 1px solid #dcdfe6;
    border-radius: 4px;
    box-sizing: border-box;
    height: 32px;

    &.is-checked {
      border-color: #409eff;
    }

    &.is-disabled {
      border-color: #ebeef5;
      background-color: #f5f7fa;
    }
  }

  // 悬停效果
  &:not(.is-disabled):hover {
    input[type="radio"]::before {
      border-color: #409eff;
    }
  }
}

// RadioGroup 样式
.el-radio-group {
  display: inline-flex;
  align-items: center;
  flex-wrap: wrap;
  font-size: 0;

  .el-radio {
    margin-right: 30px;

    &:last-child {
      margin-right: 0;
    }
  }
}

// 响应式设计
@media (max-width: 768px) {
  .el-radio {
    margin-right: 20px;
    font-size: 13px;
  }
}

// 暗色主题支持
@media (prefers-color-scheme: dark) {
  .el-radio {
    color: #e5eaf3;

    input[type="radio"] {
      &::before {
        background-color: #2d2f33;
        border-color: #4c4d4f;
      }

      &:checked::before {
        background-color: #409eff;
        border-color: #409eff;
      }
    }
  }
}

📦 组件封装策略

入口文件设计

// Radio 组件入口文件
import type { App } from 'vue'
import Radio from './src/radio.vue'
import RadioGroup from './src/radio-group.vue'
import type { RadioProps, RadioEmits } from './src/radio'
import type { RadioGroupProps, RadioGroupEmits } from './src/radio-group'

// 导出组件类型
export type { RadioProps, RadioEmits, RadioGroupProps, RadioGroupEmits }

// 单独导出组件
export { Radio, RadioGroup }

// 组件安装函数
Radio.install = (app: App): void => {
  app.component(Radio.name || 'ElRadio', Radio)
}

RadioGroup.install = (app: App): void => {
  app.component(RadioGroup.name || 'ElRadioGroup', RadioGroup)
}

// 默认导出
export default {
  Radio,
  RadioGroup,
  install(app: App): void {
    app.component(Radio.name || 'ElRadio', Radio)
    app.component(RadioGroup.name || 'ElRadioGroup', RadioGroup)
  }
}

使用方式

1. 全局注册
import { createApp } from 'vue'
import RadioComponents from './components/radio'

const app = createApp(App)
app.use(RadioComponents)
2. 按需引入
import { Radio, RadioGroup } from './components/radio'

export default {
  components: {
    Radio,
    RadioGroup
  }
}
3. 基础用法
<template>
  <!-- 单独使用 -->
  <Radio v-model="value1" value="option1">选项1</Radio>
  <Radio v-model="value1" value="option2">选项2</Radio>
  
  <!-- 组合使用 -->
  <RadioGroup v-model="value2">
    <Radio value="option1">选项1</Radio>
    <Radio value="option2">选项2</Radio>
    <Radio value="option3">选项3</Radio>
  </RadioGroup>
  
  <!-- 不同尺寸 -->
  <RadioGroup v-model="value3" size="large">
    <Radio value="large1">大尺寸选项1</Radio>
    <Radio value="large2">大尺寸选项2</Radio>
  </RadioGroup>
  
  <!-- 边框样式 -->
  <RadioGroup v-model="value4">
    <Radio value="border1" border>边框选项1</Radio>
    <Radio value="border2" border>边框选项2</Radio>
  </RadioGroup>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const value1 = ref('option1')
const value2 = ref('option2')
const value3 = ref('large1')
const value4 = ref('border1')
</script>

🏆 最佳实践总结

1. 架构设计原则

  • 单一职责:每个文件只负责一个特定功能
  • 依赖注入:使用 Vue 3 的 provide/inject 实现组件通信
  • 组合式函数:将复杂逻辑抽离,提高代码复用性
  • 类型安全:完整的 TypeScript 类型定义

2. 性能优化策略

  • 计算属性缓存:使用 computed 缓存计算结果
  • 事件防抖:使用 nextTick 确保 DOM 更新后触发事件
  • 样式优化:使用 CSS 变量和 transition 提升用户体验

3. 可维护性保障

  • 模块化设计:清晰的文件结构和职责分离
  • 文档完善:详细的类型定义和使用示例
  • 测试覆盖:单元测试和集成测试
  • 版本管理:语义化版本控制

4. 扩展性考虑

  • 主题定制:支持 CSS 变量覆盖
  • 国际化:预留多语言支持接口
  • 无障碍访问:符合 ARIA 标准
  • 移动端适配:响应式设计

5. 开发体验优化

  • TypeScript 支持:完整的类型提示
  • 开发工具:ESLint + Prettier 代码规范
  • 热重载:Vite 快速开发体验
  • 调试友好:清晰的组件名称和错误提示

🎉 总结

通过本文的详细介绍,我们成功构建了一个功能完整、可复用的 Vue 3 Radio 组件。这个组件具备了企业级应用所需的所有特性:

  • ✅ 完整的双向绑定机制
  • ✅ 灵活的组合使用方式
  • ✅ 丰富的样式定制选项
  • ✅ 完善的 TypeScript 支持
  • ✅ 优秀的性能表现
  • ✅ 良好的可维护性

这个组件不仅可以直接用于生产环境,更重要的是展示了如何使用现代前端技术栈构建高质量的组件库。希望这个实践案例能够为你的组件开发之路提供有价值的参考!


🔗 相关链接

📝 作者信息

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题也欢迎在评论区讨论交流。