Vue指南的要点笔记(五)

本篇主要内容是v-model在自定义组件中的要点。

认识v-model在标准表单元素的作用机制

官方指南里说,在原生的表单元素上:

1
<input v-model="searchText">

等价于:

1
2
3
4
<input
v-bind:value="searchText"
v-on:input="searchText = $event.target.value"
>

v-model会把它关联的响应式数据(如searchText),动态地绑定到表单元素的value属性上,然后监听表单元素的input事件:当v-model绑定的响应数据发生变化时,表单元素的value值也会同步变化;当表单元素接受用户的输入时,input事件会触发,input的回调逻辑会把表单元素value最新值同步赋值给v-model绑定的响应式数据。

官方文档里没有更多去介绍v-model的实现原理,真实的v-model机制比上面的例子复杂一些,因为v-model要处理地可不是只有普通的input元素:

v-model 在内部为不同的输入元素使用不同的属性并抛出不同的事件:
text 和 textarea 元素使用 value 属性和 input 事件;
checkbox 和 radio 使用 checked 属性和 change 事件;
select 字段将 value 作为 prop 并将 change 作为事件。

不仅如此,除了标准的表单元素,v-model还要考虑自定义组件下的使用场景。在v-model的核心源码中,能够看到v-model对于标准表单元素区分了普通的input&textarea、checkbox、radio和select这四种情形(当然也包括了对自定义组件的处理,下文会再介绍):

所以这个例子的形式:

1
2
3
4
<input
v-bind:value="searchText"
v-on:input="searchText = $event.target.value"
>

仅仅只是说明了普通input&textarea元素的处理机制。checkbox radio和select是有所区别的,我们从源码里面能够看到它是怎么实现的,比如这是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
29
30
function genCheckboxModel (
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
) {
const number = modifiers && modifiers.number
const valueBinding = getBindingAttr(el, 'value') || 'null'
const trueValueBinding = getBindingAttr(el, 'true-value') || 'true'
const falseValueBinding = getBindingAttr(el, 'false-value') || 'false'
addProp(el, 'checked',
`Array.isArray(${value})` +
`?_i(${value},${valueBinding})>-1` + (
trueValueBinding === 'true'
? `:(${value})`
: `:_q(${value},${trueValueBinding})`
)
)
addHandler(el, 'change',
`var $$a=${value},` +
'$$el=$event.target,' +
`$$c=$$el.checked?(${trueValueBinding}):(${falseValueBinding});` +
'if(Array.isArray($$a)){' +
`var $$v=${number ? '_n(' + valueBinding + ')' : valueBinding},` +
'$$i=_i($$a,$$v);' +
`if($$el.checked){$$i<0&&(${genAssignmentCode(value, '$$a.concat([$$v])')})}` +
`else{$$i>-1&&(${genAssignmentCode(value, '$$a.slice(0,$$i).concat($$a.slice($$i+1))')})}` +
`}else{${genAssignmentCode(value, '$$c')}}`,
null, true
)
}

可以看到:v-model在处理checkbox的时候,是利用checked这个属性以及change事件来完成v-model的功能的; addPropaddHandler这两个函数的作用分别是:前者用来注册属性以及属性赋值的表达式,后者用来注册事件及回调。 checkbox要考虑单选和多选模式,所以从上面的代码可以看到checked这个属性的值以及change事件的回调逻辑,都相对比较复杂。假如已经对v-model在checkbox下的使用特性比较熟悉的话,上面的代码理解起来会容易很多。

radio和select跟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
function genRadioModel (
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
) {
const number = modifiers && modifiers.number
let valueBinding = getBindingAttr(el, 'value') || 'null'
valueBinding = number ? `_n(${valueBinding})` : valueBinding
addProp(el, 'checked', `_q(${value},${valueBinding})`)
addHandler(el, 'change', genAssignmentCode(value, valueBinding), null, true)
}

function genSelect (
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
) {
const number = modifiers && modifiers.number
const selectedVal = `Array.prototype.filter` +
`.call($event.target.options,function(o){return o.selected})` +
`.map(function(o){var val = "_value" in o ? o._value : o.value;` +
`return ${number ? '_n(val)' : 'val'}})`

const assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]'
let code = `var $$selectedVal = ${selectedVal};`
code = `${code} ${genAssignmentCode(value, assignment)}`
addHandler(el, 'change', code, null, true)
}

普通input&textarea元素的实现也能在源码中看到(genDefaultModel),但是代码比其它几个都多,这里就不粘贴出来了,它的核心逻辑与官方指南介绍的形式几乎是一致的:

1
2
3
4
<input
v-bind:value="searchText"
v-on:input="searchText = $event.target.value"
>

官方指南为了简化v-model对普通input元素和textarea元素的作用机制,所以没有把源码里面考虑的逻辑全部都表现出来。

官方指南中v-model在自定义组件下的使用

官方指南中说明,v-model在自定义组件下使用,必须:

  1. 自定义组件定义一个名为value的prop
  2. 在合适的时机内部派发一个input事件,并向外传递value这个prop的最新值。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div id="vue">
<custom-input v-model="searchText"></custom-input>
<p>{{searchText}}</p>
</div>

<script type="text/javascript">
Vue.component('custom-input', {
props: ['value'],
template: `
<input
v-bind:value="value"
v-on:input="$emit('input', $event.target.value)"
>
`
});

let vue = new Vue({
el: '#vue',
data: {
searchText: ''
}
});
</script>

结合v-model在标准表单元素下的实现原理,对于v-model在自定义组件中的使用,我有两个疑问:

  1. 为什么自定义组件使用v-model需要的一定是valueprop和input事件,能不能换为别的?
  2. checkbox radio和select在v-model下的实现逻辑跟input区别很大,如果要实现自定义的checkbox radio和select,也是简单地利用checked或selected属性和change事件就够了吗?

第1个问题

这个问题可以在源码找到答案。在v-model的核心源码中,发现它依赖了另外一个文件来处理自定义组件的使用:

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
export function genComponentModel (
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
): ?boolean {
const { number, trim } = modifiers || {}

const baseValueExpression = '$$v'
let valueExpression = baseValueExpression
if (trim) {
valueExpression =
`(typeof ${baseValueExpression} === 'string'` +
`? ${baseValueExpression}.trim()` +
`: ${baseValueExpression})`
}
if (number) {
valueExpression = `_n(${valueExpression})`
}
const assignment = genAssignmentCode(value, valueExpression)

el.model = {
value: `(${value})`,
expression: JSON.stringify(value),
callback: `function (${baseValueExpression}) {${assignment}}`
}
}

这个genComponentModel,并不像标准表单元素一样直接就包含了addHandleraddProp的逻辑,所以从这两个文件看不出,v-model在自定义组件下是否使用了valueprop和input事件。仅仅能看到的是,它把核心的数据存储到了自定义组件实例的model属性上:

1
2
3
4
5
el.model = {
value: `(${value})`,
expression: JSON.stringify(value),
callback: `function (${baseValueExpression}) {${assignment}}`
}

可以肯定这个el.model一定是某个地方要使用的,所以我直接在vue源码里面搜索model这个关键词,最后发现在createComponent这个创建自定义组件的函数里面,调用了transformModel这个函数,在这个函数里面找到了v-model对于el.model这个数据的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function transformModel (options, data) {
var prop = (options.model && options.model.prop) || 'value';
var event = (options.model && options.model.event) || 'input'
;(data.attrs || (data.attrs = {}))[prop] = data.model.value;
var on = data.on || (data.on = {});
var existing = on[event];
var callback = data.model.callback;
if (isDef(existing)) {
if (
Array.isArray(existing)
? existing.indexOf(callback) === -1
: existing !== callback
) {
on[event] = [callback].concat(existing);
}
} else {
on[event] = callback;
}
}

这个函数很清晰地说明了自定义组件的v-model在默认情况下就是要利用valueprop和input事件来实现的:

1
2
var prop = (options.model && options.model.prop) || 'value';
var event = (options.model && options.model.event) || 'input'

也可以看到valueinput只是一个备选项,如果options.model提供了另外的propevent的话,就会以options.model为准了。 那么这个options.model该如何设置呢?这样就可以了:

1
2
3
4
5
6
7
8
9
10
11
Vue.component('checkbox', {
model: {
event: 'input',
prop: 'checked'
},
data(){
return {
//...
}
}
})

options.model中的options就是Vue实例构造时传递的options。所以,自定义组件使用v-model需要的valueprop和input事件,是可以更改的,在本篇最后的内容里面,会用到这个特性,来实现更加完美的自定义checkbox。

第2个问题

checkbox radio和select在v-model下的实现逻辑跟input区别很大,如果要实现自定义的checkbox radio和select,也是简单地利用checked或selected属性和change事件就够了吗?

要实现自定义的checkbox radio和select,简单地利用checked或selected属性和change事件是不够的。 以checkbox来说,假如简单地这么实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Vue.component('base-checkbox', {
model: {
prop: 'checked',
event: 'change'
},
props: {
checked: Boolean
},
template: `
<input
type="checkbox"
v-bind:checked="checked"
v-on:change="$emit('change', $event.target.checked)"
>
`
})

这是官网的一个实现,我认为是不够的,因为它连标准的checkbox该有的东西都不全,比如说true-valuefalse-value,比如说v-model绑定数组。 要想实现在标准checkbox元素基础之上的自定义checkbox,应该参考v-model对标准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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
Vue.component('checkbox', {
inheritAttrs: false,
model: {
event: 'model',
prop: 'model'
},
props: ['label', 'model', 'value', 'trueValue', 'falseValue'],
data() {
return {
checked: false
};
},
computed: {
inputListeners() {
let events = ['focus', 'click', 'change'];
return Object.fromEntries(events.filter(e => !!this.$listeners[e]).map(e => {
return [e, this.$listeners[e]];
}));
}
},
template: `
<label><input
type="checkbox"
v-bind="$attrs"
:value="value"
:checked="checked"
v-on="inputListeners"
@change="change($event.target)"
>{{label}}</label>
`,
created() {
this.setChecked(this.model);
},
watch: {
model(newVal) {
this.setChecked(newVal);
}
},
methods: {
setChecked(model) {
if (Array.isArray(model)) {
this.checked = model.indexOf(this.value) > -1;
} else {
this.checked = this.isBooleanTrueValue() ? model : model === this.trueValue;
}
},
isBooleanTrueValue() {
return this.trueValue === 'true' || this.trueValue === undefined;
},
change(dom) {
let checked = dom.checked;
if (Array.isArray(this.model)) {
//vue不推荐修改prop的内容,因为model是一个数组prop,即使直接修改也不会有警告
//为了保证代码更符合规范,所以对model进行拷贝之后再进行处理
let copy = [...this.model];
let i = copy.indexOf(this.value);

if (checked) {
i === -1 && copy.push(this.value);
} else {
i > -1 && copy.splice(i, 1);
}
//派发自定义组件的model.event事件
this.$emit('model', copy);
} else {
//派发自定义组件的model.event事件
this.$emit('model', this.isBooleanTrueValue() ? checked : checked ? this.trueValue : this.falseValue);
}
}
}
});

这个自定义的checkbox组件,自定义了v-model需要的propevent,同时还用到了官方指南在深入组件的那个部分才会介绍的内容:inheritAttrs $attrs $listeners参考1参考2),保证非prop的特性以及原生事件能够被组件内部的checkbox元素所继承,这个组件使用起来,会感觉跟使用标准的checkbox元素一模一样,因为我写它时的原则,就是要先保证标准checkbox元素该有的功能,然后再考虑自定义的新功能,这个组件的新功能比较简单,只是加了一个label属性。该组件的使用举例可以点击这个链接查看。

今后自定义表单组件,都可以参考上面自定义checkbox组件的思路来。