高级前端 Input 公共组件设计方案(Vue3 + TypeScript)

一、设计核心目标

  1. 功能完备性:覆盖日常/复杂输入场景,支持多类型、校验、格式化等高频需求;
  2. 可扩展性:预留插槽、配置项,支持业务定制化(如前缀图标、后缀操作区);
  3. 性能优化:减少不必要渲染,兼容大文本输入、高频输入场景;
  4. 易用性:API 设计简洁直观,TS 类型约束完善,支持双向绑定、事件透传;
  5. 规范性:统一样式、错误反馈、无障碍访问,符合前端工程化最佳实践。

二、组件整体架构设计

1. 目录结构(工程化拆分)

复制代码
src/components/Input/
├─ index.ts                // 组件导出(全局注册/局部引入入口)
├─ Input.vue               // 核心组件(模板+逻辑)
├─ types.ts                // TS 类型定义(Props/事件/枚举)
├─ hooks/                  // 组合式函数拆分(逻辑解耦)
│  ├─ useInputValue.ts     // 输入值管理(双向绑定、格式化)
│  ├─ useInputValidate.ts  // 输入校验逻辑
│  └─ useInputEvent.ts     // 事件处理(防抖、透传)
└─ style/                  // 样式文件(支持主题定制)
   ├─ input.scss           // 基础样式
   └─ input-theme.scss     // 主题变量(颜色、尺寸、圆角)

三、核心设计细节

1. TS 类型定义(types.ts,强类型约束)

先明确组件输入/输出类型,避免类型混乱,支持 IDE 智能提示:

typescript 复制代码
// 输入框类型枚举(覆盖主流场景)
export enum InputType {
  TEXT = 'text',
  NUMBER = 'number',
  PASSWORD = 'password',
  EMAIL = 'email',
  PHONE = 'tel',
  SEARCH = 'search',
  TEXTAREA = 'textarea',
  DATE = 'date',
  TIME = 'time'
}

// 输入框尺寸枚举
export enum InputSize {
  SMALL = 'small',
  MEDIUM = 'medium',
  LARGE = 'large'
}

// 校验规则类型(支持自定义校验、正则、内置规则)
export type ValidateRule = {
  // 内置校验类型(可选,优先于正则)
  type?: 'required' | 'email' | 'phone' | 'number' | 'url';
  // 自定义正则校验
  regExp?: RegExp;
  // 校验失败提示文案
  message: string;
  // 触发校验时机(input/blur/change)
  trigger?: 'input' | 'blur' | 'change';
  // 自定义校验函数(返回 boolean 或 Promise<boolean>,优先级最高)
  validator?: (value: string | number) => boolean | Promise<boolean>;
};

// Props 类型定义
export interface InputProps {
  // 绑定值(双向绑定核心)
  modelValue: string | number | undefined | null;
  // 输入框类型
  type?: InputType;
  // 尺寸
  size?: InputSize;
  // 占位符
  placeholder?: string;
  // 是否禁用
  disabled?: boolean;
  // 是否只读
  readonly?: boolean;
  // 是否必填(配合表单校验,仅UI提示)
  required?: boolean;
  // 最大输入长度
  maxLength?: number;
  // 最小输入长度
  minLength?: number;
  // 数值类型最小值
  min?: number;
  // 数值类型最大值
  max?: number;
  // 步长(number/date/time类型生效)
  step?: number | string;
  // 输入值格式化函数(输入后立即格式化,如手机号加空格)
  formatter?: (value: string | number) => string | number;
  // 输入值反格式化函数(提交时还原,如手机号去空格)
  parser?: (value: string | number) => string | number;
  // 校验规则数组
  rules?: ValidateRule[];
  // 防抖时长(ms,高频输入场景优化)
  debounceTime?: number;
  // 文本域行数(仅type=textarea生效)
  rows?: number;
  // 文本域是否可resize
  resize?: 'none' | 'both' | 'horizontal' | 'vertical';
  // 前缀图标(图标组件名/自定义内容,预留扩展)
  prefixIcon?: string | JSX.Element;
  // 后缀图标(同上)
  suffixIcon?: string | JSX.Element;
  // 自定义类名(支持外部样式覆盖)
  customClass?: string;
  // 无障碍访问标签(a11y)
  ariaLabel?: string;
}

// 组件事件类型
export interface InputEmits {
  // 双向绑定更新事件(符合Vue3 v-model规范)
  (e: 'update:modelValue', value: string | number | undefined | null): void;
  // 输入事件(防抖后触发,避免高频回调)
  (e: 'input', value: string | number): void;
  // 失焦事件
  (e: 'blur', event: FocusEvent): void;
  // 聚焦事件
  (e: 'focus', event: FocusEvent): void;
  // 回车事件
  (e: 'enter', value: string | number): void;
  // 校验失败事件(返回失败信息)
  (e: 'validate-error', message: string): void;
  // 校验成功事件
  (e: 'validate-success'): void;
  // 原生change事件透传
  (e: 'change', event: Event): void;
}

2. 组合式函数拆分(逻辑解耦,复用性拉满)

(1)useInputValue.ts(输入值管理,核心逻辑)

负责双向绑定、格式化/反格式化、值同步,隔离输入值核心逻辑:

typescript 复制代码
import { ref, watch, toRefs, Ref } from 'vue';
import type { InputProps, InputEmits } from '../types';

export const useInputValue = (
  props: InputProps,
  emit: InputEmits
) => {
  const { modelValue, formatter, parser } = toRefs(props);
  // 内部输入值(避免直接修改props,符合单向数据流)
  const innerValue = ref<string | number | null | undefined>(modelValue.value) as Ref<string | number>;

  // 监听外部modelValue变化,同步到内部值(支持外部强制更新)
  watch(modelValue, (newVal) => {
    if (newVal !== innerValue.value) {
      innerValue.value = newVal as string | number;
    }
  }, { immediate: true });

  // 输入值变化处理(格式化+同步外部)
  const handleValueChange = (val: string | number) => {
    // 1. 执行格式化(如手机号加空格)
    let formattedVal = formatter ? formatter(val) : val;
    // 2. 同步内部值
    innerValue.value = formattedVal;
    // 3. 同步外部v-model
    emit('update:modelValue', formattedVal);
    // 4. 返回反格式化后的值(供提交使用,可选)
    return parser ? parser(formattedVal) : formattedVal;
  };

  // 获取提交用的值(反格式化后)
  const getSubmitValue = () => {
    return parser ? parser(innerValue.value) : innerValue.value;
  };

  return {
    innerValue,
    handleValueChange,
    getSubmitValue
  };
};
(2)useInputValidate.ts(输入校验,独立复用)

支持同步/异步校验、多触发时机,可复用至其他表单组件:

typescript 复制代码
import { ref, watch, toRefs, Ref } from 'vue';
import type { InputProps, InputEmits, ValidateRule } from '../types';

// 内置校验规则(覆盖常用场景,可扩展)
const builtInValidators = {
  required: (val: string | number) => !!val && val.toString().trim() !== '',
  email: (val: string) => /^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$/.test(val),
  phone: (val: string) => /^1[3-9]\d{9}$/.test(val),
  number: (val: string | number) => !isNaN(Number(val)),
  url: (val: string) => /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/.test(val)
};

export const useInputValidate = (
  innerValue: Ref<string | number>,
  props: InputProps,
  emit: InputEmits
) => {
  const { rules, type } = toRefs(props);
  // 校验失败提示文案
  const errorMessage = ref('');
  // 是否校验中(处理异步校验loading状态)
  const isValidating = ref(false);

  // 单个规则校验(支持同步/异步)
  const validateSingleRule = async (rule: ValidateRule, value: string | number) => {
    let isValid = false;
    const { type: ruleType, regExp, validator, message } = rule;

    // 1. 优先执行自定义校验函数
    if (validator) {
      isValid = await validator(value);
    }
    // 2. 执行内置规则校验
    else if (ruleType && builtInValidators[ruleType as keyof typeof builtInValidators]) {
      isValid = builtInValidators[ruleType as keyof typeof builtInValidators](value);
    }
    // 3. 执行自定义正则校验
    else if (regExp) {
      isValid = regExp.test(value.toString());
    }

    if (!isValid) {
      errorMessage.value = message;
      emit('validate-error', message);
      return false;
    }
    return true;
  };

  // 全量规则校验(返回校验结果)
  const validate = async () => {
    if (!rules.value || rules.value.length === 0) return true;
    isValidating.value = true;
    const value = innerValue.value;
    let allValid = true;

    // 遍历所有规则执行校验
    for (const rule of rules.value) {
      const valid = await validateSingleRule(rule, value);
      if (!valid) {
        allValid = false;
        break; // 只要有一条失败,终止校验
      }
    }

    // 全量校验成功
    if (allValid) {
      errorMessage.value = '';
      emit('validate-success');
    }
    isValidating.value = false;
    return allValid;
  };

  // 清除校验状态
  const clearValidate = () => {
    errorMessage.value = '';
  };

  // 监听输入值变化,触发对应时机的校验
  watch(innerValue, async (newVal) => {
    if (!rules.value || rules.value.length === 0) return;
    // 筛选出trigger=input的规则
    const inputRules = rules.value.filter(rule => rule.trigger === 'input');
    if (inputRules.length > 0) {
      await Promise.all(inputRules.map(rule => validateSingleRule(rule, newVal)));
    }
  });

  // 失焦校验
  const handleBlurValidate = async () => {
    if (!rules.value || rules.value.length === 0) return;
    const blurRules = rules.value.filter(rule => rule.trigger === 'blur' || !rule.trigger);
    if (blurRules.length > 0) {
      await Promise.all(blurRules.map(rule => validateSingleRule(rule, innerValue.value)));
    }
  };

  return {
    errorMessage,
    isValidating,
    validate,
    clearValidate,
    handleBlurValidate
  };
};
(3)useInputEvent.ts(事件处理,防抖优化)

处理高频输入、事件透传,避免组件逻辑臃肿:

typescript 复制代码
import { ref, toRefs } from 'vue';
import type { InputProps, InputEmits } from '../types';

export const useInputEvent = (
  innerValue: string | number,
  props: InputProps,
  emit: InputEmits,
  handleValueChange: (val: string | number) => void
) => {
  const { debounceTime, type } = toRefs(props);
  // 防抖定时器
  let debounceTimer: NodeJS.Timeout | null = null;

  // 原生输入事件(防抖处理)
  const handleInput = (e: Event) => {
    const target = e.target as HTMLInputElement | HTMLTextAreaElement;
    const val = target.value;
    // 1. 同步值(格式化)
    handleValueChange(val);
    // 2. 防抖触发input事件
    if (debounceTimer) clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      emit('input', val);
    }, debounceTime.value || 300);
  };

  // 失焦事件(透传+校验)
  const handleBlur = (e: FocusEvent) => {
    emit('blur', e);
  };

  // 聚焦事件(透传)
  const handleFocus = (e: FocusEvent) => {
    emit('focus', e);
  };

  // 回车事件
  const handleEnter = (e: KeyboardEvent) => {
    if (e.key === 'Enter') {
      emit('enter', innerValue);
    }
  };

  // 原生change事件透传
  const handleChange = (e: Event) => {
    emit('change', e);
  };

  // 清除防抖定时器(组件卸载时调用,避免内存泄漏)
  const clearDebounceTimer = () => {
    if (debounceTimer) {
      clearTimeout(debounceTimer);
      debounceTimer = null;
    }
  };

  return {
    handleInput,
    handleBlur,
    handleFocus,
    handleEnter,
    handleChange,
    clearDebounceTimer
  };
};

3. 核心组件模板(Input.vue,UI+逻辑整合)

模板结构清晰,支持插槽扩展,适配多类型输入框:

vue 复制代码
<template>
  <div 
    class="input-container"
    :class="[
      `input-size-${size}`,
      { 
        'input-disabled': disabled, 
        'input-readonly': readonly, 
        'input-error': errorMessage,
        'input-required': required
      },
      customClass
    ]"
  >
    <!-- 前缀区域(图标+插槽) -->
    <div class="input-prefix" v-if="prefixIcon || $slots.prefix">
      <icon v-if="typeof prefixIcon === 'string'" :name="prefixIcon" class="input-icon" />
      <slot name="prefix" v-else-if="$slots.prefix"></slot>
    </div>

    <!-- 输入框主体(区分普通输入框/文本域) -->
    <template v-if="type !== InputType.TEXTAREA">
      <input
        :type="type"
        :value="innerValue ?? ''"
        :placeholder="placeholder"
        :disabled="disabled"
        :readonly="readonly"
        :maxlength="maxLength"
        :min="min"
        :max="max"
        :step="step"
        :aria-label="ariaLabel"
        :aria-invalid="!!errorMessage"
        :aria-describedby="errorMessage ? 'input-error' : ''"
        @input="handleInput"
        @blur="handleBlur; handleBlurValidate"
        @focus="handleFocus"
        @keydown.enter="handleEnter"
        @change="handleChange"
        class="input-core"
      />
    </template>
    <template v-else>
      <textarea
        :value="innerValue ?? ''"
        :placeholder="placeholder"
        :disabled="disabled"
        :readonly="readonly"
        :maxlength="maxLength"
        :rows="rows || 3"
        :resize="resize || 'none'"
        :aria-label="ariaLabel"
        :aria-invalid="!!errorMessage"
        :aria-describedby="errorMessage ? 'input-error' : ''"
        @input="handleInput"
        @blur="handleBlur; handleBlurValidate"
        @focus="handleFocus"
        @keydown.enter="handleEnter"
        @change="handleChange"
        class="input-core input-textarea"
      ></textarea>
    </template>

    <!-- 后缀区域(图标+插槽+校验loading) -->
    <div class="input-suffix" v-if="suffixIcon || $slots.suffix || isValidating">
      <loading v-if="isValidating" class="input-loading" size="small" />
      <icon v-else-if="typeof suffixIcon === 'string'" :name="suffixIcon" class="input-icon" />
      <slot name="suffix" v-else-if="$slots.suffix"></slot>
    </div>
  </div>

  <!-- 错误提示 -->
  <p 
    class="input-error-message" 
    id="input-error" 
    v-if="errorMessage"
  >
    {{ errorMessage }}
  </p>
</template>

<script setup lang="ts">
import { onUnmounted, toRefs } from 'vue';
import { InputType, InputSize } from './types';
import { useInputValue } from './hooks/useInputValue';
import { useInputValidate } from './hooks/useInputValidate';
import { useInputEvent } from './hooks/useInputEvent';
// 引入通用组件(图标、加载态,项目自有组件库即可)
import Icon from '../Icon/Icon.vue';
import Loading from '../Loading/Loading.vue';

// 1. 接收Props+定义Emits
const props = defineProps<InputProps>();
const emit = defineEmits<InputEmits>();
const { type, size = InputSize.MEDIUM } = toRefs(props);

// 2. 整合组合式函数逻辑
const { innerValue, handleValueChange, getSubmitValue } = useInputValue(props, emit);
const { errorMessage, isValidating, validate, clearValidate, handleBlurValidate } = useInputValidate(innerValue, props, emit);
const { handleInput, handleBlur, handleFocus, handleEnter, handleChange, clearDebounceTimer } = useInputEvent(innerValue.value, props, emit, handleValueChange);

// 3. 组件卸载清理(避免内存泄漏)
onUnmounted(() => {
  clearDebounceTimer();
});

// 4. 暴露组件方法(外部可调用校验、清除校验等)
defineExpose({
  validate,
  clearValidate,
  getSubmitValue,
  focus: () => {
    const input = document.querySelector('.input-core') as HTMLInputElement | HTMLTextAreaElement;
    input?.focus();
  },
  blur: () => {
    const input = document.querySelector('.input-core') as HTMLInputElement | HTMLTextAreaElement;
    input?.blur();
  }
});

// 5. 导出枚举(模板中使用)
defineOptions({ name: 'Input' });
</script>

4. 样式设计(input.scss,支持定制+响应式)

采用 BEM 命名规范,支持尺寸切换、主题定制,兼容无障碍访问:

scss 复制代码
// 基础容器样式
.input-container {
  position: relative;
  display: inline-flex;
  align-items: center;
  width: 100%;
  border-radius: var(--input-border-radius, 4px);
  border: 1px solid var(--input-border-color, #e5e7eb);
  background-color: var(--input-bg-color, #fff);
  transition: all 0.2s ease;
  box-sizing: border-box;

  // 禁用状态
  &.input-disabled {
    background-color: var(--input-disabled-bg, #f9fafb);
    border-color: var(--input-disabled-border, #e5e7eb);
    cursor: not-allowed;
  }

  // 只读状态
  &.input-readonly {
    background-color: var(--input-readonly-bg, #f9fafb);
  }

  // 错误状态
  &.input-error {
    border-color: var(--input-error-border, #ef4444);
    &:focus-within {
      box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
    }
  }

  // 聚焦状态(子元素聚焦时容器生效)
  &:focus-within {
    border-color: var(--input-focus-border, #3b82f6);
    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
    outline: none;
  }

  // 尺寸变体(small/medium/large)
  &.input-size-small {
    height: var(--input-small-height, 32px);
    padding: 0 8px;
    font-size: var(--input-small-font-size, 12px);
  }

  &.input-size-medium {
    height: var(--input-medium-height, 40px);
    padding: 0 12px;
    font-size: var(--input-medium-font-size, 14px);
  }

  &.input-size-large {
    height: var(--input-large-height, 48px);
    padding: 0 16px;
    font-size: var(--input-large-font-size, 16px);
  }
}

// 前缀区域
.input-prefix {
  display: flex;
  align-items: center;
  margin-right: 8px;
  color: var(--input-icon-color, #6b7280);
}

// 后缀区域
.input-suffix {
  display: flex;
  align-items: center;
  margin-left: 8px;
  color: var(--input-icon-color, #6b7280);
}

// 图标样式
.input-icon {
  width: 16px;
  height: 16px;
}

// 加载态样式
.input-loading {
  width: 14px;
  height: 14px;
}

// 核心输入框样式
.input-core {
  flex: 1;
  width: 100%;
  height: 100%;
  border: none;
  outline: none;
  background: transparent;
  color: var(--input-text-color, #111827);
  font-family: inherit;
  box-sizing: border-box;

  &::placeholder {
    color: var(--input-placeholder-color, #9ca3af);
  }

  &:disabled {
    color: var(--input-disabled-text, #9ca3af);
    cursor: not-allowed;
  }

  &:readonly {
    color: var(--input-readonly-text, #6b7280);
    cursor: default;
  }
}

// 文本域样式
.input-textarea {
  resize: var(--input-textarea-resize, none);
  min-height: inherit;
  padding: 8px 0;
  line-height: 1.5;
}

// 错误提示样式
.input-error-message {
  margin: 4px 0 0 0;
  font-size: 12px;
  color: var(--input-error-text, #ef4444);
  line-height: 1.4;
}

5. 组件导出(index.ts,支持全局/局部引入)

typescript 复制代码
import { App } from 'vue';
import Input from './Input.vue';
import type { InputProps, InputType, InputSize } from './types';

// 全局注册方法
export const install = (app: App) => {
  app.component('Input', Input);
};

// 导出组件及类型(支持局部引入+TS类型提示)
export { Input, InputType, InputSize };
export type { InputProps };

export default Input;

四、组件使用示例(覆盖常见场景)

1. 基础使用(双向绑定)

vue 复制代码
<template>
  <Input v-model="username" placeholder="请输入用户名" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Input } from '@/components/Input';

const username = ref('');
</script>

2. 带校验的输入框(手机号校验)

vue 复制代码
<template>
  <Input
    v-model="phone"
    type="phone"
    placeholder="请输入手机号"
    :rules="[
      { type: 'required', message: '手机号不能为空' },
      { type: 'phone', message: '请输入正确的手机号' }
    ]"
  />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Input, InputType } from '@/components/Input';

const phone = ref('');
</script>

3. 带格式化的输入框(手机号加空格)

vue 复制代码
<template>
  <Input
    v-model="phone"
    type="phone"
    placeholder="请输入手机号"
    :formatter="formatPhone"
    :parser="parsePhone"
  />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Input } from '@/components/Input';

const phone = ref('');

// 格式化:13800138000 → 138 0013 8000
const formatPhone = (val: string) => {
  return val.replace(/\D/g, '').replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3');
};

// 反格式化:138 0013 8000 → 13800138000(提交用)
const parsePhone = (val: string) => {
  return val.replace(/\s/g, '');
};
</script>

4. 带前缀图标的输入框

vue 复制代码
<template>
  <Input v-model="password" type="password" placeholder="请输入密码">
    <template #prefix>
      <Icon name="lock" />
    </template>
  </Input>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Input } from '@/components/Input';
import Icon from '@/components/Icon';
</script>

5. 文本域输入框

vue 复制代码
<template>
  <Input
    v-model="desc"
    type="textarea"
    placeholder="请输入描述"
    :rows="5"
    resize="vertical"
    :maxLength="200"
  />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Input, InputType } from '@/components/Input';

const desc = ref('');
</script>

五、进阶优化与扩展点

1. 性能优化

  • 输入防抖:默认300ms防抖,高频输入(如搜索框)无卡顿;
  • 减少渲染:组合式函数拆分逻辑,避免组件内冗余状态,innerValue仅同步必要更新;
  • 大文本优化:文本域支持maxLength限制,避免输入过多导致性能下降;
  • 组件卸载清理:清除防抖定时器,避免内存泄漏。

2. 功能扩展

  • 支持密码可见切换:通过后缀插槽添加「眼睛图标」,点击切换type=text/password
  • 支持标签输入:扩展type=tag,实现多标签输入(如关键词标签);
  • 支持远程搜索:整合el-select逻辑,实现输入联想(需扩展remote/remoteMethodProps);
  • 主题定制:通过CSS变量覆盖默认样式,支持多主题切换(如暗黑模式)。

3. 质量保障

  • 无障碍访问:添加aria-label/aria-invalid等属性,支持屏幕阅读器;
  • 单元测试:用Vitest测试核心逻辑(值同步、校验、格式化),覆盖率≥80%;
  • 兼容性:适配Chrome/Firefox/Safari/Edge主流浏览器,支持移动端适配。

六、设计总结

该 Input 组件核心遵循「高内聚、低耦合」原则,通过组合式函数拆分逻辑,支持多场景复用,同时兼顾性能、易用性与扩展性:

  1. 功能上:覆盖输入、校验、格式化、事件透传等全场景需求;
  2. 架构上:逻辑拆分清晰,可独立复用校验、事件处理等能力;
  3. 工程化上:TS 强类型约束,样式支持定制,符合前端最佳实践;
  4. 扩展性上:预留插槽、配置项,可快速扩展标签、远程搜索等高级功能。

日常开发中可根据业务需求,基于该架构补充定制化功能,同时保持组件核心逻辑的稳定性与复用性。

相关推荐
一颗不甘坠落的流星2 小时前
【Antd】基于 Upload 组件,导入Json文件并转换为Json数据
前端·javascript·json
LYFlied2 小时前
Vue2 与 Vue3 虚拟DOM更新原理深度解析
前端·javascript·vue.js·虚拟dom
Lucky_Turtle3 小时前
【Node】npm install报错npm error Cannot read properties of null (reading ‘matches‘)
前端·npm·node.js
小飞侠在吗3 小时前
vue shallowRef 与 shallowReacitive
前端·javascript·vue.js
惜分飞3 小时前
sql server 事务日志备份异常恢复案例---惜分飞
前端·数据库·php
GISer_Jing4 小时前
WebGL实例化渲染:性能提升策略
前端·javascript·webgl
Gomiko4 小时前
JavaScript进阶(四):DOM监听
开发语言·javascript·ecmascript
烟锁池塘柳04 小时前
【技术栈-前端】告别“转圈圈”:详解前端性能优化之“乐观 UI” (Optimistic UI)
前端·ui