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

2,652 阅读15分钟

前言

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

如果你跟之前的我一样,听说过MVVM,也对Virtual Dom有所耳闻,但是说不出个大概
那么希望这篇文章能对你有所帮助

MVVM

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

  • M:Model(数据)

  • V:View(视图)

  • VM:ViewModel

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

如何自动更新

既然要自动更新,那么这个中间人VM至少做了三件事

  • 1 监听到了数据变化

  • 2 通知视图

  • 3 视图执行更新

监听数据变化

Vue目前用到的是Object.defineProperty()这个方法
将来Vue3.0会换成Proxy,不过是有点类似的,所以不用担心到时候需要重新学,理解了思想之后一切都很快

Object.defineProperty()接收三个参数

  • 1、实例

  • 2、属性

  • 3、属性描述符

比如说我们现在想要监听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的角度也能更好的做恰当的优化

大总结

再来借用一次那张流程图

如果之前的东西没有完全看懂,可以再来对着这张图整理一下总流程

  • 1、对数据运用Object.defineProperty()进行数据劫持,并进行数据代理
  • 2、发布——订阅模式,用dep和watcher进行绑定
  • 3、编译模板成AST抽象语法树,进行静态优化后,生成render函数,返回Virtual DOM,并进行第一次的渲染
  • 4、数据更新时,由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.cn/post/684490…
不好意思!耽误你的十分钟,让MVVM原理还给你
juejin.cn/post/684490…
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.cn/post/684490…
尤大大关于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.cn/post/684490…
Vue源码学习
hcysun.me/2017/03/03/…
快速了解 Vue2 MVVM
github.com/wangfupeng1…

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

我的个人博客

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