Vue的运做原理——浅析MVVM及Virtual DOM

前言

本文不会拉出Vue的源码出来剖析一番,也不会挂一大段代码去笼统地讲,尽可能会从逻辑角度一步步来梳理html

若是你跟以前的我同样,据说过MVVM,也对Virtual Dom有所耳闻,可是说不出个大概
那么但愿这篇文章能对你有所帮助前端

MVVM

都说前端框架运用了MVVM的思想,那么MVVM是什么vue

  • M:Model(数据)node

  • V:View(视图)git

  • VM:ViewModelgithub

其中VM就是解放生产力的核心
它让咱们再也不须要去手动操做Dom更新视图,一切都是自动完成的,咱们只需专一于数据逻辑以及页面呈现web

如何自动更新

既然要自动更新,那么这个中间人VM至少作了三件事正则表达式

  • 1 监听到了数据变化算法

  • 2 通知视图编程

  • 3 视图执行更新

监听数据变化

Vue目前用到的是Object.defineProperty()这个方法
未来Vue3.0会换成Proxy,不过是有点相似的,因此不用担忧到时候须要从新学,理解了思想以后一切都很快

Object.defineProperty()接收三个参数

  • 一、实例

  • 二、属性

  • 三、属性描述符

好比说咱们如今想要监听data对象身上的a属性

data:{a:val,b:2, c:3}
    var val=1
    Object.defineProperty(data, key, {
    writable: true, // 可枚举
        enumerable: true, // 可枚举
        configurable: false, // 不能再define
        get: function () {
            dep.bind()
            return val;
        },
        set: function (newVal) {
            if (newVal === val) return;
            console.log('监听到值变化了: ', val, '==>', newVal);
            val = newVal;
            dep.notify()
        }
    });
复制代码

咱们把原来单纯的一个值,拆分红一个getter和一个setter
这样不管值被获取仍是重写,咱们都能知道

通知视图

上面只是console了一下,咱们实际须要去通知视图

因而咱们给每个属性都设一个传声筒dep,由他来负责通知
同时,咱们也得让它知道到底去通知谁

新建一个类Dep,在每一次调用Object.defineProperty()的时候,顺便new一个dep实例出来
它身上设定两个方法,bind()和notify()

视图第一次渲染会调用属性的get去取值,咱们就能够用bind()让dep绑定要通知的对象
而修改数据的时候,就触发dep.notity()去通知

咱们先无论这两个方法具体是怎么实现的
定义一个defineProperty()方法,把骨架搭出来

defineValue(data, key, val) {
    var dep = new Dep() //给这个属性创建一个传声筒
    Object.defineProperty(data, key, {
	..............
        get: function () {
            dep.bind()
            return val;
        },
        set: function (newVal) {
            if (newVal === val) return;
            val = newVal;
            dep.notify()
        }
    });
}
复制代码

视图更新

如今把目光转移到视图这一边,假设如今html长这样

<div>
			<p>{{ a }}</p>
			<p>{{ b }}</p>
			<p>{{ c }}</p>
		</div>
		<div>
		         <p>{{ c }}</p>
		</div>
复制代码

这串html在关心a,b,c三个数据
但不一样p标签关心的数据不同
若是数据c变了,咱们确定但愿c的传声筒只去通知后面两个p标签,别的p标签不用知道

因此在视图这边,也须要给每个引用到数据的地方,设立一个经纪人watcher,区分开来,这样dep就知道去通知谁了

如今咱们假设每一个watcher身上都有一个update()方法
因此dep的notify方法,就是调用全部与dep相关的watcher身上的update()

有点乱?

话讲到这里,忽然就在原来的v和m的基础上,多出来两个角色dep和watcher,感受愈来愈乱了
等等,dep和watcher,那这两我的就是VM的真身
没错,他们一块儿组成了VM核心
而这个模式就是大名鼎鼎的“发布-订阅模式

下面咱们从头开始,补全全部的细节

建立Mvvm类

-- 想一想Vue是怎么建立实例的

var app=new Vue({
			el:'#app',
			data:{
				a:1,
				b:2,
				c:3,
				d:{
				    e:4
				}
			}
		})
复制代码

咱们模仿Vue,建立一个Mvvm类,获取用户传进来的参数,并把data赋给自身的$data属性

class Mvvm {
constructor(options = {}) {
    //将全部属性挂载到$options
    this.$options = options;
    // 将data数据取出来赋给$data
    this.$data = this.$options.data;

    // 数据劫持
    this.observe(this.$data);

    //数据代理
    this.proxyData(this.$data)

    //编译页面
    this.$compiler = new Compiler(this, this.$options.el || document.body) 
    }
    observe(){}
    defineProperty()
    proxyData(){}
}
复制代码

在Mvvm实例的初始化中,赋值完以后,依次作了三件事
数据劫持,数据代理,编译页面

数据劫持

数据劫持就是咱们上面定义过的defineProperty,不过这里须要补充一个嵌套劫持,让对象属性中的属性也能被监听
重复部分就省略了,重点关注实现嵌套的代码

observe(data) {
    if (!data || typeof data !== 'object') return;
    Object.keys(data).forEach(key => {
        this.defineValue(data, key, data[key]);
    });
}
defineValue(data, key, val) {
    ...............
    _this.observe(val); // 监听子属性
    Object.defineProperty(data, key, {
        ................
        set: function (newVal) {
	    ....................
            _this.observe(val) //对新值进行监听,由于它多是个对象
            dep.notify()
        }
    });
复制代码

数据代理

在vue中,咱们获取一个数据不是经过vm.data.a这样的形式的,是直接vm.a进行读写,因此咱们还要进行一下数据代理
至关于把$data给镜像过来,暴露给用户

proxyData(data) {
    //由于只是为了省略$data,因此只须要遍历第一层,不用深度遍历
    Object.keys(data).forEach(key => {
        Object.defineProperty(this, key, {
            configurable: false,
            enumerable: true,
            get: function () {
                return this.$data[key]
            },
            set: function (newVal) {
                this.$data[key] = newVal
            }
        }
        )
    })
}
复制代码

编译页面

给咱们一段html,咱们须要分析出里面哪些地方引用了数据,哪些地方用到了指令,进行第一次的数据更新渲染
同时还有就是给用到数据的地方分配watcher

离线操做Dom

实际操做Dom是很慢的,因此咱们这里用到了fragment(文档碎片),把Dom都拷到这里面进行操做
固然这个实际上是vue1.x时候使用的,不过对咱们理解mvvm很是有帮助

定义一个转移Dom到fragment的方法

node2fragemt(el) {
    var fragment = document.createDocumentFragment()
    var child
    while (child = el.firstChild) {
        fragment.appendChild(child)
    }
    return fragment
}
复制代码

由于节点不能有两个父亲,因此调用appendChild的时候,就至关于把Dom节点抢了过来

操做完了之后,再把fragment丢给真实Dom便可

定义一些判断节点的方法

//是不是节点
isElement(node) {
    return node.nodeType == 1;
}
//是不是指令
isDirective(node) {
    return node.substring(0, 2) === 'v-';
}
	
//是不是事件指令
isEventDirevtive(dir) {
    return dir.indexOf('on') === 0;
}
//是不是文本节点
isTextElement(node) {
    return node.nodeType == 3
}
复制代码

而后定义Compiler类

class Compiler {
		constructor(vm, el) {
			this.$vm = vm
			this.$el = this.isElement(el) ? el : document.querySelector(el)
			if (this.$el) {
				this.$fragment = this.node2fragemt(this.$el)
				this.compile(this.$fragment)
				this.$el.appendChild(this.$fragment)
			}
		}
复制代码

分类解析节点

compiler类的核心是compile函数
分类去解析节点

compile(el) {
    var nodes = el.childNodes
    Array.from(nodes).forEach(node => {
        if (this.isElement(node)) {
	//普通节点
        ....................................
        }
        else if (this.isTextElement(node)) {
	//文本节点
        ...................................
        }
        //先进行上面的解析,若是发现node还有子节点,就递归地进行子节点的解析
        if (node.childNodes && node.childNodes.length)
            this.compile(node)
    })
}
复制代码

递归解析每一个节点,分为两类处理,一个是普通节点,一个是文本节点
普通节点上可能有指令,文本节点上可能有{{}}

普通指令及双向绑定

经过attribute属性取到指令,先判断一下格式是否正确,是不是v-开头的
而后这里面又分为事件指令(on:click之类)或者普通指令(v-text,v-model,v-html)
本文先只介绍普通指令

var attrs = node.attributes
    Array.from(attrs).forEach(attr => {
        if (!this.isDirective(attr.name)) return; //若是不是以v-开头的指令,直接返回不处理
        var exp = attr.value.trim() //是string类型,因此还要去除一下两边的空格
        var dir = attr.name //例如v-text
        if (this.isEventDirevtive(dir)) {
            //若是是事件处理函数
        } else {
            //普通指令
            updateFn[dir] && updateFn[dir](node, this.getVal(exp), this.$vm, exp)
            new Watcher(this.$vm, exp, (value) => {
                updateFn[dir] && updateFn[dir](node, value, this.$vm, exp);
            });
        }
    })
复制代码

能够看到在普通指令中,用dir去取到了一个方法进行执行,同时新建了一个Watcher,它的回调函数也是这个方法
Watcher先放一放,咱们看看在执行什么方法

//指令函数
var updateFn = {
		"v-text": function (node, val) {
		    node.textContent = val === undefined ? '' : val
		},
		"v-html": function (node, val) {
		    node.innerHTML = val === undefined ? '' : val
		},
		"v-model": function (node, val, vm, exps) {
		    node.value = val === undefined ? '' : val
		    node.addEventListener('input', e => {
			exp = exps.split('.')
			var len = exp.length
			if (len == 1) {
				return vm[exp] = e.target.value
			}
			var data = vm
			for (let i = 0; i < len - 1; i++) {
				data = data[exp[i]]
				console.log(exp[i])
			}
			data[exp[len - 1]] = e.target.value
		})
	}
}
复制代码

前两个很好理解,就是纯粹去用取到的数据值更新节点的值
而v-model,也就是咱们大名鼎鼎的双向绑定,其实很简单
它就只是比上面两个多一个监听事件,去更新实例上的值罢了

模板语法{{}}

处理完普通节点,来处理咱们的文本节点
其实就是处理一个模板语法{{}}
咱们想要把其中的变量取出来,因此就要用到正则表达式的捕获组,用括号去捕捉
而后用RegExp.$1去取到捕获的值
不过若是处理相似{{a}},{{b}}这种的话,捕获的时候,后一个会覆盖前一个,RegExp.$1就只能取到b
因此咱们先用match把他们拆分出来,再分别捕获

var exps = node.textContent.match(/\{\{.*?\}\}/g) //先进行拆分
if (!exps) return;
Array.from(exps).forEach(item => {
    item.match(/\{\{(.*?)\}\}/g)
    this.compileText(node, item, RegExp.$1.trim()) //经过正则的括号进行捕获,trim()用来去除空格
})
复制代码

compileText其实就是把{{}}替换成值

compileText(node, exp, content) {
    var val = this.getVal(content)
    if (val === undefined) val = "";
    var text = node.textContent //保留一份原来的格式以供更新
    node.textContent = node.textContent.replace(exp, val)
    new Watcher(this.$vm, content, (value) => {
        if (value === undefined) value = "";
        node.textContent = text.replace(exp, value)
    });
}
复制代码

这里也建立了一个watcher 因此在编译的时候,每一个用到数据的地方,都建立了一个wacther

Watcher和Dep绑定

再来回顾一下这张图

如今还剩下的就是,watcher和dep的互相关联以及watcher执行update()更新视图了
而咱们至今还没揭晓这两个类到底长啥样

首先解开Dep的面纱
还记得咱们以前卖了个关子,没有说dep.bind()是怎么实现的
下面咱们就来看看dep.bind()在干吗

class Dep {
	constructor() {
	    this.subs = new Set(); //为了保证不重复添加
	}
	bind() {
	    //注册当前活跃的用户为订阅者,并让对方添加本身
	    this.subs.add(Dep.target)
	    Dep.target.addDep(this)
	}
}
复制代码

每一个dep实例都有一个subs,保存本身要通知的那些watcher,为了避免重复,使用了Set结构、 在第一次渲染视图的时候,有向实例拿过数据,就在那时已经触发了实例属性的get方法,进而触发了bind函数了

那这个Dep.target又是哪来的?
答案就是在wathcer实例初始化的时候

Wacher类

class Watcher {
    constructor(vm, exp, cb) {
    this.$vm = vm
    this.$cb = cb
    this.$deps = new Set()

    if (typeof exp === 'function') {
        this.getter = exp
    }
    else {
        this.getter = this.createGetter(exp)
    }
    this.$value = this.runGetter()
}

addDep(dep) {
    this.$deps.add(dep)
}
runGetter() {
    if (!this.getter) return;
    Dep.target = this
    var value = this.getter.call(this.$vm, this.$vm);
    Dep.target = null
    return value;
}
createGetter(exp) {
    var exps = exp.split('.')
    return function (vm) {
        var val = vm
        exps.forEach(key => {
            val = val[key]
        });
        return val;
    }
}
复制代码

}

在watcher初始化的时候,根据exp(也就是咱们以前编译时辛辛苦苦取到的变量)的类型建立了一个getter方法
若是exp是函数,getter就是直接运行它,不然就是去实例身上取值,为了能经过相似“a.b.c”这样的字符串取到值,咱们用了代码中那个层层遍历递进的方法
有了getter以后,咱们就在初始化的时候执行一下,把值保存下来,以便未来作比对
同时制定Dep.target为本身

因此整个运行的顺序

  • 建立watcher
  • 建立getter
  • 指定Dep.target为本身
  • 运行getter
  • 触发属性的get()
  • 触发dep.bind()
  • dep把watcher添加到本身的subs中
  • watcher把dep也添加到本身的deps中
  • 绑定结束,属性把值返回给watcher=>清空Dep.target=>watcher把value保存到$value中

通知更新

最后一步,当数据更新的时候,要让dep去通知watcher执行update
还记得属性set()里面的dep.notify()方法么

class Dep{
...............
	notify() {
		//通知全部订阅者执行更新函数
		this.subs.forEach(item => {
			item.update()
		})
	}
}
复制代码

很简单,就是通知全部绑定的watcher去执行update

那么watcher的update()长啥样呢

class Wacther{
			................
			update() {
				var oldVal = this.$value
				var newVal = this.runGetter()
				if (newVal === oldVal) return;
				this.$value = newVal
				this.$cb.call(this.$vm, newVal, oldVal)
			}
	}
复制代码

就是调用getter去获取数据最新的值,而后调用以前保存的回调函数更新视图

这里你们可能有个疑问,为何dep的notify不带上新的值做为参数告诉watcher,而让watcher再本身取一次?
由于其实dep不知道手下的每一个watcher究竟在观察什么,好比dep管理着a,而a={b:'dsd',c:'dsads'},这个watcher可能只是在关心a.b变化了没,另外一个watcher在关心a.c,可是dep并不知道
因此Dep不作传值,只是在数据变化的时候通知全部相关订阅者,本身去看看数据变成啥样了

小总结

至此,咱们的Mvvm就告一段落了,其实还有很多功能,好比computed,watch,事件指令

不过搞懂核心的东西,就足够了,之后再能够继续完善
目前为止的代码能够去个人github上看
github.com/ssevenk/Mvv…

不过,如今这些东西,都只是Vue.1x
除了咱们以前提到的,Vue3.0会把Object.defineProperty()换成proxy之外
渲染和更新如今用的也不是fragment了,而是下面这一位

Virtual Dom

大名鼎鼎的Virtual Dom 并不是React发明,但由React发扬光大
曾经的Vue用的是咱们上文介绍的这种依赖收集,而后局部更新的方法,但从Vue2.x开始,使用的也是Virtual Dom了

三种更新的方法

如何把数据的更新投射到视图上,三大框架曾经各抒己见

  • Angular使用的是脏检查,当咱们触发了某些事件(定时,异步请求,事件触发等),执行完事件以后,Angular会遍历全部“注册”过的值,判断是否和以前的一致
    因此它的更新复杂度稳定在O(watcher count) + 必要的DOM更新 O(DOM change)

  • Vue曾经使用的是咱们本文讲解的依赖收集,每一次更新数据会针对新数据从新收集一次依赖
    因此复杂度为 O(data change) + 必要 DOM 更新 O(DOM change)

  • React用的的Virtual Dom,它的本质其实相似离线Dom,每次操做以后,与原来的Virtual\ Dom进行diff比对,把patch投射到真实Dom上,在diff比对上,原本两棵Dom树的比对会达到O(n^3)的复杂度,可是React团队用了取巧的方法,考虑到web应用中不多会出现跨层移动Dom节点,因此只进行两棵树的同层比对,强行把复杂度下降到了O(n)
    因此最后总的复杂度是O(template size) + 必要的 DOM 更新 O(DOM change)

Virtual Dom是最快的?

不少地方都会大肆宣扬Virtual Dom的快速,确实Virtual Dom很快,但是那也得看跟谁比
若是是跟很粗暴地把整棵真实Dom树直接更新了,也就是直接设置node.innerHTML相比,确定Virtual Dom要快多了
由于操做Dom是很耗时间的,而Virtual Dom实际上是JS对象,操做JS可快多了

但是实际上没有人会一有数据更新,就把整棵真实的Dom树给更新了的
一来,若是你去手动优化,精准地更新Dom,那确定要比Virtual Dom快

固然框架是要追求普适性的,不过即便上面那两个一样具备普适性的方法,脏检查和依赖收集相比,那也是互有胜场

借用尤大大的回答 www.zhihu.com/question/31…

  • 初始渲染:Virtual DOM > 脏检查 >= 依赖收集
  • 小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(没法优化) > Virtual DOM 无优化
  • 大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(没法/无需优化)>> MVVM 无优化

优势

既然在速度上互有胜场,那Virtual Dom必定还有更增强大的优势,能促使Vue去使用它
仍是看尤大大本身的讲解,能够看下这段视频
www.bilibili.com/video/av621…

Virtual Dom最大的优点在于

  • 它把渲染的逻辑从真实Dom中解耦出来了,也就是说若是没有最后一步:把虚拟Dom渲染回真实Dom的话,其实咱们彻底能够把这串虚拟Dom渲染到别的地方去,只要它支持JS,好比手机终端,pdf,canvas,webgl,也就是求之不得的“一次编写,到处运行”,是更能面向将来的
  • 同时Virtual Dom也实现了组件的高度抽象化,为函数式的UI编程打开了大门

修改后的流程

Vue在改用Virtual Dom后,流程又是怎么走的呢 借用这篇文章的图片 github.com/wangfupeng1…

能够看到新出来两个概念,ASTRender

与html转为Dom树的过程相似,Vue会先把模板给解析成抽象语法树,而后优化AST,找到其中静态和动态的部分,在优化方面Vue3.0会有更好的突破,目前还不是很完善,总之目标就是肯定哪些部分是可能会变化的,哪些是静态的不会变的,最后生成一个render函数

render函数

若是是在开发环境下,会在运行时把模板根据上面的步骤解析为render函数,而若是是生产环境,这一步是发生在编译打包阶段的,因此最后的文件中直接就是render函数

那这个render函数就是用来返回Virtrual Dom的
若是是初次渲染,就会把这个Virtual Dom树直接投射生成真实Dom
若是是数据更新,就相似本文介绍的那个流程,触发通知,执行update函数,触发render函数的从新执行,生成一个新的Virtual Dom树,与原来的进行diff比对,并把最小差别patch到真实Dom上进行局部从新渲染
这个渲染是异步的,也就是说一次渲染会集合多个数据的变化

就地复用

在渲染的过程当中,有一个很重要的概念,就是就地复用
数据的角度来讲,一个列表的数据变了,那就应该销毁实例从新建立
可是Virtual Dom是基于Dom进行变更检查的,若是最终的渲染结果没有变化,就不该该有这种额外的劳动
好比若是数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序, 而是简单复用此处每一个元素

不过这种机制在没有key的状况下会形成一些问题,好比按删除按钮,但被删除的元素不是你想指定的那个
因此要为每一项肯定一个惟一的id,从Virtual Dom的角度也能更好的作恰当的优化

大总结

再来借用一次那张流程图

若是以前的东西没有彻底看懂,能够再来对着这张图整理一下总流程

  • 一、对数据运用Object.defineProperty()进行数据劫持,并进行数据代理
  • 二、发布——订阅模式,用dep和watcher进行绑定
  • 三、编译模板成AST抽象语法树,进行静态优化后,生成render函数,返回Virtual DOM,并进行第一次的渲染
  • 四、数据更新时,由dep发起通知,watcher调用update,render函数从新执行,返回新的Virtual DOM,与原来的进行diff对比,把最小差别patch到真实DOM上局部从新渲染

但愿本文对于你们理清Vue的运做原理有所帮助

参考资料

尤大大对于Virtual Dom的介绍
www.bilibili.com/video/av621…
Mvvm视频教程
www.bilibili.com/video/av240…
尤大大讲解Vue源码
www.bilibili.com/video/av514…
Object.defineProperty()和proxy的区别
www.fly63.com/article/det…
数据双向绑定系列教程
www.chuchur.com/article/vue…
收藏好这篇,别再只说“数据劫持”了
juejin.im/post/5af198…
很差意思!耽误你的十分钟,让MVVM原理还给你
juejin.im/post/5abdd6…
DOM和Virtual DOM之间的区别
www.jianshu.com/p/620b0435d…
vue核心之虚拟DOM(vdom)
www.jianshu.com/p/af0b39860…
虚拟DOM与DIFF算法学习
segmentfault.com/a/119000001…
为何虚拟DOM更优胜一筹
www.cnblogs.com/rubylouvre/…
谈谈Vue/React中的虚拟DOM(vDOM)与Key值
juejin.im/post/5cff1b…
尤大大关于Virtual DOM的知乎回答
www.zhihu.com/question/31…
Vue源码学习笔记
jiongks.name/blog/vue-co…
Vue技术揭秘
ustbhuangyi.github.io/vue-analysi…
Vue源码分析
github.com/liutao/vue2…
入口文件开始,分析Vue源码实现
juejin.im/post/5adead…
Vue源码学习
hcysun.me/2017/03/03/…
快速了解 Vue2 MVVM
github.com/wangfupeng1…

其中最推荐的是看最后这一篇,我的认为是讲的最清楚的,流程图也是借用这位做者的

个人我的博客

这是个人我的网站,记录下前端学习的点滴,欢迎你们参观
www.ssevenk.com

相关文章
相关标签/搜索