vue 实现原理(一)

      学习vue也有一段时间了。为了给自己加深记忆,本篇文章将通过从零开始仿写一个vue.js,实现vue中的单向绑定、双向绑定和 computed。因为篇幅较长,所以本篇主要实现单向绑定,下一篇将实现双向绑定和 computed。

Vue.js 中的 MVVM


MVVM

      MVVM就是一种双向数据绑定,它的特点是数据影响视图,视图影响数据。MVVM 由ViewViewModelModel三部分组成。View层代表的是视图、模板,负责将Model(数据模型)转化为 UI 展示出来。Model层代表的是模型、数据。可以在Model层中定义数据修改和操作的业务逻辑。ViewModel 是一个同步 View 和 Model 的对象。在MVVM的架构下,View层和Model层并没有直接联系,而是通过ViewModel层进行交互。ViewModel层通过双向数据绑定将View层和Model层连接起来。使得 View 层和 Model 层的同步工作完全是自动的。因此开发者只需关注业务逻辑,无需手动操作 DOM,复杂的数据状态维护交给 MVVM 统一来管理。MVVM的特点是数据影响视图,视图影响数据。Augular是通过脏值检测去实现双向绑定,Vue靠的是数据劫持和发布订阅模式。下图是Vue.js中**MVVM**的体现我们知道Vue不兼容IE8以下的版本,因为它的核心实现靠的是一个ES5的方法:Object.defineProperty`。


Vue.js中MVVM的体现

Object.defineProperty

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象,即第一个参数 obj

1
Object.defineProperty(obj, prop, descriptor)

obj:必需。目标对象`

prop:必需。需定义或修改的属性的名字

descriptor:必需。目标属性所拥有的特性

1
2
3
4
5
6
Object.defineProperty(obj, "test", {
configurable: true | false, // 目标属性是否可以使用delete删除或是否可以再次修改属性的特性(writable, configurable, enumerable)
enumerable: true | false, //此属性是否可以被枚举(使用for...in或Object.keys())
value: 任意类型的值, //属性对应的值,可以使任意类型的值,默认为undefined
writable: true | false //属性的值是否可以被重写
});

注意:一旦使用 Object.defineProperty 给对象添加属性,如果不设置属性的特性,那么 configurable、enumerable、writable 这些值默认都为 false

属性 getter/setter 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var obj = {};
var val = 3;
Object.defineProperty(obj, "test", {
get: function() {
//当获取值的时候触发的函数
return val;
},
set: function(value) {
//当设置值的时候触发的函数,设置的新值通过参数value拿到
val = value;
}
});
//获取值
console.log(obj.test); //3

//设置值
obj.test = 7;

console.log(obj.test); //7

注意:Object.defineProperty 使用了 getter 或 setter 方法,不允许使用 writable 和 value 这两个属性。get 或 set 不是必须成对出现,任写其一就可以。如果不设置方法,则 get 和 set 的默认值为 undefined

属性 configurable 和 writable 一起使用的情况


configurable:true
writable:true
configurable:true
writable:false
configurable:false
writable:true
configurable:true
writable:true
修改属性的值 ✔️ ✔️
(可通过重设 value 标签修改)
✔️
通过属性赋值修改属性的值 ✔️ ✔️
delete 该属性返回 true ✔️ ✔️
修改 getter/setter 方法 ✔️ ✔️
修改属性标签 ✔️ ✔️

单向绑定

      了解完Object.defineProperty后,我们就可以从零开始先实现 Vue 中的单向绑定。单向绑定是把 Model 绑定到 View,当我们用 JavaScript 代码更新 Model 时,View 就会自动更新。因此,我们不需要进行额外的 DOM 操作,只需要进行 Model 的操作就可以实现视图的联动更新。我将通过数据劫持、数据代理、模板编译和发布订阅四个部分来实现 vue 的单向绑定。一旦数据变化,就去更新页面(只有 data–>DOM,没有 DOM–>data)。若用户在页面上做了更新,就手动收集(双向绑定是自动收集),合并到原有的数据中。

数据劫持 Observer()

1
var app1 = new Vue({ el: '#vue-app', data: { mvvm: 100, a: { a: 1 } }, });

      数据劫持部分将实现可以通过this.data.x的方式获取和修改 data 中的值。大致的思路是先获取 Vue 中的属性并把它挂在到 options 下。然后再获取 options 中的 data 属性。定义一个数据监听器的方法,能够劫持数据对象中的所有属性进行监听。将 data 中的每个对象通过 Object.defineProperty 的方式去定义。需要运用到递归,防止劫持对象中还有对象的问题,利用 getter/setter 的方法去实现。

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
function Vue(options = {}) {
// 将所有属性挂载到$options
this.$options = options;
//this._data
var data = (this._data = this.$options.data);
//观察对象,每个去劫持一下
observer(data);
}
// 主要逻辑
function Observer(data) {
for (item in data) {
let val = data[item];
//递归,劫持对象中还有对象的问题
observer(val);
// 把data属性通过Object.defineProperty的方式 定义属性
Object.defineProperty(data, item, {
//可枚举
enumerable: true,
get() {
return val;
},
set(newVal) {
// 设置的值和以前相同
if (val === newVal) {
return;
} else {
//把最新的值赋给val,当取值的时候取到的就是新的newval
val = newVal;
//定义新值的时候,也需要把再去定义成属性
observer(newVal);
}
}
});
}
}
// 观察对象给对象增加Object.defineProperty
function observer(data) {
//防止溢出
if (typeof data !== "object") return;
return new Observer(data);
}

数据代理

      在数据劫持部分,我们可以通过this.data.x的方式去获取和修改值,但是这种方式并不优雅。在Vue中是直接通过this.x的方式获取的。这就是数据代理部分需要实现的效果。大致的思路就是通过遍历的方式将this.data.x中的值直接挂载到this.x中,用this代理this._data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Vue(options = {}) {
// 将所有属性挂载到$options
this.$options = options;
//this._data
var data = (this._data = this.$options.data);
//观察对象,每个去劫持一下
observer(data);

// 挂载到this上,便于直接通过this.x访问,this代理了this._data
for (let key in data) {
Object.defineProperty(this, key, {
enumerable: true,
get() {
return this._data[key];
},
set(newVal) {
this._data[key] = newVal;
}
});
}
}

编译模板 Compile

      在以上两个部分我们已经成功的获取到 data 中的值,并且挂载到了 this 上。这个部分需要实现的是:对每个元素节点的指令进行扫描和解析,根据指令模板替换数据。大致的实现思路是:创建文档碎片,将el中第一个子元素的内容放到而放入内存中。获取将子元素集合并转成数组,循环获取每个子元素的文本内容。然后将文本内容替换为对应this.x上的值。

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
function Vue(options = {}) {
// 将所有属性挂载到$options
this.$options = options;
//this._data
var data = (this._data = this.$options.data);
//观察对象,每个去劫持一下
observer(data);
// 挂载到this上,便于直接通过this.x访问,this代理了this._data
for (let key in data) {
Object.defineProperty(this, key, {
enumerable: true,
get() {
return this._data[key];
},
set(newVal) {
this._data[key] = newVal;
}
});
}
new Compile(options.el, this);
}
// 模板编译
function Compile(el, vm) {
//开始替换
// el表示替换的范围
vm.$el = document.querySelector(el);
// 创建文档碎片
let fragment = document.createDocumentFragment();
// 将el中的内容移到内存中
//返回文档的首个子节点
while ((child = vm.$el.firstChild)) {
fragment.appendChild(child);
}
replace(fragment);
function replace(fragment) {
// 获取子节点集合并转化成数组,循环每一层
//fragment.childNodes是类数组,需要用Array,from转成数组
Array.from(fragment.childNodes).forEach(node => {
// 获取每个子节点的文本内容
let text = node.textContent;
let reg = /\{\{(.*)\}\}/;
// 判断节点类型是否为文本
if (node.nodeType === 3 && reg.test(text)) {
//与正则表达式匹配的第一个 子匹配(以括号为标志)字符串
console.log(RegExp.$1); //mvvm,a ,a.a
//将类似a.a的字符串变成字符串数组
let arr = RegExp.$1.split(".");
let val = vm;
arr.forEach(key => {
key = key.trim();
val = val[key];
console.log(val);
});
// 替换的逻辑
node.textContent = text.replace(/\{\{(.*)\}\}/, val);
}
// 如果有子节点,需要再执行replace
if (node.hasChildNodes()) {
replace(node);
}
});
}

vm.$el.appendChild(fragment);
}

发布订阅

      上一步已经实现了获取 data 可以首次更新视图,但是当 data 改变的时候,视图就不会更新了。这部分我们将通过发布订阅模式来实现视图与数据的连接。首先,我们先来简单说说发布订阅模式。

发布订阅模式

      发布订阅模式是先订阅再发布。以下的代码就是根据发布订阅模式实现的。可以这么理解:有一些方法可以帮我们订阅一些事件,放在数组中[fn,fn,fn],要发布的时候,把数组依次循环执行。

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
function Dep() {
//事件池
this.subs = [];
}
//规定每个方法中都有一个updata属性
//订阅
Dep.prototype.addSub = function(sub) {
this.subs.push(sub);
};

//依次执行
Dep.prototype.notify = function() {
this.subs.forEach(sub => {
sub.updata();
});
};
//创建Watcher类,通过Watcher创建的实例都有updata方法
function Watcher(fn) {
this.fn = fn;
}

Watcher.prototype.updata = function() {
this.fn();
};
// 监听函数
let watcher = new Watcher(function() {
console.log("发布订阅模式");
});

let dep = new Dep();
dep.addSub(watcher);
dep.notify();

采用发布订阅模式实现视图与数据的连接

      如何实现数据变化的时候,视图也跟着变化呢。以下实现的大致思路就是:当数据变化的时候,需要刷新视图,那么首先要找到编译替换的地方订阅下事件,并把新值传给订阅事件的回调函数。便于 update 的执行的时候将新值更新到视图上。取新值的时候,就会用到 get 方法,所以我们需要设置一个标志位,当有新值的时候将订阅的事件放到订阅队列里。接下来我们就会考虑到什么时候执行订阅好的事件呢?当然是 set 的时候,需要执行下订阅中的函数,去刷新视图。

大致思路:

1.当数据改变的时候,刷新视图,那么就需要找到编译替换的地方 2.找到编译替换的地方后,需要订阅下,当数据变化的时候再执行替换的逻辑

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
function Compile(el, vm) {
vm.$el = document.querySelector(el);
let fragment = document.createDocumentFragment();
while ((child = vm.$el.firstChild)) {
fragment.appendChild(child);
}
replace(fragment);
function replace(fragment) {
Array.from(fragment.childNodes).forEach(node => {
let text = node.textContent;
let reg = /\{\{(.*)\}\}/;
if (node.nodeType === 3 && reg.test(text)) {
let arr = RegExp.$1.split(".");
let val = vm;
arr.forEach(key => {
key = key.trim();
val = val[key];
console.log(val);
});
// 替换的逻辑
//函数里需要取到新值,就需要根据当前的实例、实例的值(比如mvvm)拿到新值,传给回调函数
new Watcher(vm, RegExp.$1, function(newVal) {
//订阅下,当数据变化时再执行替换的逻辑
node.textContent = text.replace(/\{\{(.*)\}\}/, newVal);
});

node.textContent = text.replace(/\{\{(.*)\}\}/, val);
}
// 如果有子节点,需要再执行replace
if (node.hasChildNodes()) {
replace(node);
}
});
}

vm.$el.appendChild(fragment);
}

// 主要逻辑
function Observer(data) {
for (item in data) {
let val = data[item];
//递归,劫持对象中还有对象的问题
observer(val);
//订阅
let dep = new Dep();
// 把data属性通过Object.defineProperty的方式 定义属性
Object.defineProperty(data, item, {
//可枚举
enumerable: true,
get() {
//取到新值,把它加入到订阅队列中。相当于一个监控函数,里面放了[watcher...],然后执行完又将 Dep.target变为null
Dep.target && dep.addSub(Dep.target);
return val;
},
set(newVal) {
// 设置的值和以前相同
if (val === newVal) {
return;
} else {
//把最新的值赋给val,当取值的时候取到的就是新的newval
val = newVal;
//定义新值的时候,也需要把再去定义成属性
observer(newVal);
//让所有的watch.update方法执行
dep.notify();
}
}
});
}
}

// 观察对象给对象增加Object.defineProperty
function observer(data) {
//防止溢出
if (typeof data !== "object") return;
return new Observer(data);
}
//
function Dep() {
//事件池
this.subs = [];
}
//规定每个方法中都有一个updata属性
//订阅
Dep.prototype.addSub = function(sub) {
this.subs.push(sub);
};

//依次执行
Dep.prototype.notify = function() {
this.subs.forEach(sub => {
sub.updata();
});
};
//创建Watcher类,通过Watcher创建的实例都有updata方法
function Watcher(vm, exp, fn) {
//为了能让update拿到三个参数,所以需要将3个参数赋给this
this.fn = fn;
this.vm = vm;
this.exp = exp;
//添加一个不存在的属性作为标志位,判断新值是否取完
Dep.target = this;
let val = vm;
let arr = exp.split(".");
arr.forEach(key => {
key = key.trim();
val = val[key];
});
Dep.target = null;
}

Watcher.prototype.updata = function() {
//执行回调函数的时候,需要传一个新值。
let val = this.vm;
let arr = this.exp.split(".");
arr.forEach(key => {
key = key.trim();
val = val[key];
});
//把最新的值传给回调函数
this.fn(val);
};

Copyright ©2019 guowj All Rights Reserved.

访客数 : | 访问量 :