Vue.js源码学习 —— v-model

如果您是刚开始准备阅读Vue.js的源码,建议先看一下本系列的Vue.js源码学习 —— 起步,相信会对您后面的阅读有很大帮助。

这篇文章我们主要分析一下v-model的背后都做了什么事情,然后我们再结合之前讲的响应系统总结一下,Vue 是如何来实现 MVVM 架构模式的。

关于指令

到目前为止我们看到项目中有两处是与 指令 有关的,我在文章Vue.js源码学习 —— patch中对此进行了介绍,推荐过去看一下,顺便也可以了解一下指令的这几个钩子函数是如何被触发的。

自定义指令

我们知道 Vue 是允许开发者自定义指令的,通过这种方式提供了一个可以让开发者直接操作真实DOM元素的途径。使用全局 API Vue.directive 注册全局指令,使用directives选项注册局部指令。实现一个指令的关键点就是提供如下几个钩子函数(均为可选的):

  • bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。

  • inserted:被绑定元素插入到父节点时调用(仅保证父节点存在,但不一定已被插入文档)。

  • update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。

  • componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。

  • unbind:只调用一次,指令与元素解绑时调用。

关于指令更多的介绍,以及对这些钩子函数的参数说明,可以查阅Vue官方文档

举个例子来看一下如何自定义一个指令:

1
2
3
4
5
6
7
8
9
10
11
// 注册全局指令 focus
Vue.directive('focus', {
// 当 el 被插入到父节点中时,就会获得焦点
inserted: function(el) {
el.focus()
}
})

new Vue({
el: '#app'
})
1
2
3
<div id="app">
<input v-focus>
</div>

现在可以在任何支持focus()的元素上使用v-focus指令,使得它在打开页面时就获得焦点。

Vue.directive 的实现

在讲选项合并时我们看到过,Vue 有3个资源选项,定义在shared/constants.js中:

1
2
3
4
5
export const ASSET_TYPES = [
'component',
'directive',
'filter'
]

这其中就包括了directive,在实现上这3个资源选项也是放在一起实现的,对于全局API来说它们的实现位于文件core/global-api/assets.js中。我把与directive有关的部分拿出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export function initAssetRegisters (Vue: GlobalAPI) {
/**
* Create asset registration methods.
*/
ASSET_TYPES.forEach(type => {
Vue[type] = function (
id: string,
definition: Function | Object
): Function | Object | void {
if (!definition) {
return this.options[type + 's'][id]
} else {
// ...
if (type === 'directive' && typeof definition === 'function') {
// 如果 Vue.directive 的第2个参数传的是一个函数,则会将该函数作为 bind 和 update 的钩子函数
definition = { bind: definition, update: definition }
}
this.options[type + 's'][id] = definition
return definition
}
}
}

所以我们上面的例子实际上是通过Vue.directiveVue.options['directives']中添了一项,如下:

1
2
3
4
5
Vue.options.directives['focus'] = {
inserted: function(el) {
el.focus()
}
}

而构造器的资源选项最终会被合并到 Vue 实例的原型上,所以所有的 Vue 实例都可以使用构造器的资源选项。

v-model的实现

Vue官方文档中对v-model指令的作用描述如下:

你可以用 v-model 指令在表单 <input><textarea><select> 元素上创建双向数据绑定。它会根据控件类型自动选取正确的方法来更新元素。尽管有些神奇,但 v-model 本质上不过是语法糖。它负责监听用户的输入事件以更新数据,并对一些极端场景进行一些特殊处理。

所以咱们重点来看一下v-model是如何实现双向数据绑定的。

注册v-model为全局指令

在文件platforms/web/runtime/index.js中我们注意到有下面一行代码:

1
extend(Vue.options.directives, platformDirectives)

platformDirectives的值如下:

所以上面这行代码的作用就是给Vue.options.directives拓展了两个属性:modelshow,这其实和我们之前分析的Vue.directive中做的事情一样,所以在这里就注册了全局指令v-modelv-show。同时还看到v-model指令提供了insertedcomponentUpdated两个钩子函数。v-show指令提供了bindupdateunbind钩子函数。

我们先来了解一下当在一个元素加上v-model时,Vue在背后做了什么。

v-model编译后的代码

尽管我们还没有看编译器那部分的代码,但是我们可以将编译后的render函数在控制台中打印出来,一样可以看到v-model实际上会转换成什么。

经过之前的分析我们知道在对组件的 VNode 打补丁之前,一定会先调用组件的render函数来返回组件的 VNode,这部分代码位于文件core/instance/render.js中的Vue.prototype._render方法内:

1
2
3
4
5
Vue.prototype._render = function (): VNode {
// ...
const { render, _parentVnode } = vm.$options
// ...
}

可以在这句之后打个断点,然后在控制台输入render敲回车,就可以看到render函数的内容了。

举一个使用v-model的例子看一下:

1
2
3
<div id="app">
<input v-model="message">
</div>
1
2
3
4
5
6
new Vue({
el: '#app',
data: {
message: 'Learn Vue'
}
})

最后在控制台中看的render函数经过格式化后如下:

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
(function anonymous(
) {
with(this){
return _c('div',
{attrs:{"id":"app"}},
[
_c('input',
{
directives:[
{
name:"model",
rawName:"v-model",
value:(message),
expression:"message"
}
],
domProps:{
"value":(message)
},
on:{
"input":function($event){
if($event.target.composing) // 只有输入完成后才更新 message,比如像中文这样
return;
message = $event.target.value
}
} // on
} // vnodedata
) // input vnode
] // children
)}
})

_c是一个在 Vue 内部使用的vm.$createElement函数,定义在文件core/instance/render.js中的initRender方法内:

1
2
3
4
5
6
7
export function initRender (vm: Component) {
// ...
// args order: tag, data, children, normalizationType, alwaysNormalize
// internal version is used by render functions compiled from templates
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// ...
}

它的第2个参数是data,它是VNodeData类型的。关于VNodeData的说明可以看这篇Vue.js源码学习 —— VNode/createElement。可以看到,v-model经过编译后转换成了input节点的data,这几个字段的含义如下:

  • directives:自定义的指令,这里看到描述的就是v-model指令的信息。

  • domProps:原生DOM的属性,这里是value属性,它的值为this.message

  • on:事件监听器在 on 内,这里需要监听的是input事件,收到input通知后,会将this.message的值设置为$event.target.value

在讲patch的实现过程时我们已经知道,注入给patch的那些模块最终会用到上面这个data中的各项,分别设置给原生的DOM元素。这些模块位于文件夹platforms/web/runtime/modules中,可以看到都是与原生DOM元素相关的内容。了解到这我们可以接着往下看v-model指令是如何做到双向数据绑定的了。

双向数据绑定

双向数据绑定 指的是当Vue实例的data数据改变时,输入控件的值也会跟着改变。当我们操作输入控件改变它的值时,Vue实例的data数据也会跟着一起改变。

domProps中指定输入控件的value属性值等与this.message,就达到了将Vue实例的data数据向输入控件同步的效果。因为domProps中的属性的值最终都会同步给原生DOM元素对应的属性。为了验证这一点,我们可以在文件platforms/web/runtime/modules/dom-props.js中的updateDOMProps函数中打断点看一下,执行过程如下图所示:

on中指定要监听的input事件,最终也会同步给原生DOM元素,实际上是让input控件监听input事件。在input事件的回调中又将控件当前的值赋值给了this.message,这就达到了输入控件向data数据同步的效果。同样为了验证,我们在文件platforms/web/runtime/modules/events.js中的add函数中打断点看一下,执行过程如下图所示:

现在就已经实现了双向数据绑定。那directives的作用是什么呢?

directivespatch的过程中会被文件core/vdom/modules/directives.js处理。首先会将我们在上面看到的v-model指令的两个钩子函数通过normalizeDirectives函数与directives合并,合并后的结果如下:

1
2
3
4
5
6
7
8
{
def: {inserted: ƒ, componentUpdated: ƒ}
expression: "message",
modifiers: {},
name: "model",
rawName: "v-model",
value: "Hello Vue"
}

接着就判断当前是处于组件的 VNode 的哪个阶段,然后去调用对应的钩子函数,这样如果def有这个钩子,那它就会被触发了。所以上面的insertedcomponentUpdated就是这样调用的。这两个钩子函数中做的事情就是对一些极端场景做一些特殊处理,正像官方文档对v-model指令描述的那样。我们主要来看一下例子中的input时会做什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const directive = {
inserted (el, binding, vnode, oldVnode) {
if (vnode.tag === 'select') {
// ...
} else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {
el._vModifiers = binding.modifiers
if (!binding.modifiers.lazy) {
el.addEventListener('compositionstart', onCompositionStart)
el.addEventListener('compositionend', onCompositionEnd)
// Safari < 10.2 & UIWebView doesn't fire compositionend when
// switching focus before confirming composition choice
// this also fixes the issue where some browsers e.g. iOS Chrome
// fires "change" instead of "input" on autocomplete.
el.addEventListener('change', onCompositionEnd)
/* istanbul ignore if */
if (isIE9) {
el.vmodel = true
}
}
}
}
// ...
}

比如我们在输入汉字的时候,文本框中先出现的是打出来的拼音,等按空格键等才算输入完成。compositionstart事件监听的就是这个输入过程开始的时候,compositionend监听的就是输入完成的时候。监听这两个事件的目的是v-model不想在输入的过程中得到更新,而是等输入结束后一次更新值。可以对比一下纯input事件,你会发现输入的过程中控件的value一直在变。MDN 上有一个很好input事件的例子,可以放到浏览器中看一下。

我们看到上面有一行判断,开发者是否指定了.lazy修饰符,如果指定了.lazy修复符就不用下面的处理了,在官方文档中也说了,此时会用change事件,而非input事件。我们接下来就看一下v-model的这几个修饰符是怎样被处理的。

v-model修饰符

.lazy

我们将上面的例子中的v-model加上.lazy修饰符后,再运行你会发现只有在输入完成时(输入框失去焦点或按回车键等)message的值才会得到更新。

1
<input v-model.lazy="message">

此时经过编译后得到的render函数如下:

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
(function anonymous() {
with (this) {
return _c('div', {
attrs: { "id": "app" }
}, [_c('input', {
directives: [{
name: "model",
rawName: "v-model.lazy",
value: (message),
expression: "message",
modifiers: { // 多了该字段
"lazy": true
}
}],
domProps: {
"value": (message)
},
on: {
"change": function($event) { // 变成 change 事件
message = $event.target.value
}
}
})])
}
}
)

可以看到在directives中多了modifiers字段,这样我们在inserted中才能用到它。然后on中的事件监听器变成了change,所以在inserted中判断了lazytrue时就不必处理了。

.number

.number修饰符会将用户输入的值自动转换为数值。

1
<input v-model.number="message" type="number">

经过编译后得到的render函数如下:

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
(function anonymous() {
with (this) {
return _c('div', {
attrs: {
"id": "app"
}
}, [_c('input', {
directives: [{
name: "model",
rawName: "v-model.number",
value: (message),
expression: "message",
modifiers: {
"number": true
}
}],
attrs: {
"type": "number"
},
domProps: {
"value": (message)
},
on: {
"input": function($event) {
if ($event.target.composing)
return;
message = _n($event.target.value)
},
"blur": function($event) {
return $forceUpdate()
}
}
})])
}
}
)

这里用了_n这个函数,这个函数是在renderMixin中加上的:

1
2
3
4
5
6
export function renderMixin (Vue: Class<Component>) {
// install runtime convenience helpers
installRenderHelpers(Vue.prototype)

// ...
}

可以看到这里给 Vue 的原型上加了一些帮助方法,而且都是用下划线加一个字母的形式命名的。因为这些方法主要在模板被编译后的render函数中使用,所以可以减少编译后文件的大小。那么_n表示如下:

1
2
3
4
5
export function installRenderHelpers (target: any) {
// ...
target._n = toNumber
// ...
}

toNumber函数的实现如下:

1
2
3
4
export function toNumber (val: string): number | string {
const n = parseFloat(val)
return isNaN(n) ? val : n
}

所以.number修饰符最终使用的是parseFloat这个JavaScript内置的方法来转换的,在转换失败后还会将原字符串返回。

.trim

.trim修饰符会自动过滤用户输入的收尾空白字符。

1
<input v-model.trim="message">

经过编译后得到的render函数如下:

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
(function anonymous() {
with (this) {
return _c('div', {
attrs: { "id": "app" }
}, [_c('input', {
directives: [{
name: "model",
rawName: "v-model.trim",
value: (message),
expression: "message",
modifiers: {
"trim": true
}
}],
domProps: {
"value": (message)
},
on: {
"input": function($event) {
if ($event.target.composing)
return;
message = $event.target.value.trim()
},
"blur": function($event) {
return $forceUpdate()
}
}
})])
}
}
)

可以看到这里其实是调用了trim()这个JavaScript内置的方法去掉字符串的首尾空格的。

关于v-model我们就先了解这些,接下来我们分析一下 MVVM 框架。

Vue对MVVM架构模式的实现

MVC、MVP、MVVM

最早被提出来用于项目架构的是 MVC 模式,一个在客户端很经典的代码层级划分方式。但随着系统业务变得越来越复杂,MVC模式暴露出了一个问题:Controller层的代码会越来越臃肿,负责的事情越来越多,逐渐变得难以维护。iOS 使用的就是 MVC 分层,做过的同学都知道当项目越来越成熟时,Controller 会变得有多重。曾经怎样写出轻 Controller 一度被讨论的很火热。

MVC 模式的一个变种是 MVP 模式,即 Model-View-Presenter,在 Android 中被广泛应用。在这种模式中 View 向 Presenter 要展示时需要用到的数据,当有动作发生时,也把动作产生的行为逻辑交给 Presenter 去处理。一个 View 可以有多个 Presenter,每个 Presenter 负责处理不同的业务。一个 Presenter 也可以被多个 View 使用。这样达到了 View 和 Presenter 都可以复用的效果。Presenter 会去操作和更新 Model,当 Model有变化时会告知 Presenter。这样 View 和 Model 完全是互相看不见的。在实现上为了达到更好的解耦效果,通常将 View 的行为抽象出一个接口,同时也会将 Presenter 能够提供的功能也抽象出一个接口。

MVVM 模式,即 Model-View-ViewModel,在2005年微软提出的,一开始被用在 WPF 框架中。ViewModel 会负责将 Model 转换成 View 可以直接拿来用的数据,如果 View 需要更多的行为,比如请求接口,也会由 ViewModel 来完成。ViewModel 也会通知 Model 去更新,Model 有变化时也会告知 ViewModel。View 和 Model 之前也是看不见的。

ViewModel 和 Presenter做的事情很像,但是 MVVM 与 MVP 不同的是,ViewModel 和 View 之间有一个隐形的 Binder,能够实现 ViewModel 和 View 之间的自动同步,而不需要手动去调用互相的方法更新对方。ViewModel 中不会持有对 View 的引用,而是 View 直接与 ViewModel 中的属性绑定来接收和发送更新。ViewModel 中的属性是可观察的,当属性发生变化时它的观察者们能够收到通知。

Vue对MVVM模式的实现

MVVM模式在 Vue 中的表现如下:

MVVM模式中的每一部分:

  • Model:简单的普通 JavaScript 对象。

    1
    2
    3
    let data = {
    message: 'Hello Vue'
    }
  • View:我们编写的视图布局。

    1
    2
    3
    4
    <div id="app">
    <input v-model="message">
    <p>Message is: {{ message }}</p>
    </div>
  • ViewModel:在 Vue 中每一个 Vue 实例就是一个 ViewModel。Vue 内部实现了一套响应系统,就是我们在Vue.js源码学习 —— 响应系统的设计中分析的。使用这套响应系统,Vue 实例会将传给它的数据对象转换成响应式的,当数据对象改变时,Vue 实例就会收到通知,然后去更新视图。

    1
    2
    3
    4
    const vm = new Vue({
    el: '#app',
    data: data
    })
  • Binder:在 Vue 中我们可以使用v-model在模板中完成双向绑定,当输入框中的文字更新时,Vue实例中对应的属性message也会得到更新,message改变时输入框中的内容也会跟着变。可见在 Vue 中视图与视图模型之间的同步放在了视图层,通过隐式的方法实现了状态的同步。这其实和最早提出并使用 MVVM 模式的 MPF 框架的设计很像,MPF 框架也是使用一种叫作XAML的标记语言充当了 Binder。

    1
    <input v-model="message">

所以理解好响应系统的设计和模板被编译后的样子,对我们更好地使用 Vue 有极大地帮助。

本文作者:意林
本文链接:http://shinancao.cn/2020/02/11/Vue-Source-Code-13/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!