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-renderModel层发生变化
vm.$data[dataName]的setter-->dep.notify()-->subs[i].update()-->updateComponent-->vm._render-->vdom中的vm.__patch__(vnode, prevVnode)--> 更新DOM补充:
View层发生变化View层将通知ViewModel以更新对应Model中的值。