这是一个理解 Vue
框架思想的项目,简单易懂,可快速理解源码并上手
原因自然是不理解不舒服斯基,咳咳,自然是想要理解 Vue
的框架到底是个什么?那么为什么一定要去理解??用着不好嘛?
用着当然好,于是自然想更好,我们在碰见一个问题时,特别是类似于设计模式的问题时,就很犯难处,怎么样的代码才是好代码?框架的代码一般怎么去组织比较好,然后他们是怎么去实现的?只有理解了核心思想,使用框架时才能更融会贯通
好了,我们现在看看 mvvm
是什么,初次看到这个概念时我以为 mvvm
就是 model
-view
-view
-model
,但实际上它并不是,它是 model
-view
-viewmodel
,但是好像这个结构看起来有点像 MVC 啊?是的,这实际上就是 MVC 的升级版,那么 MVC 模式有什么问题?
- 代码分层上虽然有优势,但是如果逻辑一多,有可能造成大量代码集中在 Controller 层
- Controller 层的代码可复用性差,因为 Controller 不像 View 和 Model 那样能够很容易的抽象出一些东西,从而进行代码复用,而实际上对于不同的业务而言,你的 View 可以一样,你的 Model 也可以一样,但是你的 Controller 也可以一样吗? 以上,我们为什么需要 MVC 呢?为什么我们的 Controller 不可以做得像下面那样简单呢?
- 初始化就初始化相应的 View 和 Model
- 监听 Model 层的事件,将 Model 数据传到 View
- 监听 View 层的事件,将 View 数据传到 Model
也就是说,只要 Model 和 View 的任何一方有数据的变化, Controller 就通知这一方去做相应的更新操作,对的,我们要做的就是将 Controller 的功能尽量的简化,并实现最大程度的代码复用,为了升级,我们得先为这个 Controller 取个新的名字,这个名字就叫做 view-model,用它实现的方法来替代老旧的 Controller 吧
为了改造 Controller ,我们现在引入了一种新的模式: 观察者模式,这个模式的也叫 发布订阅模式,它定义了一种一对多的关系,使的多个观察者能够订阅同一个主题,当这个主题的状态发生变化时,便通知所有的观察者,使得他们能够进行自我更新.现在稍微思考一下,对应到上面我们能得到什么?
我们尝试用代码理解一下看看.假设有一个 View, 它的实现如下
<div id="app">{{name}}</div>
有一个 VM,它里面有个 Model,实现如下
var vm = new Mvvm({
el: '#app',
data: {
name: 'strugglebak'
}
});
上面的例子中,主题应该对应什么?观察者应该对应什么?观察者什么时候才会订阅主题?主题什么时候通知观察者更新呢?
首先,我们知道,根据上面的说明,谁的状态随时发生变化,谁就是主题,我们在 View 中要显示一个 name,那么这个 name 的状态就发生变化了,那么data 中的 name它就是主题!而观察者是谁呢?很显然是 View 模板中的 {{name}}
,因为主题变化了,观察者需要被通知去做自我更新.然后整个流程就是如下描述
在一开始 VM 进行初始化也就是开始解析模板时开始订阅主题,当
data.name
发生改变的时候,再通知观察者更新内容
那我们怎么才能知道 data.name
何时变化呢?我们知道在 DOM 操作中,对 DOM 做了哪些修改还可以通过监听 DOM 事件来解决,但这本身的数据改变了怎么可能知道?要是有这么个监听数据变化的函数的函数就好了
别说,还真有,它的名字叫做 Object.defineProperty
mdn 上的解释大概就是
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性。这个方法执行完成后会返回修改后的这个对象。
它的用法就是这样 Object.defineProperty(obj, prop, descriptor)
- obj: 待处理对象
- prop: 要定义或要修改的属性名
- descriptor: 将要被定义或修改的属性描述符
var obj = {}
obj.name = 'strugglebak'
obj['age'] = 18
Object.defineProperty(obj, 'intro', {
value : 'hello world'
})
console.log(obj) // {name: 'strugglebak', age: 18, intro: 'hello world'}
看!我修改了一个对象的属性!你可能会疑问,阿,这就结束了?没有,接下来才是见证奇迹的时刻
var obj = {}
var age
Object.defineProperty(obj, 'age', {
get: function(){
console.log('get age...')
return age
},
set: function(val){
console.log('set age...')
age = val
}
})
obj.age = 100 // 'set age...'
console.log(obj.age) // 'get age...', 100
看,我竟然通过一个叫 get
和 set
的玩意儿实现了数据的监听! 这不就是我正想要的嘛!但是,这里还是要解释一下这两个是干嘛的
get
和 set
叫存取描述符
- get:一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。该方法返回值被用作属性值。默认为 undefined。
- set:一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。该方法将接受唯一参数,并将该参数的新值分配给该属性。默认为 undefined。
同时,我们在操作一个有属性有内容的对象时还必须设置 configurable
和 enumerable
为 true
,不然在修改对象属性时会失败,因为不设置他们默认都是 false
,即对应属性不可修改和属性不可遍历
好,现在我们可以实现一个简单的数据劫持的函数了,其实就是监听数据的变化
function on(data) {
if (!data || typeof data !== 'object') { return; }
for (let key in data) {
let value = data[key]; // 这里不要用 var
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: ()=> {
console.log(`get value is '${value}'`);
return value;
},
set: (newValue)=> {
console.log(`change value from '${value}' to '${newValue}'`);
value = newValue;
}
});
if (typeof value === 'object') {
on(value); // 递归
}
}
}
提示一下上面的 let value
为何不能用 var value
,用了 var
之后 value
变量会提升到整个作用域范围,它里面就保存着一个变量的地址,等下个变量变化了它就存放的是下个变量的地址,所以当程序结束时它存放的就是最后一个变量的地址,这样就出 bug 了.然后数据怎么劫持的?
let data = {
name: 'strugglebak'
}
console.log(data.name) // get value is 'strugglebak'
data.name = 'kabelggurts' // change value from 'strugglebak' to 'kabelggurts'
我打印这个属性就是在调用 get
方法,我赋值这个属性就是在调用 set
方法,我只要在这两个方法之间做一些逻辑的操作,就实现了数据劫持
接下来我们将实现一个最简单的观察者
// 观察者
function Observer(options) {
let {name} = options;
this.subjects = {};
this.name = name;
}
Observer.prototype.update = function() {
console.log(`'${this.name}' 执行 update 函数`);
}
Observer.prototype.subscribe = function(subject) {
this.subjects[subject.id] = subject;
console.log(`'${this.name}' 订阅了 '${subject.name}'`);
}
这个观察者有两个函数,一个是更新 update
,一个是订阅 subscribe
然后我们再实现一个主题
// 主题
function Subject(name='') {
this.name = name;
this.observers = [];
}
Subject.prototype.addObserver = function(observer) {
this.observers.push(observer);
}
Subject.prototype.removeObserver = function(observer) {
let index = this.observers.indexOf(observer);
if (index >= 0) {
this.observers.splice(index, 1);
}
}
Subject.prototype.emit = function() {
console.log('Subject 通知所有 Observer 执行 update 函数');
this.observers.forEach(observer => {
observer.update();
});
}
这个主题有 3 个函数,它能够添加和删除观察者,当其中最重要的是,它能够通知 (emit
) 其他所有的观察者执行它自己的 update
函数,所以这个主题里面必须要维护一个观察者的数组
使用如下
let subject = new Subject('subject')
let observer = new Observer('observer')
observer.update = function() {
console.log('observer update')
}
observer.subscribe(subject) //观察者订阅主题
subject.emit() // 主题更新
有了以上的知识点,我们现在就可以实现一个简单的单向数据流的框架了.那么什么是单向数据流,就是说我们修改这个 model 的 name
属性时,模板相应的会发生变化
所以我们就需要一个 Mvvm
的对象
function Mvvm(options) {
this.init(options);
on(this.$data); // 监听数据变化
new Compiler(this).compile(); // 编译模板
}
Mvvm.prototype.init = function(options) {
let {el, data} = options;
this.$el = document.querySelector(el);
this.$data = data || {};
}
还是按照我们之前的思路来的不是嘛,接下来就要实现解析模板的对象了,它大概有如下几种方法
function Compiler(vm) {
this.vm = vm;
this.node = this.vm.$el;
}
Compiler.prototype.compile = function() {
this.parse(this.node); // 解析元素中出现的 {{ }}
}
先从简单的开始吧,首先它拿到一个 vm, 然后开始解析,这挺好理解,我们再尝试者解决一下 parse
这个函数
Compiler.prototype.parse = function(node) {
if (node.nodeType === Node.ELEMENT_NODE) { // 这个 node 是个元素节点
node.childNodes.forEach(childNode => {
this.parse(childNode); // 递归解析
});
} else if (node.nodeType === Node.TEXT_NODE) { // 这个 node 是个文本
this.render2Text(node); // 将 node 渲染成文本
}
}
以上的代码也挺好理解(这里只做一些简单的判断避免代码变得复杂),我们的目的是要找出那个文本节点,因为我们知道 DOM 是个树结构,那么自然而然我们这里可以想到递归来寻找,无非就是没有找到就递归,找到了就渲染,好,现在看看它是怎么渲染这个文本节点的
Compiler.prototype.render2Text = function(node) {
let regex = /{{(.+?)}}/g; // 正则,匹配 {{}} 字符串
let match;
while (match = regex.exec(node.nodeValue)) {
let key = match[1].trim(); // "name"
let value = match[0]; // "{{name}}"
node.nodeValue = node.nodeValue.replace(value, this.vm.$data[key]);
}
}
用正则将 text 文本中的 {{name}}
替换成 data
中的属性 name
对应的值,这样一来,基本上一刷新就能渲染上去
但是这样还不够,因为下次数据变化了就没有刷新了,所以我们需要在这个函数里面再添加一个监听函数
...
node.nodeValue = node.nodeValue.replace(value, this.vm.$data[key]);
// 当对应的 data 项的数据再次发生变化时,需要再次渲染模板, 将旧数据替换成新数据
this.listenDataChange({
vm: this.vm,
key: key,
callback: (newValue, oldValue)=> {
node.nodeValue = node.nodeValue.replace(oldValue, newValue); // 传一个回调,将旧数据替换成新数据
},
});
...
这个监听函数是这样实现的
Compiler.prototype.listenDataChange = function(options) {
// 为每个变化的数据添加 observer
new Observer(options);
}
由于传了回调,我们就要修改 Observer 观察者了
function Observer(options) {
let {name, vm, key, callback} = options;
this.subjects = {};
this.name = name;
this.vm = vm;
this.key = key;
this.oldValue = this.getValue();
this.callback = callback;
}
Observer.prototype.update = function() {
console.log(`'${this.name}' 执行 update 函数`);
let oldValue = this.oldValue;
let newValue = this.getValue();
if (oldValue !== newValue) { // 如果数据变化了
this.oldValue = newValue; // 更新 observer 里面保存的 oldValue
this.callback.call(this, newValue, oldValue); // 调用回调更新模板数据
}
}
这个时候问题就来了,现在我们为每个可能变化的数据都绑定了一个观察者,现在我们唯一没有做的事情就是这么多观察者,什么时候该订阅主题呢?我们知道是需要在 on
这个监听数据变化的函数里面订阅,可是问题来了,谁来订阅?难道你需要每个观察者都过来订阅嘛?显然不是,因为这样就有一堆观察者绑定了一堆主题,请注意我们这里的一对多关系,只有变化的主题(this.data.name
)我们才去订阅它.我们这个时候想到,要是哪个数据有变化,这个时候绑定这个数据的观察者就站出来,也就是说目前绑定的这个观察者的优先级最高,它能优先更新这个数据就好了,那么怎么做呢?
于是我们想到可不可以用一个全局的 globleObserver
来做呢?首先我们假定这个全局的,优先级最高的观察者为 null, 当它不为 null 时,就可以订阅了,那么在哪里订阅?当然是在 get
函数中
var globleObserver = null;
function on(data) {
...
get: ()=> {
if (globleObserver) {
globleObserver.subscribe(subject); // 订阅
}
return value;
}
set: (newValue)=> {
console.log(`change value from '${value}' to '${newValue}'`);
value = newValue;
// 值发生改变时通知 observer
subject.emit();
}
}
然后在观察者里添加个 getValue
函数
Observer.prototype.getValue = function() {
globleObserver = this; // 下面的语句在执行时会触发 getter,
// 在 getter return 之前将监听数据的 observer 添加进 subject 数据里面
let value = this.vm.$data[this.key]; // 触发 getter
globleObserver = null; // 将 globleObserver 置 null, 为下个 observer 做准备
// 此时 subject 里面已经有了该 observer
return value;
}
最后修改一下 Observer 对象
function Observer(options) {
let {name, vm, key, callback} = options;
this.subjects = {};
this.name = name;
this.vm = vm;
this.key = key;
this.oldValue = this.getValue();
this.callback = callback;
}
这样就好了,我在一开始 new Observer 对象时就会去保存一个旧值,调用了 getValue
函数,在这个函数中,先将自己的权限设置成最高,然后通过赋值操作触发 getter
,由于此时 globelObserver
不为 null 了, 在 getter
中当前 Observer 就订阅了主题,此时再将这个 globelObserver 置 null 将权限下放,为下个 new Observer 做准备,完美解决以上问题!
上面的是实现了单向的绑定,可是一个 MVVM 是要双向的阿,那么怎么实现呢?这里我们就要学习 Vue 的方法了,也许你已经猜到了,对,就是指令,我们先定义一个 v-model
指令吧, v-model="name"
就表示绑定的是一个变量,这个变量的名字就是 name
我们需要修改 Compiler 的 parse 函数
Compiler.prototype.parse = function(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
this.parseNodeAttribute(node); // 解析指令,属性
node.childNodes.forEach(childNode => {
this.parse(childNode);
});
} else if (node.nodeType === Node.TEXT_NODE) {
this.render2Text(node);
}
}
多了个 parseNodeAttribute
,我们看看它实现了什么
Compiler.prototype.parseNodeAttribute = function(node) {
let attributes = [...node.attributes];
attributes.forEach(attribute=> {
let directive = attribute.name; // "v-model"
if (this.isModelDirective(directive)) {
this.bindModel(attribute, node);
}
});
}
恩就是把属性一个个抽出来解析嘛,很简单阿,再看
Compiler.prototype.isModelDirective = function(directive) {
return ['v-model'].includes(directive);
}
Compiler.prototype.bindModel = function(attribute, node) {
let bindKey = attribute.value; // "name"
/* --- 双向绑定区域 --- */
// 当 input 值发生变化时,对应的 data 项的值也发生变化
node.oninput = (e) => {
this.vm.$data[bindKey] = e.target.value;
}
// 当对应的 data 项的值发生变化时, input 的值也发生变化
node.value = this.vm.$data[bindKey];
// 当对应的 data 项的数据再次发生变化时,需要再次渲染模板, 将旧数据替换成新数据
this.listenDataChange({
vm: this.vm,
key: bindKey,
callback: (newValue) => {
node.value = newValue;
}
});
/* --- 双向绑定区域 --- */
}
关键的地方来了,我们判断完指令后,就要绑定 model,这里需要说明的是,一般我们绑定的数据都是输入的,所以这里的双向绑定是对于表单元素而言的,通过监听 input 事件,我们可以做到更新 data,然后通过 data,自然也可以更新表单里面的值,最后不要忘了,当对应的 data 项的数据再次发生变化时,需要再次渲染模板, 将旧数据替换成新数据
简单,继续在 parseNodeAttribute
函数里面做判断嘛
Compiler.prototype.parseNodeAttribute = function(node) {
...
if (this.isModelDirective(directive)) {
...
} else if (this.isEventDirective(directive)) { // "v-on"
this.bindEventHandle(directive, attribute, node);
}
...
}
Compiler.prototype.isEventDirective = function(directive) {
return directive.indexOf('v-on') === 0;
}
Compiler.prototype.bindEventHandle = function(directive, attribute, node) {
let eventType = directive.substr(5); // "click"
let methodsName = attribute.value; // "clikcMe"
node.addEventListener(eventType, this.vm.$methods[methodsName].bind(this.vm));
}
利用 addEventListener
这个 api 可以轻松做到,最后需要注意的是需要 bind(this.vm)
然后就是在 Mvvm 这个对象的 init
方法里面添加 methods
属性,并且将 $data
中的数据直接代理到当前 vm 对象
Mvvm.prototype.init = function(options) {
let {el, data, methods} = options;
...
this.$methods = methods || {};
// 当访问 vm.name 时相当于访问 vm.$data.name, 这里就需要用到 Object.defineProperty 数据劫持
// 当访问 vm.talk() 时相当于访问 vm.$methods.talk()
onInnerData(this);
}
增加个监听 vm 的函数吧
function onInnerData(vm) {
// 遍历 data 属性
let data = vm.$data;
if (!data || typeof data !== 'object') { return; }
for (let key in data) {
Object.defineProperty(vm, key, {
enumerable: true,
configurable: true,
get: ()=> { return data[key]; },
set: (newValue)=> { data[key] = newValue; }
});
}
// 遍历 methods 属性
let methods = vm.$methods;
if (!methods || typeof methods !== 'object') { return; }
for (let key in methods) {
Object.defineProperty(vm, key, {
enumerable: true,
configurable: true,
get: ()=> { return methods[key].bind(vm); },
set: (newValue)=> {
console.log('set methods...');
}
});
}
}
都是同样的逻辑,这样的话就可以用了,代码链接在这儿,预览链接在这儿
- MVVM 是一种设计模式,用于分离 data 和 ui
- Model 就是 data,一般用来 save data
- View 是对 data 进行处理,比如渲染,比如格式化
- ViewModel 就是个"保姆", Model 变了通知 View, View 变了通知 Model, 所有的行为逻辑都在 ViewModel 中