自己动手模拟MVVM之四单向数据流的实现

MVVM (Model-View-ViewModel) 是一种用于把数据和UI分离的设计模式。

概念

Model表示应用程序使用的数据,比如一个用户账户信息(名字、头像、电子邮件等)。它并不具有任何行为逻辑,它只是数据,因而它不会对信息进行再次加工,不会影响浏览器展示数据。数据的格式化展示是由View处理的。

View是与用户进行交互的桥梁,它包括了一些数据绑定,事件,和行为,这些都会直接影响Model和ViewModel。

ViewModel充当数据转换器,可以被看作是MVC中的Controller,它主要负责数转换(用一定的业务逻辑),它负责将Model的变化反应到View上,而当View自身有变化时也会同步Model进行改变。你可以把ViewModel看作一个藏在View后面的好帮手,它把View需要的数据暴露给它,并且富于View一定的行为能力。

优点

  1. UI与逻辑的分离。
  2. 写unit测试比较方便,毕竟测ViewModel要比测个种Event方便多了。

清理思路

今天先来完成一个从model => view的单项数据流,即当model改变的时候,view会自动更新。

顺便说一个套路,就是在写一个组件或者库的时候,要有面向接口编程的思路,什么意思? 就是在开始之前,要先考虑的是组件/库怎么被使用(别人怎么去调用),当然如果不用调用就能完成就更好了,手动滑稽。

假设有如下代码,data 里的name、job会和视图中大括号中的name、job一一映射,修改 data 里的值,会直接引起视图中对应数据的变化。

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">{{name}} 是一个 {{job}}</div>
<script>
let mvvm = new MVVM({
el: "#app",
data: {
name: "dhx",
job:"jser"
}
});

setTimeout(function () {
Object.assign(mvvm.$data, {
name:"xingmu",
job:"csser"
});
},2000)
</script>
<body>

该如何通过实践MVVM来实现这种调用方式呢?结合前面预习过的数据劫持和观察者模式:

  • 主题(subject)是什么?
  • 观察者(observer)是什么?
  • 观察者何时订阅主题?
  • 主题何时通知更新?

主题应该是data的name和job属性,观察者是视图里大括号中的name、job;MVVM初始化的时候,去解析el模板里大括号中的name、job的时候订阅主题,当name和job发生改变的时候,通知观察者更新内容。

完整代码和注释

演示地址

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">{{name}} 是一个 {{job}}</div>
<p>PS: 两秒钟后,文字自动变化,你也可以通过控制台使用mvvm.name或者mvvm.job改变变量的值</p>
<script>
// 没有改变
class Subject {
constructor() {
this.id = Symbol();
this.observers = [];
}

addObserver(observer) {
this.observers.push(observer);
}

removeObserver(observer) {
let index = this.observers.indexOf(observer);
if (index > -1) {
this.observers.splice(index, 1);
}
}

notify() {
this.observers.forEach(function (observer) {
observer.update();
});
}
}

// 观察者
class Observe {
constructor(mvvm, key, callback) {
this.mvvm = mvvm;
this.key = key; // 需要观察的属性key
this.callback = callback; // 属性变化时候的回调函数
this.subjects = {}; // 订阅的主题列表
this.value = this.mvvm[this.key]; // 获取当前key的值
}

update() {
// 缓存每一次的新值,用于比较更新
let [oldVal, value] = [this.value, this.mvvm[this.key]];
this.value = value;
this.callback.call(this.mvvm, oldVal, value);
}

subscribeTo(subject) {
// 记录订阅的主题列表,如果已经订阅,则不在添加
if (!this.subjects[subject.id]) {
subject.addObserver(this);
this.subjects[subject.id] = subject;
}
}

}

class Compile {
constructor(mvvm) {
this.mvvm = mvvm;
this.analyze(mvvm.$el);
}

analyze(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
// 如果节点是element,继续递归
Array.from(node.childNodes).map((item) => {
this.analyze(item);
});
} else if (node.nodeType === Node.TEXT_NODE) {
// 如果是文本节点,则去处理文本替换
this.compileText(node);
}
}

compileText(node) {
// 找出文本中所有的{{}}包裹的变量key
let reg = /{{(.+?)}}/g;
let match;
while (match = reg.exec(node.nodeValue)) {
// console.log(match);
let raw = match[0];
let key = match[1].trim();
// 为key设置一个观察者
this.mvvm.currentObserve = new Observe(this.mvvm, key, (oldVal, newVal) => {
// 当接收到属性值变化的通知时,执行更新
node.nodeValue = node.nodeValue.replace(oldVal, newVal);
});
// 替换文本节点中的变量
node.nodeValue = node.nodeValue.replace(raw, this.mvvm[key]);
// 此时观察者订阅已完成,销毁
this.mvvm.destroyCurrentObserve();
}
}
}

// mvvm
class MVVM {
constructor(options) {
this.init(options); // 初始化mvvm
this.dataIntercept(); // 数据劫持
new Compile(this); // 解析el模板
}

init(options) {
this.$data = options.data;
this.$el = document.querySelector(options.el);
this.bindData();

this.currentObserve = null;
}

destroyCurrentObserve() {
this.currentObserve = null;
}

bindData() {
// this代理this.$data里的属性
Object.keys(this.$data).map((key) => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return this.$data[key];
},
set(val) {
this.$data[key] = val;
}
});
});

}

dataIntercept() {
/**
* 代码跟数据劫持相比,多了创建主题、取值的时候订阅主题和设置值的时候发出通知
*/

let fn = (data) => {
if (!data || typeof data !== "object") {
return false;
}
Object.keys(data).map((key, index) => {
let initData = data[key];
// 为每个key创建一个主题
let subject = new Subject();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
set: (val) => {
// console.log(`修改属性${key}值为${val}`);
initData = val;
subject.notify(); // 如果key的值发生了变化,通知所有的观察者
},
get: () => {
if (this.currentObserve) {
// 如果this.currentObserve存在,就在获取属性的时候
// 订阅当前key的变化
this.currentObserve.subscribeTo(subject);
}
// console.log(`获取属性${key}的值${initData}`);
return initData;
}
});
if (typeof initData === "object") {
dataIntercept(initData);
}
});
};
fn(this.$data);
}
}

let mvvm = new MVVM({
el: "#app",
data: {
name: "dhx",
job: "jser"
}
});

setTimeout(function () {
mvvm.name = "xingmu";
mvvm.job = "csser";
}, 2000);
</script>
</body>
</html>

问题总结

在更新文本节点的时候,如果文本节点中存在和初始化变量值一样的内容,会被后面的更新替换掉,这一块需要再考虑考虑,如果你有的好的想法,欢迎交流。