目的:从另一种视角看vue实现过程,加深理解。
看一个简单的vue实例
<div id="app">
<div>{{price}}</div>
<div>{{price * num}}</div>
<div>{{totalPrice}}</div>
</div>
<script>
var vm = new Vue({
el: '#app',
data: {
price: 10,
num: 2
},
computed: {
totalPrice() {
return this.price * this.num * 0.9;
}
}
})
</script>
不知道基于什么原因,vue能够知道price的值发生变化,并且能够在发现变化时做出如下三件事
-
更新web页面price的值
-
重新计算乘法表达式 price * num,并更新视图
-
再次调用totalPrice函数并更新视图
那么问题来了:
vue是如何直达在price发生变化的时候都要更新那些值,又是怎么跟踪所有的内容呢?
解决问题之前我们先看看js编程通常的运行方式
let price = 10;
let num = 2;
let totla = price * num; // total = 20
price = 20;
console.log(total); // 这里打印出来的是20
打印结果很显然是20,因为我们并没有使用vue
但是在vue中,当price发生变化时total也会跟着更新输出的结果会是:40。
问题又来了:
如果我们要使用通常的方法实现,当price,num 发生变化的时候,计算total的表达式也一起更新运行,该怎么做?
解决方案
首先,我们需要用一个方法来告诉我们的应用,我们运行了什么代码,并将它保存起来,以方便后面摸个时候我们再次调用它。然后我们运行代码,在price 或者 num发生变化时,再次运行存储的代码
let price = 10;
let num = 2;
let total = 0;
let totalPrice = 0;
let target = null;
target = () => { total = price * num }//target 函数是可订阅的,它可以被赋值为其他调用price,num的函数
record(); // 记录我们的target 后面当price或num发送变化时再次调用
// 为了解释 target是可订阅的
// target = () => { totalPrice = price * num *0.9 } // 第二个target
// record(); // 再次记录
target(); // 第一次执行
price = 20;// price值发生变化
replay(); // 重新调用过price的target函数
record记录的解决方案,我们维护一个数组将所有调用price,num 的target函数存储起来,因为我们的target函数是可以订阅的可以是任何调用了price,num的函数,所以我们需要维护一个数组来存储所有函数以便当price,num发生变化的时候所有调用者都可以重新触发函数
let storage = []; // 存储target函数
function record() {
storage.push(target);
}
将target储存起来后,我们就能随时运行它们,我们可以借用一个repaly函数运行我们储存的所有target
function repaly() {
storage.forEach(run => run()); // 遍历所有函数并执行
}
那么现在我们的代码只需要:
console.log(total); // 20
price = 20;
replay();
console.log(total); // 40
全部代码如下:
let price = 10;
let num = 2;
let total = 0;
let target = null;
let storage = [];
function record() {
storage.push(target)
}
function replay() {
storage.forEach(run => run());
}
target = () => { total = price * num };
record();
target();
price = 20;
console.log(total); // 10
replay();
console.log(total); // 40
上面的代码就是js通常实现数据更新的方式,这代码已经帮我们解决了问题但是看起来还是那么简陋,接下来带着我们进行优化
优化1:
我们可以按照需求继续记录n个target,但是更好的一种方式我们可以使用一个对象或者说一个类,让这个对象维护这个target的列表,当需要被重新调用时,这个类就会得到通知
解决方案
我们需要将这些行为封装到一个单独的类当中
- 这个类需要维护一个数组,存储target函数的
- 这个类需要储存target方法
- 这个类在price,num发生变化时能够通知到调用者重新执行target
class Dep { // 管理依赖的类
constructor() {
this.subs = []; // 这个的sub就是storage 存储target用
}
depend() { // 这里的depend就是record
this.subs.push(target);
}
notify() { // 这里的notify就是repaly
this.subs.forEach(sub => sub()); // 重新执行存储的target们
}
}
现在我们的代码就变成了这样了
class Dep {
constructor() {
this.subs = [];
}
depend() {
this.subs.push(target);
}
notify() {
this.subs.forEach(sub => sub());
}
};
let dep = new Dep();
let price = 10;
let num = 2;
let total = 0;
let target = () => { total = price * num };
dep.depend(); // 存储target
target();
console.log(total); //20
price = 20;
console.log(total) // 20;
dep.notify();
console.log(total); // 40;
看上去我们的代码可重用性提高了,后面我们的每一个值(price,num...)都会有一个Dep来监控调用它的函数
优化2
现在我们的代码看起来有一定重用性,但是设置和运行target的方式看起来很不协调,也就是创建匿名函数,以及观察匿名函数更新的行为,如果我们能将他们进行封装后面就不需要调用
target = () => { total = price * num }; dep.depend(); target();
解决方案
我们可以封装一个watcher观察类,这个类需要做以下事情
- 接收一个fn变量,就是将target当做参数传递进来观测它
- 调用dep.depend(), 以订阅者的方式添加我们的target
- 调用target(); 并重置target
class Watcher {
constructor(fn) {
target = fn; // 传递进来的订阅者target
dep.depend(); // 添加到dep依赖类
target(); // 首次执行
target = null; // 重置
}
}
let watcher = new Watcher(() => {total = price * num});
现在我们的代码进一步升华
class Dep {
constructor() {
this.subs = [];
}
depend() {
this.subs.push(target);
}
notify() {
this.subs.forEach(sub => sub());
}
};
let dep = new Dep();
let price = 10;
let num = 2;
let total = 0;
let target = null;
class Watcher {
constructor(fn) {
target = fn;
dep.depend();
target();
target = null;
}
}
let watcher = new Watcher(() => {total = price * num});
console.log(total); //20
price = 20;
console.log(total) // 20;
dep.notify();
console.log(total); // 40;
优化3
我们之前有了一个dep类,但是实际上我们需要每一个变量都有一个自己的dep,也就是price一个,num一个;
假设我们已经实现了每一个变量一个dep,现在当我们运行:
new Watcher(() => {total = price * num});
因为访问到了price的值,所以我希望price的 Dep 类要将我们的匿名函数(存储在target中)放到它的订阅数组中(通过调用dep.depend())。因为num也被访问到了,所以我希望num的 Dep 类要将该匿名函数(存储在target中)放到它的订阅数组中;当然如果我们还有其他匿名函数只调用了 num 时,我们希望它放在num 的dep中。
那么我们该在什么时候为price的订阅者调用dep.notify();那肯定是price赋值、重新赋值的时候。
解决方案
我们需要学习一下es5 js提供的Object.defineProperty()函数,它允许我们为对象属性定义getter和setter函数,
它只对对象起作用,所以我们需要将之前的变量改成一个对象 data = { price: 10, num: 2 }
看一下它的用法
let data = {
price: 10,
num: 2
};
Object.defineProperty(data, 'price', {
get() {
console.log('接收到数据')
},
set(newVal) {
console.log('属性改变了')
}
})
total = data.price * data.num // 调用get 打印:接收到数据
data.price = 20; // 调用set 打印:属性改变了
可以看到当我们调用属性时触发get 当属性发生变化时触发set ;
上面我们只检测了price属性,下面我们利用遍历的方法检测所有属性
我们使用Object.keys(data),它能返回对象中所有的属性名key组成的一个数组
let data = {
price: 10,
num: 2
};
Object.keys(data).forEach(key => {
let val = data[key]; // 当前值
Object.definedProperty(data, key, {
enumerable: true,
configurable: false,
get() {
console.log(`${key}:${val}`, '属性调用了');
return val;
},
set(newVal) {
console.log(`${key}:${newVal}`, '属性更新值')
val = newVal;
}
})
})
let total = data.price * data.num;
data.price = 20;
打印:
get price: 10 属性调用了
get num: 2 属性调用了
set price: 20; 属性更新了
结合我们之前的代码
- 在get的时候price的dep将target记住
- 当set的时候执行dep.notify()
依然根据之前的方式封装成一个类
- 传递一个对象,将对象的每一个属性都创建一个dep,并实现上面写的两个功能
class Observer {
constructor(data) {
this.init(data);
}
init(data) {
Object.keys(data).forEach(key => {
let val = data[key]; // 保存当前值
let dep = new Dep; // 为属性创建dep
Object.definedProperty(data, key, {
enumerable: true,
configurable: false,
get() {
dep.depend(); // 记录target
return val;
},
set(newVal) {
val = newVal;
dep.notify(); // 值发生变化重新执行target们
}
})
})
}
}
new Oberver(data);
现在我们的代码变成这样了
let data = {
price: 10,
num: 2
}
let total = 0;
let target = null;
class Dep {
constructor() {
this.subs = [];
}
depend() {
this.subs.push(target);
}
notify() {
this.subs.forEach(sub => sub());
}
};
class Observer {
constructor(data) {
this.init(data);
}
init(data) {
Object.keys(data).forEach(key => {
let val = data[key]; // 保存当前值
let dep = new Dep; // 为属性创建dep
Object.definedProperty(data, key, {
enumerable: true,
configurable: false,
get() {
dep.depend(); // 记录target
return val
},
set(newVal) {
val = newVal;
dep.notify(); // 值发生变化重新执行target们
}
})
})
}
}
new Oberver(data);
class Watcher {
constructor(fn) {
target = fn;
target();
target = null;
}
}
let watcher = new Watcher(() => {total = price * num});
console.log(total); //20
price = 20;
console.log(total); // 40;
讲到这里有些有vue基础的同学应该已经发现上面的代码有点像vue里面的东西了
- 利用Object.definedProperty实现了observer对所有对象属性创建 dep监察
- dep收集依赖depend当数据发生变化时通知执行notify
- watcher数据订阅者管理我们正在运行的代码
接下来我们就模仿vue创建一个模板解析器将数据在视图中展示出来
创建一个解析器的类,这个视图解析器需要做到:
- 获取到使用数据的元素
- 得到watcher里面执行数据的结果,并给视图赋值
- 获取到需要数据的元素,将数据填入元素中需要数据的占位符如vue里面的{{}}
- 这个数据将被watcher监测一旦发生变化数据重新赋值并渲染到元素中即视图
class Compile {
constructor(el) {
// 传入元素如#app
this.el = document.quSelector(el)
// 伪代码
// 将订阅数据的函数或属性传给watcher 并利用回调传出值并给视图赋值
//new Watcher(fn, function(newVal, oldVal) {
//this.el.chlldNode = newVal; // 这里childNode代表el下面某个元素子元素
//})
}
}
通过以上代码我们知道,在视图渲染中我们需要将所有数据订阅的元素创建一个watcher以便获取当前最新值,并且watcher需要将最新数据传递出来所以需要进一步改变watcher
// 要实现数据变化元素重新渲染 需要实现 dep 通知 wather 再通知 compile
// 全局变量
let watcher = null;
class Watcher {
// fn 订阅数据方法 cb 回调函数用于数据发生变化时将新的值传递出去给compile进行渲染
constructor(fn, cb) {
this.cb = cb;
target = fn;
this.getter = target;
this.value = target();
target = null;
watcher = this
}
// 新增update方法当数据发生变化时(target)调用update,
// 所以这个方法要在Dep中的提醒函数中即notify中调用,要实现这个目的就要将此时的watcher储存为全局变量好在notify中调用
update(target) {
let oldVal = this.value;
// let newVal = target();
let newVal = this.getter();
if (oldVal !== newVal) {
this.cb(newVal, oldVal);
}
}
}
// Dep中
class Dep {
constructor() {
this.subs = [];
}
depend() {
this.subs.push(target);
}
notify() {
this.subs.forEach((sub) => {
watcher.update();// 数据更新通知重新渲染 页面渲染
return sub(); // 通知数据订阅 数据变化 total
});
}
};
下面完善一下Compile
class Compile {
constructor(el) {
this.el = document.querySelector(el);
this.init(thie.el);
}
// init函数中要实现找到对应的视图位置将数据填入及用{{}}占位符的元素位置
init(el) {
//1 找到视图中根节点中所有的子元素
let childNodes = el.childNodes;
// 原始数据 data 之前已经在全局定义
let data = data;
// 遍历循环找到对应的占位并赋值订阅
[].slice.call(childNodes).forEach((node) => {
let text = node.textContent;
let reg = /\{\{(.*)\}\}/; // 正则匹配{{ xxx }}
if (reg.test(text)) {
node.textContent = data[RegExp.$1]; // 赋值 将data中的xxx属性值赋值
let watcher = new Watcher(() => { // 监测
return data.total = data.price * data.quantity; // fn
}, function(newVal, oldVal) {
node.textContent = newVal; // 若有值变化重新赋值
});
}
// 当然上面只是查找到根节点第一层子节点 子节点必然是多个嵌套的所以使用递归调用函数
if (node.childNOdes && node.childNodes.length) {
this.init(node);
}
})
}
}
这样一个简单的模板渲染器已经完成
接下来我们模仿vue将我们的observer watcher compile整合起来,让我们使用起来更方便 vue里面定义了一个Vue类来整合所有的东西;我们这里也简单定义一个MVVM的类来实现
这个类需要实现的是将数据data传进来然后利用observer订阅 compile得到data的值并渲染监察watcher
class MVVM {
constructor(options) {
// options 为传进来的参数目前很显然我们需要把数据data传进来以及视图元素el
this.$options = options;
var data = options.data;
observer(data); // 订阅数据
data.price = 40; // 模拟数据发生变化是否生效
new Compile(options.el, this); // 视图模板渲染
}
}
// 使用MVVM
let vm = new MVVM({
el: '#app',
data: {
price: 10,
quantity: 2,
total: 0
}
})
这样我们实现了一个简单的MVVM模式,希望有助于我们对Vue的学习,整合代码如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app">
<p>{{total}}</p>
</div>
</body>
</html>
<script>
class Dep {
constructor() {
this.subs = [];
}
depend() {
if (target && !this.subs.includes(target)) {
this.subs.push(target);
}
}
notify() {
this.subs.forEach((sub) => {
watcher.update(sub); // 数据更新通知重新渲染 页面渲染
return sub(); // 通知数据订阅 数据变化 total
});
}
}
let target = null;
class Observer {
constructor(data) {
this.init(data)
}
init(data) {
Object.keys(data).forEach((key) => {
let dep = new Dep();
let internalVal = data[key];
observer(internalVal);
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get() {
if (target) {
dep.depend();
}
return internalVal;
},
set(newVal) {
if (newVal !== internalVal) {
internalVal = newVal;
observer(internalVal);
dep.notify();
}
}
})
})
}
}
function observer(data) {
if (!data || typeof data != 'object') {
return;
}
return new Observer(data);
}
// observer(data);
// 要实现数据变化元素重新渲染 需要实现 dep 通知 wather 再通知 compile
// 全局变量
let watcher = null;
class Watcher {
constructor(fn, cb) {
this.cb = cb;
target = fn;
this.getter = target;
this.value = target();
target = null;
watcher = this;
}
update(target) {
let oldVal = this.value;
// let newVal = target();
let newVal = this.getter();
if (oldVal !== newVal) {
this.cb(newVal, oldVal);
}
}
}
// let watcher = new Watcher(() => {data.total = data.price * data.quantity});
class Compile {
constructor(el, vm) {
this.el = document.querySelector(el);
this.vm = vm;
this.init(this.el);
}
init(el) {
let childNodes = el.childNodes;
let data = this.vm.$options.data;
[].slice.call(childNodes).forEach((node) => {
let text = node.textContent;
let reg = /\{\{(.*)\}\}/;
if (reg.test(text)) {
let watcher = new Watcher(() => {
return data.total = data.price * data.quantity;
}, function(newVal, oldVal) {
node.textContent = newVal;
});
node.textContent = data[RegExp.$1];
}
})
}
}
// new Compile('#app');
class MVVM {
constructor(options) {
this.$options = options;
var data = options.data;
observer(data);
data.price = 40;
new Compile(options.el, this);
}
}
let data = {
price: 10,
quantity: 2,
total: 0
}
let vm = new MVVM({
el: '#app',
data: data
});
vm.$options.data.price = 20;
</script>