Skip to content

Commit

Permalink
mvvm
Browse files Browse the repository at this point in the history
  • Loading branch information
wuyawei committed Apr 9, 2019
1 parent dd656ec commit 52d576f
Show file tree
Hide file tree
Showing 4 changed files with 481 additions and 352 deletions.
375 changes: 372 additions & 3 deletions vue/mvvm/1.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,10 @@ new Watcher(data, 'age', print2);
data.age = '24'; // 我今年 24
```
## MVVM
说了那么多,该练练手了。Vue 作为典型的 MVVM 框架,大大提高了前端er 的生产力,我们这次就参考 Vue 自己实现一个简易的 MVVM。当然,相对的也让我们更容易理解 Vue 的实现原理。
说了那么多,该练练手了。Vue 作为典型的 MVVM 框架,大大提高了前端er 的生产力,我们这次就参考 Vue 自己实现一个简易的 MVVM。
> 实现部分参考自 [剖析Vue实现原理 - 如何实现双向绑定mvvm](https://github.com/DMQ/mvvm)
### 什么是 MVVM ?
简单介绍一下 MVVM,更全面的讲解,大家可以看这里 [MVVM 模式](https://docs.microsoft.com/en-us/previous-versions/msp-n-p/hh848246(v=pandp.10))。MVVM 的全称是 Model-View-ViewModel,它是一种架构模式,最早由微软提出,借鉴了 MVC 等模式的思想。
Expand All @@ -648,8 +651,374 @@ ViewModel 负责把 Model 的数据同步到 View 显示出来,还负责把 Vi
> 图片来自 [MVVM 模式](https://docs.microsoft.com/en-us/previous-versions/msp-n-p/hh848246(v=pandp.10))
### 如何实现一个 MVVM?
想知道如何实现一个 MVVM,至少我们得先知道 MVVM 有什么。Vue 的响应式原理图,其实就已经可以基本说明问题了。但是为了方便理解,我们还是大致画一下原理图。
想知道如何实现一个 MVVM,至少我们得先知道 MVVM 有什么。我们先看看大体要做成个什么模样。
``` html
<body>
<div id="app">
姓名:<input type="text" v-model="name"> <br>
年龄:<input type="text" v-model="age"> <br>
职业:<input type="text" v-model="profession"> <br>
<p> 输出:{{info}} </p>
<button v-on:click="clear">清空</button>
</div>
</body>
<script src="mvvm.js"></script>
<script>
const app = new MVVM({
el: '#app',
data: {
name: '',
age: '',
profession: ''
},
methods: {
clear() {
this.name = '';
this.age = '';
this.profession = '';
}
},
computed: {
info() {
return `我叫${this.name},今年${this.age},是一名${this.profession}`;
}
}
})
</script>
```
运行效果:
![](https://user-gold-cdn.xitu.io/2019/4/9/16a02800e8074cc2?w=456&h=202&f=png&s=3727)
好,看起来是模仿(抄袭)了 Vue 的一些基本功能,比如双向绑定、computed、v-on等等。为了方便理解,我们还是大致画一下原理图。
![](https://user-gold-cdn.xitu.io/2019/4/9/16a001f32f9e505c?w=700&h=481&f=jpeg&s=101106)
从图中看,我们现在需要做哪些事情呢?数据劫持、模板编译、依赖收集、发布订阅,等一下,这些名词是不是看起来很熟悉?这不就是之前分析 Vue 源码时候做的事吗?(是啊,是啊,可不就是抄的 Vue 嘛)。OK,数据劫持、发布订阅我们都比较熟悉了,可是模板编译还没有头绪。所以又要委屈大家继续听我啰嗦了,我们继续。
从图中看,我们现在需要做哪些事情呢?数据劫持、数据代理、模板编译、发布订阅,咦,等一下,这些名词是不是看起来很熟悉?这不就是之前分析 Vue 源码时候做的事吗?(是啊,是啊,可不就是抄的 Vue 嘛)。OK,数据劫持、发布订阅我们都比较熟悉了,可是模板编译还没有头绪。不急,这就开始。
### new MVVM()
我们按照原理图的思路,第一步是 `new MVVM()`,也就是初始化。初始化的时候要做些什么呢?可以想到的是,数据的劫持以及模板(视图)的初始化。
``` javascript
class MVVM {
constructor(options) { // 初始化
this.$el = options.el;
this.$data = options.data;
if(this.$el){ // 如果有 el,才进行下一步
new Observer(this.$data);
new Compiler(this.$el, this);
}
}
}
```
好像少了点什么,computed、methods 也需要处理,补上。
``` javascript
class MVVM {
constructor(options) { // 初始化
// ··· 接收参数
let computed = options.computed;
let methods = options.methods;
let that = this;
if(this.$el){ // 如果有 el,才进行下一步
// 把 computed 的key值代理到 this 上,这样就可以直接访问 this.$data.info,取值的时候便直接运行 计算方法
for(let key in computed){
Object.defineProperty(this.$data, key, {
get() {
return computed[key].call(that);
}
})
}
// 把 methods 的方法直接代理到 this 上,这样可以访问 this.clear
for(let key in methods){
Object.defineProperty(this, key, {
get(){
return methods[key];
}
})
}
}
}
}
```
上面代码中,我们把 data 放到了 this.$data 上,但是想想我们平时,都是用 this.xxx 来访问的。所以,data 也和计算属性它们一样,需要加一层代理,方便访问。对于计算属性的详细流程,我们在数据劫持的时候再讲。
``` javascript
class MVVM {
constructor(options) { // 初始化
if(this.$el){
this.proxyData(this.$data);
// ··· 省略
}
}
proxyData(data) { // 数据代理
for(let key in data){
// 访问 this.name 实际是访问的 this.$data.name
Object.defineProperty(this, key, {
get(){
return data[key];
},
set(newVal){
data[key] = newVal;
}
})
}
}
}
```
### 数据劫持、发布订阅
初始化后我们还剩两步操作等待处理。
``` javascript
new Observer(this.$data); // 数据劫持 + 发布订阅
new Compiler(this.$el, this); // 模板编译
```
数据劫持和发布订阅,我们文章前面花了很长的篇幅一直在讲这个,大家应该都很熟悉了,所以先把它干掉。
``` javascript
class Dep { // 发布订阅
constructor(){
this.subs = []; // watcher 观察者集合
}
addSub(watcher){ // 添加 watcher
this.subs.push(watcher);
}
notify(){ // 发布
this.subs.forEach(w => w.update());
}
}

class Watcher{ // 观察者
constructor(vm, expr, cb){
this.vm = vm; // 实例
this.expr = expr; // 观察数据的表达式
this.cb = cb; // 更新触发的回调
this.value = this.get(); // 保存旧值
}
get(){ // 取值操作,触发数据 getter,添加订阅
Dep.target = this; // 设置为自身
let value = resolveFn.getValue(this.vm, this.expr); // 取值
Dep.target = null; // 重置为 null
return value;
}
update(){ // 更新
let newValue = resolveFn.getValue(this.vm, this.expr);
if(newValue !== this.value){
this.cb(newValue);
this.value = newValue;
}
}
}

class Observer{ // 数据劫持
constructor(data){
this.observe(data);
}
observe(data){
if(data && typeof data === 'object') {
if (Array.isArray(data)) { // 如果是数组,遍历观察数组的每个成员
data.forEach(v => {
this.observe(v);
});
// Vue 在这里还进行了数组方法的重写等一些特殊处理
return;
}
Object.keys(data).forEach(k => { // 观察对象的每个属性
this.defineReactive(data, k, data[k]);
});
}
}
defineReactive(obj, key, value) {
let that = this;
this.observe(value); //对象属性的值,如果是对象或者数组,再次观察
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){ // 取值时,判断是否要添加 Watcher,收集依赖
Dep.target && dep.addSub(Dep.target);
return value;
},
set(newVal){
if(newVal !== value) {
that.observe(newVal); // 观察新设置的值
value = newVal;
dep.notify(); // 发布
}
}
})
}
}
```
取值的时候,我们用到了 `resolveFn.getValue` 这么一个方法,这是一个工具方法的集合,后续编译的时候还有很多。我们先仔细看看这个方法。
``` javascript
resolveFn = { // 工具函数集
getValue(vm, expr) { // 返回指定表达式的数据
return expr.split('.').reduce((data, current)=>{
return data[current]; // this[info]、this[obj][a]
}, vm);
}
}
```
我们在之前的分析中提到过,表达式可以是一个字符串,也可以是一个函数(如渲染函数),只要能触发取值操作即可。我们这里只考虑了字符串的形式,哪些地方会有这种表达式呢?比如 `{{info}}`、比如 `v-model="name"`中 = 后面的就是表达式。它也有可能是 `obj.a` 的形式。所以这里利用 reduce 达到一个连续取值的效果。
### 计算属性 computed
初始化时候遗留了一个问题,因为涉及到发布订阅,所以我们在这里详细分析一下计算属性的触发流程,初始化的时候,模板中用到了 `{{info}}`,那么在模板编译的时候,就需要触发一次 this.info 的取值操作获取真实的值用来替换 `{{info}}` 这个字符串。我们就同样在这个地方添加一个观察者。
``` javascript
compileText(node, '{{info}}', '') // 假设编译方法长这样,初始值为空
new Watcher(this, 'info', () => {do something}) // 我们紧跟着实例化一个观察者
```
这个时候会触发什么操作?我们知道 `new Watcher() ` 的时候,会触发一次取值。根据刚才的取值函数,这时候会去取 `this.info`,而我们在初始化的时候又做了代理。
``` javascript
for(let key in computed){
Object.defineProperty(this.$data, key, {
get() {
return computed[key].call(that);
}
})
}
```
所以这时候,会直接运行 computed 定义的方法,还记得方法长什么样吗?
``` javascript
computed: {
info() {
return `我叫${this.name},今年${this.、age},是一名${this.profession}`;
}
}
```
于是又会接连触发 name、age 以及 profession 的取值操作。
``` javascript
defineReactive(obj, key, value) {
// ···
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){ // 取值时,判断是否要添加 Watcher,收集依赖
Dep.target && dep.addSub(Dep.target);
return value;
}
// ···
})
}
```
这时候就充分利用了 **闭包** 的特性,要注意的是现在仍然还在 info 的取值操作过程中,因为是 **同步** 方法,这也就意味着,现在的 Dep.target 是存在的,并且是观察 info 属性的 Watcher。所以程序会在 name、age 和 profession 的 dep 上,分别添加上 info 的 Watcher,这样,在这三个属性后面任意一个值发生变化,都会通知给 info 的 Watcher 重新取值并更新视图。
**打印一下此时的 dep,方便理解。**
![](https://user-gold-cdn.xitu.io/2019/4/10/16a030a19f46b487?w=940&h=180&f=png&s=8382)
### 模板编译
其实前面已经提到了一些模板编译相关的东西,这一部分主要做的事就是将 html 上的模板语法编译成真实数据,将指令也转换为相对应的函数。
在编译过程中,避免不了要操作 Dom 元素,所以这里用了一个 createDocumentFragment 方法来创建文档碎片。
> 文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。— [MDN](https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createDocumentFragment)
``` javascript
class Compiler{
constructor(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el); // 获取app节点
this.vm = vm;
let fragment = this.createFragment(this.el); // 将 dom 转换为文档碎片
this.compile(fragment); // 编译
this.el.appendChild(fragment); // 变易完成后,重新放回 dom
}
createFragment(node) { // 将 dom 元素,转换成文档片段
let fragment = document.createDocumentFragment();
let firstChild;
// 一直去第一个子节点并将其放进文档碎片,直到没有,取不到则停止循环
while(firstChild = node.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
isDirective(attrName) { // 是否是指令
return attrName.startsWith('v-');
}
isElementNode(node) { // 是否是元素节点
return node.nodeType === 1;
}
compile(node) { // 编译节点
let childNodes = node.childNodes; // 获取所有子节点
[...childNodes].forEach(child => {
if(this.isElementNode(child)){ // 是否是元素节点
this.compile(child); // 递归遍历子节点
let attributes = child.attributes;
// 获取元素节点的所有属性 v-model class 等
[...attributes].forEach(attr => { // 以 v-on:click="clear" 为例
let {name, value: exp} = attr; // 结构获取 "clear"
if(this.isDirective(name)) { // 判断是不是指令属性
let [, directive] = name.split('-'); // 结构获取指令部分 v-on:click
let [directiveName, eventName] = directive.split(':'); // on,click
resolveFn[directiveName](child, exp, this.vm, eventName);
// 执行相应指令方法
}
})
}else{ // 编译文本
let content = child.textContent; // 获取文本节点
if(/\{\{(.+?)\}\}/.test(content)) { // 判断是否有模板语法 {{}}
resolveFn.text(child, content, this.vm); // 替换文本
}
}
});
}
}

// 替换文本的方法
resolveFn = { // 工具函数集
text(node, exp, vm) {
// 惰性匹配,避免连续多个模板时,会直接取到最后一个花括号
// {{name}} {{age}} 不用惰性匹配 会一次取全 "{{name}} {{age}}"
// 我们期望的是 ["{{name}}", "{{age}}"]
let reg = /\{\{(.+?)\}\}/;
let expr = exp.match(reg);
node.textContent = this.getValue(vm, expr[1]); // 编译时触发更新视图
new Watcher(vm, expr[1], () => { // setter 触发发布
node.textContent = this.getValue(vm, expr[1]);
});
}
}
```
最后,我们再来看看一些指令的简单实现。
* 双向绑定 v-model
``` javascript
resolveFn = { // 工具函数集
setValue(vm, exp, value) {
exp.split('.').reduce((data, current, index, arr)=>{ //
if(index === arr.length-1) { // 最后一个成员时,设置值
return data[current] = value;
}
return data[current];
}, vm.$data);
},
model(node, exp, vm) {
new Watcher(vm, exp, (newVal) => { // 添加观察者,数据变化,更新视图
node.value = newVal;
});
node.addEventListener('input', (e) => { //input 事件(视图变化)触发,更新数据
let value = e.target.value;
this.setValue(vm, exp, value); // 设置新值
});
// 编译时触发
let value = this.getValue(vm, exp);
node.value = value;
}
}
```
## 总结
本期主要讲了 Vue 的响应式原理,包括数据劫持、发布订阅、Proxy 和 `Object.defineProperty` 的不同点等等,还顺带简单写了个 MVVM。Vue 作为一款优秀的前端框架,可供我们学习的点太多,每一个细节都值得我们深究。后续还会带来系列的 Vue、javascript 等前端知识点的文章,感兴趣的同学可以关注下。
## 交流群
> qq前端交流群:960807765,欢迎各种技术交流,期待你的加入
## 后记
如果你看到了这里,且本文对你有一点帮助的话,希望你可以动动小手支持一下作者,感谢🍻。文中如有不对之处,也欢迎大家指出,共勉。
* **文章仓库** [🍹🍰fe-code](https://github.com/wuyawei/fe-code)
更多文章:
**前端进阶之路系列**
* [【2019 前端进阶之路】Vue 组件间通信方式完整版](https://juejin.im/post/5c7b524ee51d453ee81877a7)
* [【2019 前端进阶之路】JavaScript 原型和原型链及 canvas 验证码实践](https://juejin.im/post/5c7b524ee51d453ee81877a7)
* [【2019 前端进阶之路】站住,你这个Promise!](https://juejin.im/post/5c179aad5188256d9832fb61)
**从头到脚实战系列**
* [【从头到脚】WebRTC + Canvas 实现一个双人协作的共享画板 | 掘金技术征文](https://juejin.im/post/5c9cbbb85188251c3a2f36e8)
* [【从头到脚】撸一个多人视频聊天 — 前端 WebRTC 实战(一)](https://juejin.im/post/5c3acfa56fb9a049f36254be)
* [【从头到脚】撸一个社交聊天系统(vue + node + mongodb)- 💘🍦🙈Vchat ](https://juejin.im/post/5c0a00fb6fb9a049d4419d3a)
欢迎关注公众号 **前端发动机**,第一时间获得作者文章推送,还有海量前端大佬优质文章,致力于成为推动前端成长的引擎。
![](https://user-gold-cdn.xitu.io/2019/3/16/1698668bd914d63f?w=258&h=258&f=jpeg&s=27979)
Loading

0 comments on commit 52d576f

Please sign in to comment.