一、设计核心目标
- 功能完备性:覆盖日常/复杂输入场景,支持多类型、校验、格式化等高频需求;
- 可扩展性:预留插槽、配置项,支持业务定制化(如前缀图标、后缀操作区);
- 性能优化:减少不必要渲染,兼容大文本输入、高频输入场景;
- 易用性: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 智能提示:
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 组件核心遵循「高内聚、低耦合」原则,通过组合式函数拆分逻辑,支持多场景复用,同时兼顾性能、易用性与扩展性:
- 功能上:覆盖输入、校验、格式化、事件透传等全场景需求;
- 架构上:逻辑拆分清晰,可独立复用校验、事件处理等能力;
- 工程化上:TS 强类型约束,样式支持定制,符合前端最佳实践;
- 扩展性上:预留插槽、配置项,可快速扩展标签、远程搜索等高级功能。
日常开发中可根据业务需求,基于该架构补充定制化功能,同时保持组件核心逻辑的稳定性与复用性。