表单绑定 v-model
这里的 v-model 称作“表单绑定”,这是因为它最常用的地方就是将表单和变量同步。表单绑定远不止是绑定字符串、数字,下面见分晓。
首先给出使用v-model
和使用原生的方法的对比,以便直观地看出它们的差别:
| <input :value="text" @input="text = $event.target.value"> <input v-model="text">
|
自定义的组件也能使用 v-model 以方便地进行双向绑定,后面再谈。v-model 对于原生 html 元素会正确选择要绑定的值和函数,如对 input、textarea,使用 value 属性和 input 事件;对<input type="checkbox">
和<input type="radio">
,使用 checked 属性和 change 事件,对 select 标签,使用 value 和 changed。
这里不学习绑定字符串、数字到元素的用法,因为它是比较符合直觉的,关注那些集合类型的值,如单个或多个checkbox
,以及ratio
,select
等。
对<input type="checkbox">
,可以使用布尔值、数组(或 Set)去绑定它,默认是布尔值如果引用变量的初始值未传递。布尔值应用在单选的情况,数组应用在多选的情况,数组的情况下,每个 input 都需要设置 value 字段,该 value 字段在该 input 被选中的情况下会加入数组中。
下面展示了单选和多选的 checkbox。实际上,对单选的 checkbox,可以去指定它的真值和假值,而并不需要强迫使用布尔值,下面也有体现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| <script setup lang="ts"> import { ref } from 'vue' const checked = ref<boolean>(false) const checkedNames = ref<string[]>([])
const yesNo = ref<'yes' | 'no'>('no') </script>
<template> <input type="checkbox" id="single" v-model="checked" /> <label for="single">{{ checked }}</label>
<input type="checkbox" id="yesno" v-model="yesNo" true-value="yes" :false-value="'no'"/> <label for="yesno">{{ yesNo }}</label>
<div>Checked names: {{ checkedNames }}</div> <input type="checkbox" id="jack" value="jack" v-model="checkedNames" /> <label for="jack">Jack</label> <input type="checkbox" id="john" value="john" v-model="checkedNames" /> <label for="john">John</label> <input type="checkbox" id="mike" value="mike" v-model="checkedNames" /> <label for="mike">Mike</label> </template>
|
对<input type="ratio">
,则是使用一个字符串去绑定。每个 ratio 需要指定 value 字段供绑定。
| <script setup lang="ts"> import { ref } from 'vue' const picked = ref<string>('') </script>
<template> <div>Picked: {{ picked }}</div>
<input type="radio" id="one" value="One" v-model="picked" /> <label for="one">One</label>
<input type="radio" id="two" value="Two" v-model="picked" /> <label for="two">Two</label> </template>
|
对<select>
(下拉框),单选绑定到字符串,多选绑定到数组;option 的 value 属性是选择性的,如果未给定,使用 innerText 作为 value。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <script setup lang="ts"> import { ref } from 'vue' const selected = ref('') const multipleSelect = ref<string[]>([]) </script>
<template> <span> Selected: {{ selected }}</span> <select v-model="selected"> <option disabled value="">Please select one</option> <option value="aaa">A</option> <option>B</option> <option>C</option> </select>
<span> Selected: {{ multipleSelect }}</span> <select v-model="multipleSelect" multiple> <option value="aaa">A</option> <option>B</option> <option>C</option> </select> </template>
|
最后,v-model
有三个修饰符——lazy
,number
和trim
,lazy 表示监听 change 事件而非 input 事件,后两个则显然,不表。
组件定义和交互
终于可以来最头疼的部分,组件定义及其交互了。先回顾 react,react 的父子组件使用 props 和函数去通信——父组件传入 props 给子组件,父组件传入回调函数到子组件供子组件调用。
Vue 的父子组件通信和 React 有相似之处,但关于子组件向父组件传递信息这个部分有些许差异(虽然本质一致),在 React 中,父组件给子组件绑定函数,而在 Vue 中,父组件绑定处理子组件传递的事件的函数,React 是直接处理函数,而 Vue 中引入了所谓事件,这也决定子组件向父组件传递信息的方式——不是直接调用回调,而是 emit 事件,比如$emit('click', ...)
这样,这更符合传统 js 的编程模型(但这里的事件并非 DOM 事件,而是 Vue 自己抽象来的)。
首先,理解单文件组件的编译,单文件组件中包含 script 和 template 两个标签,它们看上去是分离的,但其实编译时会被放到一起,转换成 js 文件:
| <script setup> import { ref } from 'vue'
const count = ref(0) </script>
<template> <button @click="count++">You clicked me {{ count }} times.</button> </template>
|
| import { ref } from 'vue'
export default { setup() { const count = ref(0) return { count } }, template: ` <button @click="count++"> You clicked me {{ count }} times. </button>` }
|
每一个 Vue 文件都是一个单独的组件定义,它称为单文件组件 SFC。比如把上面的 vue 文件命名为ButtonCounter.vue
,然后在另一个 Vue 文件中便能够引用它。
| <script setup lang="ts"> import ButtonCounter from '@/ButtonCounter.vue'; </script> <template> <ButtonCounter v-for="n in 10" :key="n"></ButtonCounter> </template>
|
注意到,组件命名是大驼峰,和 React 一致。但如果直接在 HTML 中使用该组件(供后续编译),需要使用中划线分割。
注意到。每个 Counter 都有自己独立的状态,互不影响。这是因为每个 Counter 都是该组件的独立的实例。
以及,在这里和在 React 是不一样的——React 只要 import 就能用,而 Vue 有一个组件注册机制,外部组件需要在这个文件中进行注册才能够在 template 中使用,组件也可以注册到全局,不用导入就使用。在使用 setup 时,注册是自动的。关于组件注册,后面再细究。
定义 props
注意,使用 ts 时,这里有魔法!
使用 defineProps 函数去定义 props,defineProps 有两种传参方式——传运行时类型,如 String,Number 等,就像以前的 react 一样,以及传 ts 类型。但问题在于——传 ts 的类型的时候,Vue 的编译器会对该 ts 类型进行语法、语义分析,然后生成运行时类型!这会导致一些复杂的类型或者泛型会失败。我们始终使用 ts 类型。defineProps 的用法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <script setup lang="ts">
const props = defineProps<{ name: string, age: number, clazz: string }>() function display() { alert(`name: ${props.name}, age: ${props.age}, clazz: ${props.clazz}`) } </script>
<template>
<p>name: {{ name }} age: {{ age }} clazz: {{ clazz }}</p> <button @click="display">show</button> </template>
<script setup lang="ts"> import IdolInfo from '@/IdolInfo.vue'; </script> <template>
<IdolInfo name="Haruka" :age="16" clazz="765"></IdolInfo> <IdolInfo name="Chihaya" :age="17" clazz="765"></IdolInfo> <IdolInfo name="Miki" :age="14" clazz="961"></IdolInfo> </template>
|
defineProps 是一个“宏”,不需要显式导入,只能在<script setup>
中使用,它定义的变量会在 template 中自动可用(但在 js 中仍旧需要以props
去使用)。
props 需要默认值怎么办?解决方式是使用一个 withDefault 宏包裹,这个做法甚至能够正确地进行类型推断,舒服!注意这里的数组类型使用一个工厂函数去包装以避免共享可变变量——这里的 withDefault,以及 defineXXX 恐怕是每个组件只执行一次的(它们都称为 compiler macro,它们是编译期执行的而非运行时),说 setup 只执行一次,是说对每个实例只执行一次。
| const props = withDefaults(defineProps<{ name: string, age: number, clazz?: string, friends?: string[] }>(), { clazz: '?', friends: () => [] })
|
一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute,但下面的 emits 则是可以省略的。
定义 emits
如果是 React,到这里已经结束了,然而这是 Vue,还有事件监听要处理。正如 defineProps 定义 props,defineEmits 定义 emits,它和 defineProps 一样也属于是宏,不需要显式导入。对于 emits……这个好像是 ts 可以实现的,不需要魔法。
emits 使用 ts 类型时,定义有两种方式——基于属性的和基于声明类型的,前者允许做一些检查,只有检查通过才实际 emit:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const emit = defineEmits({ change: (id: number) => { }, update: (value: string) => { } })
const emit = defineEmits<{ (e: 'change', id: number): void (e: 'update', value: string): void }>()
const emit = defineEmits<{ change: [id: number] update: [value: string] }>()
|
下面是一个使用上的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| <script setup lang="ts"> import { ref } from 'vue'; const emits = defineEmits<{ (e: 'update', newValue: number): void, (e: 'clean'): void, }>()
const counter = ref(0) function clean() { counter.value = 0 emits('clean') } </script> <template> <button @click="$emit('update', ++counter)">inc</button> <button @click="clean">clean</button> <p>count: {{ counter }}</p> </template>
<script setup lang="ts"> import ButtonCounter from '@/ButtonCounter.vue'; function onUpdate(newValue: number) { alert('counter update: ' + newValue) } </script> <template> <ButtonCounter @update="onUpdate"/> </template>
|
需要注意的是,emits 可以传递多个参数,但父类组件只有直接@event="onEvent"
这样监听时能拿到所有参数,而$event
只能拿到第一个参数。因此,最佳实践是,应当总是 emits 单个参数,如果硬要 emits 多个参数,使用对象或数组去包裹多个值。
注意——无论是 props 还是 emits,js 变量名均适用小驼峰,模板中均使用中划线分割。
最后,defineEmits 并非必须——其实不定义 emits,它仍旧是可以直接使用的,只不过能够提供类型补全和 debug 的便捷罢了。此外,emits 是可以递归向上传递的,这牵扯到透传机制,后面再说。下面是一个递归的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <script setup lang="ts"> import ChildElem from '@/ChildElem.vue'; function magic() { alert('bingo') } </script> <template> <ChildElem @something-really-bad="magic"/> </template>
<script setup lang="ts"> import GrandChildElem from '@/GrandChildElem.vue' </script> <template> <GrandChildElem /> </template>
<script setup lang="ts"> </script> <template> <button @click="$emit('somethingReallyBad')">click me</button> </template>
|
关于操作子组件
受管组件和不受管组件是 React 的术语,没有内部状态,所有状态均由父组件通过 props 传递的组件称为受管组件,反之称为不受管组件。前者就像纯函数,更好维护,但代码量更多,后者相对不好维护和测试,但代码量更少,在表单情形下很好用。
但对于不受管组件,有时候会有这样的需求,即要清空子组件中的所有内容,或者要让某个组件执行特定操作(如弹窗),这个无法通过 props 做到。在 React 中,是通过 ref 和 useImperactiveHandle 去实现的——让父组件持有子组件的引用,从而能够调用子组件定义的函数,而在 Vue 中,则是 ref 与 defineExpose 的组合,使用它们允许父组件直接操作子组件,这对 debug 不方便,但却很有用。注意这里的 defineExpose 也是编译器宏。
实际上,defineExpose 的能力比它在 React 中的同事强多了——子组件可以把自己的 ref 和 reactive 传递给父组件,让父组件直接操作子组件的属性……这很恐怖。给开发者的自由越大,实际上约束也越大,但写起原型来确实舒爽。不过使用 defineExpose 在顶层暴露 ref 变量的时候,它会被自动解包。
测试过,无论子组件的 ref 变量是在顶层还是通过函数返回,它都是响应式的,这等于就是说父组件可以直接得到子组件的状态,这耦合程度也是没谁了。
下面做了一个简单的不受管的登陆表单组件(也就两个 input),并提供方法去清空和获取输入。注意这里的loginFormRef
不止能访问 expose 的 get,clean 方法,它还能访问诸如$el
等方法,实际上loginFormRef
就等于子组件的 this。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| <script setup lang="ts"> import LoginForm from '@/LoginForm.vue'; import { ref } from 'vue';
const loginFormRef = ref<InstanceType<typeof LoginForm>>()
function login() { if (!loginFormRef.value) {return} const {username, password} = loginFormRef.value.get() alert('login: ' + username + ' ' + password) } </script> <template> <LoginForm ref="loginFormRef"/> <button @click="loginFormRef?.clean()">clear</button> <button @click="login">login</button> </template>
<script setup lang="ts"> import { ref } from 'vue'; const username = ref('') const password = ref('') defineExpose({ get() { return { username: username.value, password: password.value } }, clean() { username.value = '' password.value = '' } }) </script> <template> <div> <label for="username">username</label> <input type="text" v-model="username" /> <label for="password">password</label> <input type="password" v-model="password" /> </div> </template>
|
另外,ref 也可以用来拿到元素的实例,下面拿到元素的实例然后通过 DOM 操作设置了元素的内容:
| <script setup lang="ts"> import { onMounted, ref, useTemplateRef } from 'vue';
const someP = useTemplateRef('someP') onMounted(() => { if (!someP.value) { return } someP.value.innerText = "Hello, Happy World!" }) </script> <template> <p ref="someP"></p> </template>
|
至此,基础的部分都学完了(一些更偏重实践的东西、style 等没提),我……我摆了我突然。先悠着点。后面要做可能是去做一个音程练习工具吧,模仿这个。