先给结论,JSX里不仅能用v-model,灵活度还比模板更高,但得换个书写姿势
这是最近两个月在前端交流群里被问得最多的Vue3新语法组合问题——毕竟很多从模板转JSX做复杂交互组件、做低代码平台自定义面板、或者适配跨端场景(比如uni-app用Vue3 + JSX写高性能原生兼容组件、Taro 3.6+做渲染层拆分)的开发者,刚上手总会摸不清:模板里v-model默认绑value/input,带修饰符.sync也直接换冒号绑定事件前缀,双向绑定的底层逻辑全帮你封装好,连修饰符事件的处理都省事儿;可JSX里全是显式的函数调用、原生/组件属性绑定,v-model的语法糖会不会就“失效”变复杂,甚至要自己去手写emit + update:xxx的完整逻辑? Vue3的核心编译器@vue/compiler-sfc,不管是处理单文件组件的template还是JSX/TSX文件的render函数,本质上都是把v-model这类语法糖,转换成了“绑定值到特定prop + 侦听update:特定prop事件 + 同步修改本地响应式数据”的三段式逻辑,不过template编译器是自动选“特定prop”(不带参数默认是modelValue),JSX编译器(vitejs/plugin-vue-jsx)需要你稍微明确点,但这种明确反而带来了更多可能性——比如不用像模板那样只能传字符串参数的v-model,JSX可以直接绑定变量参数,或者一次写多个v-model不带冲突。
基础用法对比:单文件组件的template vs render函数的JSX/TSX
先拿最简单的场景对比,比如做一个输入框的父组件,分别用两种写法实现双向绑定。
模板写法(给回忆一下,对比用)
模板里默认v-model绑子组件的modelValue,侦听update:modelValue事件,父组件只要写
JSX/TSX的基础单v-model写法
JSX里没有template那样的冒号绑定修饰符的语法,vitejs/plugin-vue-jsx给我们留了两种语法糖替代方案,都是官方推荐的:
第一种是最接近模板、最常用的“v-model={[绑定的数据, 可选参数, 可选修饰符对象]}”数组语法,比如和刚才模板完全等价的父组件代码:
// MyInput的defineProps/defineEmits和模板完全一样,不用改!这是Vue3 JSX最爽的地方之一
import { ref } from 'vue'
import MyInput from './MyInput.vue'
export default {
setup() {
const inputVal = ref('')
return () => (
<div>
{/* 数组第一个元素是响应式数据(ref/reactive里的key都可以,不过key会自动解包吗?等下修饰符部分说) */}
{/* 第二个可选参数不传,就是默认modelValue */}
{/* 第三个可选参数是修饰符对象,{ trim: true, number: true }这种,对象的key就是修饰符名,value是布尔值true */}
<MyInput v-model={[inputVal, 'modelValue', { trim: true }]} />
<p>当前输入内容:{inputVal.value}</p>
</div>
)
}
}
哦对,刚才的问题,JSX数组语法里的第一个元素,如果是ref,编译器会自动帮你.value解包吗?不对,父组件render函数里直接输出ref.value,编译器才会解,但v-model数组第一个是ref本身,编译器处理的时候会直接用它的响应式引用来赋值和侦听,不用手动加.value;如果是reactive里的某个属性,比如form.name,那就直接传就行,编译器会自动生成访问路径。
第二种是显式三段式逻辑但用vue提供的withModifiers辅助函数(如果带修饰符的话)的写法,适合理解底层逻辑或者自定义场景需求,
// 不带修饰符的话,直接这么写就行
<MyInput
modelValue={inputVal.value}
onUpdate:modelValue={(newVal) => (inputVal.value = newVal)}
/>
// 带修饰符的话,用withModifiers包装处理函数
import { ref, withModifiers } from 'vue'
import MyInput from './MyInput.vue'
export default {
setup() {
const inputVal = ref('')
// withModifiers第一个参数是原始处理函数,第二个是修饰符数组
const handleUpdate = withModifiers(
(newVal) => (inputVal.value = newVal),
['trim']
)
return () => (
<div>
<MyInput modelValue={inputVal.value} onUpdate:modelValue={handleUpdate} />
<p>当前输入内容:{inputVal.value}</p>
</div>
)
}
}
进阶用法1:一次绑定多个v-model(不带冲突,参数可以是字符串或变量)
模板里要绑定多个v-model,得加冒号参数,比如
比如做一个多标签页的表单编辑器,每个标签页对应不同的字段,字段名是从后端接口返回的动态数组:
import { ref, reactive } from 'vue'
import MyTabForm from './MyTabForm.vue'
export default {
setup() {
const form = reactive({})
const tabs = ref([
{ label: '姓名', field: 'userName' },
{ label: '手机号', field: 'phone' },
{ label: '地址', field: 'address' }
])
// 假设接口返回tabs,先初始化form里的空字段
tabs.value.forEach((tab) => (form[tab.field] = ''))
return () => (
<div>
{/* 用map渲染多个标签页组件,这里假设每个标签页组件有一个tab prop,还有动态的v-model绑定 */}
{/* 注意这里的第二个参数用的是tab.field变量,完全没问题! */}
{tabs.value.map((tab) => (
<MyTabForm key={tab.field} tab={tab} v-model={[form[tab.field], tab.field]} />
))}
{/* 输出整个form看看,不用.value,因为render函数里直接用reactive的属性会解包吗?不对,reactive整体不会,但单个属性在JSX插值里会,v-model数组里单个属性直接传就行 */}
<pre>{JSON.stringify(form, null, 2)}</pre>
</div>
)
}
}
进阶用法2:JSX原生标签的v-model用法
刚才说的都是自定义组件的v-model,那原生标签(比如input、textarea、select、checkbox、radio)呢?会不会和自定义组件不一样?
其实也差不多,但原生标签有默认的prop和事件映射,vitejs/plugin-vue-jsx还做了额外的优化——比如原生input type="text"/textarea默认映射的是value/input事件,type="checkbox"/radio默认映射的是checked/change事件,select默认映射的是value/change事件,而且这些原生标签的默认修饰符(trim/number/lazy),用数组语法的第三个参数或者显式三段式逻辑的withModifiers都能直接生效。
比如写一个包含多个原生标签的小表单:
import { ref, reactive } from 'vue'
export default {
setup() {
const form = reactive({
name: '',
age: 0,
bio: '',
gender: '男',
hobbies: [],
agree: false
})
return () => (
<div style={{ padding: '20px' }}>
<div style={{ margin: '10px 0' }}>
<label>姓名:</label>
<input type="text" v-model={[form.name, 'value', { trim: true }]} />
</div>
<div style={{ margin: '10px 0' }}>
<label>年龄:</label>
{/* number修饰符会自动把输入的字符串转成数字,这里用数组语法的话会生效 */}
<input type="number" v-model={[form.age, 'value', { number: true }]} />
</div>
<div style={{ margin: '10px 0' }}>
<label>简介:</label>
<textarea v-model={[form.bio]} rows="3" />
</div>
<div style={{ margin: '10px 0' }}>
<label>性别:</label>
<input type="radio" name="gender" value="男" v-model={[form.gender]} /> 男
<input type="radio" name="gender" value="女" v-model={[form.gender]} /> 女
</div>
<div style={{ margin: '10px 0' }}>
<label>爱好:</label>
{/* checkbox如果绑定数组,会自动选中符合value的项 */}
<input type="checkbox" value="编程" v-model={[form.hobbies]} /> 编程
<input type="checkbox" value="阅读" v-model={[form.hobbies]} /> 阅读
<input type="checkbox" value="运动" v-model={[form.hobbies]} /> 运动
</div>
<div style={{ margin: '10px 0' }}>
<input type="checkbox" v-model={[form.agree]} /> 同意用户协议
</div>
<pre>{JSON.stringify(form, null, 2)}</pre>
</div>
)
}
}
这里要注意一个小细节:原生radio的v-model数组语法里,第二个参数不用传,因为默认是checked,和input text的value不一样,但编译器会自动识别type属性,然后选对prop和事件。
进阶用法3:自定义v-model修饰符(子组件如何接收和处理修饰符)
模板里自定义v-model修饰符,子组件是通过defineProps接收一个名为modelModifiers的prop(如果v-model有参数的话,比如v-model:name,那prop名就是nameModifiers),然后在emit的时候根据修饰符做处理,这个逻辑在Vue3 JSX里也完全通用,而且不管父组件用的是数组语法还是显式三段式逻辑,子组件的接收方式都不变。
比如给刚才的MyInput组件加一个自定义修饰符capitalize,功能是把输入的第一个字母转成大写:
// 子组件MyInput.vue
<script setup>
// 接收modelValue和modelModifiers
const props = defineProps({
modelValue: String,
modelModifiers: {
type: Object,
default: () => ({})
}
})
// 定义update:modelValue事件
const emit = defineEmits(['update:modelValue'])
// 处理输入事件
const handleInput = (e) => {
let newVal = e.target.value
// 如果有trim修饰符,先去空格?不过刚才说父组件用数组语法的trim修饰符,withModifiers会帮子组件处理吗?
// 哦不对!官方的trim/number/lazy修饰符,是在父组件的编译器里处理的,也就是用数组语法的第三个参数或者显式三段式的withModifiers时,处理函数已经把trim/number/lazy的逻辑执行了,子组件收到的已经是处理后的值
// 但自定义修饰符,必须子组件自己处理!
if (props.modelModifiers.capitalize) {
newVal = newVal.charAt(0).toUpperCase() + newVal.slice(1)
}
emit('update:modelValue', newVal)
}
</script>
<template>
<input type="text" :value="modelValue" @input="handleInput" />
</template>
然后父组件用数组语法调用:
import { ref } from 'vue'
import MyInput from './MyInput.vue'
export default {
setup() {
const inputVal = ref('')
return () => (
<div>
{/* 第三个参数里加capitalize: true就行 */}
<MyInput v-model={[inputVal, 'modelValue', { capitalize: true }]} />
<p>当前输入内容:{inputVal.value}</p>
</div>
)
}
}
如果父组件用显式三段式逻辑调用,那自定义修饰符需要子组件自己接收,父组件不用做任何额外处理,因为自定义修饰符是通过prop传递给子组件的:
// 父组件显式三段式逻辑调用MyInput带capitalize修饰符
import { ref } from 'vue'
import MyInput from './MyInput.vue'
export default {
setup() {
const inputVal = ref('')
const handleUpdate = (newVal) => (inputVal.value = newVal)
return () => (
<div>
{/* 显式传modelModifiers prop!这个是自定义修饰符必须的,不管父组件用哪种写法 */}
<MyInput
modelValue={inputVal.value}
onUpdate:modelValue={handleUpdate}
modelModifiers={{ capitalize: true }}
/>
<p>当前输入内容:{inputVal.value}</p>
</div>
)
}
}
哦对,刚才的数组语法里,第三个参数的修饰符,除了官方的trim/number/lazy,自定义的都会自动传给子组件对应的Modifiers prop,不用显式传,这就是数组语法的方便之处。
常见问题和踩坑指南
JSX数组语法里的第一个元素传ref还是reactive的属性?
都可以,传ref的话,编译器会自动用它的响应式引用,不用手动加.value;传reactive的属性的话,编译器会自动生成访问路径,也不用手动加.value,但要注意,如果是reactive的属性,在父组件render函数里直接赋值的话,比如form.name = '张三',是没问题的,但如果是ref的话,在setup函数里赋值要加.value,在render函数里的事件处理函数里赋值也要加.value。
原生标签的v-model数组语法里,第二个参数可以省略吗?
可以,编译器会自动根据type属性选对默认prop:
- type="text"/password/search/url/email/hidden等文本类:默认value
- type="checkbox"/radio:默认checked
- select:默认value
- textarea:默认value
自定义组件的v-model参数如果是变量,子组件的prop和事件名怎么定义?
子组件的prop名就是变量的值,事件名就是update:变量的值,对应的Modifiers prop名就是变量的值Modifiers,比如变量是tab.field,值是userName,那子组件的defineProps里要接收userName和userNameModifiers,defineEmits里要定义update:userName。
为什么我的JSX v-model不生效?
检查这几个地方:
- 父组件用的是数组语法吗?如果是显式三段式逻辑,有没有绑定对prop和事件?prop名和事件名有没有写错?
- 子组件有没有defineProps接收对应的prop?有没有defineEmits定义对应的事件?
- 子组件有没有在合适的时机emit事件?比如input事件、change事件、点击事件?
- 子组件emit的参数对不对?比如原生input text的emit参数是e.target.value,自定义组件的emit参数是新的绑定值?
- 如果是ref的话,有没有在setup函数里正确定义?有没有return出来?
- 如果是@vitejs/plugin-vue-jsx的话,有没有在vite.config.js里正确配置?比如有没有安装插件?有没有在plugins数组里添加vueJsx()?
Vue3里用JSX开发组件,v-model不仅能用,而且灵活度比模板更高:数组语法最接近模板,适合大多数场景;显式三段式逻辑适合理解底层逻辑或者自定义场景;多个v-model可以绑定变量参数,原生标签和自定义组件的用法也几乎一致,自定义修饰符的接收和处理方式也和模板完全通用,只要掌握了“书写姿势”,JSX的v-model就能和模板里一样简单好用,甚至能解决模板里解决不了的动态绑定问题。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网
