【Vue】源码解析

现在三大框架风起云涌, JQuery老大哥的光辉不再, 使前端成为了各路诸侯的兵家必争之地,

当然作为一名优质的前端,光跟风学框架是肯定不行的, 要知其然而知其所以然

要了解MVVM的本质原理, virtual DomDiff算法解决的问题

拒绝盲目跟风

🍎diffDom优劣

现在很多人都说 Vue, React 多牛, Diff算法快,不用操作Dom

其实不然。

Diff算法不是不需要操作Dom, 而是不需要开发者去操作Dom了, Diff算法其实不快, 就算使用了virtual Dom, 还得花实现把真实Dom 转换为 virtual Dom 再去比对, 这远远没有js直接 getElementById直达目标来的快

那Diff算法不快为什么还要用呢?

Diff算法其实是给那些比较随意的新手开发者准备的

看这个例子

1
2
3
4
5
6
<!-- 原本的dom -->
<ul id="ul">
<li>a</li>
<li>a</li>
<li>a</li>
</ul>
1
2
3
4
5
6
7
8
// 拿到结果不管三七二十一把旧dom全替换了
$.get('/api', (res) => {
var _HTML = ""
for(var i=0; i< res.length; i++){
_HTML = "<li>" + res[i] +"</li>"
}
$("#ul").html(_HTML)
})

如果是原始的Dom操作, 有很多小白会像这个例子一样, 不管Dom需不需要更新, 他都把ajax返回的请求全部跑一边,生成HTML模板, 然后把原本的所有li都删了, 再把新的模板放进去, Dom少还看不出来, 如果Dom多了呢, 上千的Dom, 这顿操作一下就玩炸了。

况且网站优化原则就是尽量减小Dom操作, 如果是有经验的开发者, 会选择找到有变化的位置,使用append插入

📄Vue架构目录

Vue官网下载Vue源码看看,

打开里面会有一个 src目录, 里面就是整个的Vue源码

目前有6个目录, 作用分别如下

Vue目录

Vue.js 的组成是由 core + 对应的 ‘平台’ 补充代码构成(独立构建和运行时构建只是 platformsweb 平台的两种选择)

Vue的核心原理就在core文件夹中, 让我们进入 core 文件夹看看

core文件夹

了解了目录,接下来我们就来研究Vue的双向绑定

🔗双向绑定(响应式原理) 所涉及到的技术

  • Obejct.defineProperty 【提供getter 和 setter】
  • Observer 【提供getter 和 setter】
  • watcher 【提供getter 和 setter】
  • Dep 【负责收集watcher】
  • Directive 【处理Vue模板指令】

Obejct.defineProperty

Obejct.defineProperty 是整个Vue的灵魂,

来看一下Obejct.defineProperty 如何使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var obj = {}
var c;
Object.defineProperty(obj, 'a', {
get() {
console.log('getter')
return a
},
set(newVal) {
console.log('setter')
c = newVal
this.a = newVal
}
})

obj.a = '234'
console.log(c) // 234
console.log(obj.a) // 234

它帮助Vue实现了双向绑定, 但也因为这个, Vue也只能舍弃了对低版本浏览器的支持。

defineProperty兼容

它只能兼容到IE9 , 并且市面上的polyfill实现的也并不是很好

那低版本如何代替Obejct.defineProperty , 难道真没了它就不行吗?

当然有

  1. 👆 > IE 7

实际上在IE7的时候就已经有暴露了 __defineGetter__ 方法,
__defineGetter__

具体用法如下

1
2
3
4
5
6
7
var random = {};
random.__defineGetter__('ten', function() {
return Math.floor(Math.random()*10); });
random.__defineGetter__('hundred', function() {
return Math.floor(Math.random()*100); });

random.ten // 随机的一个值

  1. 👇 < IE 7

早年间的IE 是支持VBScript, VBScript 就可以直接写类, 并且也支持getset方法

1
2
3
4
5
6
7
8
class Test {
get name () {

}
set name() {

}
}

🐶霸道的IE

说了这么多IE的坏, 这里也带一嘴IE的好,

IE能够调用EXE程序, 比如JS无法设置打印机的宽高,就可以利用ActiveObjectX来做到, 甚至可以修改word格式等等 , 所以办公类的项目离不开IE

😕MVVM 双向数据绑定流程

MVVM: Model–view–viewmodel

那怎么区分这些层呢

  • 🚀Model: Observer
  • ✈️view : directive
  • 🚚viewmodel: Watcher && Dep 【用于连接 Model 和 view】

双向数据绑定

先看Directive, 这就是我们平时写的vue指令, 如上面举例得的v-text="times", 这就是一个指令, 一个Directive会分配一个Watcher

Observer

观察者模式是软件设计模式的一种。在此种模式中,一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知。这通常透过呼叫各观察者所提供的方法来实现。此种模式通常被用来实时事件处理系统。订阅者模式涉及三个对象:发布者、主题对象、订阅者,三个对象间的是一对多的关系,每当主题对象状态发生改变时,其相关依赖对象都会得到通知,并被自动更新。

简单的描述就是:

你想买漫画, 但是问了报刊亭的大爷, 大爷说现在没有, 还没到货, 然后你回去了, 第二天你又去问, 大爷还是说没有, 如果你每天这样问, 大爷估计会嫌你烦。 如果这时候你把你的电话给大爷, 大爷记录到他的本子上, 当大爷的漫画到货的时候电话通知你。

这时你就是订阅者, 大爷就是发布者, 你们就存在一个发布订阅者的关系

Vue 中的Observer

Observer会观察两种类型的数据,ObjectArray
对于Array类型的数据,由于 JavaScript 的限制, Vue 不能检测变化,会先重写操作数组的原型方法,重写后能达到两个目的,

当数组发生变化时,触发 notify 如果是 pushunshiftsplice 这些添加新元素的操作,则会使用observer观察新添加的数据重写完原型方法后,遍历拿到数组中的每个数据 使用observer观察它而对于Object类型的数据,则遍历它的每个key,使用 defineProperty 设置 gettersetter,当触发getter的时候,observer则开始收集依赖,而触发setter的时候,observer则触发notify

对 Object 的处理

Observer 对象的标志就是__ob__ 这个属性,这个属性保存了 Observer 对象自己本身。对象在转化为 Observer 对象的过程中是一个递归的过程,对象的子元素如果是对象或数组的话,也会转化为 Observer 对象

对数组的处理

其实 observeArray 方法就是对数组进行遍历,递归调用 observe 方法,最终都会走入 walk 方监控单个元素。而 walk 方法就是遍历对象,结合 defineReactive 方法递归将属性转化为 gettersetter

Watcher

Watcher 是将模板和 Observer 对象结合在一起的纽带。Watcher 是订阅者模式中的订阅者。Watcher 的两个参数: expOrFn 最终会被转换为 getter 函数, cb 是更新时执行的回调。依赖收集的入口就是get函数。

getter 函数是用来连接监控属性与 Watcher 的关键

只有通过watcher 触发的getter 会收集依赖,而所谓的被收集的依赖就是当前watcher.初始化时传入的参数 expOrFn 中涉及到的每一项数据,然后触发该数据项的 getter 函数;getter 函数中就是通过判断 Dep.target的有无来判断是 Watcher 初始化时调用的还是普通数据读取,如果有则进行依赖收集

Dep

这个方法是在响应式的过程中调用的,用户修改数据触发 setter 函数,函数的最后一行就是调用 dep.notify 去通知订阅者更新视图。

Directive

Directive

关于编译这块vue分了两种类型,一种是文本节点,一种是元素节点

vue内置了这么多的指令,这些指令都会抛出两个接口bind 和 update,这两个接口的作用是,编译的最后一步是执行所有用到的指令的bind方法,而 update 方法则是当watcher 触发 update 时,Directive会触发指令的update方法

observe -> 触发setter -> watcher -> 触发update -> Directive -> 触发update -> 指令

💥源码分析

Vue的完全版源码有很多判断以及其他的逻辑, 对于观看源码的人,会造成极大的困难,

因此准备了这版仿照Vue流程实现的 实现了双向绑定的简版Vue, 方便学习理解

通过这版对Vue源码的简易翻版, 我们来快速理解Vue原理

✨ new Vue

首先看一下,我们的 new Vue, 这是所有操作的入口

1
2
3
4
5
6
7
new Vue({
data: {
nickname: '张三',
email: "123123@qq.com"
},
el: '#app'
})

相信使用过Vue的小伙伴都明白, 这里定义了一个data, 用于存放变量, el是目标dom的选择器

🔥 new Vue 执行时做了什么

1
2
3
4
5
6
7
8
9
10
11
function Vue(option) {
var data = option.data
this.data = data
// 挂载 getter 和 setter
observe(data, this)
var id = option.el
// 编译 模板
var dom = new Compile(document.querySelector(id), this)
// 把编译好的模板挂载到 #app 上
document.querySelector(id).appendChild(dom)
}

我们可以看到, Vue其实是一个构造函数, 它接收了一个参数option, 这个option就是我们new Vue传入的那个对象
因此我们可以通过option 拿到datael两个变量, 当然这是js基础哈, 我就不再说了

拿到data后, 我们可以看到它调用了一个 observe方法, 将data和this传入( 此时this时Vue实例 )

接下来又根据el来获取dom, 同样的将获取到的domthis传入了 Compile中, 并且还接收了一个返回值, 然后又将这个返回值挂到了 #app

是不是感觉, 什么鬼?, 这顿操作是啥

首先看一下疑惑的 observe, 传入了 datathis, 然后就没动静了, 既然如此, 我们就进入observe看看

找到Observe的构造函数

1
2
3
4
5
function observe(obj, vm) {
Object.keys(obj).forEach(key => {
defineReactive(vm, key, obj[key])
})
}

可以看到,它接收一个objvm, 哦, 这里就一一对应上了, 就是我们刚才传入的 datathis

来看看它做了什么,

它把obj枚举了一遍, 并将每一次的 vm, key,value 都传入一个叫 defineReactive的方法

好,那就让来看defineReactive做了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function defineReactive(vm, key, val) {
// 为每个变量分配一个 dep实例
var dep = new Dep()
// 配置getter和setter并且挂载到vm上
Object.defineProperty(vm, key, {
get() {
if ( Dep.target ) {
// JS的浏览器单线程特性, 保证整个全局变量在同一时间内, 只有一个监听器使用
dep.addSub(Dep.target)
}
return val
},
set(newVal) {
if ( newVal == val ) return;
val = newVal;
// 作为发布者发出通知
dep.notify()
}
})
}

我们看到, 它new了一个Dep, 这个Dep就是报刊亭大爷的电话本, 用来收集所有想买报纸或杂志的人的电话, 等到到货时就好挨个通知

然后我们看到了灵魂函数 Object.defineProperty,

嗷那我们应该就明白了, 这里的一顿操作就是为了给data里的每个属性都挂载上 gettersetter, 并且将这些属性直接转移到了vm上(Vue实例)

那既然如此,

🤪让我们看看 getter方法做了什么,

首先它判断了一下Dep.target, 如果Dep.targettrue , 就调用depaddSub方法, 这里Dep.target是啥我们先不管, 留个印象即可

然后它直接returnval

😵再来看看setter方法

setter方法接收一个新值, 首先就是判断了新值和原本的值是否相等, 如果相等就不做处理了, 如果不相等, 它将新值赋给val

然后调用dep实例上的notify方法, notify 看着名字也知道是通知, 也就是大爷挨个打电话的一个操作

好, 这一块我们理顺了, 是为了挂上gettersetter, 但又遇到了新问题depdep到底在干什么, 为什么被gettersetter都使用了

找到Dep的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Dep() {
// 存放watcher
this.subs = []
}

Dep.prototype = {
// 添加watcher, 也就是添加订阅
addSub(sub) {
this.subs.push(sub)
},
// 通知所有watcher
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}

我们可以看到, Dep构造函数中维护了一个 subs数组, 并且下面的在prototype上定义了几个方法, addSubnotify
这不就是刚刚observe里调用的两个方法吗,
哦,明白了, addSub原来是将getter中传入的 Dep.target追加到每个Dep实例都单独维护的一个subs数组中呀, notify就是遍历整个数组,挨个调用update方法(先不管update的具体实现)

好, 解决了observe方法,那我们就回到最初的Vue构造函数中, 继续往下走, 攻克剩余的绿色区域

Compile

我们可以看到,它通过el 获取到了dom, 并在new Compiledom 传入

那我们就找到Compile的构造函数一探究竟

1
2
3
4
5
6
function Compile(node, vm) {
if(node) {
this.$frag = this.nodeToFragment(node, vm)
return this.$frag
}
}

可以看到, 它接收了一个node, 和一个vm , 并且判断了一下node是否存在,

并将nodevm,传入了this.nodeToFragment方法, 又将其的返回结果return出去, 也就是new Compile之后返回的值 ,如下

Compile02

this.nodeToFragment这个方法做了什么, 让我们找到他

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Compile.prototype = {
nodeToFragment(node, vm) {
var _this = this
// 创建文档片段
var frag = document.createDocumentFragment()
var child;
while ( child = node.firstChild ) {
// 替换变量
_this.compileElement(child, vm)
// 剪贴子元素
frag.append(child)
}
return frag
},
compileElement(node, vm) {
var reg = /\{\{(.*)\}\}/;
// 节点类型为元素, 根据nodeType来判断
if ( node.nodeType === 1 ) {
// 获取自定义属性
var attr = node.attributes
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == "v-model") {
// 获取v-model 绑定的属性名
var name = attr[i].nodeValue
// 双向绑定
node.addEventListener('input', function(e) {
// 给相应的data属性赋值, 进而触发该属性的set方法
// 再批处理渲染元素
vm[name] = e.target.value
})
// 把this ,节点, 还有v-model绑定的变量交给watcher
new Watcher(vm, node, name, "value")
}
}
}

// 节点类型为text
if ( node.nodeType === 3 ) {
if ( reg.test(node.nodeValue) ) {
var name = RegExp.$1; // 获取匹配到的字符串
name = name.trim()
// 把this ,节点, 还有{{ xxx }}中使用的变量交给watcher
new Watcher(vm, node, name, 'nodeValue')
}
}
}
}

我们看到它在Compile原型上挂了nodeToFragment, compileElement两个方法, nodeToFragment方法接收 node, vm参数

先保存了一下this指向, 然后使用document.createDocumentFragment()方法创建了一个文档片段, 并将在while循环中传入的node节点的第一个元素赋值给 child变量,
然后使用compileElement(child, vm)childvm 传入, 然后将child 追加给创建好的文档片段frag, 你肯定会觉得这是个死循环, 其实不是的, 这个appenddom有剪切的效果,
所以他会一直抽离node的第一个节点,直至node空了, 吸干他

完成了这顿操作后, 再将frag文档片段返回

然后我们来看看它在while中调用的compileElement方法做了什么

它同样接收nodevm , 首先就是定义一个正则, 这是用来匹配双括号的, 也就是我们平时的变量写法

然后它判断了一下这个 node的节点类型, 如果nodeType == 1, 那就说明是元素, 如果nodeType == 3 那就说明节点类型是text

如果节点类型是元素, 就利用attributes 方法,获取到该元素身上的属性, 查看是否存在v-model这样一个属性, 如果有,就获取到v-model填写的变量,交给变量name,
然后监听该元素的input事件,

所以每当改元素发生input时间时,就将元素上的value根据v-model上获取到的name作为vmkey去修改vm实例上的对应的值, 因为vm上的变量已经被挂载此来触发vm

最后还创建了一个Watcher实例, 传入vm, node ,name, "value"这几个参数,

Watcher的具体实现我们待会去看

接下来就是判断node.nodeType == 3, 也就是text类型的节点, 如果是此类节点, 就先用正则去匹配一下语法, 看看有没有使用到某个变量,
如果匹配到了, 则通过RegExp.$1获取到被匹配到的值, 然后去除左右的空格, 交给变量name
最后,同样的创建了一个Watcher实例, 传入vm, node ,name, "value"这几个参数,

出现两次Watcher, 什么情况, 到底干了啥
那, 现在就来让我们看看神秘的Watcher构造函数

找到Watcher的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let uid = 0;

function Watcher(vm, node, name, type) {
// 单例, 使用原因未知
Dep.target = this
// 姓名
this.name = name;
// 呵呵哒 uid
this.id = ++uid;
// 与变量相关的Node节点
this.node = node;
// vm 实例
this.vm = vm;
// 变量类型 nodeValue || value
this.type = type;
// 触发自己原型上的update方法
this.update()
// Watcher 实例创建结束就把单例置空
Dep.target = null
}

此时我们发现了一个关键的东西Dep.target , 这个鬼东西原来在这里, 它被赋值为了Watcher的实例, 然后在Watcher实例上挂载了name,也就是用到的变量, 还使用了一个uid, 不过这uid也是呵呵了,用数字作为uid, Vue的真实源码就这么干的, 为每个Watcher都配分一个uid, 这会造成数组空间的不连续, 引发内存泄漏

接着说, 然后他将传入的node节点, vm实例, 还有type( ‘nodeValue’ 和 ‘value’ ), 都挂到了实例上面, 并且还在调用了update方法后, 将Dep.target设为null

那我们来看下update做了啥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Watcher.prototype = {
update() {
this.get()
if(!batcher) {
// bastcher 单例
batcher = new Batcher()
}
// 加入队列
batcher.push(this)
},
// 获取新值挂到自己的实例上
get() {
this.value = this.vm[this.name] // 触发getter
}
}

看到update方法, 首先调用了一下get方法, 这个get呢就是根据this.namevm实例上取一次值, 并挂到Watcher实例上的value属性上, 并且他还会触发一次getter方法,将自己加入到dep中, 也就是加入到报刊亭大爷的电话本中, 便于之后的通知

然后判断了一下window.batcher是否存在, 如果不存在就创建一个, 保证其是一个单例模式,
如果存在, 就将自己(watcher实例),通过push方法传入

看到这里,又晕了, 什么时候又冒出来一个Batcher

我们又找到Batcher的构造函数好好分析下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 批处理构造函数
function Batcher() {
// 重置 has queue waiting
this.reset()
}

Batcher.prototype.reset = function () {
this.has = {}
this.queue = []
this.waiting = false
}

// 将watcher 添加到队列中
Batcher.prototype.push = function (job) {
let id = job.id
// 先根据 对象的key 看看是否已经有了这个watcher
if (!this.has[id]) {
// console.log(batcher)
this.queue.push(job)
// 将watcher 的key的设为true
this.has[id] = true

// 延迟执行
if (!this.waiting ) {
this.waiting = true
if ( "Promise" in window ) {
Promise.resolve().then(() => {
this.flush()
})
} else {
setTimeout(() => {
this.flush()
}, 0)
}
}
}
}


// 执行并情况事件队列
Batcher.prototype.flush = function() {
this.queue.forEach(job => {
job.cb()
})
this.reset()
}

Batcher的构造函数很简单, 就调用了一下自己的reset方法, 但好像事情远没有这么简单,我们不是在 Watcherupdate方法中调用了batcher.push吗, 我也可以在这原型上找的这个方法, 首先它接收一个job参数, 也就是Watcher实例,

获取到该watcherid, 然后使用这个id,去has这个对象上访问一下, 看看是否存在,
如果不存在,在证明之前没有添加进来过, 然后将该watcher实例加到queue队列中,
并将has对象中id对应的值设为true, 以防止重复加入队列

并且判断一下waiting,得知当前是否处于等待状态, 如果不是, 就将waiting改为true, 然后就是判断当前浏览器的支持情况, 将处理的任务扔到异步队列中

它这里这么做是为了,只批处理一次, 你一瞬间加入多个watcher, 很容易造成重复执行, 利用Watcherid来过滤, 并且利用异步, 等你要加的watcher都加完了, 我再给你统一的去执行所有Watcher

也就是异步任务结束后调用的flush方法, 它在内部会遍历queue队列, 挨个的调用Watchercb方法
在这一切都执行完成之后, 又调用了一次reset方法, 将bascher的三个属性重置为初始状态

此时关注点又回到了Watcher身上, 它的cb方法又做了什么

1
2
3
4
5
6
7
8
9
10
Watcher.prototype = {
// ...省略其他方法

// 给dom赋值
cb() {
// 最终实际虚拟dom 处理结果, 只处理一次
// 虚拟dom -> diff( 虚拟dom ) -> 局部更新 -> createElement(vNode) -> render
this.node[this.type] = this.value
},
}

可以看到cb方法做的事情很简单那, 就是根据元素的值类型去修改元素对象的值, 而这个this.value早在之前调用 Watcherget方法时就被赋上了

到这里,整个流程就走完了, 相信你还是一头雾水, 我们把整个流程来串一下

  1. new Vue
  2. data中的值挂上 gettersetter 的相应方法, 然后暂且搁置,因为此时还无人调用gettersetter
  3. 通过 Compile解析模板, 挨个递归#app下的dom, 判断元素类型, 如果是元素,并且使用了v-model, 就绑定一个input事件, 如果是文本类型节点,就去匹配是使用了语法, 最后为他们都创建了一个watcher
  4. 每个watcher 用来保存相关的元素对象, vm实例,使用的变量 以及元素值类型, 并将自己的实例交给, Dep.target, 并触发自己的update方法,update方法又会调用get方法, get方法又会触发该变量的getter, 这也就使得getter中可以将该watcher放入dep实例中, 最后将自己也放入Bacher中,用以批处理以及将Dep.target置空
  5. Batcher是个单例, 根据Watcherid, 它用来过滤重复传入的Watcher, 保证一个Watcher只触发一次, 并将更新事件丢入异步,等当前的连续操作执行完成后去调用Watchercb方法更新dom
  6. 之后用户修改了变量, setter又会调用dep这个发布者来发出通知, 相关的Watcherupdate方法再次被调用, 又会加入batcher , batcher等待异步完成后又调用Watchercb方法更新dom

到这里就整个串完了,但是感觉废话还是有点多, 再简化一点流程:

new Vue –> Observe 挂载 settergetter –> Compile 编译模板 –> 为每个指令分配一个watcher –> 创建时会调用一次watcher.update 将自己加入到batcher的队列 –>
并且此时会触发 getterwatcher加入dep –> batcher 统一来处理watcher后初始化自己 –> 当用户修改某个变量时 –> dep通知watcher –> watcher又被加入batcher处理 –> watcher 更新dom

Vuebatcher还是实现的不是很好, 缺少调度机制, 这点上还是React Fiber更优秀点,Fiber如果遇上了长时间的任务会选择放弃, 避免阻塞进程。

😕好了, 神秘的Vue源码已被揭开面纱, 但这仅仅是简易版的实现, 真实的Vue非常庞大, 还有更多的内容, 这里只是让大家明白MVVM的核心原理

项目源码:

https://github.com/nxl3477/note/tree/master/Javascript/Vue/%E5%AE%9E%E7%8E%B0vue

优质文献:

你的支持将鼓励我继续创作