破酥 | C4iN
MVVM原理

MVVM原理

MVVM原理

了解vue的底层和实现一个简单的MVVM,这部分主要介绍MVVM的基本概念,以及实现对数组的mvvm。

几个模型

MVC

MVC:所有通信都是单向的。

  • 视图(View):用户界面。
  • 控制器(Controller):业务逻辑
  • 模型(Model):数据保存
img

接受用户指令时,MVC 可以分成两种方式:

  • 通过 View 接受指令,传递给 Controller。
  • 接通过controller接受指令。

Controller 非常薄,只起到路由的作用,而 View 非常厚,业务逻辑都部署在 View。

img

MVP

MVP 模式将 Controller 改名为 Presenter,同时改变了通信方向。

img
  • 各部分之间的通信,都是双向的。
  • View 与 Model 不发生联系,都通过 Presenter 传递。
  • View 非常薄,不部署任何业务逻辑,称为”被动视图”(Passive View),即没有任何主动性,而 Presenter非常厚,所有逻辑都部署在那里。

MVVM

MVVM 模式将 Presenter 改名为 ViewModel,基本上与 MVP 模式完全一致。唯一的区别是,它采用双向绑定(data-binding):View的变动,自动反映在 ViewModel,反之亦然。

img

MVVM

mvvm 的核心是数据劫持(数据代理)、数据编译和”发布订阅模式”。

  • 数据层(Model):应用的数据及业务逻辑
  • 视图层(View):应用的展示效果,各类UI组件
  • 业务逻辑层(ViewModel):框架封装的核心,它负责将数据与视图关联起来

Vue2数据双向绑定真正实现其实靠的也是ES5中提供的Object.defineProperty,而Vue3改为使用Proxy来实现数据劫持。

数据劫持

Object.defineProperty(obj, propertyName, descriptor)用于修改属性描述符对象,作用是直接在一个对象上定义一个新属性,或者修改一个已经存在的属性。具体内容可以参考MDN

  • obj,propertyName:要应用描述符的对象及其属性。
  • descriptor:要应用的属性描述符对象。

按照vue的方法,vue会将所有的data挂载在ViewModel上,进行数据劫持简单说就是为对象增加get和set方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 数据劫持:递归遍历所有对象
* @param {Object} data 劫持对象
*
* */

export function dataProxy(data: { [index: string | number | symbol]: unknown }) {
Object.keys(data).forEach(key => {
let val: unknown = data[key]
if (!val || typeof val !== 'object') { return }
Object.defineProperty(data, key, {
get() {
return val
},
set(v: never) {
if (v === val) return
val = v
}
})
})
}

渲染器

渲染器的作用是把虚拟 DOM 渲染为真实 DOM,当数据变化时,只需要在虚拟DOM中找到对应的VNode,并且只更新其对应的真实DOM节点。组件树中的 vnodes 必须是唯一的。

我们可以根据真实DOM构建VNode,vnode的创建就是简单的赋值操作:

1
2
3
4
5
6
7
8
9
10
11
12
export interface VNode {

type: string | symbol
children: string | Array<VNode>
props: Record<symbol, unknown>
/**
* DOM属性,可以是HTML元素容器,文本节点以及注释,对应其渲染出的真实DOM
*/
el: Container | Text | Comment
// 唯一标识
key: string | number | symbol | undefined
}

看下面的例子:

1
2
3
4
5
6
7
8
<div class="TEST">
<div :class="test">
TEST
</div>
<div @click="handleClick">
Click
</div>
</div>

会生成成如下虚拟DOM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
type: "div",
props: {
class: "TEST",
},
children: [
{
type: "div",
props: {
class: getProps(test), // 这里test是响应式的,需要特殊处理
}
children: "TEST",
},
{
type: "div",
props: {
onClick: handleClick, // 这里click是响应式的,需要特殊处理
}
children: "Click",
},
],


}

接下来把虚拟DOM渲染为真实DOM,也就是实现一个渲染器,下面是实现的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function renderer(vnode: VNode, container: Container) {
const el = document.createElement(vnode.type)

for (const key in vnode) {
/* 添加props到元素的属性、事件中 */
}
if (typeof vnode.children === 'string') {
/* 如果子节点是字符串,则说明是文本节点*/
} else if (Array.isArray(vnode.children)) {
/* 如果子节点是数组,则递归调用renderer,遍历所有子节点 */
vnode.children.forEach(child => renderer(child, el))
} else {
console.log("Wrong Type")
}

// 将元素添加到挂载点下
container.appendChild(el)

}

对于组件,组件就是一组 DOM 元素的封装,我们其实返回的是组件对应的渲染函数:

1
2
3
4
5
6
7
8
9
const Component = function () {
return {
tag: "div",
props: {
class: component,
},
children: "component"
}
}

可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。搞清楚了组件的本质,我们就可以定义用虚拟 DOM 来描述组件 了。很简单,我们可以让虚拟 DOM 对象中的 tag 属性来存储组件函数:

1
2
3
const vnode = {
type: Component
}

渲染器只需要判断type是不是函数就行了:

1
2
if (typeof vnode.type === "string") { mountElement }
else if (typeof vnode.type === "function") { mountComponent }

对于mountElement,与之前的renderer函数基本相同。对于mountComponent

1
2
3
4
5
6
function mountComponent(vnode, container) {
// 调用组件函数获取渲染内容
const component = vnode.type()
// 递归渲染
renderer(component, container)
}

编译器

我们知道,当模板进入mvvm时首先要编译成渲染函数。模板中可能含有:

  • 不带动态绑定的HTML元素
  • 带动态绑定的HTML元素
  • 注释

在Vue中采用带编译时信息的虚拟 DOM,当遇到不带动态绑定的HTML元素时,Vue会进行静态提升,在每次渲染时都使用这份相同的 vnode,渲染器知道新旧 vnode 在这部分是完全相同的,所以会完全跳过对它们的差异比对。

对于单个有动态绑定的元素来说,在为这些元素生成渲染函数时,Vue 在 vnode 创建调用中直接编码了每个元素所需的更新类型(style,class之类)。

在Vue中有一个概念“区块”,指内部结构是稳定的一个部分,v-ifv-for 指令会创建新的区块节点。每一个块都会追踪其所有带更新类型标记的后代节点 (不只是直接子节点),编译的结果会被打平为一个数组,仅包含所有动态的后代节点,这样减少了我们在虚拟 DOM 协调时需要遍历的节点数量,模板中任何的静态部分都会被高效地略过。

1
2
3
4
5
6
7
8
9
10
11
<div> <!-- root block -->
<div>...</div> <!-- 不会追踪 -->
<div :id="id"></div> <!-- 要追踪 -->
<div> <!-- 不会追踪 -->
<div>{{ bar }}</div> <!-- 要追踪 -->
</div>
</div>

div (block root)
- div 带有 :id 绑定
- div 带有 {{ bar }} 绑定

对于每一个传入实例,编译器需要读取哪些节点是绑定的,创建各个节点对应的vnode,根据DOM结构创建渲染函数,并进行扁平化处理。

参考:

MVC,MVP 和 MVVM 的图示 - 阮一峰的网络日志 (ruanyifeng.com)

mvvm的概念、原理及实现 - 神奇的小胖子 - 博客园 (cnblogs.com)

Author:破酥 | C4iN
Link:https://c4in1.github.io/2024/08/08/MVVM/MVVM原理及简单实现/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可