# 探究 Vue 中的 MVVM
Vue 最吸引人的功能就是双向绑定,数据改变会触发视图的改变,视图改变同样也会触发数据的改变。下面我们一起探究这其中的原理吧。
# 监测数据变化
要想实现双向绑定,第一步要解决的问题就是如何知道数据变化了,即数据劫持。JavaScript 中访问器属性带给了我们思路。
# 访问器属性
访问器属性的特点:
- 获取属性的值时调用 get 函数
- 设置属性的值时调用 set 函数
因此无论是对属性的取值还是赋值操作,我们都可以执行自定义的逻辑代码。
# Observe
下面定义一个类用于将对象的属性变为可监测
class Observe {
constructor(data) {
// 判断是否为对象
if (Object.prototype.toString.call(data) !== '[object Object]') {
return;
}
// 遍历 data 中的每个属性,以此转化为访问器属性
for(let key in data) {
this.defineReactive(data, key, data[key]);
}
}
// 变为可监测
defineReactive(data, key, value){
Object.defineProperty(data, key, {
configurable: true, // 可配置
enumerable: true, // 可枚举
get() {
return value;
},
set(newValue) {
// 值改变
if (value !== newValue) {
console.log(`属性 ${key} 改变了`);
value = newValue;
}
}
});
}
}
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
下面我们测试一下:
var data = {
a: 1
}
// 将对象的属性转为可监测
new Observe(data);
data.a = 10;
console.log(data.a);
2
3
4
5
6
7
8
控制台打印:
# 处理属性对象
不难看出上面的 Observe 类只遍历了对象第一层属性,若属性是个对象,那么不能监测这个对象内部的属性。如下:
var data = {
// 属性 a 的值为一个带有属性 b 的对象
a: {
b: 1
}
}
new Observe(data);
// 修改属性 b
data.a.b = 2;
// 修改属性 a
data.a = 2;
2
3
4
5
6
7
8
9
10
11
此时,修改属性 b 的值控制台不打打印任何信息,而修改属性 a ,控制台依然会打印“属性 a 改变了”。
为了能够深层遍历每一个属性,我们采用递归的方式。修改 defineReactive 函数:
defineReactive(data, key, value){
// 处理子属性
new Observe(data[key]);
Object.defineProperty(data, key, {
configurable: true,
enumerable: true,
get() {
return value;
},
set(newValue) {
if (value !== newValue) {
console.log(`属性 ${key} 改变了`);
value = newValue;
}
}
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
再使用上面的测试代码测试一下,控制台打印“属性 b 改变了”。
# 页面编译
上面完成了监测数据变化的功能,下面我们来编写编译指令和模板。我们以下面这个 HTML 为例:
<div id="app">
<input type="text" v-model="person.name" />
<div>姓名:{{ person.name }}</div>
<div>年龄:{{ person.age }}</div>
</div>
<script>
var vm = new Vue({
el: "#app",
data: {
person: {
name: 'randy',
age: '25'
}
}
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Vue 构造函数
先写一个简单的 Vue 构造函数。
class Vue{
constructor(options) {
// 将传入的属性都放到 Vue 实例上,便于获取
this.$options = options;
this.$el = options.el;
this.$data = options.data;
// 将 data 对象的属性设为可监测
new Observe(this.$data);
}
}
2
3
4
5
6
7
8
9
10
11
打印 Vue 实例,可以看到带有 options、el 和 $data 属性,并且 $data 对象中的每个属性都变为了可监测。查看页面:
# 编译函数
现在我们需要使用 data 对象中的数据去替换掉 HTML 代码中指令和模板语法,使其页面显示正常的数据。思路:拿到 HTML 元素,逐个替换里面的内容,然后将最终的 HTML 代码重新输出到页面。
- 定义一个编译函数,参数:el 和 vm
- el: Vue 挂载的根元素
- vm: Vue 实例
class Compiler{
constructor(el, vm) {}
}
2
3
- 获取需要编译的 HTML 元素
注意:el 既可以是 class 选择器,也可以是 HTMLElement 。
class Compiler{
constructor(el, vm) {
this.el = this.isHTMLElement(el)?el:document.querySelector(el);
// 打印
console.log(this.el);
}
// 判断是否为 HTML 元素
isHTMLElement(node) {
return node.nodeType === 1;
}
}
2
3
4
5
6
7
8
9
10
11
12
- Vue 构造器增加编译功能
export default class Vue{
constructor(options) {
// 省略之前的代码...
new Compiler(this.$el, this);
}
}
2
3
4
5
6
7
控制台会打印根元素。
处理 v-model 指令
- 增加 complie 方法,传入父元素和 Vue 实例
class Compiler{ // 省略之前的代码... compile(node, vm) {} }
1
2
3
4
5- 遍历子节点,处理元素节点
compile(node, vm) { // 获取子节点,返回值包括: // 文本节点(nodeType:3) // 元素节点(nodeType:1) let childNodes = node.childNodes; childNodes.forEach((childNode) => { // 判断是否为元素节点 if(this.isElementNode(childNode)) { this.compileElement(childNode, vm); } }); }
1
2
3
4
5
6
7
8
9
10
11
12我们把
childNodes
打印出来看看:childNodes
是一个类数组,可以看到父元素中一共有7个子节点,其中3个是元素节点:input、div、div,另外4个是文本节点,也就是我们在代码中换行符(见图中红框)。我们只需要处理元素节点,因此增加一个 isElementNode 函数去判断是否为元素节点。
class Compiler{ // 省略之前的代码... isElementNode(node) { return node.nodeType === 1; } }
1
2
3
4
5
6
7
8- 增加 compileElement 函数,处理带有 v-model 的元素节点
class Compiler{ // 省略之前的代码... compileElement(node, vm) { // 1. 获取元素节点的所有属性 let attrs = node.attributes; [...attrs].forEach((attr) => { let {name, value} = attr; // 2. 判断属性中是否带有 v-model if (this.isDirective(name)) {} }); } }
1
2
3
4
5
6
7
8
9
10
11
12
13- 增加 isDirective 函数,判断属性是否为指令
class Compiler{ // 省略之前的代码... isDirective(attrName) { return attrName.startsWith("v-"); } }
1
2
3
4
5
6
7- 根据表达式取值并赋值给元素节点
compileElement(node, vm) { let attrs = node.attributes; [...attrs].forEach((attr) => { let {name, value} = attr; if (this.isDirective(name)) { // 此时 value 为 person.name // 4. 从 data 对象中获取值 let val = value.split('.').reduce((data, current) => { return data[current]; }, vm.$data); // 5. 给元素节点重新赋值 node.value = val; } }); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15再看页面,输入框中以及填入“randy”
处理模板语法
回看 compile 函数,不难发现当前我们只能遍历到根节点的子节点,对于像
<div>{{ person.name }}</div>
这种元素,我们需要获取到其子元素,才能处理模板语法。因此修改 compile 函数:
compile(node, vm) { let childNodes = node.childNodes; childNodes.forEach((childNode) => { if(this.isElementNode(childNode)) { this.compileElement(childNode, vm); // 递归 compile 函数,处理元素含有子节点 this.compile(childNode, vm); } else { // 处理文本节点 this.compileText(childNode, vm); } }); }
1
2
3
4
5
6
7
8
9
10
11
12
13- 增加 compileText 函数
compileText(node, vm) { // 获取文本节点中的内容 let content = node.textContent; // 判断是否为模板语法 if(/\{\{(.+?)\}\}/.test(content)) { // 替换后的内容 let newContent = content.replace(/\{\{(.+?)\}\}/g, (...args) => { let expr = args[1].trim(); return expr.split('.').reduce((data, current) => { return data[current]; }, vm.$data); }); // 设置文本节点的内容 node.textContent = newContent; } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16首先我们利用正则表达式
/\{\{(.+?)\}\}/
去匹配模板语法,然后使用 replace 函数去获取{{}}
中的内容。这里需要注意一点:replace 中的正则表达式比上面的正则表达式在末尾多了一个 g,也就是 replace 中使用的全局搜索。为什么要使用全局搜索呢?下面我们先来试试去掉 g 的效果: 此时是能正常匹配的。但是我们实际写代码的时候,可能会这样写<div>{{ obj.a }} {{ ob.b }}</div>
在一个 HTML 元素使用多个模板语法,再使用上面的正则匹配就会有问题: 使用全局搜索 下面我们再看看 replace 函数中指定的参数函数,该用法可以参考 replace 中指定一个函数作为参数 (opens new window)。这个函数中
args[1]
就是中的表达式,接着根据这个表达式我们去 data 中取值然后返回,最后将替换后的字符串重新作为文本节点的内容。看看页面:
至此我们完成了 Vue 中页面编译的功能。
# 处理回流和重绘
虽然上面完成了基本功能,都是存在一个问题:每次去处理 HTML 元素上的指令和模板语法的时候都会引起浏览器回流和重绘,这就太影响性能了。所以,我们可以先将 HTML 元素保存起来,等处理完所有的指令和模板语法之后再一次性去渲染页面。我们先暂时使用 DocumentFragment 来实现这个功能。
class Compiler {
constructor(el, vm) {
this.el = this.isElementNode(el)?el:document.querySelector(el);
// 将 HTML 节点存入到 DocumentFragment 文档片段中
let fragment = this.nodeToFragment(this.el);
// 生成最终的 HTML
this.compile(fragment, vm);
// 插入到父元素中
this.el.appendChild(fragment);
}
nodeToFragment(node) {
let fragment = document.createDocumentFragment();
let childNode;
while(childNode = node.firstChild) {
fragment.appendChild(childNode);
}
return fragment;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 增加一个工具模块
目前我们的编译逻辑只是针对于 v-model 和模板语法,但如果要扩展新的功能那么我们的 compileElement 中的逻辑就会被变得很臃肿,因此提供一个工具类去处理各个指令的逻辑,这样就便于扩展了。
let CompileUtil = {
// 取值
getValue(expr, vm) {
return expr.split(".").reduce((data, key) => {
return data[key]
}, vm.$data);
},
// v-model 赋值
model(node, expr, vm) {
node.value = this.getValue(expr, vm);
},
// 模板语法
text(node, expr, vm) {
let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
return this.getValue(args[1].trim(), vm);
});
node.textContent = content;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
修改 compileElement 和 compileText
// 编译元素节点
compileElement(node, vm) {
// 获取元素所有属性
let attrs = node.attributes;
// 将类数组转为数组
[...attrs].forEach((attr) => {
let {name, value} = attr;
// 判断是否带有 v-* 指令
if(this.isDirective(name)) {
let [,directive] = name.split("-");
CompileUtil[directive](node, value, vm);
}
});
}
compileText(node, vm) {
let content = node.textContent;
// 判断是否为模板语法
if(/\{\{(.+?)\}\}/g.test(content)) {
CompileUtil['text'](node, content, vm);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 双向绑定
以上我们完成了数据监测和页面编译,现在我们需要实现:1、修改 data 数据后页面跟着变化;2、在输入框输入数据后 data 数据也跟着变化。在 Vue 中实现双向绑定采用的是发布-订阅模式。
# Watcher
Watcher 是一个订阅者,订阅的是数据变化,当知道数据发生变化后,那 Watcher 就去更新视图。这也是观察者模式。
class Watcher{
constructor(vm, expr, callback) {
this.vm = vm;
this.expr = expr;
this.callback = callback;
}
}
2
3
4
5
6
7
接受三个参数:
- vm : Vue 实例
- expr : 取值表达式,例如:
person.name
、person.age
- callback : 回调函数,即得知数据变化后,要做什么
# 增加一个 update 函数
比较数据旧值和数据改变后的新值,若两者不相等,那么执行 callback 函数
class Watcher{
constructor(vm, expr, callback) {
this.vm = vm;
this.expr = expr;
this.callback = callback;
}
}
update() {
let value = CompileUtil.getValue(this.expr, this.vm);
if(this.oldValue != value) {
this.callback(value);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 增加一个 get 函数
获取旧值
class Watcher{
constructor(vm, expr, callback) {
this.vm = vm;
this.expr = expr;
this.callback = callback;
this.oldValue = this.get();
}
}
get() {
let value = CompileUtil.getValue(this.expr, this.vm);
return value;
}
update() {
let value = CompileUtil.getValue(this.expr, this.vm);
if(this.oldValue != value) {
this.callback(value);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 创建实例
最好的订阅时机就是在页面编译的时候,因为此时的 data 对象未发生任何改变。
let CompileUtil = {
model(node, expr, vm) {
let fn = this.updater['modelUpdate'];
// 创建一个观察者
new Watcher(vm, expr, (newValue) => {
fn(node, newValue);
});
let value = this.getValue(expr, vm);
fn(node, value);
},
text(node, expr, vm) {
let fn = this.updater['textUpdate'];
let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
// 创建一个观察者
new Watcher(vm, args[1].trim(), () => {
fn(node, this.getContentValue(expr, vm));
});
return this.getValue(args[1].trim(), vm);
});
fn(node, content);
},
getContentValue(expr, vm) {
return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
return this.getValue(args[1].trim(), vm);
});
},
updater: {
// model 赋值
modelUpdate(node, value) {
node.value = value;
},
// text 赋值
textUpdate(node, value) {
node.textContent = value;
}
}
}
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
# Dep 函数
当前我们有了数据变化的订阅者,还需要一个汇总这些订阅者的容器。当发布数据变化的时候,这个容器会调用这些订阅者的更新操作以便更新视图。
class Dep{
constructor(){
// 订阅者数组
this.subs = [];
}
// 收集订阅者
addSub(watcher) {
this.subs.push(watcher);
}
// 发布数据变化
notify(){
// 调用每一个订阅者的更新
this.subs.forEach((watcher) => watcher.update());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 收集订阅者
有了装订阅者的容器,那么我们就该考虑在什么时候去收集这些订阅者了。回到 Watcher 类,当我们实例化的时候 new Watcher()
,构造函数中会执行 this.oldValue = this.get()
,注意这是一个取值操作,而现在 data 对象的所有属性都是访问器属性,即每次取值都会调用属性对应的 get 方法,因此在 get 方法中我们去收集订阅者。
defineReactive(data, key, value){
// 处理子属性
new Observe(data[key]);
Object.defineProperty(data, key, {
configurable: true,
enumerable: true,
get() {
let dep = new Dep();
dep.addSub(???);
return value;
},
set(newValue) {
if (value !== newValue) {
value = newValue;
}
}
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
因为 addSub 方法接受一个 watcher 参数,而 get 中如何去拿到相应的 watche 呢?看下面的解决方法(我是想不出来):
export default class Watcher{
constructor(vm, expr, callback) {
// 省略代码...
}
get() {
// 给 Dep 类定义一个 target 属性,然后指向当前 watcher
Dep.target = this;
let value = CompileUtil.getValue(this.expr, this.vm);
// 重置
Dep.target = null;
return value;
}
update() {
// 省略代码...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
对应 get 的修改:
get() {
let dep = new Dep();
if (Dep.target) {
dep.addSub(Dep.target);
}
return value;
}
2
3
4
5
6
7
因为 JavaScript 是单线程的,因此 get 每次都能正确收集到本属性对应的观察者。
# 发布数据变化
根据上面的思路,我们现在只需要在 set 函数中去发布数据变化的通知即可。
defineReactive(data, key, value){
// 处理子属性
new Observe(data[key]);
let dep = new Dep();
Object.defineProperty(data, key, {
configurable: true,
enumerable: true,
get() {
if (Dep.target) {
dep.addSub(Dep.target);
}
return value;
},
set(newValue) {
if (value !== newValue) {
value = newValue;
dep.notify();
}
}
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
我们要 let dep = new Dep()
放到外部去,不然 *set 是无法获取 dep 实例对象。查看页面,并且修改 data 数据:
# 处理输入框
目前可以通过改变数据触发页面的改变了,不过在输入框输入后数据并没有发生改变,即视图不能驱动数据的改变。下面让我们来完成这个功能把。
# 添加输入事件
我们需要给 input 输入框添加一个输入事件,然后在事件回调函数中去修改 data 对象对应的属性值。
let CompileUtil = {
// 省略上方代码...
model(node, expr, vm) {
let fn = this.updater['modelUpdate'];
// 创建一个观察者
new Watcher(vm, expr, (newValue) => {
fn(node, newValue);
});
// 增加输入事件
node.addEventListener('input', (e) => {
var value = e.target.value;
// 改变属性的值
this.setValue(expr, vm, value);
});
let value = this.getValue(expr, vm);
fn(node, value);
},
// 省略下方代码....
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 增加赋值方法
let CompileUtil = {
// 赋值
setValue(expr, vm, value) {
expr.split(".").reduce((data, key, index, arr) => {
// 找到对应的属性,然后赋值
if(index === arr.length - 1) {
data[key] = value;
}
return data[key];
}, vm.$data);
},
// 省略下方代码....
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在输入框中输入值:
# 总结
以上内容主要围绕了监测数据(Observe) 、页面编译(Compiler)、双向绑定(Watcher、Dep) 讨论实现一个 MVVM 的响应系统,旨在讲清楚思路,若有错误还望谅解。