这段时间开始结合黄奕老师的文章 Vue.js 技术揭秘 和其他网络资源开始学习vue源码,自己将一些重要过程用xmind记录下来, 一图胜千言,请读者保存图片到本地放大了看。
vue 特点
vue的核心是设计一套 数据响应框架,只关心视图层。数据驱动视图,不用像几年前jQ那样手动去操作dom, 数据的改变直接映射到页面的改变(反之亦可)。
vue核心思想除了数据驱动(数据的变化能引起视图的变化)还有 组件化。入口文件从new Vue()开始挂载根实例,然后不同组件作为根实例的子孙节点挂载在父节点(其他vue实例)上。
diff算法
先放出本人手绘diff算法。patch/patchVnode/updateChildren 三个方法层层深入进行虚拟dom比对。
- patch判断新老vnode是否存在
- patchVode判断新老vnode是否有子节点
- updateChildren进行新老节点的子节点比对。(diff算法核心)
核心api
MDN官方说明,参考此处
举个小栗子,看Object.defineProperty将对象的 数据属性修改成 访问器属性: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
42var obj = {a:1, b:2}
function defineReactive (target, key ,val){
Object.defineProperty(target, key, {
get: function(){
console.log(`key:${key} is got:${val}`)
return val
},
set: function (newVal){
if(newVal !== val ){
console.log(`key:${key} is set to :${newVal}`)
val = newVal
}
}
})
}
console.log('before', Object.getOwnPropertyDescriptor(obj, 'a'))
// {value: 1, writable: true, enumerable: true, configurable: true}
Object.keys(obj).forEach( item => defineReactive(obj, item, obj[item]))
console.log('after ',Object.getOwnPropertyDescriptor(obj, 'a'))
// after {get: ƒ, set: ƒ, enumerable: true, configurable: true}
console.log(obj)
// {}
// 访问原有属性
obj.a // key:a is got:1
obj.a = 100 // key:a is set to :100
// 删除属性并新增同名属性
delete obj.a
obj.a
obj.a = 100
obj.a
// 新增属性
obj.c=3
obj.c
局限性:
如果用这个api 观测(或叫拦截)对象的原有属性的访问和操作,是没问题的。
但是对于对象的新增/删除属性的操作是拦截不到的(在vue中用$set这个api来弥补),
也不适用于数组,在vue中观测数组实际上是修改了数组的原型,调用数组原生api会触发类似setter函数的逻辑。
针对以上情况,vue3.0使用了 proxy这个api进行数据观测,详见另一篇博文
设计模式与相关概念
vue是观察者模式还是发布订阅模式?
观察者模式和发布订阅模式是类似的,区别在于发布是否有调度中心,观察者模式没有。
在观察者模式中,目标(发布者)和观察者(订阅者)是基类,目标提供维护观察者的一系列方法,观察者提供更新接口。具体观察者和具体目标继承各自的基类,然后具体观察者把自己注册到具体目标里,在具体目标发生变化时候,调度观察者的更新方法。subject维护一个observer列表,发布消息的时候遍历这个列表,调用每个observer的update方法(即订阅回调函数);
在发布-订阅模式中,调度中心对外暴露发布publish 和订阅 subscribe方法,订阅方调用调度中心的subscribe方法注册事件名和事件回调函数,发布方调用调度中心的publish方法进行消息发布,调度中心publish 方法触发会去遍历已注册的事件名,找到对应事件的回调函数并执行。参考文章
总结区别:虽然两种模式都存在订阅者和发布者(具体观察者可认为是订阅者、具体目标可认为是发布者),但是观察者模式是由具体目标调度的,而发布/订阅模式是统一由调度中心调的,所以 观察者模式的订阅者与发布者之间是存在依赖的,而发布/订阅模式则不会。
所以严格说,vue是观察者模式, 不是发布-订阅模式, 之后我们会讲到vue中的dep实例通过一个sub属性维护一个watcher列表,即订阅者将自己注册到发布者中,存在依赖关系,说明是观察者模式。
在vue中实际上模型是类似这样的,
几个概念 dep/ sub/ watcher
跟数据驱动相关几个概念是dep,sub,watcher,dep是发布者角色,watcher是订阅者角色(被通知发生变化然后进行视图更新),watcher通过sub注册到dep中。
每个watcher有会对应一个vm实例,watcher可以分为3种,(具体watcher分析可以见下图)
- 渲染watcher,实例化Vue的时候最后在挂载阶段会创建一个渲染watcher(渲染watcher执行构造函数的时候求值,即会调用watcher的this.getter,即实例化传入的参数updateComponent函数,完成渲染)
- computed watcher, 实例化Vue在beforeCreate和create之间initState会执行initComputed, 特殊的地方在于执行构造函数的时候不求值
- user watcher, 实例化Vue在beforeCreate和create之间initState会执行initWatch,执行构造函数的时候求值,特殊的地方在于有回调函数作为参数实例化watcher
定义在data中的每个数据会持有一个dep,这个dep下会有一个sub,存放跟这个数据相关的watcher(一定有渲染watcher, 如果这个数据作为计算依赖出现在computd中就会有computed Watcher, 如有作为key出现在watch中就会有user Watcher), 当数据被改变,sub下面的watcher会收到通知,然后调用watcher的update方法,如果是user watcher而且sync是ture,立马执行求值,如果前后值不相等则执行回调;如果是computed watcher, 跳过(因为computed Watcher会关联render watcher);如果是render watcher, 会被放入全局的一个队列中,在下次tick(下次事件循环)中遍历这个队列中的所有watcher,调用watcher的run方法,即求值,对于render watcher就是重新渲染,对于user watcher则是执行回调函数。
最关键的依赖收集和派发更新是通过Object.defindeProperty(obj, key, descriptor)这个api实现,在beforeCreate和create之间initState的initComputed和initWatch之前调用,给每个数据创建一个dep,设置了数据的getter和setter函数逻辑,访问数据会触发getter, 这个时候进行依赖收集,添加相关watcher到这个dep中;修改数据触发setter函数,setter会执行通知dep中的watcher逻辑,即便执行watcher的update方法,具体过程见上面。
目前主要详细了解的包括
- new Vue发生了什么?
挂载的过程?
数据观测
派发更新
Watcher分析
- 不同类型watcher
- computed属性的实现与watcher
vue生命周期相关
new Vue发生了什么?
new Vue会调用vue原型上的init方法,具体做的事情是合并创建vue实例传入的配置项,然后开始各种初始化操作,包括生命周期/事件/数据等,注意对数据的初始化发生在beforeCreate和created之间,而且先初始化data, 然后computed, 然后watch
在初始化data的时候做用Object.defineProperty了两件事情:1. 做了数据代理,通过实例直接访问到实例属性的_data上定义的数据 2. 做了数据观察, 进行数据getter/setter函数逻辑设置(后面依赖收集和派发更新会用到)
挂载的过程
先说点背景知识。
首先,什么时候会有挂载?当调用$mount方法或者根vue实例传入了el。
我们写的模板最后都会被转化成renderFn,即render函数,这个转化的过程事靠vue的编译器帮我们做的,如果不要编译器,那么我们就要自己写render函数,即在创建vue实例的时候传入render属性,值是函数,这个函数的第一个参数是vue原型上的createElement方法,但是我们一般不这么做,所以我们一般用的是带编译器的版本runtime+ compiler,而不是runtime only 版本。在new vue的最后一步是,判断有vm.$options.el,则执行vm.$mount(vm.$options.el)。
在带编译器的版本中,这个mount方法会先将el/template转化成renderFn, 再调用公共的mount方法。
在这个公共的mount方法中,核心逻辑是定义一个updateComponent方法(与渲染有关),最后这个方法作为参数去实例化渲染watcher,而在实例化渲染watcher执行构造函数的时候,就会去执行这个updateComponent方法,完成渲染。具体说下这个updateComponent函数
1
2
3updateComponent = ()=> {
vm._update(vm._render(), hydrating)
}_render得到的是虚拟dom, _update执行的核心是调用 _patch方法,将虚拟dom映射成真实的dom, vm.$el = vm.patch(vm.$el, vnode, hydrating, false), 之前的vm.$el是原来文档中的dom节点,执行后是被vue实例替换的新节点。
响应式对象
数据观测
核心逻辑:执行oberve(data), 判断data上是否有_ob属性,没有的话实例化一个Observer。
然后看Obsever类的构造函数:
首先,会将自身指向data的_ob属性
然后,如果data不是数组,调用walk方法,walk会遍历data上键名,为每个key实例化一个dep,调用defineReactive(obj, key)方法设置getter & setter,再调用observe方法观察子属性(如果也是对象)。如果data是个深层嵌套的对象,那么会递归调用observe方法。下面看数组的情况。对数组的观测,实际上是修改了数组的原型,源码中创建了一个新对象arrayMethods,这个新对象的proto指向Array.prototype。 用Object.define.property定义了这个对象上的key, push/pop/shift/unshipt/splice/sort/reverse,这些都是对引起数组变化的的原生js操作, 在执行这些操作的时候,会调用ob.dep.notify()通知数组依赖的watcher(观测数组会为数组定义一个_ob_属性)。
1
2
3
4
5
6
7if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
// 修改需要观测的数组的原型,从Arry.prototype修改为arrayMethods
augment(value, arrayMethods, arrayKeys)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
38const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// notify change
ob.dep.notify()
return result
})
})
派发更新
派发更新这里涉及到一个 重点是异步更新,vue的更新是异步的,这里是vue设计上的性能优化,如果我们在前端逻辑执行类似this.a= 0 和this.a+=1 执行10次,最后只会渲染最后一次的结果。
vue源码自己实现了一个事件循环,与数据改变相关的watcher一般会被放入一个全局队列,而且不会重复添加,再下个事件循环中(对于上面的例子,此时的a已经执行完同步代码变成了10),再执行watcher的run方法,对于渲染watcher具体会执行渲染逻辑(对于渲染watcher,getter是传入的updateComponent函数)。
深入分析watcher
watcher分析
Watcher类实例化的时候传入的参数有5个,vm, expOrFn, cb, options, isRenderWatcher,针对不同三类watcher,以上的参数传入有些差别,比如回调函数cb一般只有user watcher才会有,其他watcher传入空函数noop。原型上有很多方法,get/update/run/evaluta/depend, 最重要的是get方法,因为update/run/evalute最终都会调用到get, get的核心是将当前watcher置为Dep.target,然后调用this.getter(),this.getter一般是实例化watcher传入的expOrFn(如果传入的是函数的话,对于user watcher则不同,传入的是字符串); depend方法则跟watcher依赖的watcher相关,比如computed watcher自身也有一个dep会收集render watcher作为依赖。
- 不同类型watcher
下中有详细对比不同watcher,包括他们的创建时机,依赖添加时机,执行构造函数差异。
- computed属性的实现与watcher
此处逻辑有vue新老版本差异,黄奕老师文章中提到的是老版本的,一般我们现在用的是新版本。我是结合老师文章,自己demo调试发现其中差异。
总结
官方版本原理解读 参考
个人解读:
vue的数据驱动是基于观察者模式实现
根据vue的生命周期,在created之前进行数据观测,利用Object.defineProperty将数据上需要观测的属性(及其子属性)修改为访问器属性并在getter函数实现依赖收集的逻辑,在setter函数实现派发通知(订阅者)的逻辑
在渲染过程中(user watcher是渲染前)触发getter进行watcher依赖收集,数据发生变更触发setter,实现派发通知, 即相关watcher的run方法被调用,实现视图更新和回调执行的逻辑
思考题
- 数据对应的watcher是什么?
firstName 的sub有两个watcher:computed Watcher/ renderWatcher
lastName的sub 有两个watcher:userWatcher/ computed Watcher/renderWatcher - 如果在data中定义个number, 对应的subs是什么?
[], 空数组,因为渲染的时候number没有被访问到,没有视图依赖 - 当点击按钮触发change事件发生什么?
lastName的setter被触发,传入了一个新值,对应的watcher被通知,renderWatcher负责重新渲染, userWatcher负责执行数据变更的回调 - 接上那computed Watcher是干什么用的?
computed Watcher 作为桥梁关联render Watcher, 即data的变化通过computed Watcher 被渲染watcher订阅 - computed 重新计算,那是不是每次都重新渲染呢
diff算法 比对新老vnode,虚拟dom无变化,没有createElem 重新渲染。