v-model
is a very important built-in directive in Vue, which creates two-way bindings on form input elements or components. These elements include:
<input>
<select>
<textarea>
components
v-model
is actually a syntax sugar for the value attribute and the input event
1
2
3
| <input type="text" :value="iptVal" @input="$event => iptVal = $event.target.value" />
<!-- v-model -->
<input type="text" v-model="iptVal" />
|
We usually use the v-model
directive to complete data binding when developing forms, and this directive can also be used on components.
Bind a Single Attribute
Basic Binding
Taking the custom component CustomInput as an example
1
2
3
4
5
6
7
| <script setup>
const txt = ref('');
</script>
<template>
<CustomInput v-model="txt" />
</template>
|
v-model
will be expanded into the following form
1
2
3
4
| <CustomInput
:modelValue="txt"
@update:modelValue="newValue => txt = newValue"
/>
|
The CustomInput
component needs to do two things internally:
Bind the value attribute of the internal native <input>
element to the modelValue
prop
When the native input event triggers, trigger an update:modelValue
custom event with a new value
Here is the corresponding code:
1
2
3
4
5
6
7
8
9
10
| <template>
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>
<script setup>
const props = defineProps({
'modelValue': String,
})
const emit = defineEmits(["update:modelValue"])
</script>
|
Some people may find this writing way too cumbersome, and it will make the tag code lengthy.
Another way to implement v-model
in the component is to use a writable computed property with both a getter and a setter.
Binding with computed
When using computed properties, the get method should return modelValue prop, and the set method should trigger the corresponding event
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| <template>
<input v-model="value" />
</template>
<script setup>
const value = computed({
get() {
return props.modelValue
},
set(value) {
emit("update:modelValue", value)
}
})
</script>
|
This way of writing can simplify the properties in the tag and make the logic clear
Binding a single attribute can be easily done with v-model, but what if multiple attributes need to be bound bidirectionally?
Use v-model to bind multiple attributes
By default, v-model
uses modelValue
as prop on a component, and update:modelValue
as the corresponding event
But we can change these names by specifying a parameter for v-model
1
2
3
| <template>
<CustomInput v-model:first-name="first" v-model:last-name="last" />
</template>
|
In the same way, you can use two ways to bind, but the prop has changed from the original modelValue to the parameter name passed in, and the corresponding event has also changed to update:parameter
name.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| <template>
<input
type="text"
:value="firstName"
@input="$emit('update:firstName', $event.target.value)"
/>
<input
type="text"
:value="lastName"
@input="$emit('update:lastName', $event.target.value)"
/>
</template>
<script setup>
const props = defineProps({
firstName: String,
lastName: String,
})
// Use it in computed
const emit = defineEmits(['update:firstName', 'update:lastName'])
</script>
|
Binding an Object
In a complex component, if multiple fields need to be bound in two ways. Using the method mentioned above can be a bit cumbersome.
Here are two ways to bind an object in two ways:
Define the parent component searchBar as a complex form component:
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
| <template>
<searchBar v-model="modelValue" />
</template>
<script setup>
import { ref } from "vue"
const modelValue = ref({
keyword: "123",
selectValue: "",
options: [
{
label: "all",
value: ""
},
{
label: "a1",
value: "1"
},
{
label: "a2",
value: "2"
},
]
})
</script>
|
Then, in the searchBar component, we receive modelValue and define the type as Object:
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
| <template>
<div>
<!-- <input type="text" v-model="modelValue.keyword"> can achieve two-way binding -->
<input type="text"
:value="modelValue.keyword"
@input="handleKeywordChange"
>
<select v-model="modelValue.selectValue">
<option v-for="o in modelValue.options" :key="o.value" :value="o.value">
{{ o.label }}
</option>
</select>
</div>
</template>
<script lang="ts" setup>
const props = defineProps({
modelValue: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(["update:modelValue"]);
// For example, with input
const handleKeywordChange=(val)=>{
emit("update:modelValue",{
...props.modelValue,
keyword:val.target.value
})
}
</script>
|
If an object is passed in, as explained in the comments, <input type="text" v-model="modelValue.keyword">
. Although this can directly perform bidirectional binding, it will disrupt the one-way data flow.
It’s the same as the emit trigger event above, but the passed data has become an object.
Despite emit allowing bidirectional binding, it is too cumbersome. The following describes a more elegant way of writing, which can be said to be a trick — computed + proxy.
If you use computed binding, you might write this kind of code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| <template>
<input type="text" v-model="model.keyword">
</template>
<script lang="ts" setup>
const model = computed({
get() {
return props.modelValue
},
set(value) {
// console.log(value) // Found no print
emit("update:modelValue", {
...props.modelValue,
keyword: value
})
}
})
<script>
|
But when you input, you will find that the setter is not triggered because computed will do a layer of proxy, the proxy object has not changed.
If you want to trigger the setter, see the following picture:
1
2
3
4
| // It only changes this way
model.value = {
keyword:"asdfad"
}
|
This method cannot trigger the setter, so it cannot bind in both directions, what should we do?
Return a proxy object in the getter!
Return a proxy object in the getter!
Return a proxy object in the getter!
Since the properties of the proxy object are consistent with the properties of the proxied object, we use proxy to wrap the original object.
Then the v-model
is bound to the object after the proxy, if the properties of the proxy object change, it will trigger the set method in the proxy object, at which point we can trigger emit.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| const model = computed({
get() {
return new Proxy(props.modelValue, {
set(obj, name, val) {
emit("update:modelValue", {
...obj,
[name]: val
})
return true
}
})
},
set(value) {
emit("update:modelValue", {
...props.modelValue,
keyword: value
})
}
})
|
Modifiers
We know that v-model
has some built-in modifiers, such as .trim, .number and .lazy.
In some scenarios, we may want the v-model
of a custom component to support custom modifiers.
Let’s create a custom modifier capitalize that automatically converts the first letter of the string value bound by v-model
to uppercase:
1
| <CustomInput v-model.capitalize="txt" />
|
We added the capitalize modifier, which will be automatically passed into modelModifiers prop:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| <template>
<input :value="modelValue" @input="emitValue" />
</template>
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: {
default: () => ({})
}
})
const emitValue = (e) => {
let value = e.target.value;
// use the modifier
if (props.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
emit('update:modelValue', value)
}
</script>
|
Problem Background:
In the basics section, we’ve already explained how to encapsulate a component with a customized v-model
. However, in actual development, using @input
and :value
to bind our values in the subcomponent is somewhat cumbersome. Is there a simpler way?
We often want to directly bind the v-model
to the subcomponent which needs bidirectional binding:
1
2
| <!-- subcomponent -->
<input type="text" v-model="xxx" />
|
Given this, in receiving the pass value from the parent component in the subcomponent, what should xxx bind to? Directly bind it to props.modelValue
?
1
2
| <!-- subcomponent -->
<input type="text" v-model="props.modelValue"/>
|
An error will occur:
1
| ⚠️reactivity.esm-bundler.js:512 Set operation on key "modelValue" failed: target is readonly.
|
This is because props is a readonly value (isReadonly(props) === true
), so we can’t use it directly.
Therefore, we need an intermediate value to bind the v-model
.
Method One: Relay Through watch
We can bind the v-model
to an internal variable and use watch to monitor it and synchronize the data of props.xxx
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| <!-- subcomponent -->
<template>
<input type="text" v-model="proxy" />
</template>
<script setup>
import { ref, watch } from "vue";
const emit = defineEmits();
const props = defineProps({
modelValue: String,
});
const proxy = ref(props.modelValue);
watch(
() => proxy.value,
(v) => emit("update:modelValue", v)
);
</script>
|
Sometimes, we may perform bidirectional binding to an object or array. In this case, we can use watch’s deep option to deeply monitor and synchronize proxy:
1
2
3
4
5
| watch(
() => proxy.value,
(v) => emit("update:modelValue", v),
{deep: true}
);
|
As props.modelValue
may have a default value passed in, we can add the immediate option so that the proxy is assigned the default value as soon as the component is created;
Method Two: Get and Set with computed
You can also use the get and set provided by computed to synchronize data:
1
2
3
4
5
6
7
8
| const proxy = computed({
get() {
return props.modelValue;
},
set(v) {
emit("update:modelValue", v);
},
});
|
Ultimate: Encapsulate v-model Hooks
First, let’s encapsulate the watch method as a hook:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| <!-- subcomponent -->
<template>
<input type="text" v-model="proxy" />
</template>
<script setup>
import { ref, watch, computed } from "vue";
const emit = defineEmits();
const props = defineProps({
modelValue: String,
});
const proxy = ref(props.modelValue);
watch(
() => proxy.value,
(v) => emit("update:modelValue", v)
);
</script>
|
In the subcomponent, we bind an internal value proxy to the input via v-model
and initialize the proxy variable with the value of props.modelValue
(ref(props.modelValue
);
In watch, we monitor the binding value proxy on the input. When the value changes due to input, we dispatch the emit('update:modelValue', v)
event to pass the changed value to the external component dynamically.
After extracting common logic:
1
2
3
4
5
6
7
8
9
10
| // useVmodel1.js
import { ref, watch } from "vue";
export function useVmodel(props, emit) {
const proxy = ref(props.modelValue);
watch(
() => proxy.value,
(v) => emit("update:modelValue", v)
);
return proxy;
}
|
The simplest hooks are encapsulated.
1
2
3
4
5
6
7
8
9
10
11
12
| <template>
<input type="text" v-model="proxy" />
</template>
<script setup>
import { ref, watch, computed } from "vue";
import { useVmodel } from "./hooks/useVmodel1";
const emit = defineEmits();
const props = defineProps({
modelValue: String,
});
const proxy = useVmodel(props, emit);
</script>
|
Continue the encapsulation
Considering the following points, continue the encapsulation:
emit can not be passed, for a simpler call syntax.
Events such as multiple v-model:test1
, emit("update:xxxx")
need to extract the event name xxxx.
Through the getCurrentInstance
method provided by vue3, we can get the current component instance. As modelValue can be overridden, extract it into a variable:
1
2
3
4
5
6
7
8
9
10
11
12
13
| //useVmodel2.js
import { ref, watch, getCurrentInstance } from "vue";
export function useVmodel(props, key = "modelValue", emit) {
const vm = getCurrentInstance();
const _emit = emit || vm?.emit;
const event = `update:${key}`;
const proxy = ref(props[key]);
watch(
() => proxy.value,
(v) => _emit(event, v)
);
return proxy;
}
|
Good. Now, we can call our hooks in a simpler way:
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
| <!-- subcomponent childModel -->
<template>
<input type="text" v-model="modelValue" />
<input type="text" v-model="test" />
</template>
<script setup>
import { useVmodel } from "./hooks/useVmodel2";
const emit = defineEmits();
const props = defineProps({
modelValue: String,
test: String,
});
const modelValue = useVmodel(props);
const test = useVmodel(props, "test");
</script>
<!-- parent component -->
<template>
<Model v-model="modelValue" v-model:test="test" />
</template>
<script setup>
import { ref, watch } from "vue";
import Model from "./childModel.vue";
const modelValue = ref("");
const test = ref("");
</script>
|