Code前端首页关于Code前端联系我们

Vue3 input怎么用?从双向绑定基础到踩坑、自定义输入组件全场景全讲透

terry 29分钟前 阅读数 10 #Vue

刚接触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绑定、带防抖的实时搜索组件。

总结一下核心的知识点:

  1. Vue3 input v-model分原生HTML表单元素和自定义组件两种绑定方式,原生表单元素默认是:value + @input(纯文本类)、:checked + @change(单选复选类),自定义组件默认是:modelValue + @update:modelValue,也可以改成自己想要的名字。
  2. Composition API里绑定到模板的变量必须是响应式的,基本类型用ref,引用类型用reactive或者ref都行,reactive解构会丢失响应式,要用toRefs包一下。
  3. 单向数据流原则:父组件传给子组件的prop是只读的,不能直接改,要通过emit事件让父组件自己去更新数据。
  4. 防抖节流:实时搜索用防抖,滚动加载用节流,原生JavaScript实现的防抖函数要注意定时器变量的作用域。

掌握了这些知识点,Vue3 input的开发应该就不会有什么问题了,当然还有很多其他的用法,比如带表单验证的自定义组件、带自动完成的输入框、带字数限制的输入框等等,这些都可以在今天的基础上继续扩展。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门