vue:源码整理, 数据代理和响应式

最近看了一下 Vue 相关的东西 ,整理一下流程。

最近很慌, 想看源码, 先把简单的相关概念理一理

Object.defineProperty

首先, 概念 :

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
42
43
44
45
46
47
48
// 定义一个对象
const obj = {
firstName: 'D',
lastName: 'Z'
}

// 数据描述符-----------------------------------------------
// 新增一个属性
Object.defineProperty(obj, 'age', {
configurable: false, // 不可重新定义
enumerable: false, // 不可枚举
value: 18, // 值
writable: false // 不可写
})

// 不可重新配置
Object.defineProperty(obj, 'age', {
configurable: false,
enumerable: true
}) // 报错

// 不可删除
delete obj.age // false
console.log(obj.age) // 18

// 不可枚举
Object.keys(obj) // ["firstName", "lastName"]

// 不可写
obj.age = 17
console.log(obj.age) // 18

// 存取描述符-----------------------------------------------
Object.defineProperty(obj, 'fullName', {
get() {
return this.firstName + '-' + this.lastName
},
set(value) {
const arr = value.split('-')
this.firstName = arr[0]
this.lastName = arr[1]
}
})

obj.fullName // D-Z
obj.fullName = 'A-B'
obj.lastName // B
obj.firstName // A

configurable: false 不可重新定义, 也就是说再执行 Object.defineProperty(obj, 'age', {...})将会报错, 并且, 属性不可删除
enumerable: false 不可枚举, 也就是再执行Object.keys()for in 等将取不到该属性
value:18
writable: false 不可写, 也就是对age赋值操作将不会生效, 但不会报错
get取值操作 , function, 默认undefined
set属性值修改时,触发执行该方法 , function, 默认undefined

以上这一些属性, 称为描述符, 需要注意的是, 当使用了 getter 或 setter 方法,不允许使用 writable 和 value 这两个属性
get set 看起来就很像 vue 的计算属性

伪数组

伪数组 (ArrayLike) ,又称类数组。是一个类似数组的对象,但是有如下几个特征。
按索引方式储存数据

1
0: xxx, 1: xxx, 2: xxx...

具有 length 属性

1
但是length属性不是动态的,不会随着成员的变化而改变

不具有数组的 push(), forEach()等方法

1
2
3
arrayLike.__proto__ === Object.prototype //true
arrayLike instanceof Object //true
arrayLike instanceof Array //false

以下是常见伪数组:

  • arguments
  • NodeList => document.querySelectorAll(‘div’).constructor.name // NodeList
  • HTMLCollection => document.getElementsByClassName(‘head_wrapper’).constructor.name // HTMLCollection
  • jQuery => $()

转换为真数组方法

  1. 遍历伪数组存入真数组
  2. Array.prototype.splice.call(arrayLike)
  3. Array.from(arrayLike)
  4. […arrayLike]
  5. 原型继承,arrayLike.proto=Array.prototype
  6. 其他工具库中的方法,如 jQuery 中的 makeArray() toArray()等

NodeType

元素,属性,文本等, 都实现了 Node 接口, 都会有 NodeType 属性, 它标识了节点属于的 类型
常见的有:
1 -> 元素 节点
3 -> 文字节点
8 -> 注释节点
11 -> DocumentFragment 节点

1
2
3
4
5
<div class="my">
123
<span>456</span>
<span>789</span>
</div>

上面的 html 片段, 分别用 childNodes 和 children 取值, 结果分别是什么呢

1
2
document.querySelector('.my').childNodes // NodeList(5) [text, span, text, span, text]
document.querySelector('.my').children // HTMLCollection(2) [span, span]

从图中可以看到, my div 下, 包含了 5 个 Node, 其中 123, 以及两个小红框位置的换行符, 属于 TextNode , span 标签属于 ElementNode

在这里插入图片描述
在这里插入图片描述
childNodes 和 children 结果分别是节点 list 和 子元素 list

DocumentFragment

文档片段接口,表示一个没有父级文件的最小文档对象.
通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到 DOM 树。在 DOM 树中,文档片段被其所有的子元素所代替。
因为文档片段存在于内存中,并不在 DOM 树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。

1
2
3
4
let content = document.createDocumentFragment()
let el = document.querySelector('.nums')
content.appendChild(el)
// 这个时候 .nums 元素会从页面消失, 存入content这个Fragment片段

Proxy

Proxy 用于修改某些操作的默认行为,也可以理解为在目标对象之前架设一层拦截,外部所有的访问都必须先通过这层拦截,因此提供了一种机制,可以对外部的访问进行过滤和修改。这个词的原理为代理,在这里可以表示由它来“代理”某些操作,译为“代理器”。

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = new Proxy(
{},
{
get(target, key, receiver) {
console.log(`getting ${key}!`)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log(`setting ${key}!`)
return Reflect.set(target, key, value, receiver)
},
}
)

上面代码对一个空对象架设了一层拦截,重新定义了属性的读取(get)和设置(set)行为。对设置了拦截行为的对象 obj,去读写它的属性,用自己的定义覆盖了语言的原始定义,运行得到下面的结果

1
2
3
4
5
6
obj.count = 1
// setting count!
++obj.count
// getting count!
// setting count!
// 2

1. 数据代理 -> Object.defineProperty()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let vm = new Vue({
data: {
a: 0,
},
})
// 为什么能通过 vm.a 访问到 data 中的 a ?
// 因为会通过 Object.defineProperty 设置了数据代理
Object.defineProperty(vm, 'a', {
get() {
return data.a
},
set(val) {
data.a = val
},
})

2. 模板解析 -> NodeType -> Reg -> moustache -> v-on -> bind/html/class/text

Vue 2 是虚拟节点 VNode

1
2
3
4
5
6
7
8
9
10
11
12
13
let vm = new Vue({
el: '#my',
data: {
a: 0,
},
})
// 拿到 el 对应的模板以后, createDocumentFragment() 产生一个Dom片段
// 再将el所有子节点插入到该Fragment
// 遍历Fragment中每个节点
// 文本 => 匹配{{}}
// 元素 => 编译元素的指令
// 普通指令/事件指令 => 编译完后删除该指令
// 包含子节点 => 继续迭代

3. 数据绑定和响应式 -> 使用数据劫持的技术实现

一旦更新 data 中某个属性数据, 所有界面直接使用或间接使用该属性的节点会更新
基本思路: 通过 defineProperty 监视 data 中所有层级数据的变化, 变化则更新界面

1
2
3
4
vm.a = 3
=> vm.a.set // 更新data中的a
=> vm.data.a = 3
=> vm.data.a.set //更新界面
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
增加observe => 递归对每个属性 Object.defineProperty 增加 get set, 并且设为configurable:false
除了事件指令, 每个指令/表达式 => 增加watcher

Dep -> {
实例创建时间 => 给data中每个属性增加observe数据劫持时(增加get,set)创建的, 在模板编译之前, new watcher()以前
数量 => 与data中属性一一对应(所有层级)
结构 => {
id: 标识 uid
subs: [] subscribe订阅者数组, 内容是 watchers // 即需要更新的所有相关表达式
}
}
watcher -> {
实例创建时间 => 初始化解析大括号表达式/一般指令时创建
数量 => 与大括号表达式/一般指令一一对应
结构 => {
cb: cb 界面更新回调
vm: vm
exp: 对应的表达式
depIds: {} 相关的n个dep // 主要用于判断关系是否建立, 避免再次建立dep watcher关系
value: get() 当前表达式value
}
}

Dep与Watcher关系
关系 ->
data属性 -> 一个dep -> n个watcher(属性在模板多次使用)
表达式 -> watcher -> n个dep(多层表达式, 如a.b, 对应了两个dep)
建立 ->
初始化data数据 -> 每个数据增加dep, 并增加 get, set -> 编译模板 -> 拿到表达式 -> 每个表达式增加一个watcher -> watcher初始化会有个value属性 -> 该value属性调用了dep的get方法 -> dep的get方法depend() -> 通过watcher的depIds判断关系是否建立 -> dep 保存watcher到subs -> watcher 的depIds保存dep
响应式 ->
vm.name = 'abc' -> data中name属性值变化 -> name的 set() -> dep.notify() -> dep中subs数组中每个watcher进行update() -> watcher.cb 回调 -> updater更新界面


总结

整体实现分为已下步骤

  1. 实现一个 Observer,对数据进行劫持,通知数据的变化(将使用的要点为:Object.defineProperty()方法)
  2. 实现一个 Compile,对指令进行解析,初始化视图,并且订阅数据的变更,绑定好更新函数
  3. 实现一个 Watcher,将其作为以上两者的一个中介点,在接收数据变更的同时,让 Dep 添加当前 Watcher,并及时通知视图进行 update
  4. 实现一些 VUE 的其他功能(Computed、menthods)
  5. 实现 MVVM,整合以上几点,作为一个入口函数

在这里插入图片描述