MVVM 架构模式
对 MVVM 的理解
定义
Model
数据模型,一般是JavaScript
对象,用于存储业务数据。ViewModel
视图模型,如Vue.js
。其中包含视图展示逻辑、数据状态等一系列必要因素。View
视图层,即DOM
树。
注:如在
Vue.js
中,通过DOM listener
监听View
变化来通知ViewModel
更新Model
,Model
通过数据绑定
(Data binding
)来通知ViewModel
操作DOM
。MVVM
框架(Model-View-ViewModel
(wiki))三要素响应式(👉Repo: 演示)
- 响应式原理是为了在
web
应用被渲染的数据发生改变时,自行以最优解对目标节点进行更新。或者视图发生变化时,通知Model
进行数据修改。
- 响应式原理是为了在
模板引擎
模板的语法与普通
HTML
语法相近。相较于
HTML
的静态特性,模板是动态的,模板能够实现逻辑(如v-for
、v-if
)并且能够内嵌 JS 变量。
编译逻辑:模板(字符串) => 转换为 js 代码实现逻辑和变量 => HTML
渲染(
ViewModel
中的视图展示逻辑)ViewModel
中的视图展示逻辑通过render
函数来实现,其中render
函数的核心是vdom
。vdom
借由diff
算法可以在操作DOM
时带来极低的性能消耗(原因:章节 - Virtual DOM)。
传统 JS 库(如
jQuery
)与MVVM
框架的差异最大的区别就是
MVVM
框架实现了 关注点分离。使用传统 JS 框架或原生 JS 开发时:
必须同时顾及业务逻辑实现与
DOM
操作最优解。即数据模型与视图层混杂在一起,形成耦合。即关注点混杂,后期应用拓展常常需要兼顾之前的模块逻辑,违背开放封闭原则。当
web
应用后期拓展到一定复杂度后,其中的复杂的DOM
操作将可能带来巨大的性能消耗。后期的拓展和维护也将要付出昂贵的成本,每一次拓展和维护都要顾及对之前的DOM
树的影响。因为前期不需要搭建额外的视图模型(
ViewModel
,视图层与数据模型之间通信的桥梁),那么在小型简单web
应用开发方面传统 JS 框架或原生 JS 开发仍保持着开发流程简洁的优势。
MVVM
框架通过建立一个视图模型 ViewModel
来解耦数据模型 Model
和视图层 View
:建立视图模型(
ViewModel
)中间层用于数据模型与视图层的通信,使得后期拓展更易遵循开放封闭原则。以数据驱动视图更新,只关心数据模型的变化,DOM 操作被封装。开发人员只需要专注 JS 逻辑的实现即可。并不需要直接接触真实
DOM
,MVVM
框架会自行通过web
应用的数据来驱动真实DOM
的渲染。所有真实DOM
树的更新都是依靠ViewModel
来实施高效的页面渲染和更新。
实现 MVVM
(以 Vue.js
为例)
1. Vue.js 中响应式原理
(👉Repo: 演示)
2. Vue.js 如何解析模板
模板
本质:字符串
内含逻辑语句,如
v-if
、v-for
等语句对比静态的
HTML
,模板是动态的。最终的编译结果是
HTML
。模板必须转换为 JS 代码来实现模板中的逻辑语句和引用的 JS 变量。
- 前端三大语言中只有 JS 具有逻辑实现,即图灵完备语言。
三大语言中只有 JS (
render
函数)能实现将字符串转换为HTML
。
render
函数模板中的所有信息在
render
函数中均有体现- 模板如下:
<div class="app"> <p>{{name}}</p> </div>
1
2
3render
函数体如下:
// with 用于指定 with 代码块中的上一级作用域,在查询当前作用域中未声明变量的声 // 明时,起作用 // this 指向 vue 实例 vm with(this) { // _c 即 vm._c,调用 createElement(),即创建 vnode return _c( 'div', { attrs: {'id': 'app'} }, [ // name 即 vm.name 即 vm._data.name // vm._v 即 createTextVNode(val),创建 文本 vnode // vm._s 即 toString(val),转换变量 name 为字符串 _c('p', [_v(_s(name))]) ] ) }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18注:暂只关心设计理念,模板转换为 JS 代码(
render
函数)的过程属于工具化细节,暂不过多纠结。Vue.js
指令实现<input v-model="msg" @keyup.enter="submit" type="text" id="inputPanel">
1// 拦截 Vue.js 源码中 `code.render` 可以得到当前模板转换后的 render 函数 with (this) { // this 即为 vm return _c( 'input', { directives: [ { name: "model", rawName: "v-model", // v-model 绑定的值 value: (msg), expression: "msg" } ], // HTML 标签字符串的属性 attrs: { "type": "text", "id": "inputPanel" }, // DOM 树对象(由浏览器转换 HTML 字符串而来)自身的属性 domProps: { // DOM 绑定 vm.msg // 即当赋值 vm.msg 时,DOM 会做出相应改变 "value": (msg) }, // 监听事件 on: { "keyup": function ($event) { if (!('button' in $event) && _k($event.keyCode, "enter", 13, $event.key, "Enter")) return null; return submit($event) }, // 由 v-model 指令添加的 input 事件监听 "input": function ($event) { if ($event.target.composing) return; // 当输入事件触发时,设置 vm.msg 的值 msg = $event.target.value } } } )
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
44v-model 实现
v-model
指令本质是一个语法糖,他是监听DOM
树对象属性和监听input
事件的封装。domProps: { // 即 document.querySelector('input').value = msg "value": (msg) } // ... on: { // 此处亦是 v-on 的实现 "input": function ($event) { if ($event.target.composing) return; // 当输入事件触发时,设置 vm.msg 的值 msg = $event.target.value } }
1
2
3
4
5
6
7
8
9
10
11
12v-for
实现v-for
本质是for 循环
得到目标元素的render
函数所构成的数组,该数组可用于父render
函数中。(
v-if
本质是if 语句判断
)模板如下:
<ul> <li v-for="item in items">{{item}}</li> </ul>
1
2
3render
函数如下:_c( 'ul', // vm._l 即 renderList 函数 // 返回一个每项均为 li 的 render 函数的数组 _l((items), function (item) { return _c( 'li', [ _v(_s(item)) ] ) } ) )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
render
函数(vm._c
)逻辑Vue.js
中的render
函数是由snabbdom
演变而来。如他们的传参方式。vm._c
即相当于snabbdom
中的h
函数(API 见章节 - Virtual DOM),最终将创建一个vnode
。/** * 1. src/core/instance/lifecycle.js 定义 * Vue.prototype._update = function () {} * 2. vm._update 用于对比新旧 vnode 的差异 */ vm._update(vnode) { // 在对比新旧 vnode 之前,缓存旧的 vnode const prevVnode = vm._vnode // 缓存新的 vnode,用于下一次对比 vm._vnode = vnode if (!prevVnode) { // 初次渲染,挂载到 vm.$el 上,并渲染出真实 DOM,并形成 DOM 映射 // vm.__patch__ 即如同 snabbdom 中的 patch 函数 vm.$el = vm.__patch__(vm.$el, vnode) } else { // 将新 vnode 对比旧的 vnode 得到差异并根据 DOM 映射进行 DOM 更新 vm.$el = vm.__patch__(prevVnode, vnode) } } /** * 1. src/core/instance/lifecycle.js 定义 * 2. 仅用于 Watcher 实例中 * 结合响应式原理: * vm.$data[someData] 的 setter * --> dep.notify() * --> subs[i].update() * --> updateComponent * --> vm._render */ function updateComponent () { // vm._render() 即 render 函数,即 vm._c,调用后将返回一个新的 vnode vm._update(vm._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
37
3. Vue.js 实现的整体流程
解析模板为 JS 代码实现模板中的逻辑,并构建
render
函数实现
v-if
、v-for
、v-on
等模板中的逻辑语句。构建
render
函数,并得到vdom
,vdom
与将真实DOM
树形成映射。
形成响应式机制
重写
数据对象
(如vm.$data[dataName]
) 的getter
和setter
。getter
添加收集依赖
方法dep.depend()
,使其选择性在依赖收集容器dep.subs
中添加数据依赖Watcher
,以避免不必要的渲染。setter
添加更新依赖
函数dep.notify()
,使其在变化时触发依赖收集更新。- 在
setter
中触发更新依赖函数时,未参与构建DOM
中的数据对象容器是没有数据依赖的(即dep.subs
为空数组),即不会触发后续的一系列更新DOM
的方法,即避免了不必要的重复渲染。
- 在
注:
Vue.js
中数据依赖都是以Watcher
的实例为形式存储的(subs
数组),Watcher
存在一个原型方法update
。update
方法在被调用时,将调用Vue
的原型方法_render
(即render
函数)。在
Vue.js
中将data
选项中的属性代理到Vue
实例下。- 因为在
render
函数中指定了with
作用域都是在Vue
实例下,而不是data
之类的选项中,那么此处的数据代理是必不可少的。
- 因为在
DOM listener
监听View
层的DOM
树对象属性的变化。
首次渲染,且绑定依赖
将
render
函数渲染成真实DOM
。- 执行
render
函数实现初次DOM
渲染。
updateComponent
-->vm._render
-->vm.__patch__(vm.$el, vnode)
--> 生成DOM
树- 缓存当次的
vnode
为vm._vnode
以用于下次vm.__patch__(vnode, prevVnode)
成为prevVnode
对比新的vnode
。
- 执行
绑定依赖
- 在执行
render
函数时,就会访问到数据对象
(因为先前在模板中使用了代表数据对象
的变量,需要将变量转化为DOM
节点),就会触发2
中的响应式机制,即触发getter
函数中的dep.depend()
,那么就会只对 参与构建DOM
树的数据对象
开始收集数据依赖并存储于容器中。
- 在执行
响应式机制的依赖发生变化时将触发
re-render
Model
层发生变化
vm.$data[dataName]
的setter
-->dep.notify()
-->subs[i].update()
-->updateComponent
-->vm._render
-->vdom
中的vm.__patch__(vnode, prevVnode)
--> 更新DOM
补充:
View
层发生变化View
层将通知ViewModel
以更新对应Model
中的值。