欢迎大家访问我的个人网站 - Sunday俱乐部
在前面的章节中我们已经学习了Vue.js
的基础内容并且了解了Vue.js
的源码实现,包括:Vue的生命周期、Vue的数据响应、Vue的渲染流程等等,在这一章节我们会和大家一起去实现一个响应式的框架 -- MVue
,MVue
会遵循Vue
的代码逻辑和实现思路,我们希望能够借助MVue
来让大家更好的理解整个Vue
的核心思想:响应式数据渲染。
在开始我们的MVue
开发之前,我们需要先了解一些必备的知识。首先是Object.defineProperty(obj, prop, descriptor)
,这个方法可以用来定义对象的属性描述符,我们可以点击这里来查看这个方法的详细定义。我们这里主要使用到的是get、set
描述符,我们可以使用get、set
来监听对象的属性setter、getter
的调用事件。我们看一下下面的这段代码:
type="text" id="input-msg">
"output-msg">
复制代码
在上面的代码中我们利用Object.defineProperty
监听了obj.msg
的setter、getter
事件。所有当我们在控制台去调用obj.msg
的时候就会调用console.log('getter');
,当我们调用obj.msg = '123'
的时候就会调用console.log('setter');
,由此我们就成功的监听了obj.msg
的数据变化。那么这有什么意义呢?我们可以利用这个功能来做些什么呢?我们看一下下面的代码:
type="text" id="input-msg">
"output-msg">
复制代码
在上面的代码中,我们通过监听input
的input事件
来去改变obj[key]
的值,使obj[key]
的值始终等于用户输入的值,当obj[key]
的值因为用户的输入而发生了改变的时候,会激活Object.defineProperty
中的setter
事件,然后我们获取到最新的obj[key]
的值并把它赋值给outputMsg
。这样当我们在input
中进行输入的时候,中的值也会跟随我们的输入变化。这种通过
Object.defineProperty
来监听数据变化的方式就是Vue
中数据响应的核心思想。
其次大家需要了解的就是观察者模式,大家可以点击这里来查看观察者模式的详细解释,相信这里会比我解释的更加清楚。
当大家了解完观察者模式之后我们就可以正式开始我们的MVue
的开发工作。
思路整理
整个框架的思路被分成三大块。
首先就是**视图渲染,**我们在html
或者中进行
html
内容编写的时候,往往是这样:
"app">
type="text" v-model='msg'>
{{msg}}
复制代码
其中的v-model='msg'
和 {{msg}}
浏览器是无法解析的,那么我们就需要把 浏览器不认识的内容转化为浏览器可以解析的内容,在Vue
中,Vue
通过**虚拟DOM(VNode
)**描述真实DOM
,然后通过_update
来进行具体渲染。我们这里不去描述这个VNode
直接通过_update
方法来对DOM
进行渲染操作,这个动作是发生在Compile
中。Compile
会解析我们的具体指令,并重新渲染DOM
。
其次是监听我们的数据变化,在最初的例子中我们已经知道我们可以通过Object.defineProperty(obj, prop, descriptor)
来实现数据的监听,那么就需要一个Observer
类来进行数据劫持的工作,这时Observer
承担的就是发布者的工作。当我们通过Observer
来监听到数据变化之后,我们需要通知我们的观察者,但是对于我们的发布者来说,它并不知道谁是这个观察者,这个观察者是一个还是多个?所以这个时候,就需要有一个人来负责去收集这些依赖的工作,这个人就是Dep(Dependency)
,我们通过Dep
来去通知观察者Watcher
,Watcher
订阅Dep
,Dep
持有Watcher
,两者互相依赖形成一个消息中转站。当Watcher
接收到消息,需要更改视图的时候,那么就会发布具体的消息根据具体指令的不同(Directive
)来执行具体的操作Patch
。这就是我们的整个从监听到渲染的过程,如下图:
最后我们需要把所有的东西整合起来形成一个入口函数,输出给用户方便用户进行调用,就好像Vue
中的new Vue({})
操作,这里我们叫它MVue
。
综合以上的内容,我们需要完成的代码内容包括
├── compile.js 渲染DOM,解析指令
├── dep.js 收集依赖
├── directive.js 所有支持到的指令
├── mvue.js 入口函数
├── observer.js 数据劫持
├── patch.js 根据具体的指令来修改渲染的内容
└── watcher.js 观察者。订阅Dep,发布消息
复制代码
我们预期的完成效果应该是这样
"app">
type="text" v-model='msg'>
{{msg}}
复制代码
入口函数
首先我们需要先生成MVue
的入口函数,我们仿照Vue
的写法,创建一个MVue
的类,并获取传入的options
。
function MVue (options) {
this.$options = options;
this._data = options.data || {};
}
MVue.prototype = {
_getVal: function (exp) {
return this._data[exp];
},
_setVal: function (exp, newVal) {
this._data[exp] = newVal;
}
}
复制代码
首先我们实现一个MVue
的构造函数,并为它提供了两个私有的原型方法_getVal
和_setVal
用于获取和设置data
中对应key
的值。这时我们就可以通过下面的代码来创建对应的MVue
实例。
var vm = new MVue({
el: '#app',
data: {
msg: 'hello'
}
});
复制代码
然后我们就可以在MVue
的构造函数之中去进行我们的 视图渲染 和 数据监听 的操作。
视图渲染
然后我们进行我们的视图渲染,我们再来回顾一下我们需要解析的视图结构
"app">
type="text" v-model='msg'>
{{msg}}
复制代码
在这段 {{msg}} hellohtml
之中v-model
和{{msg}}
是我们MVue
中的自定义指令,这些指令我们的浏览器是无法解析的,所以需要我们把这些指令解析为浏览器可以解析的html
代码。以
为例,当我们声明data: {msg: 'hello'}
的时候,应解析为
。
我们的模板解析的操作是通过compile.js
来完成的。
function Compile (vm, el) {
this.$vm = vm;
el = this.$el = this.isElementNode(el) ? el : document.querySelector(el);
if (!el) {
return;
}
this._update(el);
};
Compile.prototype = {
/**
* Vue中使用vm._render先根据真实DOM创建了虚拟DOM,然后在vm._update把虚拟DOM转化为真实DOM并渲染,
* 我们这里没有虚拟DOM,所以直接通过createElm方法创建一个fragment用以渲染
*/
_update: function (el) {
this.$fragment = document.createDocumentFragment();
// 复制el的内容到创建的fragment
this.createElm(el);
// 把解析之后的fragment放入el中,此时fragment中的所有指令已经被解析为具体数据
el.appendChild(this.$fragment);
},
/**
* 创建新的DOM 用来替换 原DOM
*/
createElm: function (node) {
var childNode = node.firstChild;
if (childNode) {
this.$fragment.appendChild(childNode);
this.createElm(node);
}
}
}
复制代码
我们声明了一个Compile
的构造方法,并调用了它的_update
原型函数,在_update
中我们声明了一个fragment
用于承载解析之后的模板内容,通过createElm
的递归调用获取el
中的元素,并把获取出的元素放入fragment
中,最后把fragment
添加到el
里面。至此我们已经成功的获取到了el
中的元素,并把这些元素重新规制。
接下来我们就需要对获取出来的元素进行解析操作,其实就是对v-model
和{{*}}
等指令进行解析,这个解析的时机应该在 **遍历出所有的元素之后,添加fragment
到el
之前。**我们看一下解析DOM
的代码:
Compile.prototype = {
_update: function (el) {
...
// 解析被创建完成的fragment,此时fragment已经拥有了el内所有的元素
this.compileElm();
...
},
...
/**
* 对DOM进行解析
*/
compileElm: function (childNodes) {
var reg = /\{\{(.*)\}\}/;
if (!childNodes) {
childNodes = this.$fragment.childNodes;
}
[].slice.call(childNodes).forEach(node => {
if (node.childNodes.length > 0) {
// 迭代所有的节点
this.compileElm(node.childNodes);
}
// 获取elementNode节点
if (this.isElementNode(node)) {
if (reg.test(node.textContent)) {
// 匹配 {{*}}
this.compileTextNode(node, RegExp.$1);
}
// 匹配elementNode
this.compileElmNode(node);
}
});
},
/**
* 解析elementNode,获取elm的所有属性然后便利,检查属性是否属于已经注册的指令,
* 如果不是我们的自定义指令,那么就不需要去处理它了
* 如果是已注册的指令,我们就交给directive去处理。(演示只有一个v-model)
*/
compileElmNode: function (node) {
var attrs = [].slice.call(node.attributes),
$this = this;
attrs.forEach(function (attr) {
if (!$this.isDirective(attr.nodeName)) {
return;
}
var exp = attr.value;
// 匹配v-model指令
directives.model($this.$vm, node, exp);
// 去掉自定义指令
node.removeAttribute(attr.name);
});
},
/**
* 解析{{*}}
*/
compileTextNode: function (node, exp) {
directives.text(this.$vm, node, exp);
},
/**
* 判断是否是已注册的指令,这里就判断是否包含 v-
*/
isDirective: function (attrNodeName) {
return attrNodeName.indexOf('v-') === 0;
},
/**
* 判断elmNode节点
*/
isElementNode: function (node) {
return node.nodeType === 1;
}
}
复制代码
由上面的代码可以看出,解析的操作主要在compileElm
方法中进行,这个方法首先获取到fragment
的childNodes
,然后对childNodes
进行了forEach
操作,如果其中的node
还有子节点的话,则会再次调用compileElm
方法,然后解析这个node
,如果是一个ElementNode
节点,则再去判断是否为{{*}}
双大括号结构,如果是则会执行compileTextNode
来解析{{*}}
,然后通过compileElmNode
来解析ElmNode
中的指令。
compileTextNode
中的实现比较简单,主要是调用了directives.text(vm, node, exp)
进行解析,这里我们稍后再看,我们先主要来看下compileElmNode
做了什么。
compileElmNode
首先把node
中所有的属性转成了数组并拷贝给了attrs
,然后对attrs
进行遍历获取其中的指令
,因为我们目前只有一个v-model
指令,所以我们不需要在对指令进行判断,可以直接调用directives.model(vm, node, exp)
来进行v-model
的指令解析,最后在DOM
中删除我们的自定义指令。
至此我们就复制了el
的所有元素,并根据不同的指令把它们交由directives
中对应的指令解析方法进行解析,这就是我们compile.js
中所做的所有事情。接下来我们看一下directives
是如何进行指令解析操作的,代码如下:
// directives.js
/**
* 指令集和
*
* v-model
*/
var directives = {
/**
* 链接patch方法,将指令转化为真实的数据并展示
*/
_link: function (vm, node, exp, dir) {
var patchFn = patch(vm, node, exp, dir);
patchFn && patchFn(node, vm._getVal(exp));
},
/**
* v-model事件处理,这里的v-model只针对了type='text'>
*/
model: function (vm, node, exp) {
this._link(vm, node, exp, 'model');
var val = vm._getVal(exp);
node.addEventListener('input', function (e) {
var newVal = e.target.value;
if (newVal === val) return;
vm._setVal(exp,newVal);
val = newVal;
});
},
/**
* {{}}事件处理
*/
text: function (vm, node, exp) {
this._link(vm, node, exp, 'text');
}
}
复制代码
由上面的代码我们可以看出,我们首先定义了一个directives
变量,它包含了_link、model、text
三个指令方法,其中_link
为私有方法,model、text
为公开的指令方法,关于_link
我们最后在分析,我们先来看一下model
。
model
指令方法对应的为v-model
指令,它接受三个参数,vm
为我们的MVue
实例,node
为绑定该指令的对应节点,exp
为绑定数据的key。我们先不去管this._link
的调用,大家先来想一下我们在index.html
中对于v-model
的使用,我们把v-model='msg'
绑定到了我们的input
标签上,意为当我们在input
上进行输入的时候msg
始终等于我们输入的值。那么我们在model
指令方法中所要做的事情就很明确了,首先我们通过vm._getVal(exp);
获取到msg
当前值,然后我们监听了node
的input
事件,获取当前用户输入的最新值,然后通过vm._setVal(exp,newVal)
配置到vm._data
中,最后通过val = newVal
重新设置val
的值。
然后是text
指令方法,这个方法直接调用了this._link
,并且我们还记得在model
指令方法中也调用了this._link
,那么我们来看一下_link
的实现。
在_link
中,他接收四个参数,其中dir
为我们的指令代码,然后它调用了一个patch
方法,获取到了一个patchFn
的变量,这个patch
方法位于patch.js
中。
// patch.js
/**
* 更改node value,在编译之前,替换 v-model {{*}} 为真实数据
* @param {*} vm
* @param {*} node
* @param {*} exp
* @param {*} dir
*/
function patch (vm, node, exp, dir) {
switch (dir) {
case 'model':
/**
* input / textear
*/
return function (node , val) {
node.value = typeof val === 'undefined' ? '' : val;
}
case 'text':
/**
* {{*}}
*/
return function (node , val) {
node.textContent = typeof val === 'undefined' ? '' : val;
}
}
}
复制代码
patch
的方法实现比较简单,它首先去判断了传入的指令,然后根据不同的指令返回了不同的函数。比如在model
指令方法中,因为我们只支持input、 textear
,所以我们接收到的node
只会是它们两个中的一个,然后我们通过node.value = val
来改变node中的value
。
我们在directives.js
中获取到了patch
的返回函数patchFn
,然后执行patchFn
。至此我们的模板已经被解析为浏览器可以读懂的html
代码。
"app">
type="text">
hello
复制代码
数据监听实现
然后我们来看一下 数据监听模块的实现 ,我们根据上面的 思路整理 想一下这个数据监听应该如何去实现?我们知道了我们应该在observer
里面去实现它,但是具体应该怎么做呢?
再来明确一下我们的目标,我们希望 **通过observer
能够监听到我们数据data
的变化,当我们调用data.msg
或者data.msg = '123'
的时候,会分别激活getter
或者setter
方法。**那么我们就需要对整个data
进行监听,当我们获取到data
对象之后,来遍历其中的所有数据,并分别为它们添加上getter
和setter
方法。
// observer.js
function observer (value) {
if (typeof value !== 'object') {
return;
}
var ob = new Observer(value);
}
function Observer (data) {
this.data = data;
this.walk();
}
Observer.prototype = {
walk: function () {
var $this = this;
var keys = Object.keys(this.data);
keys.forEach(function (key) {
$this.defineReactive(key, $this.data[key]);
});
},
defineReactive: function (key, value) {
var dep = new Dep();
Object.defineProperty(this.data, key, {
enumerable: true,
configurable: true,
set: function (newValue) {
if (value === newValue) {
return;
}
value = newValue;
dep.notify();
},
get: function () {
dep.depend();
return value;
}
});
},
}
复制代码
在observer.js
中我们通过observer (value)
方法来生成Observer
对象,其中传入的value
为data: {msg: 'hello'}
。然后调用Observer
的原型方法walk
,遍历data
调用defineReactive
,通过Object.defineProperty
为每条数据都添加上setter、getter
监听,同时我们声明了一个Dep
对象,这个Dep
对象会负责收集依赖并且派发更新。大家结合我们的思路整理想一下,我们应该在什么时候去收集依赖?什么时候去派发更新?
当用户通过input
进行输入修改数据的时候,我们是不是应该及时更新视图?所以在setter
方法被激活的时候,我们应该调用dep.notify()
方法,用于派发更新事件。
当我们的数据被展示出来的时候,也就是在getter
事件被激活的时候,我们应该去收集依赖,也就是调用dep.depend()
方法。
然后我们来看一下Dep
方法的实现,在Dep.js
中。
// Dep.js
var uid = 0;
function Dep () {
// 持有的watcher订阅者
this.subs = [];
this.id = uid++;
}
Dep.prototype = {
// 使dep与watcher互相持有
depend () {
// Dep.target为watcher实例
if (Dep.target) {
Dep.target.addDep(this)
}
},
// 添加watcher
addSub: function (sub) {
this.subs.push(sub);
},
// 通知所有的watcher进行更新
notify: function () {
this.subs && this.subs.forEach(function (sub) {
sub.update();
});
}
}
复制代码
Dep.js
的实现比较简单,它主要是就负责收集依赖(watcher
)并且派发更新(watcher.update()
),我们可以看到Dep
首先声明了subs
用于保存订阅了Dep
的watcher
实例,然后给每个Dep
实例创建了一个id
,然后我们为Dep
声明了三个原型方法,当调用notify
的时候,Dep
回去遍历所有的subs
然后调用他的update()
方法,当调用depend
的时候会调用watcher
的addDep
方法使Dep
与Watcher
互相持有。其中的Dep.target
和sub
都为Watcher
实例。
然后我们来看一下Watcher.js
的代码实现。
// watcher
function Watcher (vm, exp, patchFn) {
this.depIds = {};
this.$patchFn = patchFn;
this.$vm = vm;
this.getter = this.parsePath(exp)
this.value = this.get();
}
Watcher.prototype = {
// 更新
update: function () {
this.run();
},
// 执行更新操作
run: function () {
var oldVal = this.value;
var newVal = this.get();
if (oldVal === newVal) {
return;
}
this.$patchFn.call(this.$vm, newVal);
},
// 订阅Dep
addDep: function (dep) {
if (this.depIds.hasOwnProperty(dep.id)) {
return;
}
dep.addSub(this);
this.depIds[dep.id] = dep;
},
// 获取exp对应值,这时会激活observer中的get事件
get: function () {
Dep.target = this;
var value = this.getter.call(this.$vm, this.$vm._data);
Dep.target = null;
return value;
},
/**
* 获取exp的对应值,应对a.b.c
*/
parsePath: function (path) {
var segments = path.split('.');
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
}
复制代码
在Watcher.js
中它直接接收了patchFn
,大家还记得这个方法是干什么的吧?patchFn
是更改node value
,在编译之前,替换 v-model 、 {{*}}
为真实数据的方法,在Watcher.js
接收了patchFn
,并把它赋值给this.$patchFn
,当我们调用this.$patchFn
的时候,就会改变我们的DOM
渲染。
然后我们调用parsePath
用于解析对象数据,并返回一个解析函数,然后把它赋值给this.getter
。最后我们调用get()
方法,在get()
中我们给Dep.target
持有了Watcher
,并激活了一次getter
方法,使我们在observer
中监听的getter
事件被激活,会调用dep.depend()
方法,然后调用watcher.addDep(dep)
,使Dep
与Watcher
互相持有,相互依赖。
然后我们看一下update
方法的实现,我们知道当数据的setter
事件被激活的时候,会调用dep.notify()
,dep.notify()
又会遍历所有的订阅watcher
执行update
方法,那么在upadte
方法中,直接执行了this.run
,在run()
方法中,首先获取了 当前watcher
所观察的exp
的改变前值oldVal
和修改后值newVal
,然后通过patchFn
去修改DOM
。
以上就是我们整个数据监听的流程,它首先通过observer
来监听数据的变化,然后当数据的getter
事件被激活的时候,调用dep.depend()
来进行依赖收集,当数据的setter
事件被激活的时候,调用dep.notify()
来进行派发更新,这些的具体操作都是在我们的观察者watcher
中完成的。
整合MVue
最后我们就需要把我们的 视图渲染 和 数据监听 链接起来,那么这个连接的节点应该在哪里呢?我们再来捋一下我们的流程。
当用户编写了我们的指令代码
"app">
"text" v-model='msg'>
{{msg}}
复制代码
的时候,我们通过Compile
进行解析,当发现了我们的自定义指令v-model、{{*}}
的时候,会进行directives
进行指令解析,其中监听的用户的输入事件,并调用了vm._setVal()
方法,从而会激活在observer
中定义的setter
事件,setter
会进行派发更新的操作,调用dep.notify()
方法,然后便利subs
调用update
方法。
结合上面的描述,我们应该在两个地方去完成连接节点。首先是在调用vm._setVal()
方法的时候,我们需要保证observer
中的setter
事件可以被激活,那么我们最好在入口函数中去声明这个observer
:
function MVue (options) {
this.$options = options;
this._data = options.data || {};
observer(this._data);
new Compile(this, this.$options.el);
}
MVue.prototype = {
_getVal: function (exp) {
return this._data[exp];
},
_setVal: function (exp, newVal) {
this._data[exp] = newVal;
}
}
复制代码
然后当setter
事件被激活之前,我们需要初始化完成watcher
使其拥有vm、exp、patchFn
,那么最好的时机应该在获取到patchFn
这个返回函数的时候,所以应该在:
var directives = {
_bind: function (vm, exp, patchFn) {
new Watcher(vm,exp, patchFn);
},
/**
* 链接patch方法,将指令转化为真实的数据并展示
*/
_link: function (vm, node, exp, dir) {
var patchFn = patch(vm, node, exp, dir);
patchFn && patchFn(node, vm._getVal(exp));
this._bind(vm, exp, function (value) {
patchFn && patchFn(node, value);
});
},
...
}
复制代码
通过_bind
方法来去初始化watcher
。
使用与扩展
至此我们的MVue
框架就已经被开发完成了,我们可以点击这里来获取本课程中所有的代码,当我们需要使用我们的MVue
的时候,我们可以这么做:
"app">
type="text" v-model='msg'>
{{msg}}
复制代码
因为我们的MVue
并没有进行模块化,所以需要把所有的JS
全部引入才能使用。大家也可以尝试一下把MVue
进行模块化,这样就可以只通过引入来使用
MVue
了。
现在我们的MVue
还非常的简单,大家可以想一下如何为我们的MVue
增加更多的功能,比如说更多的指令或者添加v-on:click
的事件处理?这里给大家留下三个问题,目的是希望大家能够亲自写一下这个项目,可能会让大家有更多的理解。
1、实现
v-show
指令 2、实现v-on:click
事件监听 3、如何和Vue
一样可以直接通过this.msg
来获取我们在data
中定义的数据
这三个问题的解决方案都在我们的代码中,大家可以作为参考。
这一章为Vue.js
的最后一章,从下一章开始我们就会进入Vue
周边生态的学习,希望大家一定要亲自实现一下MVue
的代码。
前端技术日新月异,每一种新的思想出现,都代表了一种技术的跃进、架构的变化,那么对于目前的前端技术而言,MVVM 的思想已经可以代表当今前端领域的前沿思想理念,Angular、React、Vue 等基于 MVVM 思想的具体实现框架,也成为了人们争相学习的一个热点。而 Vue 作为其中唯一没有大公司支持但却能与它们并驾齐驱并且隐隐有超越同类的趋势,不得不说这种增长让人感到惊奇。
本系列课程内容将会带领大家由浅入深的学习 Vue 的基础知识,了解 Vue 的源码设计和实现原理,和大家一起看一下尤雨溪先生的编程思想、架构设计以及如何进行代码实现。本系列课程内容主要分为三大部分:
Vue
的基础知识:在这一部分将学习 Vue 的基础语法及其源码的实现。例如,Vue
的生命周期钩子如何设计?当声明了一个directive
时,Vue
究竟执行了什么?为什么只有通过vue.set
函数才能为响应式对象添加响应式属性?如果我们自己要实现一个响应式的框架的话,应该如何下手、如何思考等。Vue
的周边生态:在这一部分将学习Vue
的周边生态圈,包括有哪些UI
库可以和Vue
配合快速构建界面、如何使用vue-router
构建前端路由、如何使用Vuex
进行状态管理、如何使用Axios
进行网络请求、如何使用Webpack
、使用vue-cli
构建出的项目里的各种配置有什么意义? 项目实战:在这一部分将会通过一个有意思的自动对话系统来进行项目实战,争取通过这个小项目把学到的知识点进行一个整合。