从0搭建Vue3组件库之Input组件

656 阅读8分钟

Input 组件是任何用户界面中最基本且最常用的交互元素之一。它允许用户输入文本、密码、数字等信息,并且可以通过各种属性和事件进行高度定制。Element Plus 作为 Vue 3 的流行组件库,提供了功能丰富且易于使用的 Input 组件。本文将详细介绍如何基于 Vue 3 和 TypeScript 实现一个仿照 Element Plus 设计的 Input 组件,涵盖需求分析、设计思路、代码实现以及总结等方面。


image.png

需求分析

功能需求

  1. 支持 Input 和 Textarea 两种模式:用户可以通过 type 属性选择是使用普通输入框还是文本区域。
  2. 支持不同大小:提供 largedefaultsmall 三种尺寸,用户可以通过 size 属性设置输入框的大小。
  3. 一键清空:当输入框中有内容时,显示一个清空按钮,点击后清空输入框内容并触发 clear 事件。
  4. 切换密码显示:对于 password 类型的输入框,提供一个图标按钮,允许用户切换密码的显示与隐藏。
  5. 自定义前缀/后缀 (prefix/suffix) :允许用户在输入框的前后添加图标或其他自定义内容。
  6. 自定义前置/后置 (prepend/append) :允许用户在输入框的外部添加前置或后置内容,如邮箱后缀、域名前缀等。
  7. 支持原生属性:支持常见的原生属性,如 placeholderdisabledreadonlyautocomplete 等。

设计思路

1. 基础结构

我们将继续使用一个容器来包裹输入框,并通过插槽(slots)实现前缀、后缀、前置和后置内容的自定义。对于 InputTextarea 两种模式,我们可以通过 type 属性动态切换使用的元素。

2. 尺寸控制

通过 size 属性,我们可以为输入框设置不同的高度和内边距,确保不同尺寸的输入框在视觉上保持一致。

3. 清空按钮

清空按钮将根据 showClear 属性和输入框的内容动态显示。点击清空按钮后,输入框内容会被清空,并触发 clear 事件。

4. 密码显示切换

对于 password 类型的输入框,我们将添加一个图标按钮,允许用户切换密码的显示与隐藏。通过 togglePassword 方法,动态切换 input 元素的 type 属性。

5. 前缀/后缀与前置/后置

  • 前缀/后缀:通过 prefix 和 suffix 插槽,用户可以在输入框的前后添加自定义内容,如图标。
  • 前置/后置:通过 prepend 和 append 插槽,用户可以在输入框的外部添加前置或后置内容,如邮箱后缀、域名前缀等。

6. 原生属性支持

我们将支持常见的原生属性,如 placeholderdisabledreadonlyautocomplete 等,并通过 v-bind="$attrs" 将这些属性传递给底层的 inputtextarea 元素。

确定方案

  • 属性
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 的所有属性。

优点

  • 灵活性:允许父组件传递任意的原生属性(例如 maxLengtharia-* 等)到 <input><textarea> 元素,而无需在子组件显式声明。
  • 代码简洁:避免了手动列举所有可能的属性。

举例

假设父组件使用了 JdInput 组件:

<JdInput
  modelValue="Hello"
  placeholder="Enter text"
  maxlength="20"
  aria-label="Input for name"
  disabled
/>

JdInput 组件中:

  • modelValueplaceholder 已被声明为 props,会在组件内部直接使用。
  • maxlengtharia-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);
    }
  }
}