深入剖析vue原理以及MVVM响应式原理的实现
本文所有代码实现都收录于我的github,感兴趣的可以点击访问。
一、什么是MVVM
大家都知道vue.js遵循的是mvvm的设计理念,下面简要说明什么是mvvm。
采用分而治之思想,把不同的代码放到不同的模块当中,然后通过特定的逻辑联系到一起。
- 1、M:model、就是模型数据,普通的JS对象。
- 2、V:view、就是Dom。
- 3、VM:view-model、就是Vue,view和model不可以直接交互,需要通过VM联系到一起。
M 到 V(数据驱动视图):Data Bindings:通过数据绑定联系到一起。
V 到 M(视图影响数据):Dom Listeners:通过事件监听联系到一起。
只要数据进行了改变,同时视图也会同时更新。
理解了基本思想之后,我们要做什么才能实现VM呢?
- 1.首先,需要利用Object.defineProperty,将要观察的对象,转化成getter/setter,以便拦截对象赋值与取值操作,称之为Observer,也就是数据观察者;
- 2.需要将DOM解析,提取其中的指令与占位符,并赋与不同的操作,称之为Compile,也就是指令解析器;
- 3.需要将Compile的解析结果,与Observer所观察的对象连接起来,建立关系,在Observer观察到对象数据变化时,接收通知,同时更新DOM,称之为Watcher,也就是订阅者,它是Observer和Compile之间通信的桥梁;
- 4.最后,需要一个公共入口对象,接收配置,协调上述三者,称为vm,也就是Vue;
二、几种实现双向绑定的做法
目前几种主流的mvc(vm)框架都实现了单向数据绑定(例如react就是典型的数据单向绑定),简单的理解双向数据绑定无非就是在单向绑定的基础上给可输入元素(input、textare等)添加了change(input)事件,来动态修改model和 view。
一、实现数据绑定的做法有大致如下几种:
- 1 .发布者-订阅者模式(backbone.js)
- 2.脏值检查(angular.js)
- 3.数据劫持(vue.js)
一、发布者-订阅者模式
一般通过sub, pub的方式实现数据和视图的绑定监听,更新数据方式通常做法是 vm.set(‘property’, value)。
这种方式现在毕竟太low了,我们更希望通过 vm.property = value 这种方式更新数据,同时自动更新视图,于是有了下面两种方式。
二、脏值检查
angular.js 是通过脏值检测的方式比对数据是否有变更,来决定是否更新视图,最简单的方式就是通过 setInterval() 定时轮询检测数据变动,当然Google不会这么low,angular只有在指定的事件触发时进入脏值检测,大致如下:
- 1.DOM事件,譬如用户输入文本,点击按钮等。( ng-click )
- 2.XHR响应事件 ( $http )
- 3.浏览器Location变更事件 ( $location )
- 4.Timer事件( timeout ,timeout,interval )
- 5.执行 digest() 或digest()或apply()
三、数据劫持
vue.js 则是采用数据劫持结合发布者-订阅者模式
的方式,通过Object.defineProperty()
来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。
三、vue.js数据劫持实现
一、思路整理
已经了解到vue是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过Object.defineProperty()
来实现对属性的劫持,达到监听数据变动的目的,无疑这个方法是本文中最重要、最基础的内容之一。
整理了一下,要实现mvvm的双向绑定,就必须要实现以下几点:
- 1、实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者
- 2、实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数
- 3、实现一个Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图
- 4、mvvm入口函数,整合以上三者上述流程如图所示:
二、指令解析器Compile的实现
指令解析器
的主要作用就是对指令进行解析。例如:v-text,v-html,v-on,v-bind等。解析指令之后,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图,如图所示:
在创建指令解析器之前,我们要提供入口类,也就是vm,用来接受配置,协调其它三者:
// 入口类
class Myvue{
constructor(options){
this.$el = options.el;
this.$data = options.data;
this.$options = options;
if(this.$el){
// 2.实现指令的解析器
new Compile(this.$el,this)
}
}
}
指令解析器的解析过程:
- 1.首先对el属性挂在的元素进行编译,将模板中的指令(v-text等)或者插值表达式({
{}})进行替换,但是频繁的编译和替换会导致页面的回流和重绘,会影响页面的性能,所以我们要利用
文档碎片对象
,会减少页面的回流和重绘。文档碎片的作用:将替换之后的内容放到缓存中,需要使用时会进行获取。 - 2.将文档碎片对象作为模板进行编译。
- 3.将文档碎片追加到根元素中。
指定解析器的部分代码如下:
// 指令解析器
class Compile{
constructor(el,vm){
// 当前传入的el是一个元素节点则赋值给当前类的el,否则自行获取元素节点
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
/* 需要对根节点下的每一个节点进行编译,然后将页面中的数据(例如{
{person.name}})进行替换
频繁的编译和替换会导致页面的回流和重绘,会影响页面的性能
文档碎片的作用:将替换之后的内容放到缓存中,需要使用时会进行获取
*/
// 1.获取文档碎片对象,会减少页面的回流和重绘
const fragment = this.node2Fragment(this.el);
// 2.将文档碎片对象作为模板进行编译
this.compile(fragment);
// 3.将文档碎片追加到根元素中
this.el.appendChild(fragment)
}
// 创建文档碎片对象
node2Fragment(el){
// 创建文档碎片对象
const f = document.createDocumentFragment();
let firstChild;
// 遍历传入的DOM节点
while(firstChild = el.firstChild){
// 追加文档碎片
f.appendChild(firstChild);
}
return f;
}
// 编译模板:获取到的文档碎片内容
/** 内容如下:
* <h2>{
{person.name}}--{
{person.age}}</h2>
<h3>{
{person.fav}}</h3>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
<h3>{
{msg}}</h3>
<div v-text="msg"></div>
<div v-html="msg"></div>
<input type="text" v-model="msg">
*/
compile(fragment){
// 1.获取子节点
const childNodes = fragment.childNodes;
[...childNodes].forEach(child => {
// 元素节点
if(this.isElementNode(child)){
// 编译元素节点
this.compileElement(child)
// 文本节点
}else{
// 编译文本节点 主要处理 {
{}} 形式的表达式
this.compileText(child)
}
// 递归遍历
if(child.childNodes && child.childNodes.length){
this.compile(child)
}
})
}
}
在对文档碎片对象进行递归遍历时,我们从文档碎片对象中获取到的节点可能是元素节点,也可能是文本节点。对于元素节点和文本节点的编译过程是不一样的,所以我们在compile函数中进行了区分,对元素节点和文本节点的编译过程代码如下:
// 编译元素节点
compileElement(node){
// console.log(node);//<div v-text='msg'></div>
// 获取属性节点
const attributes = node.attributes;
// console.log(attributes);//{0: v-on:click, v-on:click: v-on:click, length: 1}
[