Input 组件是任何用户界面中最基本且最常用的交互元素之一。它允许用户输入文本、密码、数字等信息,并且可以通过各种属性和事件进行高度定制。Element Plus 作为 Vue 3 的流行组件库,提供了功能丰富且易于使用的 Input 组件。本文将详细介绍如何基于 Vue 3 和 TypeScript 实现一个仿照 Element Plus 设计的 Input 组件,涵盖需求分析、设计思路、代码实现以及总结等方面。
需求分析
功能需求
- 支持
Input和Textarea两种模式:用户可以通过type属性选择是使用普通输入框还是文本区域。 - 支持不同大小:提供
large、default、small三种尺寸,用户可以通过size属性设置输入框的大小。 - 一键清空:当输入框中有内容时,显示一个清空按钮,点击后清空输入框内容并触发
clear事件。 - 切换密码显示:对于
password类型的输入框,提供一个图标按钮,允许用户切换密码的显示与隐藏。 - 自定义前缀/后缀 (prefix/suffix) :允许用户在输入框的前后添加图标或其他自定义内容。
- 自定义前置/后置 (prepend/append) :允许用户在输入框的外部添加前置或后置内容,如邮箱后缀、域名前缀等。
- 支持原生属性:支持常见的原生属性,如
placeholder、disabled、readonly、autocomplete等。
设计思路
1. 基础结构
我们将继续使用一个容器来包裹输入框,并通过插槽(slots)实现前缀、后缀、前置和后置内容的自定义。对于 Input 和 Textarea 两种模式,我们可以通过 type 属性动态切换使用的元素。
2. 尺寸控制
通过 size 属性,我们可以为输入框设置不同的高度和内边距,确保不同尺寸的输入框在视觉上保持一致。
3. 清空按钮
清空按钮将根据 showClear 属性和输入框的内容动态显示。点击清空按钮后,输入框内容会被清空,并触发 clear 事件。
4. 密码显示切换
对于 password 类型的输入框,我们将添加一个图标按钮,允许用户切换密码的显示与隐藏。通过 togglePassword 方法,动态切换 input 元素的 type 属性。
5. 前缀/后缀与前置/后置
- 前缀/后缀:通过
prefix和suffix插槽,用户可以在输入框的前后添加自定义内容,如图标。 - 前置/后置:通过
prepend和append插槽,用户可以在输入框的外部添加前置或后置内容,如邮箱后缀、域名前缀等。
6. 原生属性支持
我们将支持常见的原生属性,如 placeholder、disabled、readonly、autocomplete 等,并通过 v-bind="$attrs" 将这些属性传递给底层的 input 或 textarea 元素。
确定方案
- 属性
export interface InputProps {
type?: string
// 实现v-model必备的
modelValue: string
size?: 'large' | 'small'
disabled?: boolean
clearable?: boolean
// 展示密码
showPassword?: boolean
placeholder?: string
readonly?: boolean
// 自动填充
autocomplete?: string
autofocus?: boolean
// 关联表单
form?: string
}
- 事件
export interface InputEmits {
// 和modelValue结合支持v-model属性
(e: 'update:modelValue', value: string): void
// input事件就是指值有变化就算
(e: 'input', value: string): void
// input的change事件指修改了值, 并且失去了focus
(e: 'change', value: string): void
(e: 'focus', value: FocusEvent): void
(e: 'blur', value: FocusEvent): void
(e: 'clear'): void
}
- 实例
// 最终要暴露出去的
export interface InputInstance {
ref: HTMLInputElement | HTMLTextAreaElement
}
代码实现
- 组件
<template>
<div
class="jd-input"
:class="{
[`jd-input--${type}`]: type,
[`jd-input--${size}`]: size,
'is-disabled': disabled,
'is-prepend': $slots.prepend,
'is-append': $slots.append,
'is-prefix': $slots.prefix,
'is-suffix': $slots.suffix,
'is-focus': isFocus,
}"
>
<!-- input -->
<template v-if="type !== 'textarea'">
<!-- 输入框前置内容,只对非 type="textarea" 有效 -->
<!-- prepend slot -->
<div v-if="$slots.prepend" class="jd-input__prepend">
<slot name="prepend"></slot>
</div>
<div class="jd-input__wrapper">
<!-- 输入框头部内容,只对非 type="textarea" 有效 -->
<!-- prefix slot -->
<span v-if="$slots.prefix" class="jd-input__prefix">
<slot name="prefix"></slot>
</span>
<!--原生input属性+拓展属性,仅展示部分 -->
<input
class="jd-input__inner"
v-bind="attrs"
ref="inputRef"
:type="showPassword ? (passwordVisible ? 'text' : 'password') : type"
:disabled="disabled"
:readonly="readonly"
:autocomplete="autocomplete"
:placeholder="placeholder"
:autofocus="autofocus"
:form="form"
v-model="innerValue"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@change="handleChange"
/>
<!-- 输入框尾部内容,只对非 type="textarea" 有效 -->
<!-- suffix slot -->
<span
v-if="$slots.suffix || showClear || showPasswordArea"
class="jd-input__suffix"
@click="keepFocus"
>
<slot name="suffix"></slot>
<!-- 可能用到的图标 -->
<Icon
icon="circle-xmark"
v-if="showClear"
@click="clear"
class="jd-input__clear"
@mousedown.prevent="NOOP"
/>
<Icon
icon="eye"
v-if="showPasswordArea && passwordVisible"
class="jd-input__password"
@click="togglePasswordVisible"
/>
<Icon
icon="eye-slash"
v-if="showPasswordArea && !passwordVisible"
class="jd-input__password"
@click="togglePasswordVisible"
/>
</span>
</div>
<!-- append slot -->
<!-- 输入框后置内容,只对非 type="textarea" 有效 -->
<div v-if="$slots.append" class="jd-input__append">
<slot name="append"></slot>
</div>
</template>
<!-- textarea形式 -->
<!--原生属性+拓展属性,仅展示部分 -->
<template v-else>
<textarea
class="jd-textarea__wrapper"
v-bind="attrs"
ref="inputRef"
:disabled="disabled"
:readonly="readonly"
:autocomplete="autocomplete"
:placeholder="placeholder"
:autofocus="autofocus"
:form="form"
v-model="innerValue"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@change="handleChange"
/>
</template>
</div>
</template>
- 定义属性和事件
const props = withDefaults(defineProps<InputProps>(),
{
type: 'text',
autocomplete: 'off'
}
)
const emits = defineEmits<InputEmits>()
- 禁止透传
<input
class="jd-input__inner"
v-bind="attrs"
......
/>
defineOptions({
name: 'JDInput',
inheritAttrs: false
})
// 使用手动传入
const attrs = useAttrs()
v-bind="attrs" 的作用
在 <input> 或 <textarea> 元素中,v-bind="attrs" 的作用是将 attrs 中的所有属性动态绑定到该元素上。
这些属性通过 useAttrs 获取,它包含了父组件传递给子组件的、未显式声明为 props 的所有属性。
优点
- 灵活性:允许父组件传递任意的原生属性(例如
maxLength、aria-*等)到<input>或<textarea>元素,而无需在子组件显式声明。 - 代码简洁:避免了手动列举所有可能的属性。
举例
假设父组件使用了 JdInput 组件:
<JdInput
modelValue="Hello"
placeholder="Enter text"
maxlength="20"
aria-label="Input for name"
disabled
/>
在 JdInput 组件中:
modelValue和placeholder已被声明为props,会在组件内部直接使用。maxlength和aria-label没有在props中声明,它们会通过useAttrs收集到attrs中。
绑定到 <input> 后,实际渲染的 HTML:
<input
class="jd-input__inner"
placeholder="Enter text"
maxlength="20"
aria-label="Input for name"
disabled=""
value="Hello"
/>
- 其他属性和值
// 输入框绑定的值
const innerValue = ref(props.modelValue)
watch(() => props.modelValue, (newVal) => {
innerValue.value = newVal
})
// 是否聚焦
const isFocus = ref(false)
// 密码是否可见
const passwordVisible = ref(false)
const togglePasswordVislble = () => {
passwordVisible.value = !passwordVisible.value
}
// 展示清除图标的条件
const showClear = computed(() => props.clearable && !props.disabled && !!innerValue.value && isFocus.value)
// 当输入框为密码框时,切换输入框类型
const showPasswordArea = computed(() => props.showPassword && !props.disabled && !!innerValue.value)
- 其他方法
const keepFocus = async () => {
await nextTick()
inputRef.value.focus()
}
// input事件
const handleInput = () => {
emits("update:modelValue", innerValue.value);
emits("input", innerValue.value);
};
// change事件
const handleChange = () => {
emits("change", innerValue.value);
};
// focus事件
const handleFocus = (event: FocusEvent) => {
isFocus.value = true;
emits("focus", event);
};
// blur事件
const handleBlur = (event: FocusEvent) => {
isFocus.value = false;
emits("blur", event);
};
// 清空输入框
const clear = () => {
innerValue.value = "";
emits("update:modelValue", "");
emits("clear");
emits("input", "");
emits("change", "");
};
- 暴露input节点
// 将inputRef确定为DOM类型
// 暴露ref,供父组件调用
const inputRef = ref() as Ref<HTMLInputElement>
defineExpose({
ref: inputRef
})
类型声明
export interface InputProps {
// type?: "text" | "password" | "textarea";
type?: string;
// 实现v-model必备的
modelValue: string;
size?: "large" | "small";
disabled?: boolean;
clearable?: boolean;
// 展示密码
showPassword?: boolean;
placeholder?: string;
readonly?: boolean;
// 自动填充
autocomplete?: string;
autofocus?: boolean;
// 关联表单
form?: string;
}
// input 原生事件
export interface InputEmits {
// 和modelValue结合支持v-model属性
(e: "update:modelValue", value: string): void;
// input事件就是指值有变化就算
(e: "input", value: string): void;
// input的change事件指修改了值, 并且失去了focus
(e: "change", value: string): void;
(e: "focus", value: FocusEvent): void;
(e: "blur", value: FocusEvent): void;
(e: "clear"): void;
}
// 最终要暴露出去的
export interface InputInstance {
ref: HTMLInputElement | HTMLTextAreaElement;
}
样式
.jd-input {
--jd-input-text-color: var(--jd-text-color-regular);
--jd-input-border: var(--jd-border);
--jd-input-hover-border: var(--jd-border-color-hover);
--jd-input-focus-border: var(--jd-color-primary);
--jd-input-transparent-border: 0 0 0 1px transparent inset;
--jd-input-border-color: var(--jd-border-color);
--jd-input-border-radius: var(--jd-border-radius-base);
--jd-input-bg-color: var(--jd-fill-color-blank);
--jd-input-icon-color: var(--jd-text-color-placeholder);
--jd-input-placeholder-color: var(--jd-text-color-placeholder);
--jd-input-hover-border-color: var(--jd-border-color-hover);
--jd-input-clear-hover-color: var(--jd-text-color-secondary);
--jd-input-focus-border-color: var(--jd-color-primary);
}
.jd-input {
--jd-input-height: var(--jd-component-size);
position: relative;
font-size: var(--jd-font-size-base);
display: inline-flex;
width: 100%;
line-height: var(--jd-input-height);
box-sizing: border-box;
vertical-align: middle;
&.is-disabled {
cursor: not-allowed;
.jd-input__wrapper {+
background-color: var(--jd-disabled-bg-color);
box-shadow: 0 0 0 1px var(--jd-disabled-border-color) inset;
}
.jd-input__inner {
color: var(--jd-disabled-text-color);
-webkit-text-fill-color: var(--jd-disabled-text-color);
cursor: not-allowed;
}
.jd-textarea__inner {
background-color: var(--jd-disabled-bg-color);
box-shadow: 0 0 0 1px var(--jd-disabled-border-color) inset;
color: var(--jd-disabled-text-color);
-webkit-text-fill-color: var(--jd-disabled-text-color);
cursor: not-allowed;
}
}
&.is-prepend {
> .jd-input__wrapper {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.is-append {
> .jd-input__wrapper {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}
.jd-input--large {
--jd-input-height: var(--jd-component-size-large);
font-size: 14px;
.jd-input__wrapper {
padding: 1px 15px;
.jd-input__inner {
--jd-input-inner-height: calc(var(--jd-input-height, 40px) - 2px);
}
}
}
.jd-input--small {
--jd-input-height: var(--jd-component-size-small);
font-size: 12px;
.jd-input__wrapper {
padding: 1px 7px;
.jd-input__inner {
--jd-input-inner-height: calc(var(--jd-input-height, 24px) - 2px);
}
}
}
.jd-input__prefix,
.jd-input__suffix {
display: inline-flex;
white-space: nowrap;
flex-shrink: 0;
flex-wrap: nowrap;
height: 100%;
text-align: center;
color: var(--jd-input-icon-color, var(--jd-text-color-placeholder));
transition: all var(--jd-transition-duration);
}
.jd-input__prefix {
> :first-child {
margin-left: 0px !important;
}
> :last-child {
margin-right: 8px !important;
}
}
.jd-input__suffix {
> :first-child {
margin-left: 8px !important;
}
> :last-child {
margin-right: 0px !important;
}
}
.jd-input__prepend,
.jd-input__append {
background-color: var(--jd-fill-color-light);
color: var(--jd-color-info);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 100%;
border-radius: var(--jd-input-border-radius);
padding: 0 20px;
white-space: nowrap;
}
.jd-input__prepend {
border-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: 1px 0 0 0 var(--jd-input-border-color) inset,
0 1px 0 0 var(--jd-input-border-color) inset,
0 -1px 0 0 var(--jd-input-border-color) inset;
}
.jd-input__append {
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: 0 1px 0 0 var(--jd-input-border-color) inset,
0 -1px 0 0 var(--jd-input-border-color) inset,
-1px 0 0 0 var(--jd-input-border-color) inset;
& > .jd-input__wrapper {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.jd-input--textarea {
position: relative;
display: inline-block;
width: 100%;
vertical-align: bottom;
font-size: var(--jd-font-size-base);
}
.jd-textarea__wrapper {
position: relative;
display: block;
resize: vertical;
padding: 5px 11px;
line-height: 1.5;
box-sizing: border-box;
width: 100%;
font-size: inherit;
font-family: inherit;
color: var(--jd-input-text-color, var(--jd-text-color-regular));
background-color: var(--jd-input-bg-color, var(--jd-fill-color-blank));
background-image: none;
-webkit-appearance: none;
box-shadow: 0 0 0 1px var(--jd-input-border-color, var(--jd-border-color))
inset;
border-radius: var(--jd-input-border-radius, var(--jd-border-radius-base));
transition: var(--jd-transition-box-shadow);
border: none;
&:focus {
outline: none;
box-shadow: 0 0 0 1px var(--jd-input-focus-border-color) inset;
}
&::placeholder {
color: var(--jd-input-placeholder-color);
}
}
.jd-input__wrapper {
display: inline-flex;
flex-grow: 1;
align-items: center;
justify-content: center;
padding: 1px 11px;
background-color: var(--jd-input-bg-color, var(--jd-fill-color-blank));
background-image: none;
border-radius: var(--jd-input-border-radius, var(--jd-border-radius-base));
transition: var(--jd-transition-box-shadow);
box-shadow: 0 0 0 1px var(--jd-input-border-color, var(--jd-border-color))
inset;
&:hover {
box-shadow: 0 0 0 1px var(--jd-input-hover-border-color) inset;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--jd-input-focus-border-color) inset;
}
.jd-input__inner {
--jd-input-inner-height: calc(var(--jd-input-height, 32px) - 2px);
width: 100%;
flex-grow: 1;
-webkit-appearance: none;
color: var(--jd-input-text-color, var(--jd-text-color-regular));
font-size: inherit;
height: var(--jd-input-inner-height);
line-height: var(--jd-input-inner-height);
padding: 0;
outline: none;
border: none;
background: none;
box-sizing: border-box;
&::placeholder {
color: var(--jd-input-placeholder-color);
}
}
.jd-icon {
height: inherit;
line-height: inherit;
display: flex;
justify-content: center;
align-items: center;
transition: all var(--jd-transition-duration);
margin-left: 8px;
}
.jd-input__clear,
.jd-input__password {
color: var(--jd-input-icon-color);
font-size: 14px;
cursor: pointer;
&:hover {
color: var(--jd-input-clear-hover-color);
}
}
}