Vue 3 深入学习笔记 07v-model 与受控组件设计一、为什么要学习 v-model在前面的学习中我们已经掌握了组件设计与职责边界知道了父组件通过props向子组件传递数据。子组件通过emit通知父组件发生了什么。共享状态应该由合适的组件统一管理。但是在实际开发中还有一个非常常见的问题。如果父组件需要管理数据而子组件又需要修改数据应该怎么设计例如我们开发一个订单搜索页面。父组件需要保存搜索关键词子组件负责展示输入框。当用户在输入框中输入ORD001时我们希望输入框显示ORD001。父组件中的keyword同步变成ORD001。父组件可以使用这个关键词执行搜索。如果父组件主动清空关键词子组件的输入框也同步清空。这种需求就是 Vue 中v-model的典型应用场景。本篇将重点学习v-model的工作原理。原生表单元素与组件上的v-model有什么区别。如何实现自定义组件的v-model。如何理解受控组件与非受控组件。多个v-model如何使用。如何设计可复用的表单组件。二、先理解原生元素上的 v-model在学习组件上的v-model之前我们先看一个最简单的例子。script setup import { ref } from vue const keyword ref() /script template input v-modelkeyword / p当前关键词{{ keyword }}/p /template当用户在输入框中输入内容时keyword会自动更新。例如用户输入ORD001页面显示当前关键词ORD001这里发生了什么对于普通文本输入框可以将v-model理解为以下两部分input :valuekeyword inputkeyword $event.target.value /上面只是帮助理解的简化写法。在使用ref时实际 JavaScript 中需要通过keyword.value修改值在模板中Vue 会自动解包顶层的ref。对应的 JavaScript 写法是keyword.value event.target.value这两部分分别承担不同职责语法职责:valuekeyword将状态同步到输入框input...将用户输入同步回状态因此v-model并不是一种神秘的双向绑定机制而是将数据传递与数据变更通知组合起来的便捷语法。需要注意不同表单元素的实现细节并不完全相同。例如复选框和单选框会使用不同的属性与事件但整体思想一致。三、组件上的 v-model 与原生元素不同这是本篇最重要的知识点之一。先看原生元素input v-modelkeyword /再看自定义组件SearchInput v-modelkeyword /它们的写法非常相似但内部机制不同。原生元素的v-model直接处理输入框等 DOM 元素的属性和事件。组件上的v-model则需要父子组件配合父组件传递当前值。子组件通知父组件值发生变化。父组件更新状态后再将新值传回子组件。在 Vue 3 中默认的组件v-model可以展开为SearchInput :modelValuekeyword update:modelValuekeyword $event /其中有两个重要约定名称作用modelValue父组件传入的当前值update:modelValue子组件通知父组件更新值的事件请记住这两个名称。理解了它们就基本掌握了 Vue 3 组件v-model的核心原理。四、手动实现一个支持 v-model 的组件我们通过一个完整例子来理解。假设我们要开发一个通用的搜索输入框SearchInput.vue。1. 子组件 SearchInput.vuescript setup const props defineProps({ modelValue: { type: String, default: } }) const emit defineEmits([update:modelValue]) function handleInput(event) { emit(update:modelValue, event.target.value) } /script template input :valueprops.modelValue inputhandleInput placeholder请输入搜索关键词 / /template这里有三个关键点。第一通过 props 接收父组件的数据。const props defineProps({ modelValue: { type: String, default: } })子组件并不自己决定最终保存什么值而是接收父组件传来的modelValue。第二通过 emit 通知父组件更新数据。emit(update:modelValue, event.target.value)当用户输入新内容时子组件发出事件并将新值作为参数传出去。第三输入框始终展示父组件传入的值。input :valueprops.modelValue /父组件更新modelValue后输入框也会显示最新值。2. 父组件使用 SearchInput.vuescript setup import { ref } from vue import SearchInput from ./SearchInput.vue const keyword ref() /script template SearchInput v-modelkeyword / p当前关键词{{ keyword }}/p button clickkeyword 清空关键词 /button /template运行后可以看到用户输入时父组件的keyword自动更新。页面上的关键词同步变化。点击清空按钮输入框也会同步清空。整个过程如下用户输入内容 ↓ SearchInput 发出 update:modelValue ↓ 父组件更新 keyword ↓ 父组件重新向 SearchInput 传递 modelValue ↓ 输入框显示最新值这就是 Vue 3 组件v-model的基本工作流程。五、为什么子组件不能直接修改 props理解v-model时一个非常容易犯的错误是直接修改props。例如script setup const props defineProps({ modelValue: String }) function handleInput(event) { props.modelValue event.target.value } /script这种写法是不正确的。Vue 的设计原则是父组件拥有传入的数据子组件不能直接修改 props。为什么假设父组件有以下代码const keyword ref(ORD001)然后传递给子组件SearchInput :model-valuekeyword /如果子组件直接修改传入的值就会出现一个问题父组件的数据应该由谁负责修改如果父组件和子组件都可以随意修改同一份状态那么数据的修改来源就会变得难以追踪。Vue 因此采用了明确的协作方式父组件拥有数据 ↓ 通过 props 传给子组件 ↓ 子组件需要修改数据 ↓ 通过 emit 通知父组件 ↓ 父组件修改自己的状态这样做的价值不只是符合 Vue 的语法要求更重要的是让数据流向清晰、便于维护。注意props的只读约束主要针对属性本身。对于传入的对象或数组其嵌套属性在 JavaScript 中仍可能被修改但这不代表应该这样做。通常应避免在子组件中直接修改父组件拥有的对象或数组。六、受控组件与非受控组件现在进入一个更重要的设计概念受控组件。1. 什么是受控组件受控组件是指组件展示的值由外部状态决定组件通过事件请求外部更新状态。例如SearchInput v-modelkeyword /在这种设计中keyword由父组件维护。子组件通过modelValue接收值。子组件通过update:modelValue通知父组件更新。父组件可以随时修改keyword从而改变输入框显示内容。这意味着父组件始终可以控制输入框的当前值。受控组件并不意味着所有逻辑都必须写在父组件里而是指关键状态的最终所有权在外部。2. 什么是非受控组件非受控组件是指组件主要在内部管理自己的状态外部不必持续提供当前值。例如script setup import { ref } from vue const keyword ref() function search() { console.log(搜索关键词, keyword.value) } /script template input v-modelkeyword / button clicksearch 搜索 /button /template在这个例子中输入框的状态由当前组件内部的keyword管理。外部组件不需要知道用户输入了什么也不需要控制输入框的每一次变化。这就是一种内部状态管理的思路。严格来说判断一个组件是否受控关键在于其值是否由外部持续提供和控制而不是看它有没有使用ref。3. 两者如何选择对比项受控组件非受控组件状态主要由谁管理父组件或外部组件内部外部能否直接控制当前值可以通常不能直接控制是否适合父组件统一重置表单很适合需要额外提供方法或同步机制是否适合独立、简单的输入功能可以但可能略显复杂通常很方便是否适合多个组件协同使用同一状态很适合需要额外设计通信实际开发中两者没有绝对的优劣。例如一个独立的搜索框可以内部管理输入状态。一个复杂的订单编辑表单通常需要由父组件或表单模块统一管理数据。一个需要父组件随时重置、回显和校验的通用输入组件通常适合使用受控设计。核心是明确状态的所有权。七、v-model 与 props emit 的关系学到这里你可能会有一个疑问既然v-model最终也是通过props和emit实现的那么为什么不一直使用props emit答案是两者的底层协作方式一致只是表达形式不同。假设父组件需要传递一个关键词并监听关键词变化。写法一显式使用 props emitSearchInput :model-valuekeyword update:model-valuekeyword $event /写法二使用 v-modelSearchInput v-modelkeyword /第二种写法更加简洁。当组件存在多个需要双向同步的字段时v-model的优势会更加明显。但是如果子组件只需要通知父组件执行一个操作例如OrderTable view-orderhandleViewOrder /这里不适合强行使用v-model。因为“查看订单”表达的是一个操作事件并不是某个需要双向同步的值。因此可以这样区分需要同步某个值 考虑v-model。需要通知外部发生了某个动作 使用emit自定义事件。八、多个 v-model一个组件管理多个字段真实业务中一个组件经常需要同时管理多个值。例如订单筛选组件需要维护keyword订单编号。status订单状态。如果只使用默认的v-model就只能通过默认的modelValue对应一个值。Vue 3 支持为v-model指定不同的名称。1. 父组件script setup import { ref } from vue import OrderFilter from ./OrderFilter.vue const keyword ref() const status ref(all) /script template OrderFilter v-model:keywordkeyword v-model:statusstatus / p订单编号{{ keyword }}/p p订单状态{{ status }}/p /template这里有两个v-modelv-model:keywordkeyword v-model:statusstatus它们分别绑定不同的数据。2. 子组件 OrderFilter.vuescript setup defineProps({ keyword: { type: String, default: }, status: { type: String, default: all } }) const emit defineEmits([ update:keyword, update:status ]) /script template div input :valuekeyword inputemit(update:keyword, $event.target.value) placeholder订单编号 / select :valuestatus changeemit(update:status, $event.target.value) option valueall全部/option option valuepending待支付/option option valuepaid已支付/option /select /div /template这里的映射关系是父组件写法子组件接收的 prop子组件发出的事件v-model:keywordkeywordupdate:keywordv-model:statusstatusupdate:status可以看出命名参数keyword对应keyword属性和update:keyword事件。这使得一个组件能够同时维护多个由外部控制的字段而不需要把所有字段塞进一个对象中。3. 使用 defineModel 简化代码Vue 3.4 及以上版本提供了defineModel()可以进一步简化组件v-model的实现。默认v-model的子组件可以这样写script setup const model defineModel({ type: String, default: }) /script template input v-modelmodel / /template父组件仍然可以这样使用SearchInput v-modelkeyword /defineModel()帮助我们处理modelValue和update:modelValue这套约定不需要手动编写对应的props和emit。如果要使用命名v-model例如v-model:keyword则可以这样写script setup const keyword defineModel(keyword, { type: String, default: }) /script template input v-modelkeyword / /template对于 Vue 3.4 及以上版本编写自定义v-model组件时通常可以优先考虑defineModel()。如果项目使用 Vue 3.3 或更早版本则需要继续采用手动定义props和emit的方式。还需要注意一个细节defineModel()的default值可能导致父组件绑定值与子组件初始值不同步尤其是在父组件绑定值为undefined时。因此如果希望父子状态始终一致最好让父组件明确提供初始值。九、v-model 在实际业务中的应用我们将前面的知识结合起来看看如何设计一个订单编辑组件。需求如下父组件维护订单数据。用户点击编辑后打开编辑弹窗。子组件展示订单编辑表单。用户修改订单名称。点击取消时不修改原始订单数据。点击保存时将修改结果提交给父组件。1. 父组件 OrderPage.vuescript setup import { ref } from vue import OrderEdit from ./OrderEdit.vue const order ref({ id: 101, name: 测试订单, status: pending }) const editVisible ref(false) const editForm ref({ name: , status: pending }) function openEdit() { // 创建独立的编辑数据避免直接修改原始订单 editForm.value { name: order.value.name, status: order.value.status } editVisible.value true } function saveEdit(form) { order.value { ...order.value, ...form } editVisible.value false } /script template div p订单名称{{ order.name }}/p p订单状态{{ order.status }}/p button clickopenEdit 编辑订单 /button OrderEdit v-ifeditVisible :model-valueeditForm savesaveEdit canceleditVisible false / /div /template这里父组件维护原始订单数据和编辑表单的初始值。打开编辑窗口时我们复制需要编辑的字段而不是直接把原始订单对象交给表单修改。2. 子组件 OrderEdit.vuescript setup import { reactive, watch } from vue const props defineProps({ modelValue: { type: Object, required: true } }) const emit defineEmits([ save, cancel ]) const form reactive({ name: , status: pending }) watch( () props.modelValue, (value) { Object.assign(form, value) }, { immediate: true } ) function handleSave() { emit(save, { ...form }) } /script template div input v-modelform.name / select v-modelform.status option valuepending待支付/option option valuepaid已支付/option /select button clickhandleSave 保存 /button button clickemit(cancel) 取消 /button /div /template这个例子使用的是props emit的数据传递方式而不是直接对整个表单对象使用v-model。原因是这里需要区分两个概念表单内部正在编辑的临时数据。父组件真正拥有的订单数据。只有用户点击保存后子组件才通知父组件提交修改结果。这是一种常见的表单设计方式。3. 为什么不直接对原始订单使用 v-model假设直接写成OrderEdit v-modelorder /如果子组件通过v-model实时同步每一次表单输入那么父组件的order就会在用户编辑过程中持续变化。此时用户点击取消原始订单数据可能已经被修改。所以对于具有“保存”和“取消”语义的编辑表单通常需要维护一份独立的临时数据。当然如果业务明确要求实时同步例如实时预览设置那么直接使用v-model可能更合适。关键不是能不能使用v-model而是数据应该在什么时候真正生效。十、学习 v-model 时最容易犯的错误错误 1认为 v-model 可以直接修改父组件数据不准确。组件v-model通过传值和更新事件实现同步。子组件发出事件后最终仍由父组件更新其状态。错误 2所有事件都使用 v-model不正确。v-model适合表达某个值的同步关系而不是所有用户操作。例如查看详情、删除订单、导出数据等操作通常应该使用自定义事件。错误 3把对象传给子组件后直接修改对象属性例如props.order.name 新名称即使某些情况下这段代码可以修改嵌套属性也会让数据所有权变得模糊。更好的做法是由父组件维护原始对象子组件通过事件提交修改请求或者使用独立的编辑副本。错误 4认为 defineModel 会自动创建全局共享状态不正确。const model defineModel()它处理的是当前组件的v-model通信并不会让其他组件自动共享同一份状态。跨页面共享数据仍然需要合适的状态管理方案例如 Pinia。错误 5认为受控组件就必须没有内部状态不正确。受控组件仍然可以维护内部状态例如下拉菜单是否展开、动画进度、临时校验信息等。只要关键业务值由外部控制就仍然可以采用受控设计。十一、课后练习假设现在要开发一个通用的UserNameInput.vue组件。需求如下父组件维护用户名username。子组件展示用户名输入框。用户输入时父组件中的username自动更新。父组件点击“重置”按钮后输入框同步清空。请完成以下三个练习。练习 1理解原理下面两种写法有什么区别UserNameInput v-modelusername /UserNameInput :model-valueusername update:model-valueusername $event /请说明它们之间的关系。练习 2实现组件请使用传统的defineProps()和defineEmits()实现UserNameInput.vue。要求接收父组件传入的用户名。用户输入时发出update:modelValue事件。不允许直接修改props。练习 3设计订单编辑表单一个订单编辑弹窗包含订单名称和订单状态。用户可以修改表单但只有点击“保存”后才更新原始订单点击“取消”则丢弃修改。请思考应该直接对原始订单使用v-model吗临时表单数据应该由谁维护子组件应该通过什么方式提交修改结果十二、课后练习参考答案练习 1理解原理两种写法的功能基本等价。第一种使用v-model语法糖第二种显式写出了数据传递和事件监听。可以将第一种展开为UserNameInput :model-valueusername update:model-valueusername $event /其中:model-valueusername父组件把当前用户名传给子组件。update:model-valueusername $event子组件通知父组件更新用户名。$event就是子组件发出事件时传递的新值。练习 2实现组件UserNameInput.vuescript setup const props defineProps({ modelValue: { type: String, default: } }) const emit defineEmits([ update:modelValue ]) function handleInput(event) { emit(update:modelValue, event.target.value) } /script template input :valueprops.modelValue inputhandleInput placeholder请输入用户名 / /template父组件script setup import { ref } from vue import UserNameInput from ./UserNameInput.vue const username ref() /script template UserNameInput v-modelusername / p当前用户名{{ username }}/p button clickusername 重置 /button /template当用户输入时子组件发出更新事件父组件修改username。点击重置时父组件直接将username设置为空字符串新的值会通过props传给子组件输入框随之清空。练习 3设计订单编辑表单参考答案不建议直接使用原始订单作为实时双向绑定的数据。推荐设计如下父组件保存原始订单。打开编辑弹窗时创建一份临时表单数据。子组件只编辑临时表单。点击保存时子组件通过emit(save, formData)提交修改结果。父组件接收修改结果更新原始订单。点击取消时直接关闭弹窗并丢弃临时数据。例如function openEdit() { editForm.value { name: order.value.name, status: order.value.status } } function saveEdit(formData) { order.value { ...order.value, ...formData } }这种设计将原始数据与编辑过程分离避免用户在尚未保存时就修改了正式数据。十三、本篇总结本篇需要掌握以下核心知识原生元素上的v-model用于同步表单元素与响应式状态。Vue 3 组件上的v-model默认通过modelValue和update:modelValue实现。子组件不应该直接修改props而应该通过事件通知父组件。受控组件由外部控制关键状态非受控组件主要管理自己的内部状态。多个v-model可以分别同步组件中的多个字段。Vue 3.4 及以上版本可以使用defineModel()简化组件双向绑定。对于需要保存和取消的编辑表单应当区分原始数据和临时编辑数据。本篇最重要的设计思想v-model解决的是值如何同步的问题而受控组件设计解决的是状态由谁拥有、由谁负责修改的问题。