🎨 useCustomFieldValue - 表单自定义神器
🌟 介绍
想要在表单中使用自己的组件?想要突破传统表单控件的限制?🎨 useCustomFieldValue 就是你的表单自定义神器!
这个强大的 Hook 就像一座桥梁,连接你的创意组件和 Vant 表单系统:
- 🔗 无缝集成 - 让任何组件都能成为表单项
- 📊 数据同步 - 自动处理表单数据收集和验证
- 🎮 完全控制 - 保持组件的独立性和灵活性
- 🚀 零配置 - 一行代码搞定表单集成
🎯 核心能力:
- 🎨 自定义表单项 - 任何组件都能成为表单控件
- 📋 表单数据管理 - 自动参与表单数据收集
- ✅ 验证支持 - 完美支持表单验证机制
- 🔄 响应式更新 - 数据变化自动同步到表单
🚀 代码演示
🎨 基本用法 - 自定义评分组件
最常见的场景:创建一个星级评分表单项
html
<!-- 🌟 StarRating.vue - 自定义星级评分组件 -->
<template>
<div class="star-rating">
<div class="rating-display">
<span
v-for="star in 5"
:key="star"
class="star"
:class="{ active: star <= currentRating, hover: star <= hoverRating }"
@click="setRating(star)"
@mouseenter="hoverRating = star"
@mouseleave="hoverRating = 0"
>
{{ star <= (hoverRating || currentRating) ? '⭐' : '☆' }}
</span>
</div>
<div class="rating-info">
<span class="rating-text">{{ ratingText }}</span>
<span class="rating-value">({{ currentRating }}/5)</span>
</div>
<div class="rating-description" v-if="currentRating > 0">
{{ ratingDescriptions[currentRating - 1] }}
</div>
</div>
</template>js
// 🌟 StarRating.vue
import { ref, computed } from 'vue';
import { useCustomFieldValue } from '@vant/use';
export default {
name: 'StarRating',
setup() {
const currentRating = ref(0);
const hoverRating = ref(0);
// 🎯 核心:将组件值注册到表单系统
useCustomFieldValue(() => currentRating.value);
// 🎨 评分描述
const ratingDescriptions = [
'😞 很不满意',
'😐 不太满意',
'😊 一般般',
'😄 比较满意',
'🤩 非常满意'
];
// 🎯 计算评分文本
const ratingText = computed(() => {
if (currentRating.value === 0) return '请点击星星评分';
return ratingDescriptions[currentRating.value - 1];
});
// 🎮 设置评分
const setRating = (rating) => {
currentRating.value = rating;
console.log(`⭐ 用户评分:${rating}星`);
};
return {
currentRating,
hoverRating,
ratingText,
ratingDescriptions,
setRating
};
}
};html
<!-- 📋 使用自定义评分组件的表单 -->
<template>
<div class="rating-form-demo">
<van-form @submit="handleSubmit" ref="formRef">
<!-- 📝 基本信息 -->
<van-field
v-model="formData.productName"
name="productName"
label="📦 商品名称"
placeholder="请输入商品名称"
:rules="[{ required: true, message: '请输入商品名称' }]"
/>
<!-- 🌟 自定义评分表单项 -->
<van-field
name="rating"
label="⭐ 商品评分"
:rules="[
{ required: true, message: '请为商品评分' },
{ validator: validateRating }
]"
>
<template #input>
<star-rating />
</template>
</van-field>
<!-- 💬 评价内容 -->
<van-field
v-model="formData.comment"
name="comment"
label="💬 评价内容"
type="textarea"
placeholder="请分享您的使用体验..."
rows="3"
/>
<!-- 📸 上传图片 -->
<van-field name="images" label="📸 晒图">
<template #input>
<image-uploader />
</template>
</van-field>
<!-- 🎯 提交按钮 -->
<div class="form-actions">
<van-button
type="primary"
native-type="submit"
block
:loading="isSubmitting"
>
{{ isSubmitting ? '📤 提交中...' : '✅ 提交评价' }}
</van-button>
</div>
</van-form>
<!-- 📊 表单数据预览 -->
<div class="form-preview" v-if="showPreview">
<h4>📊 表单数据预览</h4>
<pre>{{ JSON.stringify(lastSubmitData, null, 2) }}</pre>
</div>
</div>
</template>js
// 📋 表单页面逻辑
import { ref, reactive } from 'vue';
import StarRating from './components/StarRating.vue';
import ImageUploader from './components/ImageUploader.vue';
export default {
components: {
StarRating,
ImageUploader
},
setup() {
const formRef = ref();
const isSubmitting = ref(false);
const showPreview = ref(false);
const lastSubmitData = ref(null);
// 📝 表单数据
const formData = reactive({
productName: '',
comment: ''
});
// ✅ 评分验证器
const validateRating = (value) => {
if (!value || value === 0) {
return '请为商品评分';
}
if (value < 1 || value > 5) {
return '评分必须在1-5星之间';
}
return true;
};
// 📤 提交表单
const handleSubmit = async (values) => {
console.log('📋 表单提交数据:', values);
isSubmitting.value = true;
try {
// 🌐 模拟API提交
await new Promise(resolve => setTimeout(resolve, 2000));
// ✅ 提交成功
lastSubmitData.value = values;
showPreview.value = true;
console.log('✅ 评价提交成功!', {
商品名称: values.productName,
评分: `${values.rating}星`,
评价内容: values.comment,
图片数量: values.images?.length || 0
});
// 🎉 成功提示
await showSuccessToast('🎉 评价提交成功!感谢您的反馈!');
// 🔄 重置表单
formRef.value?.resetValidation();
Object.assign(formData, {
productName: '',
comment: ''
});
} catch (error) {
console.error('❌ 提交失败:', error);
showFailToast('❌ 提交失败,请重试');
} finally {
isSubmitting.value = false;
}
};
return {
formRef,
formData,
isSubmitting,
showPreview,
lastSubmitData,
validateRating,
handleSubmit
};
}
};🎮 高级用法 - 自定义滑块组件
创建一个带动画效果的价格范围选择器:
html
<!-- 💰 PriceRangeSlider.vue - 价格范围滑块 -->
<template>
<div class="price-range-slider">
<div class="price-display">
<div class="price-item">
<label>💰 最低价格</label>
<div class="price-value">¥{{ range.min }}</div>
</div>
<div class="price-separator">-</div>
<div class="price-item">
<label>💎 最高价格</label>
<div class="price-value">¥{{ range.max }}</div>
</div>
</div>
<div class="slider-container">
<!-- 🎚️ 双滑块实现 -->
<div class="slider-track" ref="trackRef">
<div
class="slider-range"
:style="rangeStyle"
></div>
<div
class="slider-thumb min-thumb"
:style="{ left: minThumbPosition }"
@mousedown="startDrag('min', $event)"
@touchstart="startDrag('min', $event)"
>
<div class="thumb-tooltip">¥{{ range.min }}</div>
</div>
<div
class="slider-thumb max-thumb"
:style="{ left: maxThumbPosition }"
@mousedown="startDrag('max', $event)"
@touchstart="startDrag('max', $event)"
>
<div class="thumb-tooltip">¥{{ range.max }}</div>
</div>
</div>
</div>
<div class="price-presets">
<span class="preset-label">🎯 快速选择:</span>
<button
v-for="preset in pricePresets"
:key="preset.label"
class="preset-btn"
:class="{ active: isPresetActive(preset) }"
@click="applyPreset(preset)"
>
{{ preset.label }}
</button>
</div>
</div>
</template>js
// 💰 PriceRangeSlider.vue
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue';
import { useCustomFieldValue } from '@vant/use';
export default {
name: 'PriceRangeSlider',
props: {
min: { type: Number, default: 0 },
max: { type: Number, default: 10000 },
step: { type: Number, default: 100 }
},
setup(props) {
const trackRef = ref();
const isDragging = ref(false);
const dragType = ref('');
// 💰 价格范围状态
const range = reactive({
min: props.min,
max: props.max
});
// 🎯 将价格范围注册到表单
useCustomFieldValue(() => ({
min: range.min,
max: range.max,
formatted: `¥${range.min} - ¥${range.max}`
}));
// 🎨 预设价格范围
const pricePresets = [
{ label: '💸 0-1000', min: 0, max: 1000 },
{ label: '💰 1000-3000', min: 1000, max: 3000 },
{ label: '💎 3000-5000', min: 3000, max: 5000 },
{ label: '👑 5000+', min: 5000, max: 10000 }
];
// 📊 计算滑块位置
const minThumbPosition = computed(() => {
const percent = (range.min - props.min) / (props.max - props.min) * 100;
return `${percent}%`;
});
const maxThumbPosition = computed(() => {
const percent = (range.max - props.min) / (props.max - props.min) * 100;
return `${percent}%`;
});
const rangeStyle = computed(() => {
const minPercent = (range.min - props.min) / (props.max - props.min) * 100;
const maxPercent = (range.max - props.min) / (props.max - props.min) * 100;
return {
left: `${minPercent}%`,
width: `${maxPercent - minPercent}%`
};
});
// 🎮 拖拽处理
const startDrag = (type, event) => {
isDragging.value = true;
dragType.value = type;
const handleMove = (e) => {
if (!isDragging.value) return;
const rect = trackRef.value.getBoundingClientRect();
const clientX = e.clientX || e.touches[0].clientX;
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const value = Math.round((props.min + percent * (props.max - props.min)) / props.step) * props.step;
if (type === 'min') {
range.min = Math.min(value, range.max - props.step);
} else {
range.max = Math.max(value, range.min + props.step);
}
console.log(`💰 价格范围更新: ¥${range.min} - ¥${range.max}`);
};
const handleEnd = () => {
isDragging.value = false;
dragType.value = '';
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('mouseup', handleEnd);
document.removeEventListener('touchmove', handleMove);
document.removeEventListener('touchend', handleEnd);
};
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleEnd);
document.addEventListener('touchmove', handleMove);
document.addEventListener('touchend', handleEnd);
event.preventDefault();
};
// 🎯 应用预设
const applyPreset = (preset) => {
range.min = preset.min;
range.max = preset.max;
console.log(`🎯 应用预设: ${preset.label}`);
};
// ✅ 检查预设是否激活
const isPresetActive = (preset) => {
return range.min === preset.min && range.max === preset.max;
};
return {
trackRef,
range,
pricePresets,
minThumbPosition,
maxThumbPosition,
rangeStyle,
startDrag,
applyPreset,
isPresetActive
};
}
};📊 复杂场景 - 多选标签组件
创建一个带搜索功能的多选标签表单项:
html
<!-- 🏷️ TagSelector.vue - 多选标签组件 -->
<template>
<div class="tag-selector">
<!-- 🔍 搜索框 -->
<div class="search-section">
<van-field
v-model="searchKeyword"
placeholder="🔍 搜索标签..."
clearable
@input="handleSearch"
>
<template #left-icon>
<van-icon name="search" />
</template>
</van-field>
</div>
<!-- 🏷️ 已选标签 -->
<div class="selected-tags" v-if="selectedTags.length > 0">
<div class="section-title">
✅ 已选标签 ({{ selectedTags.length }}/{{ maxSelection }})
</div>
<div class="tag-list">
<van-tag
v-for="tag in selectedTags"
:key="tag.id"
type="primary"
closeable
@close="removeTag(tag)"
>
{{ tag.emoji }} {{ tag.name }}
</van-tag>
</div>
</div>
<!-- 🎯 可选标签 -->
<div class="available-tags">
<div class="section-title">
🎯 可选标签 ({{ filteredTags.length }})
</div>
<!-- 📂 分类标签 -->
<div class="category-tabs">
<button
v-for="category in categories"
:key="category.id"
class="category-tab"
:class="{ active: activeCategory === category.id }"
@click="setActiveCategory(category.id)"
>
{{ category.emoji }} {{ category.name }}
</button>
</div>
<!-- 🏷️ 标签网格 -->
<div class="tag-grid">
<div
v-for="tag in filteredTags"
:key="tag.id"
class="tag-item"
:class="{
selected: isTagSelected(tag),
disabled: !canSelectTag(tag)
}"
@click="toggleTag(tag)"
>
<span class="tag-emoji">{{ tag.emoji }}</span>
<span class="tag-name">{{ tag.name }}</span>
<span class="tag-count" v-if="tag.count">({{ tag.count }})</span>
</div>
</div>
<!-- 📝 自定义标签 -->
<div class="custom-tag-section">
<van-field
v-model="customTagName"
placeholder="💡 创建自定义标签..."
@keyup.enter="addCustomTag"
>
<template #button>
<van-button
size="small"
type="primary"
:disabled="!customTagName.trim()"
@click="addCustomTag"
>
➕ 添加
</van-button>
</template>
</van-field>
</div>
</div>
</div>
</template>js
// 🏷️ TagSelector.vue
import { ref, computed, reactive } from 'vue';
import { useCustomFieldValue } from '@vant/use';
export default {
name: 'TagSelector',
props: {
maxSelection: { type: Number, default: 5 },
allowCustom: { type: Boolean, default: true }
},
setup(props) {
const searchKeyword = ref('');
const activeCategory = ref('all');
const customTagName = ref('');
const selectedTags = ref([]);
// 🎯 将选中的标签注册到表单
useCustomFieldValue(() => ({
tags: selectedTags.value,
tagIds: selectedTags.value.map(tag => tag.id),
tagNames: selectedTags.value.map(tag => tag.name),
count: selectedTags.value.length
}));
// 📂 标签分类
const categories = [
{ id: 'all', name: '全部', emoji: '🌟' },
{ id: 'tech', name: '技术', emoji: '💻' },
{ id: 'design', name: '设计', emoji: '🎨' },
{ id: 'business', name: '商业', emoji: '💼' },
{ id: 'life', name: '生活', emoji: '🌱' }
];
// 🏷️ 预设标签
const allTags = ref([
// 技术类
{ id: 1, name: 'Vue.js', emoji: '💚', category: 'tech', count: 1234 },
{ id: 2, name: 'React', emoji: '⚛️', category: 'tech', count: 2345 },
{ id: 3, name: 'TypeScript', emoji: '🔷', category: 'tech', count: 987 },
{ id: 4, name: 'Node.js', emoji: '🟢', category: 'tech', count: 1567 },
// 设计类
{ id: 5, name: 'UI设计', emoji: '🎨', category: 'design', count: 876 },
{ id: 6, name: 'UX体验', emoji: '✨', category: 'design', count: 654 },
{ id: 7, name: '原型设计', emoji: '📐', category: 'design', count: 432 },
// 商业类
{ id: 8, name: '产品管理', emoji: '📊', category: 'business', count: 789 },
{ id: 9, name: '市场营销', emoji: '📈', category: 'business', count: 567 },
{ id: 10, name: '数据分析', emoji: '📉', category: 'business', count: 345 },
// 生活类
{ id: 11, name: '健身运动', emoji: '💪', category: 'life', count: 234 },
{ id: 12, name: '美食烹饪', emoji: '🍳', category: 'life', count: 456 },
{ id: 13, name: '旅行摄影', emoji: '📸', category: 'life', count: 678 }
]);
// 🔍 过滤标签
const filteredTags = computed(() => {
let tags = allTags.value;
// 按分类过滤
if (activeCategory.value !== 'all') {
tags = tags.filter(tag => tag.category === activeCategory.value);
}
// 按搜索关键词过滤
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase();
tags = tags.filter(tag =>
tag.name.toLowerCase().includes(keyword) ||
tag.emoji.includes(keyword)
);
}
return tags;
});
// 🎮 标签操作
const toggleTag = (tag) => {
if (!canSelectTag(tag)) return;
if (isTagSelected(tag)) {
removeTag(tag);
} else {
addTag(tag);
}
};
const addTag = (tag) => {
if (selectedTags.value.length >= props.maxSelection) {
showToast(`最多只能选择 ${props.maxSelection} 个标签`);
return;
}
selectedTags.value.push(tag);
console.log(`✅ 添加标签: ${tag.emoji} ${tag.name}`);
};
const removeTag = (tag) => {
const index = selectedTags.value.findIndex(t => t.id === tag.id);
if (index > -1) {
selectedTags.value.splice(index, 1);
console.log(`❌ 移除标签: ${tag.emoji} ${tag.name}`);
}
};
const isTagSelected = (tag) => {
return selectedTags.value.some(t => t.id === tag.id);
};
const canSelectTag = (tag) => {
return !isTagSelected(tag) && selectedTags.value.length < props.maxSelection;
};
// 📂 分类切换
const setActiveCategory = (categoryId) => {
activeCategory.value = categoryId;
console.log(`📂 切换分类: ${categoryId}`);
};
// 🔍 搜索处理
const handleSearch = (value) => {
console.log(`🔍 搜索标签: ${value}`);
};
// 💡 添加自定义标签
const addCustomTag = () => {
const name = customTagName.value.trim();
if (!name) return;
// 检查是否已存在
const exists = allTags.value.some(tag =>
tag.name.toLowerCase() === name.toLowerCase()
);
if (exists) {
showToast('该标签已存在');
return;
}
// 创建新标签
const newTag = {
id: Date.now(),
name,
emoji: '💡',
category: 'custom',
count: 0,
isCustom: true
};
allTags.value.push(newTag);
addTag(newTag);
customTagName.value = '';
console.log(`💡 创建自定义标签: ${name}`);
};
return {
searchKeyword,
activeCategory,
customTagName,
selectedTags,
categories,
filteredTags,
toggleTag,
removeTag,
isTagSelected,
canSelectTag,
setActiveCategory,
handleSearch,
addCustomTag
};
}
};📚 API 参考
🔧 类型定义
ts
// 🎯 自定义表单值函数
function useCustomFieldValue(customValue: () => unknown): void;
// 💡 使用示例类型
type CustomValue =
| string // 🔤 简单字符串值
| number // 🔢 数字值
| boolean // ✅ 布尔值
| object // 📦 复杂对象
| Array<any> // 📋 数组数据
| null // 🚫 空值
| undefined; // ❓ 未定义
// 🎨 常见自定义组件值类型
interface RatingValue {
rating: number; // ⭐ 评分值
description?: string; // 📝 评分描述
}
interface PriceRangeValue {
min: number; // 💰 最小价格
max: number; // 💎 最大价格
formatted: string; // 🎨 格式化显示
}
interface TagsValue {
tags: Tag[]; // 🏷️ 标签数组
tagIds: number[]; // 🆔 标签ID数组
tagNames: string[]; // 📝 标签名称数组
count: number; // 📊 标签数量
}📋 参数说明
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| customValue | 🎯 获取表单项值的函数 💡 返回值将作为表单项的值参与表单数据收集和验证 | () => unknown | - |
🎮 使用要点
✅ 正确用法
🎯 在组件内部调用
js// ✅ 在自定义组件的 setup 中调用 export default { setup() { const value = ref(''); useCustomFieldValue(() => value.value); return { value }; } };📊 返回响应式数据
js// ✅ 返回响应式数据,自动同步更新 const data = reactive({ count: 0, name: '' }); useCustomFieldValue(() => data);🔄 动态计算值
js// ✅ 返回计算属性或动态值 const items = ref([]); useCustomFieldValue(() => ({ items: items.value, count: items.value.length, isEmpty: items.value.length === 0 }));
❌ 避免的用法
🚫 在组件外部调用
js// ❌ 不要在组件外部调用 const value = ref(''); useCustomFieldValue(() => value.value); // 错误! export default { setup() { return { value }; } };🚫 返回非响应式数据
js// ❌ 返回静态值,无法响应变化 useCustomFieldValue(() => 'static value');
🎯 实际应用场景
🛒 电商场景
js
// 🌟 商品评分组件
const ratingComponent = () => {
const rating = ref(0);
useCustomFieldValue(() => ({
rating: rating.value,
text: getRatingText(rating.value)
}));
};
// 💰 价格范围选择器
const priceRangeComponent = () => {
const range = reactive({ min: 0, max: 1000 });
useCustomFieldValue(() => range);
};📱 移动应用
js
// 📸 图片上传组件
const imageUploaderComponent = () => {
const images = ref([]);
useCustomFieldValue(() => ({
images: images.value,
count: images.value.length,
urls: images.value.map(img => img.url)
}));
};
// 📍 地址选择器
const addressPickerComponent = () => {
const address = reactive({
province: '',
city: '',
district: '',
detail: ''
});
useCustomFieldValue(() => address);
};🏢 企业应用
js
// 👥 人员选择器
const memberSelectorComponent = () => {
const selectedMembers = ref([]);
useCustomFieldValue(() => ({
members: selectedMembers.value,
memberIds: selectedMembers.value.map(m => m.id),
count: selectedMembers.value.length
}));
};
// 📊 数据图表组件
const chartComponent = () => {
const chartData = ref(null);
useCustomFieldValue(() => ({
data: chartData.value,
type: 'chart',
timestamp: Date.now()
}));
};💡 最佳实践
✅ 推荐做法
🎯 明确的数据结构
js// ✅ 返回结构化数据,便于表单处理 useCustomFieldValue(() => ({ value: currentValue.value, displayText: getDisplayText(), isValid: validateValue(), metadata: getMetadata() }));🔄 响应式数据同步
js// ✅ 使用响应式数据,确保实时同步 const formValue = computed(() => ({ ...baseData.value, computed: calculateValue() })); useCustomFieldValue(() => formValue.value);✅ 数据验证支持
js// ✅ 配合表单验证使用 const validateCustomValue = (value) => { if (!value || !value.required) { return '请完成必填项'; } return true; };
❌ 避免的做法
🚫 频繁的复杂计算
js// ❌ 避免在返回函数中进行复杂计算 useCustomFieldValue(() => { // 复杂的计算逻辑... return heavyCalculation(); // 可能影响性能 });🚫 副作用操作
js// ❌ 不要在返回函数中执行副作用 useCustomFieldValue(() => { console.log('value changed'); // 副作用 updateOtherState(); // 副作用 return value.value; });
🛠️ 调试技巧
🔍 数据监控
js
// 📊 监控表单值变化
const debugCustomValue = () => {
const value = ref('');
useCustomFieldValue(() => {
const currentValue = value.value;
console.log('🎯 自定义表单值更新:', {
value: currentValue,
type: typeof currentValue,
timestamp: new Date().toISOString()
});
return currentValue;
});
return { value };
};🧪 表单集成测试
js
// 🧪 测试表单数据收集
const testFormIntegration = () => {
const testValue = ref({ test: true });
useCustomFieldValue(() => {
console.log('📋 表单收集数据:', testValue.value);
return testValue.value;
});
// 🎮 模拟数据变化
setTimeout(() => {
testValue.value = { test: false, updated: true };
}, 1000);
};📚 相关文档
📋 表单相关
- Form 表单 - 表单组件基础用法
- Field 输入框 - 表单输入项组件
- Uploader 文件上传 - 文件上传组件
🎮 状态管理
- useToggle - 布尔值状态切换
- useEventListener - 事件监听管理
- useClickAway - 点击外部监听
🛠️ 开发工具
- 组合式 API 介绍 - 了解更多实用 Hook
- 表单验证指南 - 表单验证最佳实践
- 自定义组件开发 - 组件开发指南
💡 实战案例
- Rate 评分 - 评分组件应用
- Slider 滑块 - 滑块组件应用
- Tag 标签 - 标签组件应用
- Picker 选择器 - 选择器组件应用