Search K
Appearance
Appearance
📊 SEO元描述:2024年最新Vue自定义表单组件教程,详解表单组件设计、v-model实现、组件库开发。包含完整组件案例,适合Vue.js开发者构建可复用表单组件。
核心关键词:Vue自定义表单组件 2024、Vue组件库开发、Vue表单组件设计、v-model组件、Vue可复用组件、表单组件封装
长尾关键词:Vue自定义表单组件怎么开发、Vue组件库如何构建、Vue表单组件封装技巧、自定义表单控件开发、Vue组件设计模式
通过本节Vue自定义表单组件,你将系统性掌握:
自定义表单组件是什么?这是构建可维护和可复用前端应用的核心技术。自定义表单组件是将表单控件封装成独立、可复用的Vue组件,也是现代前端组件化开发的重要实践。
💡 学习建议:自定义表单组件是Vue组件化开发的高级技能,建议从简单组件开始,逐步掌握复杂组件设计
// 🎉 组件设计原则示例
const componentDesignPrinciples = {
// 单一职责:每个组件只负责一个功能
singleResponsibility: {
good: 'InputField.vue - 只处理文本输入',
bad: 'FormControl.vue - 处理输入、验证、布局等多个职责'
},
// 开放封闭:对扩展开放,对修改封闭
openClosed: {
good: '通过props和slots提供扩展点',
bad: '直接修改组件内部代码来适应新需求'
},
// 依赖倒置:依赖抽象而不是具体实现
dependencyInversion: {
good: '通过事件和props与父组件通信',
bad: '直接访问父组件的数据和方法'
}
}// 🎉 组件接口设计规范
const componentInterface = {
// Props设计
props: {
// 必需的props
required: ['modelValue', 'name'],
// 可选的props
optional: ['placeholder', 'disabled', 'readonly'],
// 类型定义
types: {
modelValue: [String, Number, Boolean, Array, Object],
disabled: Boolean,
placeholder: String
}
},
// Events设计
events: {
// 标准事件
standard: ['update:modelValue', 'focus', 'blur'],
// 自定义事件
custom: ['validate', 'clear', 'search']
},
// Slots设计
slots: {
// 内容插槽
default: '默认内容',
// 具名插槽
named: ['prefix', 'suffix', 'error']
}
}<!-- 🎉 BaseInput.vue - 基础输入框组件 -->
<template>
<div class="base-input" :class="inputClasses">
<!-- 标签 -->
<label v-if="label" :for="inputId" class="input-label">
{{ label }}
<span v-if="required" class="required-mark">*</span>
</label>
<!-- 输入框容器 -->
<div class="input-wrapper">
<!-- 前缀插槽 -->
<div v-if="$slots.prefix" class="input-prefix">
<slot name="prefix"></slot>
</div>
<!-- 输入框 -->
<input
:id="inputId"
ref="inputRef"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
:autocomplete="autocomplete"
class="input-control"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
@keyup="handleKeyup"
/>
<!-- 后缀插槽 -->
<div v-if="$slots.suffix || showClearButton" class="input-suffix">
<!-- 清除按钮 -->
<button
v-if="showClearButton"
type="button"
class="clear-button"
@click="handleClear"
>
×
</button>
<slot name="suffix"></slot>
</div>
</div>
<!-- 帮助文本 -->
<div v-if="helpText || errorMessage" class="input-help">
<span v-if="errorMessage" class="error-text">{{ errorMessage }}</span>
<span v-else-if="helpText" class="help-text">{{ helpText }}</span>
</div>
<!-- 字符计数 -->
<div v-if="showCount && maxlength" class="input-count">
{{ currentLength }}/{{ maxlength }}
</div>
</div>
</template>
<script>
import { ref, computed, nextTick } from 'vue'
export default {
name: 'BaseInput',
props: {
// v-model绑定值
modelValue: {
type: [String, Number],
default: ''
},
// 输入框类型
type: {
type: String,
default: 'text',
validator: (value) => {
return ['text', 'password', 'email', 'number', 'tel', 'url'].includes(value)
}
},
// 标签文本
label: {
type: String,
default: ''
},
// 占位符
placeholder: {
type: String,
default: ''
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否只读
readonly: {
type: Boolean,
default: false
},
// 是否必填
required: {
type: Boolean,
default: false
},
// 最大长度
maxlength: {
type: Number,
default: null
},
// 是否显示字符计数
showCount: {
type: Boolean,
default: false
},
// 是否可清除
clearable: {
type: Boolean,
default: false
},
// 帮助文本
helpText: {
type: String,
default: ''
},
// 错误消息
errorMessage: {
type: String,
default: ''
},
// 尺寸
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
// 自动完成
autocomplete: {
type: String,
default: 'off'
}
},
emits: [
'update:modelValue',
'focus',
'blur',
'clear',
'keydown',
'keyup',
'input'
],
setup(props, { emit }) {
const inputRef = ref(null)
const isFocused = ref(false)
// 生成唯一ID
const inputId = computed(() => {
return `input-${Math.random().toString(36).substr(2, 9)}`
})
// 当前长度
const currentLength = computed(() => {
return String(props.modelValue || '').length
})
// 是否显示清除按钮
const showClearButton = computed(() => {
return props.clearable &&
props.modelValue &&
!props.disabled &&
!props.readonly
})
// 输入框样式类
const inputClasses = computed(() => {
return {
'is-disabled': props.disabled,
'is-readonly': props.readonly,
'is-focused': isFocused.value,
'has-error': props.errorMessage,
[`size-${props.size}`]: true
}
})
// 处理输入事件
const handleInput = (event) => {
const value = event.target.value
emit('update:modelValue', value)
emit('input', event)
}
// 处理焦点事件
const handleFocus = (event) => {
isFocused.value = true
emit('focus', event)
}
// 处理失焦事件
const handleBlur = (event) => {
isFocused.value = false
emit('blur', event)
}
// 处理清除事件
const handleClear = () => {
emit('update:modelValue', '')
emit('clear')
// 聚焦到输入框
nextTick(() => {
inputRef.value?.focus()
})
}
// 处理键盘事件
const handleKeydown = (event) => {
emit('keydown', event)
}
const handleKeyup = (event) => {
emit('keyup', event)
}
// 公开方法
const focus = () => {
inputRef.value?.focus()
}
const blur = () => {
inputRef.value?.blur()
}
const select = () => {
inputRef.value?.select()
}
return {
inputRef,
inputId,
currentLength,
showClearButton,
inputClasses,
handleInput,
handleFocus,
handleBlur,
handleClear,
handleKeydown,
handleKeyup,
focus,
blur,
select
}
}
}
</script>
<style scoped>
.base-input {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.input-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
font-size: 0.875rem;
}
.required-mark {
color: #ef4444;
margin-left: 0.25rem;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background-color: #ffffff;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.input-wrapper:hover {
border-color: #9ca3af;
}
.base-input.is-focused .input-wrapper {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.base-input.has-error .input-wrapper {
border-color: #ef4444;
}
.base-input.is-disabled .input-wrapper {
background-color: #f9fafb;
border-color: #e5e7eb;
cursor: not-allowed;
}
.input-control {
flex: 1;
border: none;
outline: none;
background: transparent;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: #374151;
}
.input-control:disabled {
cursor: not-allowed;
color: #9ca3af;
}
.input-control::placeholder {
color: #9ca3af;
}
.input-prefix,
.input-suffix {
display: flex;
align-items: center;
padding: 0 0.5rem;
color: #6b7280;
}
.clear-button {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
background: none;
color: #9ca3af;
cursor: pointer;
border-radius: 50%;
font-size: 1rem;
line-height: 1;
transition: color 0.15s ease-in-out;
}
.clear-button:hover {
color: #6b7280;
background-color: #f3f4f6;
}
.input-help {
margin-top: 0.25rem;
font-size: 0.75rem;
}
.error-text {
color: #ef4444;
}
.help-text {
color: #6b7280;
}
.input-count {
margin-top: 0.25rem;
font-size: 0.75rem;
color: #9ca3af;
text-align: right;
}
/* 尺寸变体 */
.size-small .input-control {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.size-medium .input-control {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.size-large .input-control {
padding: 0.75rem 1rem;
font-size: 1rem;
}
</style><!-- 🎉 BaseSelect.vue - 基础选择器组件 -->
<template>
<div class="base-select" :class="selectClasses">
<!-- 标签 -->
<label v-if="label" :for="selectId" class="select-label">
{{ label }}
<span v-if="required" class="required-mark">*</span>
</label>
<!-- 选择器容器 -->
<div class="select-wrapper" @click="toggleDropdown">
<div class="select-display">
<!-- 选中值显示 -->
<span v-if="displayText" class="select-text">{{ displayText }}</span>
<span v-else class="select-placeholder">{{ placeholder }}</span>
<!-- 下拉箭头 -->
<div class="select-arrow" :class="{ 'is-open': isOpen }">
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M6 8.5L2.5 5h7L6 8.5z"/>
</svg>
</div>
</div>
<!-- 下拉选项 -->
<transition name="dropdown">
<div v-if="isOpen" class="select-dropdown">
<div class="select-options">
<!-- 搜索框 -->
<div v-if="filterable" class="select-search">
<input
ref="searchInput"
v-model="searchQuery"
type="text"
placeholder="搜索选项..."
class="search-input"
@click.stop
/>
</div>
<!-- 选项列表 -->
<div class="options-list">
<div
v-for="option in filteredOptions"
:key="getOptionValue(option)"
class="select-option"
:class="{
'is-selected': isSelected(option),
'is-disabled': isOptionDisabled(option)
}"
@click="selectOption(option)"
>
<!-- 自定义选项内容 -->
<slot name="option" :option="option">
{{ getOptionLabel(option) }}
</slot>
<!-- 选中标记 -->
<div v-if="isSelected(option)" class="option-check">✓</div>
</div>
<!-- 无选项提示 -->
<div v-if="filteredOptions.length === 0" class="no-options">
{{ noDataText }}
</div>
</div>
</div>
</div>
</transition>
</div>
<!-- 帮助文本 -->
<div v-if="helpText || errorMessage" class="select-help">
<span v-if="errorMessage" class="error-text">{{ errorMessage }}</span>
<span v-else-if="helpText" class="help-text">{{ helpText }}</span>
</div>
</div>
</template>
<script>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
export default {
name: 'BaseSelect',
props: {
// v-model绑定值
modelValue: {
type: [String, Number, Boolean, Object, Array],
default: null
},
// 选项数据
options: {
type: Array,
default: () => []
},
// 选项值字段名
valueKey: {
type: String,
default: 'value'
},
// 选项标签字段名
labelKey: {
type: String,
default: 'label'
},
// 选项禁用字段名
disabledKey: {
type: String,
default: 'disabled'
},
// 标签文本
label: {
type: String,
default: ''
},
// 占位符
placeholder: {
type: String,
default: '请选择'
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否必填
required: {
type: Boolean,
default: false
},
// 是否可搜索
filterable: {
type: Boolean,
default: false
},
// 是否多选
multiple: {
type: Boolean,
default: false
},
// 无数据文本
noDataText: {
type: String,
default: '无数据'
},
// 帮助文本
helpText: {
type: String,
default: ''
},
// 错误消息
errorMessage: {
type: String,
default: ''
},
// 尺寸
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
}
},
emits: ['update:modelValue', 'change', 'focus', 'blur'],
setup(props, { emit }) {
const isOpen = ref(false)
const searchQuery = ref('')
const searchInput = ref(null)
// 生成唯一ID
const selectId = computed(() => {
return `select-${Math.random().toString(36).substr(2, 9)}`
})
// 获取选项值
const getOptionValue = (option) => {
return typeof option === 'object' ? option[props.valueKey] : option
}
// 获取选项标签
const getOptionLabel = (option) => {
return typeof option === 'object' ? option[props.labelKey] : option
}
// 检查选项是否禁用
const isOptionDisabled = (option) => {
return typeof option === 'object' ? option[props.disabledKey] : false
}
// 检查选项是否选中
const isSelected = (option) => {
const value = getOptionValue(option)
if (props.multiple) {
return Array.isArray(props.modelValue) && props.modelValue.includes(value)
}
return props.modelValue === value
}
// 显示文本
const displayText = computed(() => {
if (!props.modelValue) return ''
if (props.multiple) {
if (!Array.isArray(props.modelValue) || props.modelValue.length === 0) {
return ''
}
const labels = props.modelValue.map(value => {
const option = props.options.find(opt => getOptionValue(opt) === value)
return option ? getOptionLabel(option) : value
})
return labels.join(', ')
} else {
const option = props.options.find(opt => getOptionValue(opt) === props.modelValue)
return option ? getOptionLabel(option) : props.modelValue
}
})
// 过滤后的选项
const filteredOptions = computed(() => {
if (!props.filterable || !searchQuery.value) {
return props.options
}
const query = searchQuery.value.toLowerCase()
return props.options.filter(option => {
const label = getOptionLabel(option).toLowerCase()
return label.includes(query)
})
})
// 选择器样式类
const selectClasses = computed(() => {
return {
'is-disabled': props.disabled,
'is-open': isOpen.value,
'has-error': props.errorMessage,
[`size-${props.size}`]: true
}
})
// 切换下拉框
const toggleDropdown = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
if (isOpen.value && props.filterable) {
nextTick(() => {
searchInput.value?.focus()
})
}
}
// 选择选项
const selectOption = (option) => {
if (isOptionDisabled(option)) return
const value = getOptionValue(option)
if (props.multiple) {
const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = currentValue.indexOf(value)
if (index > -1) {
currentValue.splice(index, 1)
} else {
currentValue.push(value)
}
emit('update:modelValue', currentValue)
emit('change', currentValue)
} else {
emit('update:modelValue', value)
emit('change', value)
isOpen.value = false
}
}
// 点击外部关闭下拉框
const handleClickOutside = (event) => {
if (!event.target.closest('.base-select')) {
isOpen.value = false
}
}
// 监听搜索查询重置
watch(isOpen, (newValue) => {
if (!newValue) {
searchQuery.value = ''
}
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
return {
isOpen,
searchQuery,
searchInput,
selectId,
displayText,
filteredOptions,
selectClasses,
getOptionValue,
getOptionLabel,
isOptionDisabled,
isSelected,
toggleDropdown,
selectOption
}
}
}
</script>
<style scoped>
.base-select {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.select-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
font-size: 0.875rem;
}
.required-mark {
color: #ef4444;
margin-left: 0.25rem;
}
.select-wrapper {
position: relative;
cursor: pointer;
}
.select-display {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background-color: #ffffff;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.select-display:hover {
border-color: #9ca3af;
}
.base-select.is-open .select-display {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.base-select.has-error .select-display {
border-color: #ef4444;
}
.base-select.is-disabled .select-display {
background-color: #f9fafb;
border-color: #e5e7eb;
cursor: not-allowed;
}
.select-text {
color: #374151;
font-size: 0.875rem;
}
.select-placeholder {
color: #9ca3af;
font-size: 0.875rem;
}
.select-arrow {
display: flex;
align-items: center;
color: #6b7280;
transition: transform 0.15s ease-in-out;
}
.select-arrow.is-open {
transform: rotate(180deg);
}
.select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: 0.25rem;
background-color: #ffffff;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.select-options {
max-height: 200px;
overflow-y: auto;
}
.select-search {
padding: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.search-input {
width: 100%;
padding: 0.25rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.875rem;
outline: none;
}
.search-input:focus {
border-color: #3b82f6;
}
.options-list {
padding: 0.25rem 0;
}
.select-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
color: #374151;
transition: background-color 0.15s ease-in-out;
}
.select-option:hover {
background-color: #f3f4f6;
}
.select-option.is-selected {
background-color: #eff6ff;
color: #3b82f6;
}
.select-option.is-disabled {
color: #9ca3af;
cursor: not-allowed;
}
.option-check {
color: #3b82f6;
font-weight: bold;
}
.no-options {
padding: 1rem;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
}
.select-help {
margin-top: 0.25rem;
font-size: 0.75rem;
}
.error-text {
color: #ef4444;
}
.help-text {
color: #6b7280;
}
/* 下拉动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease-in-out, transform 0.15s ease-in-out;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-0.5rem);
}
/* 尺寸变体 */
.size-small .select-display {
padding: 0.25rem 0.5rem;
}
.size-small .select-text,
.size-small .select-placeholder {
font-size: 0.75rem;
}
.size-medium .select-display {
padding: 0.5rem 0.75rem;
}
.size-medium .select-text,
.size-medium .select-placeholder {
font-size: 0.875rem;
}
.size-large .select-display {
padding: 0.75rem 1rem;
}
.size-large .select-text,
.size-large .select-placeholder {
font-size: 1rem;
}
</style><!-- 🎉 FormField.vue - 表单字段包装组件 -->
<template>
<div class="form-field" :class="fieldClasses">
<!-- 字段标签 -->
<div v-if="label || $slots.label" class="field-label">
<slot name="label">
<label :for="fieldId">
{{ label }}
<span v-if="required" class="required-mark">*</span>
</label>
</slot>
</div>
<!-- 字段控件 -->
<div class="field-control">
<slot
:field-id="fieldId"
:has-error="hasError"
:error-message="currentErrorMessage"
></slot>
</div>
<!-- 字段帮助信息 -->
<div v-if="showHelp" class="field-help">
<transition name="fade">
<div v-if="hasError" class="error-message">
{{ currentErrorMessage }}
</div>
<div v-else-if="helpText" class="help-message">
{{ helpText }}
</div>
</transition>
</div>
</div>
</template>
<script>
import { computed, inject } from 'vue'
export default {
name: 'FormField',
props: {
// 字段标签
label: {
type: String,
default: ''
},
// 字段名称
name: {
type: String,
required: true
},
// 是否必填
required: {
type: Boolean,
default: false
},
// 帮助文本
helpText: {
type: String,
default: ''
},
// 错误消息
errorMessage: {
type: String,
default: ''
},
// 字段布局
layout: {
type: String,
default: 'vertical',
validator: (value) => ['vertical', 'horizontal', 'inline'].includes(value)
}
},
setup(props) {
// 注入表单上下文
const formContext = inject('formContext', null)
// 生成字段ID
const fieldId = computed(() => {
return `field-${props.name}-${Math.random().toString(36).substr(2, 9)}`
})
// 当前错误消息
const currentErrorMessage = computed(() => {
return props.errorMessage ||
(formContext?.errors?.[props.name]) ||
''
})
// 是否有错误
const hasError = computed(() => {
return !!currentErrorMessage.value
})
// 是否显示帮助信息
const showHelp = computed(() => {
return hasError.value || props.helpText
})
// 字段样式类
const fieldClasses = computed(() => {
return {
[`layout-${props.layout}`]: true,
'has-error': hasError.value,
'is-required': props.required
}
})
return {
fieldId,
currentErrorMessage,
hasError,
showHelp,
fieldClasses
}
}
}
</script>
<style scoped>
.form-field {
margin-bottom: 1.5rem;
}
.field-label {
margin-bottom: 0.5rem;
}
.field-label label {
display: block;
font-weight: 500;
color: #374151;
font-size: 0.875rem;
}
.required-mark {
color: #ef4444;
margin-left: 0.25rem;
}
.field-control {
position: relative;
}
.field-help {
margin-top: 0.25rem;
min-height: 1.25rem;
}
.error-message {
color: #ef4444;
font-size: 0.75rem;
}
.help-message {
color: #6b7280;
font-size: 0.75rem;
}
/* 布局变体 */
.layout-horizontal {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.layout-horizontal .field-label {
flex: 0 0 120px;
margin-bottom: 0;
padding-top: 0.5rem;
}
.layout-horizontal .field-control {
flex: 1;
}
.layout-inline {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
margin-right: 1rem;
}
.layout-inline .field-label {
margin-bottom: 0;
}
.layout-inline .field-help {
margin-top: 0;
margin-left: 0.5rem;
}
/* 动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>通过本节Vue自定义表单组件的学习,你已经掌握:
A: 在Vue 3中,需要定义modelValue prop和emit update:modelValue事件。在Vue 2中使用value prop和input事件。
A: 遵循单一职责原则,提供合理的默认值,使用类型验证,保持API的一致性和向后兼容性。
A: 使用CSS变量、scoped样式、CSS-in-JS或者提供主题配置系统来实现样式的隔离和定制。
A: 可以使用provide/inject、事件总线、状态管理或者直接的props/events进行组件间通信。
A: 正确使用语义化HTML、ARIA属性、键盘导航支持、焦点管理等无障碍功能。
// 🎉 组件API设计最佳实践
const componentAPI = {
// Props设计原则
props: {
// 使用明确的类型定义
type: [String, Number, Boolean, Array, Object],
// 提供合理的默认值
default: () => ({}),
// 添加验证器
validator: (value) => ['small', 'medium', 'large'].includes(value),
// 必需的props
required: true
},
// Events设计原则
events: {
// 使用描述性的事件名
'update:modelValue': '更新绑定值',
'change': '值改变时触发',
'focus': '获得焦点时触发',
'blur': '失去焦点时触发'
},
// Slots设计原则
slots: {
// 提供灵活的内容插槽
default: '默认内容',
prefix: '前缀内容',
suffix: '后缀内容',
// 作用域插槽传递数据
option: { option: 'Object', index: 'Number' }
}
}// 🎉 组件测试示例
import { mount } from '@vue/test-utils'
import BaseInput from '@/components/BaseInput.vue'
describe('BaseInput', () => {
test('should emit update:modelValue when input changes', async () => {
const wrapper = mount(BaseInput, {
props: {
modelValue: ''
}
})
const input = wrapper.find('input')
await input.setValue('test value')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['test value'])
})
test('should display error message when provided', () => {
const wrapper = mount(BaseInput, {
props: {
errorMessage: 'This field is required'
}
})
expect(wrapper.find('.error-message').text()).toBe('This field is required')
expect(wrapper.classes()).toContain('has-error')
})
})"自定义表单组件是构建可维护前端应用的核心技能,良好的组件设计能够大幅提升开发效率和代码质量。继续学习文件上传处理,掌握处理文件上传的完整解决方案!"