一、设计核心目标
- 功能完备性:覆盖日常/复杂输入场景,支持多类型、校验、格式化等高频需求;
- 可扩展性:预留插槽、配置项,支持业务定制化(如前缀图标、后缀操作区);
- 性能优化:减少不必要渲染,兼容大文本输入、高频输入场景;
- 易用性:API 设计简洁直观,TS 类型约束完善,支持双向绑定、事件透传;
- 规范性:统一样式、错误反馈、无障碍访问,符合前端工程化最佳实践。
二、组件整体架构设计
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 智能提示:
// 输入框类型枚举(覆盖主流场景)exportenumInputType{TEXT='text',NUMBER='number',PASSWORD='password',EMAIL='email',PHONE='tel',SEARCH='search',TEXTAREA='textarea',DATE='date',TIME='time'}// 输入框尺寸枚举exportenumInputSize{SMALL='small',MEDIUM='medium',LARGE='large'}// 校验规则类型(支持自定义校验、正则、内置规则)exporttypeValidateRule={// 内置校验类型(可选,优先于正则)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 类型定义exportinterfaceInputProps{// 绑定值(双向绑定核心)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;// 文本域是否可resizeresize?:'none'|'both'|'horizontal'|'vertical';// 前缀图标(图标组件名/自定义内容,预留扩展)prefixIcon?:string|JSX.Element;// 后缀图标(同上)suffixIcon?:string|JSX.Element;// 自定义类名(支持外部样式覆盖)customClass?:string;// 无障碍访问标签(a11y)ariaLabel?:string;}// 组件事件类型exportinterfaceInputEmits{// 双向绑定更新事件(符合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(输入值管理,核心逻辑)
负责双向绑定、格式化/反格式化、值同步,隔离输入值核心逻辑:
import{ref,watch,toRefs,Ref}from'vue';importtype{InputProps,InputEmits}from'../types';exportconstuseInputValue=(props:InputProps,emit:InputEmits)=>{const{modelValue,formatter,parser}=toRefs(props);// 内部输入值(避免直接修改props,符合单向数据流)constinnerValue=ref<string|number|null|undefined>(modelValue.value)asRef<string|number>;// 监听外部modelValue变化,同步到内部值(支持外部强制更新)watch(modelValue,(newVal)=>{if(newVal!==innerValue.value){innerValue.value=newValasstring|number;}},{immediate:true});// 输入值变化处理(格式化+同步外部)consthandleValueChange=(val:string|number)=>{// 1. 执行格式化(如手机号加空格)letformattedVal=formatter?formatter(val):val;// 2. 同步内部值innerValue.value=formattedVal;// 3. 同步外部v-modelemit('update:modelValue',formattedVal);// 4. 返回反格式化后的值(供提交使用,可选)returnparser?parser(formattedVal):formattedVal;};// 获取提交用的值(反格式化后)constgetSubmitValue=()=>{returnparser?parser(innerValue.value):innerValue.value;};return{innerValue,handleValueChange,getSubmitValue};};(2)useInputValidate.ts(输入校验,独立复用)
支持同步/异步校验、多触发时机,可复用至其他表单组件:
import{ref,watch,toRefs,Ref}from'vue';importtype{InputProps,InputEmits,ValidateRule}from'../types';// 内置校验规则(覆盖常用场景,可扩展)constbuiltInValidators={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)};exportconstuseInputValidate=(innerValue:Ref<string|number>,props:InputProps,emit:InputEmits)=>{const{rules,type}=toRefs(props);// 校验失败提示文案consterrorMessage=ref('');// 是否校验中(处理异步校验loading状态)constisValidating=ref(false);// 单个规则校验(支持同步/异步)constvalidateSingleRule=async(rule:ValidateRule,value:string|number)=>{letisValid=false;const{type:ruleType,regExp,validator,message}=rule;// 1. 优先执行自定义校验函数if(validator){isValid=awaitvalidator(value);}// 2. 执行内置规则校验elseif(ruleType&&builtInValidators[ruleTypeaskeyoftypeofbuiltInValidators]){isValid=builtInValidators[ruleTypeaskeyoftypeofbuiltInValidators](value);}// 3. 执行自定义正则校验elseif(regExp){isValid=regExp.test(value.toString());}if(!isValid){errorMessage.value=message;emit('validate-error',message);returnfalse;}returntrue;};// 全量规则校验(返回校验结果)constvalidate=async()=>{if(!rules.value||rules.value.length===0)returntrue;isValidating.value=true;constvalue=innerValue.value;letallValid=true;// 遍历所有规则执行校验for(construleofrules.value){constvalid=awaitvalidateSingleRule(rule,value);if(!valid){allValid=false;break;// 只要有一条失败,终止校验}}// 全量校验成功if(allValid){errorMessage.value='';emit('validate-success');}isValidating.value=false;returnallValid;};// 清除校验状态constclearValidate=()=>{errorMessage.value='';};// 监听输入值变化,触发对应时机的校验watch(innerValue,async(newVal)=>{if(!rules.value||rules.value.length===0)return;// 筛选出trigger=input的规则constinputRules=rules.value.filter(rule=>rule.trigger==='input');if(inputRules.length>0){awaitPromise.all(inputRules.map(rule=>validateSingleRule(rule,newVal)));}});// 失焦校验consthandleBlurValidate=async()=>{if(!rules.value||rules.value.length===0)return;constblurRules=rules.value.filter(rule=>rule.trigger==='blur'||!rule.trigger);if(blurRules.length>0){awaitPromise.all(blurRules.map(rule=>validateSingleRule(rule,innerValue.value)));}};return{errorMessage,isValidating,validate,clearValidate,handleBlurValidate};};(3)useInputEvent.ts(事件处理,防抖优化)
处理高频输入、事件透传,避免组件逻辑臃肿:
import{ref,toRefs}from'vue';importtype{InputProps,InputEmits}from'../types';exportconstuseInputEvent=(innerValue:string|number,props:InputProps,emit:InputEmits,handleValueChange:(val:string|number)=>void)=>{const{debounceTime,type}=toRefs(props);// 防抖定时器letdebounceTimer:NodeJS.Timeout|null=null;// 原生输入事件(防抖处理)consthandleInput=(e:Event)=>{consttarget=e.targetasHTMLInputElement|HTMLTextAreaElement;constval=target.value;// 1. 同步值(格式化)handleValueChange(val);// 2. 防抖触发input事件if(debounceTimer)clearTimeout(debounceTimer);debounceTimer=setTimeout(()=>{emit('input',val);},debounceTime.value||300);};// 失焦事件(透传+校验)consthandleBlur=(e:FocusEvent)=>{emit('blur',e);};// 聚焦事件(透传)consthandleFocus=(e:FocusEvent)=>{emit('focus',e);};// 回车事件consthandleEnter=(e:KeyboardEvent)=>{if(e.key==='Enter'){emit('enter',innerValue);}};// 原生change事件透传consthandleChange=(e:Event)=>{emit('change',e);};// 清除防抖定时器(组件卸载时调用,避免内存泄漏)constclearDebounceTimer=()=>{if(debounceTimer){clearTimeout(debounceTimer);debounceTimer=null;}};return{handleInput,handleBlur,handleFocus,handleEnter,handleChange,clearDebounceTimer};};3. 核心组件模板(Input.vue,UI+逻辑整合)
模板结构清晰,支持插槽扩展,适配多类型输入框:
<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 命名规范,支持尺寸切换、主题定制,兼容无障碍访问:
// 基础容器样式 .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,支持全局/局部引入)
import{App}from'vue';importInputfrom'./Input.vue';importtype{InputProps,InputType,InputSize}from'./types';// 全局注册方法exportconstinstall=(app:App)=>{app.component('Input',Input);};// 导出组件及类型(支持局部引入+TS类型提示)export{Input,InputType,InputSize};exporttype{InputProps};exportdefaultInput;四、组件使用示例(覆盖常见场景)
1. 基础使用(双向绑定)
<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. 带校验的输入框(手机号校验)
<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. 带格式化的输入框(手机号加空格)
<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. 带前缀图标的输入框
<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. 文本域输入框
<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 组件核心遵循「高内聚、低耦合」原则,通过组合式函数拆分逻辑,支持多场景复用,同时兼顾性能、易用性与扩展性:
- 功能上:覆盖输入、校验、格式化、事件透传等全场景需求;
- 架构上:逻辑拆分清晰,可独立复用校验、事件处理等能力;
- 工程化上:TS 强类型约束,样式支持定制,符合前端最佳实践;
- 扩展性上:预留插槽、配置项,可快速扩展标签、远程搜索等高级功能。
日常开发中可根据业务需求,基于该架构补充定制化功能,同时保持组件核心逻辑的稳定性与复用性。