深入理解 vue 转

一  理解 vue 的核心理念
使用 vue 会让人感到身心愉悦, 它同时具备 angular 和 react 的优点, 轻量级,api 简单, 文档齐全, 简单强大, 麻雀虽小五脏俱全.

倘若用一句话来概括 vue, 那么我首先想到的便是官方文档中的一句话:

Vue.js(读音 /vjuː/,类似于 view) 是一套构建用户界面的渐进式框架。
这句话可能大家并不陌生, 但是真正理解这句话的可能并不多, 其实, 读懂了这句话, 也就明白了 vue 的核心理念.

那么, 怎样理解什么是渐进式框架? 在这之前, 我们首先要理解什么是框架. 在最初的前端开发中, 为了完成某个功能, 我们需要通过 js 在 HTML 页面中获得 dom 节点, 随后获得 dom 节点中的文本内容或者在 dom 节点上添加事件, 进行一系列的程序操作, 但是, 如果任务量很大的情况下, 代码会随着业务的增加而变得臃肿和混乱, 在现实的开发中, 负责的逻辑和巨大的开发量, 是原生 js 无法完成的.

这个时候, 开发人员将 js 代码分为了三个板块, 数据 (Model), 逻辑控制 (*), 视图 (View), 数据板块只负责数据部分, 视图板块负责更改样式, 逻辑控制负责联系视图板块和数据板块, 这样子有很大的好处, 当需求发生变动时, 只需要修改对应的板块就好

这种开发模式, 就是所谓的 MV结构, 我们现在了解的 MVC,MVP,MVVM都是 MV的衍生物,对比这几种框架模式,我们会总结出来一个本质的特点,那就是这些开发模式都是让视图和数据间不会发生直接联系.对比用原生 JS 获得 dom 的操作,你会发现原生 dom 流其实是将 dom 作为数据,从 dom 中获得Model,随后又更改 dom 来实现更新视图,视图和模型其实混在一起,所以代码自然混乱,不易维护.

 

在具有响应式系统的 Vue 实例中,DOM 状态只是数据状态的一个映射 即 UI=VM(State) ,当等式右边 State 改变了,页面展示部分 UI 就会发生相应改变。很多人初次上手 Vue 时,觉得很好用,原因就是这个.不过,Vue 的核心定位并不是一个框架,设计上也没有完全遵循 MVVM 模式,可以看到在图中只有 State 和 View 两部分, Vue 的核心功能强调的是状态到界面的映射,对于代码的结构组织并不重视, 所以单纯只使用其核心功能时,它并不是一个框架,而更像一个视图模板引擎,这也是为什么 Vue 开发者把其命名成读音类似于 view 的原因。

上文提到,Vue 的核心的功能,是一个视图模板引擎,但这不是说 Vue 就不能成为一个框架。如下图所示,这里包含了 Vue 的所有部件,在声明式渲染(视图模板引擎)的基础上,我们可以通过添加组件系统、客户端路由、大规模状态管理来构建一个完整的框架。更重要的是,这些功能相互独立,你可以在核心功能的基础上任意选用其他的部件,不一定要全部整合在一起。可以看到,所说的“渐进式”,其实就是 Vue 的使用方式,同时也体现了 Vue 的设计的理念.

 

 

二 探讨 vue 的双向绑定原理及实现
  成果图

    

下面介绍两个内容

  1.vue 双向绑定的原理

  2.实现简易版 vue 的过程,包括声明式数据渲染及一些简单的指令

 

vue 双向绑定原理

vue 的双向绑定是由数据劫持结合发布者-订阅者模式实现的,那么什么是数据劫持?vue 是如何进行数据劫持的?说白了就是通过 Object.defineProperty() 来劫持对象属性的 setter 和 getter 操作,在数据变动时做你想要做的事情.我们可以看一下通过控制台梳齿一个定义在 vue 初始化数据上的对象是什么.

var vm = new Vue({
data: {
test : {
a: 1
}
},
created: function () {
console.log(this.test);
}
});
打印结果:

在打印结果中我们可以看到属性 a 有两个方法:get 和 set. 为什么会有这两个方法呢,这正是 vue 通过 Object.defineProperty() 进行数据劫持的.

Object.defineProperty() 这个方法是做什么的呢?文档上是这样说的

 简单的说,他可以控制一个对象属性的一些特有操作,比如读写权,是否可枚举,这里我们主要研究它的 get 和 set 方法,如果想清楚更多用法,可以参考:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty

我们可以很轻松的打印出一个对象的属性数据:

var Book = {
name: ‘人性的弱点’
};
console.log(Book.name); // 人性的弱点
但是如果在执行 console.log(Book.name) 的同时,给书的书名增加一个书名号呢,这个时候应该怎么做,这时候我们就需要用到 Object.defineProperty( ) 了:

// 在 console.log(book.name) 同时, 直接给书加一个书号
var Book = {};
var name = ‘‘;
Object.defineProperty(Book,‘name’,{
set:function(value) {
name = value;
console.log(‘你取了一个书名叫:’+value);
},
get:function() {
console.log(‘get 方法被监听到’);
return ‘<’+name+’>’;
}
});
Book.name = ‘人性的弱点’; // 你取了一个书名叫: 人性的弱点
console.log(Book.name); //< 人性的弱点 >
通过 Object.defineProperty() 这个方法设置了 Book 对象的 name 属性,对其 get 和 set 方法进行重写操作,get 方法在获得 name 属性时被调用,set 方法在设置 name 属性时被触发.所以在执行 Book.name=‘人性的弱点’ 这个语句时调用 set 方法,输出你取了一个书名叫:人性的弱点.当调用 console.log(Book.name) 时触发 get 方法,输出<人性的弱点>,如果在代码中加入这句话时,会打印出什么呢?

console.log(Book)
结果如下:

与上面 vue 打印数据进行对比非常类似,说明 vue 确实是通过这种方式进行数据劫持的.那么什么是发布者-订阅者模式呢??

订阅者和发布者模式,通常用于消息队列中.一般有两种形式来实现消息队列,一是使用生产者和消费者来实现,二是使用订阅者-发布者模式来实现,其中订阅者和发布者实现消息队列的方式,就会用订阅者模式.

打个比方,所谓的订阅者,就像我们在日常生活中订阅报纸一样,在订阅报纸的时候,通常都得需要在报社或者一些中介机构进行注册,当有新版的报纸发刊的时候,邮递员就需要向订阅该报纸的人,依次发放报纸.

所谓的订阅者,就像我们在日常生活中,订阅报纸一样。我们订阅报纸的时候,通常都得需要在报社或者一些中介机构进行注册。当有新版的报纸发刊的时候,邮递员就需要向订阅该报纸的人,依次发放报纸。

所有如果用代码实现该模式,需要进行两个步骤:

1、初始化发布者、订阅者。
2、订阅者需要注册到发布者,发布者发布消息时,依次向订阅者发布消息。
订阅者注册

发布者发布消息

那么接下来我们将通过 vue 原理实现一个简单的 mvvm 双向绑定的 demo

思路分析

要想实现 mvvm,主要包含两个方面,视图变化更新数据,数据变化更新视图.

view 变化更新 data 其实可以通过事件监听实现,比如 input 标签监听 input 事件,所有我们着重分析 data 变化更新 view.

data 变化更新 view 的重点是如何知道 view 什么时候变化了,只要知道什么时候 view 变化了,那么接下来的就好处理了.这个时候我们上文提到的 Object.defineProperty() 就起作用了.通过 Object.defineProperty() 对属性设置一个 set 函数,当属性变化时就会触发这个函数,所以我们只需要将一些更新的方法放在 set 函数中就可以实现 data 变化更新 view 了.

实现过程

我们已经知道如何实现数据的双向绑定了, 那么首先要对数据进行劫持监听,所以我们首先要设置一个监听器 Observer, 用来监听所有的属性,当属性变化时,就需要通知订阅者 Watcher, 看是否需要更新.因为属性可能是多个,所以会有多个订阅者,故我们需要一个消息订阅器 Dep 来专门收集这些订阅者,并在监听器 Observer 和订阅者 Watcher 之间进行统一的管理.以为在节点元素上可能存在一些指令,所以我们还需要有一个指令解析器 Compile,对每个节点元素进行扫描和解析,将相关指令初始化成一个订阅者 Watcher,并替换模板数据并绑定相应的函数,这时候当订阅者 Watcher 接受到相应属性的变化,就会执行相对应的更新函数,从而更新视图.

整理上面的思路,我们需要实现三个步骤,来完成双向绑定:

1. 实现一个监听器 Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。

2. 实现一个订阅者 Watcher,可以收到属性的变化通知并执行相应的函数,从而更新视图。

3. 实现一个解析器 Compile,可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。
流程图如下:

1. 实现一个监听器 Observer

数据监听器的核心方法就是 Object.defineProperty(),通过遍历循环对所有属性值进行监听,并对其进行 Object.defineProperty() 处理,那么代码可以这样写:

// 对所有属性都要蒋婷, 递归遍历所有属性
function defineReactive(data,key,val) {
observe(val); // 递归遍历所有的属性
Object.defineProperty(data,key,{
enumerable:true, // 当且仅当该属性的 configurable 为 true 时,该属性描述符才能够被改变,同时该属性也能从对应的对象上被删除。
configurable:true, // 当且仅当该属性的 enumerable 为 true 时,该属性才能够出现在对象的枚举属性中
get:function() {
return val;
},
set:function(newVal) {
val = newVal;
console.log(‘属性’+key+‘已经被监听, 现在值为:"’+newVal.toString()+‘"’);
}
})
}

function observe(data) {
if(!data || typeof data !== ‘object’) {
return;
}
Object.keys(data).forEach(function(key){
defineReactive(data,key,data[key]);
});
}

var library = {
book1: {
name: ''
},
book2: ''
};
observe(library);
library.book1.name = ‘vue 权威指南’; // 属性 name 已经被监听了,现在值为:“vue 权威指南”
library.book2 = ‘没有此书籍’; // 属性 book2 已经被监听了,现在值为:“没有此书籍”
通过 observe()方法进行遍历向下找到所有的属性,并通过 defineReactive() 方法进行数据劫持监听.

在上面的思路中,我们需要一个可以容纳消息订阅者的消息订阅器 Dep,订阅器主要收集消息订阅者,然后在属性变化时执行相应订阅者的更新函数,那么消息订阅器 Dep 需要有一个容器,用来存放消息订阅者.我们将上面的监听器 Observer 稍微修改一下:

function defineReactive(data,key,val) {
observe(val);
var dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function() {
if (是否需要添加订阅者) { //Watcher 初始化触发
dep.addSub(watcher); // 在这里添加一个订阅者
}
return val;
},
set: function(newVal) {
if (val === newVal) {
return;
}
val = newVal;
console.log(‘属性’ + key + ‘已经被监听了,现在值为:“’ + newVal.toString() + ‘”’);
dep.notify(); // 如果数据变化,通知所有订阅者
}
});
}

function observe(data) {
if(!data || typeof data !== ‘object’) {
return;
}
Object.keys(data).forEach(function(key){
defineReactive(data,key,data[key]);
});
}

function Dep() {
this.subs = [];
}

//prototype 属性使您有能力向对象添加属性和方法
//prototype 这个属性只有函数对象才有,具体的说就是构造函数具有. 只要你声明定义了一个函数对象,这个 prototype 就会存在
// 对象实例是没有这个属性
Dep.prototype = {
addSub:function(sub) {
this.subs.push(sub);
},
notify:function() {
this.subs.forEach(function(sub) {
sub.update(); // 通知每个订阅者检查更新
})
}
}
Dep.target = null;
在代码中,我们将订阅器 Dep 添加一个订阅者设计在 get 里面,这是为了让 Watcher 在初始化时触发,因此判断是否需要需要添加订阅者,至于具体实现的方法,我们在下文中深究.在 set 方法中,如果函数变化,就会通知所有的订阅者,订阅者们将会执行相对应的更新函数,到目前为止,一个比较完善的 Observer 已经成型了,下面我们要写订阅者 Watcher.

2.实现订阅者 Watcher

根据我们的思路,订阅者 Wahcher 在初始化时要将自己添加到订阅器 Dep 中,那么如何进行添加呢?

我们已经知道监听器 Observer 是在 get 函数中执行了添加订阅者的操作的,所以我们只需要在订阅者 Watcher 在初始化时触发相对应的 get 函数来执行添加订阅者的操作即可.那么怎么触发对应的 get 函数呢?我们只需要获取对应的属性值,就可以通过 Object.defineProperty( ) 触发对应的 get 了.

在这里需要注意一个细节,我们只需要在订阅者初始化时才执行添加订阅者,所以我们需要一个判断,在 Dep.target 上缓存一下订阅者,添加成功后去除就行了,代码如下:

function Watcher(vm,exp,cb) {
this.vm = vm; // 指向 SelfVue 的作用域
this.exp = exp; // 绑定属性的 key 值
this.cb = cb; // 闭包
this.value = this.get();
}

Watcher.prototype = {
update:function() {
this.run();
},
run:function() {
var value = this.vm.data[this.exp];
var oldVal = this.value;
if(value !== oldVal) {
this.value = value;
this.cb.call(this.vm,value,oldVal);
}
},
get:function() {
Dep.target = this; // 缓存自己
var value = this.vm.data[this.exp]; // 强制执行监听器里的 get 函数
Dep.target = null; // 释放自己
return value;
}
}
这个时候我们需要对监听器 Observer 中的 defineReactive() 做稍微的调整:

function defineReactive(data,key,val) {
observe(val);
var dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function() {
if(Dep.target) { // 判断是否需要添加订阅者
dep.addSub(Dep.target);
}
return val;
},
set: function(newVal) {
if (val === newVal) {
return;
}
val = newVal;
console.log(‘属性’ + key + ‘已经被监听了,现在值为:“’ + newVal.toString() + ‘”’);
dep.notify(); // 如果数据变化,通知所有订阅者
}
});
}
到目前为止,一个简易版的 Watcher 已经成型了,我们只需要将订阅者 Watcher 和监听器 Observer 关联起来,就可以实现一个简单的双向绑定.因为这里还没有设计指令解析器,所以对于模板数据我们都进行写死处理,假设模板上有一个节点元素,且 id 为'name', 并且双向绑定的绑定变量也是’name’,且是通过两个大双括号包起来(暂时没有什么用处),模板代码如下:

{{name}}

我们需要定义一个 SelfVue 类,来实现 observer 和 watcher 的关联,代码如下:

// 将 Observer 和 Watcher 关联起来
function SelfVue(data,el,exp) {
this.data = data;
observe(data);
el.innerHTML = this.data[exp];
new Watcher(this,exp,function(value) {
el.innerHTML = value;
});
return this;
}
然后在页面上 new 一个 SelfVue,就可以实现双向绑定了:

这时我们打开页面,显示的是 'hello world',2s 后变成了 'byebye world', 一个简单的双向绑定实现了.

对比 vue, 我们发现了有一个问题,我们在为属性赋值的时候形式是: ’  selfVue.data.name = ‘byebye world’  ‘, 而我们理想的形式是:’  selfVue.name = ‘byebye world’  ’,那么怎么实现这种形式呢,只需要在 new SelfVue 时做一个代理处理,让访问 SelfVue 的属性代理为访问 selfVue.data 的属性,原理还是使用 Object.defineProperty( ) 对属性在包装一层.代码如下:

function SelfVue(data,el,exp) {
var self = this;
this.data = data;
//Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组
Object.keys(data).forEach(function(key) {
self.proxyKeys(key); // 绑定代理属性
});
observe(data);
el.innerHTML = this.data[exp]; // 初始化模板数据的值
new Watcher(this,exp,function(value) {
el.innerHTML = value;
});
return this;
}

SelfVue.prototype = {
proxyKeys:function(key) {
var self = this;
Object.defineProperty(this,key,{
enumerable:false,
configurable:true,
get:function proxyGetter() {
return self.data[key];
},
set:function proxySetter(newVal) {
self.data[key] = newVal;
}
});
}
}
这样我们就可以用理想的形式改变模板数据了.

3.实现指令解析器 Compile

再上面的双向绑定 demo 中,我们发现整个过程都没有解析 dom 节点,而是固定某个节点进行替换数据,所以接下来我们要实现一个解析器 Compile 来解析和绑定工作,分析解析器的作用,实现步骤如下:

1. 解析模板指令,并替换模板数据,初始化视图

2. 将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器
为了解析模板,首先要获得 dom 元素,然后对含有 dom 元素上含有指令的节点进行处理,这个过程对 dom 元素的操作比较繁琐,所以我们可以先建一个 fragment 片段,将需要解析的 dom 元素存到 fragment 片段中在做处理:

nodeToFragment:function(el) {
var fragment = document.createDocumentFragment(); //createdocumentfragment() 方法创建了一虚拟的节点对象,节点对象包含所有属性和方法。
var child = el.firstChild;
while(child) {
// 将 Dom 元素移入 fragment 中
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
}
接下来需要遍历所有节点,对含有指令的节点进行特殊的处理,这里我们先处理最简单的情况,只对带有 ‘{{变量}}’ 这种形式的指令进行处理,代码如下:

// 遍历各个节点, 对含有相关指定的节点进行特殊处理
compileElement:function(el) {
var childNodes = el.childNodes; //childNodes 属性返回节点的子节点集合,以 NodeList 对象。
var self = this;
//slice() 方法可从已有的数组中返回选定的元素。
[].slice.call(childNodes).forEach(function(node) {
var reg = /{{(.*)}}/;
var text = node.textContent; //textContent 属性设置或返回指定节点的文本内容
if(self.isTextNode(node) && reg.test(text)) {// 判断是否符合 {{}} 的指令
//exec() 方法用于检索字符串中的正则表达式的匹配。
// 返回一个数组,其中存放匹配的结果。如果未找到匹配,则返回值为 null。
self.compileText(node,reg.exec(text)[1]);
}
if(node.childNodes && node.childNodes.length) {
self.compileElement(node); // 继续递归遍历子节点
}
});
},
compileText:function(node,exp) {
var self = this;
var initText = this.vm[exp];
this.updateText(node,initText); // 将初始化的数据初始化到视图中
new Watcher(this.vm,exp,function(value) {
self.updateText(node,value);
});

},
updateText:function(node,value) {
    node.textContent = typeof value == 'undefined' ? '': value;
},

获取到最外层节点后,调用 compileElement 函数,对所有子节点进行判断,如果节点是文本节点且匹配 {{}} 这种形式指令的节点就开始进行编译处理,编译处理首先需要初始化视图数据,对应上面所说的步骤 1,接下去需要生成一个并绑定更新函数的订阅器,对应上面所说的步骤 2。这样就完成指令的解析、初始化、编译三个过程,一个解析器 Compile 也就可以正常的工作了。

为了将解析器 Compile 与监听器 Observer 和订阅者 Watcher 关联起来,我们需要再修改一下类 SelfVue 函数:

function SelfVue(options) {
var self = this;
this.vm = this;
this.data = options.data;
Object.keys(this.data).forEach(function(key) {
self.proxyKeys(key); // 绑定代理属性
});
observe(options.data);
new Compile(options.el,this.vm);
return this;
}
更改后,我们就不要像之前通过传入固定的元素值进行双向绑定了,可以随便命名各种变量进行双向绑定了:

{{title}}

{{name}}

{{content}}

到这里,一个数据双向绑定功能已经基本完成了,接下去就是需要完善更多指令的解析编译,在哪里进行更多指令的处理呢?答案很明显,只要在上文说的 compileElement 函数加上对其他指令节点进行判断,然后遍历其所有属性,看是否有匹配的指令的属性,如果有的话,就对其进行解析编译。这里我们再添加一个 v-model 指令和事件指令的解析编译,对于这些节点我们使用函数 compile 进行解析处理:

compile:function(node) {
var nodeAttrs = node.attributes; //attributes 属性返回指定节点的属性集合,即 NamedNodeMap。
var self = this;
//Array.prototype 属性表示 Array 构造函数的原型,并允许为所有 Array 对象添加新的属性和方法。
//Array.prototype 本身就是一个 Array
Array.prototype.forEach.call(nodeAttrs,function(attr) {
var attrName = attr.name; // 添加事件的方法名和前缀:v-on:click=“onClick” , 则 attrName = ‘v-on:click’ id=“app” attrname= ‘id’
if(self.isDirective(attrName)) {
var exp = attr.value; // 添加事件的方法名和前缀:v-on:click=“onClick” ,exp = ‘onClick’

            //substring() 方法用于提取字符串中介于两个指定下标之间的字符。返回值为一个新的字符串
            //dir = 'on:click'
            var dir = attrName.substring(2);  
            if(self.isEventDirective(dir)) {   //事件指令
                self.compileEvent(node,self.vm,exp,dir);
            }else {          //v-model指令
                 self.compileModel(node,self.vm,exp,dir);
            }

            node.removeAttribute(attrName);
        }
    });
}

上面的 compile 函数是挂载 Compile 原型上的,它首先遍历所有节点属性,然后再判断属性是否是指令属性,如果是的话再区分是哪种指令,再进行相应的处理.

最后我们再次改造一下 SelfVue,是它的格式看上去更像 vue:

function SelfVue(options) {
var self = this;
this.data = options.data;
this.methods = options.methods;
Object.keys(this.data).forEach(function(key) {
self.proxyKeys(key);
});
observe(options.data);
new Compile(options.el,this);
options.mounted.call(this);
}
测试一下:

{{title}}

{{name}}

click me!
效果如下:

 

到目前为止,我们简易版的 demo 已经成功了,通过上面这个例子,我们可以更加深刻的理解 vue 的一些机制,比如双向绑定,声明式渲染等.

 

写在后面

因为代码量比较多,所以对于一些不重要的没有一一展示,我把代码放在我的 github 上了,github Clone with HTTPS:https://github.com/2686685661/SelfVue.git

参考博客:https://www.cnblogs.com/libin-1/p/6893712.html

作者:李闪磊
来源:CSDN
原文:https://blog.csdn.net/lishanleilixin/article/details/79360244
版权声明:本文为博主原创文章,转载请附上博文链接!

  
    展开阅读全文