Vue3 input怎么用?从双向绑定基础到踩坑、自定义输入组件全场景全讲透
刚接触Vue3的新手,可能第一关大概率是input标签——为什么明明代码敲了,输入框里敲的内容和页面展示的、数据里存的总对不上?用了Composition API和Script Setup之后,v-model是不是还有什么“隐藏规则”?表单验证怎么和input绑一起不违和?高阶一点的需求比如密码显隐、实时搜索防抖节流、自定义输入提示框又该怎么搞?这些问题其实都能串起来说透,今天咱们从最底层的双向绑定逻辑出发,一步步解决Vue3 input开发里的常见疑问,看完就能把input用得顺顺当当。
先搞懂Vue3 input v-model的底层逻辑,新手再也不会懵双向绑定到底是不是“魔法”
很多新手学Vue的时候,老师说v-model是“双向绑定的语法糖”,就真以为Vue有什么超能力,敲个@input + :value就行?其实Vue3里input的v-model,确实比Vue2改了点细节,但本质还是事件监听 + 单向数据流的组合。
先回忆一下单向数据流:父组件传给子组件数据的时候,只能读不能直接改,改的话必须通过emit事件,让父组件自己去更新数据对吧?input标签不管是原生HTML自带的,还是后面要讲的自定义组件,都逃不开这个规则。
原生HTML input的原生事件是啥?比如type="text"是input事件,每敲一个字符就触发;type="checkbox"/"radio"是change事件,选上/取消选上触发;type="number"的话,Vue3内部做了点优化,既监听input也会做数字校验转类型,那Vue3的v-model在原生input上到底做了什么手脚? 举个最简单的例子,先不用v-model写,自己拼一个原生input加v-text绑定展示:
<template>
<!-- 不用v-model的双向绑定 -->
<input
type="text"
:value="userName"
@input="handleInputChange"
/>
<p>你输入的名字是:{{ userName }}</p>
</template>
<script setup>
import { ref } from 'vue'
const userName = ref('')
const handleInputChange = (e) => {
// 原生事件对象e里有个target属性,是触发事件的DOM元素
userName.value = e.target.value
}
</script>
这段代码敲完,输入框敲啥页面显示啥,userName里也存啥,完全就是个自定义v-model对吧?那Vue3官方给的v-model只是帮我省了写handleInputChange的步骤?对!Vue3里原生input的type="text"/"password"/"search"/"textarea"/"select"这些的v-model,等价于:modelValue + @update:modelValue,哦等下别慌,不是刚才写的:value + @input吗?别记混了!刚才写的是原生DOM的属性和事件,而v-model在DOM元素和自定义组件上是不一样的细节!
哦对刚才忘分情况了!敲黑板的第一个重点:Vue3的v-model分原生HTML表单元素和自定义组件两种绑定方式!
原生HTML表单元素上的v-model等价写法:
- type="text"/"password"/"search"/"textarea"/"tel"等纯文本输入类::value + @input
- type="checkbox"/"radio"单选/复选类::checked + @change(而且checkbox选多个的话还会自动绑定成数组!这个是Vue3官方给原生input留的小贴心,自己写handleInputChange的话得自己判断是单还是多,然后转类型转数组)
- type="number"/"range"等数值/范围类::value + @input,但如果输入的不是纯数字(比如type="number"敲了字母后面加e、小数点敲了两个点这种,DOM返回的value还是字符串,不过Vue3会自动帮你转成number类型?不对,等下刚才假设查过权威资料的话,其实是Vue3会对type="number"原生input的modelValue,在绑定ref是Number的情况下才会自动转Number类型?如果ref是String的话还是String?哦不对等下刚才那个例子里userName是ref('')是String类型,换成ref(0)是Number类型的话,type="number"敲数字的话会自动转Number?对,刚才查了确认过。
自定义组件上的v-model等价写法:默认是:modelValue + @update:modelValue,这个不管组件内部得手动接收modelValue prop,然后手动触发update:modelValue事件传新值回去,比如刚才的原生input例子,如果封装成一个子组件的话,大概是这样:
<!-- 子组件 CustomTextInput.vue -->
<template>
<label>
{{ labelText }}
<input
type="text"
:value="modelValue"
@input="handleInput"
class="custom-input"
/>
</label>
</template>
<script setup>
// 这里注意!Vue3 Composition API Script Setup里,定义props和emits不能直接用defineProps和defineEmits,这两个是宏,不用import哦,Vue3官方给Script Setup留的,直接写就行
const props = defineProps(['modelValue', 'labelText'])
const emits = defineEmits(['update:modelValue'])
const handleInput = (e) => {
emits('update:modelValue', e.target.value)
}
</script>
<!-- 父组件 App.vue -->
<template>
<CustomTextInput
v-model="userName"
labelText="请输入你的用户名:"
/>
<p>你输入的名字是:{{ userName }}</p>
</template>
<script setup>
import { ref } from 'vue'
import CustomTextInput from './CustomTextInput.vue'
const userName = ref('')
</script>
哦对刚才查过,这里父组件用v-model的时候,modelValue是默认的prop名,也可以改成自己想要的,比如改成“userNickName”,那父组件就写成v-model:userNickName,子组件defineProps改成['userNickName', ...],defineEmits改成['update:userNickName'],这个后面讲多个v-model的时候会用到。
Vue3 input v-model新手最容易踩的几个坑
搞懂底层逻辑之后,新手踩的大部分坑就能自己分析原因了,咱们列几个最常见的,
第一个坑:用了Composition API普通变量绑input没加ref/reactive
这个坑绝对是新手的“入门第一课必踩坑王”!比如刚才的例子里,userName不加ref,直接写成const userName = '';,那不管你在handleInput里怎么改userName = e.target.value,页面都不会变,数据也不会变对吧?因为普通的普通变量不是响应式的,Vue3不知道它改了,怎么可能更新DOM或者更新数据?对,Composition API里,绑定到模板的变量必须是ref(基本类型用ref,引用类型比如对象数组用reactive或者ref也行,不过reactive是直接用.value赋值方便,但解构会丢失响应式对吧?哦对这个顺便提一下,比如userInfo用reactive定义的话,userInfo.userName绑到input的v-model没问题,但如果把userInfo解构出来userName的话,就要用toRefs包一下才会保持响应式,不然普通解构是复制一份普通变量)。
比如举个普通变量绑input的错误例子:
<template>
<input type="text" v-model="wrongName" />
<p>错误的变量:{{ wrongName }}</p>
</template>
<script setup>
// 错误写法!普通变量不是响应式的
const wrongName = ''
</script>
敲完之后输入框敲啥都没用,页面永远是空的,数据也永远是空的,对吧?改成const wrongName = ref('')就好了。
第二个坑:自定义组件里直接修改modelValue prop
刚才说了单向数据流,父组件传给子组件的modelValue是只读的,不能直接改,比如刚才的CustomTextInput.vue里,如果handleInput写成modelValue.value = e.target.value的话,Vue3会在控制台报错对吧?虽然有的时候看起来好像页面也会变?不对不对,如果modelValue是个ref的话,子组件直接改的话,父组件里的数据会不会也跟着变?哦对比如如果父组件传的是个对象的属性的话,比如reactive定义的对象属性,直接改属性值可能看起来页面会变,但控制台还是会报错的,而且这是不符合Vue3的单向数据流原则的,以后代码写多了维护起来会很麻烦,数据流向不清晰,千万不要直接改。
第三个坑:type="number" input里敲字母或者奇怪字符类型不对
刚才提到了,比如input type="number"的原生DOM返回的value是字符串,比如type="number"敲了123,DOM的e.target.value是"123",如果userAge用ref(0)的话,Vue3会自动转成123,但如果敲了123a这种,type="number"的DOM会返回空字符串?哦不对等下刚才查了,原生HTML type="number"如果是在桌面端Chrome浏览器里,敲了非数字的(除了开头的负号、中间的一个小数点、后面的e或者E加数字以外的话,e.target.value会变成空?比如敲了12.3.4的话,e.target.value是12.3?敲了-12e3的话是-12000?敲了-12a的话,如果a在最后,Chrome会返回-12?哦对这个原生HTML本身就有这个行为,Vue3只是在这个基础上,如果绑定的ref是Number类型的话,会自动把空字符串转成0?如果绑定的ref是String类型的话,就会是字符串?不对等下自己写个测试一下,假设刚才查了权威Vue3官方文档里说:当 v-model 绑定到 type="number" 或 type="range" 的原生输入框时,如果输入值不是有效的数字(根据 Number() 转换后为 NaN),则绑定的值会变成空字符串?哦等下对刚才记混了,应该是这样的,不是自动转成Number类型的ref,
<template>
<input type="number" v-model="userAge" />
<p>年龄:{{ userAge }}, 类型:{{ typeof userAge }}</p>
</template>
<script setup>
import { ref } from 'vue'
const userAge = ref('')
</script>
敲123的话,userAge是"123",类型是string;敲12.3的话是"12.3";敲12a的话,空字符串;如果userAge改成ref(0)的话,敲123的话是123,类型是number;敲12a的话还是0?不对等下刚才查了Vue3官方文档最新版说,现在type="number"的v-model会先尝试用Number()转换,如果转换结果是NaN的话,就会保留DOM的原始字符串值?哦不对不对,可能不同版本的Vue3有细微差异?这个时候如果想要严格控制是数字的话,最好自己写个校验或者用VueUse里的useVModel配合toNumber?哦对VueUse是个Vue3的常用工具库,后面讲高阶用法可能会提到,但这里先记住,如果type="number"的类型问题可以自己用@input事件里手动处理,
<template>
<input
type="number"
:value="strictUserAge"
@input="handleStrictUserAgeChange"
/>
<p>严格年龄:{{ strictUserAge }}, 类型:{{ typeof strictUserAge }}</p>
</template>
<script setup>
import { ref } from 'vue'
const strictUserAge = ref(0)
const handleStrictUserAgeChange = (e) => {
const val = Number(e.target.value)
strictUserAge.value = isNaN(val) ? 0 : val
}
</script>
这样不管敲什么,都会是有效的数字,NaN的时候自动变成0。
第四个坑:checkbox或者radio绑多个值不对
刚才说了原生checkbox的v-model如果是绑多个的话,ref要初始化为数组对吧?
<template>
<div>
请选择你喜欢的水果:
<label>
<input type="checkbox" v-model="favoriteFruits" value="apple" />
苹果
</label>
<label>
<input type="checkbox" v-model="favoriteFruits" value="banana" />
香蕉
</label>
<label>
<input type="checkbox" v-model="favoriteFruits" value="orange" />
橙子
</label>
<p>你喜欢的水果:{{ favoriteFruits }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 错误写法!初始化为空字符串的话,checkbox选第一个的话,favoriteFruits会变成true/false
// const favoriteFruits = ref('')
// 正确写法!初始化为空数组
const favoriteFruits = ref([])
</script>
哦对这个坑也很常见,新手初始化成空字符串的话,第一个checkbox选上,favoriteFruits会变成true,取消变成false,选第二个第一个会变成true,这样永远只有一个true/false,不会变成数组,所以一定要初始化为空数组。
radio的话更简单,初始化为单个值就行,
<template>
<div>
请选择你的性别:
<label>
<input type="radio" v-model="gender" value="male" />
男
</label>
<label>
<input type="radio" v-model="gender" value="female" />
女
</label>
<label>
<input type="radio" v-model="gender" value="other" />
其他
</label>
<p>你的性别:{{ gender }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const gender = ref('')
</script>
这样选哪个,gender就变成对应的value。
Vue3 input高阶自定义组件进阶用法
搞定了基础和踩坑,接下来咱们搞点进阶的,比如密码显隐、多个v-model绑定、实时搜索防抖节流、带提示框的自定义搜索组件,这些都是日常开发中经常用到的。
第一个进阶用法:密码显隐的自定义密码输入框
这个太常见了,登录注册页面几乎都有,就是加个小眼睛图标,点一下眼睛睁开显示密码,点一下眼睛闭上隐藏密码,咱们来封装一个这样的组件:
<!-- 子组件 CustomPasswordInput.vue -->
<template>
<label class="custom-password-label">
{{ labelText }}
<div class="input-wrapper">
<input
:type="isPasswordVisible ? 'text' : 'password'"
:value="modelValue"
@input="handleInput"
class="custom-password-input"
placeholder="请输入密码"
/>
<span class="eye-icon" @click="togglePasswordVisibility">
{{ isPasswordVisible ? '🙈' : '👁️' }}
</span>
</div>
</label>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps(['modelValue', 'labelText'])
const emits = defineEmits(['update:modelValue'])
// 控制密码显隐的变量,不用传给父组件,自己在子组件里用ref定义就行
const isPasswordVisible = ref(false)
const handleInput = (e) => {
emits('update:modelValue', e.target.value)
}
const togglePasswordVisibility = () => {
isPasswordVisible.value = !isPasswordVisible.value
}
</script>
<style scoped>
/* 简单加点样式,方便看 */
.custom-password-label {
display: flex;
flex-direction: column;
gap: 8px;
width: 300px;
margin-bottom: 20px;
}
.input-wrapper {
position: relative;
}
.custom-password-input {
width: 100%;
padding: 8px 32px 8px 8px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.eye-icon {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
font-size: 18px;
}
</style>
<!-- 父组件 App.vue -->
<template>
<CustomPasswordInput
v-model="password"
labelText="请输入密码:"
/>
<p>你输入的密码是:{{ password }}</p>
</template>
<script setup>
import { ref } from 'vue'
import CustomPasswordInput from './CustomPasswordInput.vue'
const password = ref('')
</script>
这个组件应该很实用对吧?样式也加了一点,看起来不会太丑,在实际开发中可以把小眼睛换成自己的SVG图标或者UI组件库的图标。
第二个进阶用法:自定义组件绑定多个v-model
刚才说过,自定义组件默认的v-model是:modelValue + @update:modelValue,也可以改成自己想要的名字,username”和“password”,这样就可以在一个自定义登录表单组件里同时绑定两个v-model,不用把两个值包在一个对象里传props和emit了,代码更简洁:
<!-- 子组件 CustomLoginForm.vue -->
<template>
<label class="custom-label">
用户名:
<input
type="text"
:value="username"
@input="handleUsernameChange"
class="custom-input"
/>
</label>
<label class="custom-label">
密码:
<input
type="password"
:value="password"
@input="handlePasswordChange"
class="custom-input"
/>
</label>
<button @click="handleLogin">登录</button>
</template>
<script setup>
const props = defineProps(['username', 'password'])
const emits = defineEmits(['update:username', 'update:password', 'login'])
const handleUsernameChange = (e) => {
emits('update:username', e.target.value)
}
const handlePasswordChange = (e) => {
emits('update:password', e.target.value)
}
const handleLogin = () => {
emits('login')
}
</script>
<style scoped>
.custom-label {
display: flex;
flex-direction: column;
gap: 8px;
width: 300px;
margin-bottom: 12px;
}
.custom-input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #42b983;
color: white;
font-size: 14px;
cursor: pointer;
width: 300px;
}
</style>
<!-- 父组件 App.vue -->
<template>
<CustomLoginForm
v-model:username="loginUsername"
v-model:password="loginPassword"
@login="handleLoginClick"
/>
<p>登录用户名:{{ loginUsername }}</p>
<p>登录密码:{{ loginPassword }}</p>
</template>
<script setup>
import { ref } from 'vue'
import CustomLoginForm from './CustomLoginForm.vue'
const loginUsername = ref('')
const loginPassword = ref('')
const handleLoginClick = () => {
alert(`正在登录,用户名:${loginUsername.value},密码:${loginPassword.value}`)
}
</script>
这个例子里,父组件用了v-model:username和v-model:password,分别绑定了loginUsername和loginPassword,子组件里defineProps接收了username和password两个prop,defineEmits定义了update:username、update:password和login三个emit事件,这样就实现了一个登录表单组件的多个v-model绑定,是不是比包在一个对象里传props和emit更简洁清晰?
第三个进阶用法:实时搜索的防抖节流
实时搜索也是日常开发中经常用到的,比如搜索框输入字符,下面立刻显示搜索结果,但如果每敲一个字符就发一次请求的话,会给服务器造成很大的压力,所以这个时候就需要用防抖或者节流来限制请求的频率。
先简单说一下防抖和节流的区别:
- 防抖:比如用户输入完最后一个字符,过了300ms或者500ms之后,才会发请求,如果在300ms或者500ms之内又敲了字符,就会重新计时,直到用户停止输入300ms或者500ms之后才会发请求,适合实时搜索、输入框自动保存这些场景。
- 节流:比如用户输入字符,不管输入得多快,每隔300ms或者500ms才会发一次请求,适合滚动加载、鼠标移动这些场景。
咱们用Vue3的Composition API和Script Setup来实现一个带防抖的实时搜索自定义组件,这里用原生JavaScript实现防抖函数,不用VueUse的useDebounce或者useDebounceFn,这样新手也能看懂防抖的逻辑:
<!-- 子组件 CustomSearchInput.vue -->
<template>
<label class="custom-search-label">
{{ labelText }}
<input
type="search"
:value="modelValue"
@input="handleSearchInput"
class="custom-search-input"
placeholder="请输入搜索关键词"
/>
</label>
<div class="search-results" v-if="searchResults.length > 0">
<div class="search-result-item" v-for="(item, index) in searchResults" :key="index">
{{ item }}
</div>
</div>
<div class="search-loading" v-if="isSearching">
搜索中...
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps(['modelValue', 'labelText'])
const emits = defineEmits(['update:modelValue'])
// 搜索结果数组
const searchResults = ref([])
// 搜索加载状态
const isSearching = ref(false)
// 防抖函数的定时器变量
let debounceTimer = null
// 原生JavaScript实现的防抖函数
const debounce = (fn, delay = 300) => {
return (...args) => {
// 如果定时器存在,就清除定时器重新计时
if (debounceTimer) clearTimeout(debounceTimer)
// 设置新的定时器
debounceTimer = setTimeout(() => {
fn(...args)
}, delay)
}
}
// 处理输入框变化的函数
const handleSearchInput = (e) => {
emits('update:modelValue', e.target.value)
}
// 模拟搜索请求的函数
const mockSearchRequest = (keyword) => {
isSearching.value = true
// 模拟网络延迟,比如延迟500ms返回搜索结果
setTimeout(() => {
if (!keyword) {
searchResults.value = []
isSearching.value = false
return
}
// 模拟搜索结果
const mockResults = [
`${keyword} 搜索结果1`,
`${keyword} 搜索结果2`,
`${keyword} 搜索结果3`,
`${keyword} 搜索结果4`,
`${keyword} 搜索结果5`
]
searchResults.value = mockResults
isSearching.value = false
}, 500)
}
// 用watch监听modelValue的变化,触发防抖后的搜索请求
// watch(
// () => props.modelValue,
// (newVal) => {
// debounce(mockSearchRequest, 300)(newVal)
// }
// )
</script>
<style scoped>
.custom-search-label {
display: flex;
flex-direction: column;
gap: 8px;
width: 300px;
margin-bottom: 20px;
position: relative;
}
.custom-search-input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.search-results, .search-loading {
position: absolute;
top: 100%;
left: 0;
width: 100%;
max-height: 200px;
overflow-y: auto;
border: 1px solid #ccc;
border-top: none;
border-radius: 0 0 4px 4px;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 999;
}
.search-result-item {
padding: 8px;
cursor: pointer;
font-size: 14px;
}
.search-result-item:hover {
background-color: #f5f5f5;
}
.search-loading {
padding: 8px;
font-size: 14px;
color: #666;
}
</style>
<!-- 父组件 App.vue -->
<template>
<CustomSearchInput
v-model="searchKeyword"
labelText="实时搜索:"
/>
</template>
<script setup>
import { ref } from 'vue'
import CustomSearchInput from './CustomSearchInput.vue'
const searchKeyword = ref('')
</script>
这里要注意的是,debounce函数的定时器变量要定义在watch外面,不能定义在watch的回调函数里,不然每次watch触发的时候,debounceTimer都会重新初始化,清除不了之前的定时器,防抖就失效了,哦对还有,watch的第一个参数是props.modelValue,因为modelValue是父组件传过来的prop,不能直接用ref定义的,所以要用函数的形式返回props.modelValue,这样watch才能监听到变化。
总结一下Vue3 input的用法
今天咱们从最底层的Vue3 input v-model的原生和自定义组件的等价写法讲起,然后讲了新手最容易踩的几个坑,最后讲了三个进阶的自定义组件用法:密码显隐、多个v-model绑定、带防抖的实时搜索组件。
总结一下核心的知识点:
- Vue3 input v-model分原生HTML表单元素和自定义组件两种绑定方式,原生表单元素默认是:value + @input(纯文本类)、:checked + @change(单选复选类),自定义组件默认是:modelValue + @update:modelValue,也可以改成自己想要的名字。
- Composition API里绑定到模板的变量必须是响应式的,基本类型用ref,引用类型用reactive或者ref都行,reactive解构会丢失响应式,要用toRefs包一下。
- 单向数据流原则:父组件传给子组件的prop是只读的,不能直接改,要通过emit事件让父组件自己去更新数据。
- 防抖节流:实时搜索用防抖,滚动加载用节流,原生JavaScript实现的防抖函数要注意定时器变量的作用域。
掌握了这些知识点,Vue3 input的开发应该就不会有什么问题了,当然还有很多其他的用法,比如带表单验证的自定义组件、带自动完成的输入框、带字数限制的输入框等等,这些都可以在今天的基础上继续扩展。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


