由于vue3的原理我学习的不深入,所以本篇主要介绍vue2及其响应式原理
什么是VUE?有何优点?
- 渐进式框架,可以把VUE当作第三方库来使用,也可以利用它来构建复杂的单页项目
- 上手简单,只要会基础的HTML,CSS,JS就可以上手开发
- 内部采用虚拟DOM,运行高效
- 数据双向绑定
VUE与MVVM模型
VUE类似MVVM模型,它利用VIewModel层将model层的数据和VIew层进行了双向绑定,view层数据变化时会自动同步到model上,而model数据变化时也会使view层更新。而VIew与Model层并没有直接的联系,这也就降低了耦合度。这样做使得程序猿在编写代码时,只关心与业务逻辑的实现,基本不用手动操作DOM元素,无需担心数据更新问题。
为什么VUE类似于MVVM模型?
VUE官网有这么一句话
原因是vue有个 refs 属性,可以直接获取到页面的DOM元素,破环了View与Model无关联的要求,所以vue严格上不属于MVVM模型
VUE基本原理
数据劫持
当vue实例被创建时,放在其data中的数据会被加入到vue的响应式系统中,这些数据在变化时,会导致视图的更新。
new Vue({
// 挂载到页面的根元素
el: '#app',
data: {
name: 'zs',
obj: {
age: 20
}
}
})
便于理解,我就用简单的demo展示一下vue响应式原理的实现 其实vue2的响应式是基于Object.defineProperty实现的,它劫持了监听了data中的每个属性,为他们 单独设置了getter和setter,并且每个属性都拥有自己的 dep 属性,存放他所依赖的 watcher(依赖收集),当属性变化后会通知自己对应的 watcher 去更新。
function reactive(data) {
if (Object.prototype.toString.call(data) == '[object Object]') {
Object.keys(data).forEach(key => {
if (Object.prototype.toString.call(data[key]) == '[object Object]') {
reactive(data[key])
}
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
return data[key]
},
set(newVal) {
if (newVal !== data[key]) {
data[key] = newVal
}
}
})
})
}
}
上述精简版数据绑定代码与vue2内部运行原理类似,vue3中的数据绑定主要采用Proxy,待会细说
vue2中的数据劫持有缺陷,其中无法实现data中新增属性的劫持,必须使用this.$set()来向内置响应式对象中(data)中添加一个值,且这个值也是响应式的 再有一个就是vue2无法实现对数组的劫持,包括数组下标或length属性的改变,官方给出的解决方案是重写了Array原型的部分方法(splice,push,sort 等等)
Dep(订阅者)
vue中使用Dep类来创建订阅者,订阅者的作用是存放watcher对象,当数据变化的时候通知对应的watcher进行更新
class Dep {
constructor() {
this.watchers = []
}
// 收集对应的watcher
addWatcher(item) {
this.watchers.push(watcher)
}
// 数据变化时通知收集的watcher进行update
notify() {
this.watcher.forEach(item => {
item?.update()
})
}
}
Watcher (观察者)
Vue 中使用Watch类来创建订阅者,data中每个属性可能对应多个订阅者,订阅者的作用是当数据更新时进行update
class Watcher {
constructor() {
Dep.target = this
}
update() {
// 更新逻辑代码
}
}
Dep.target = null
上述就是vue的观察者和订阅者模式,在下面我会介绍vue如何通过数据劫持配合观察者-订阅者模式来进行数据双向绑定
// 在进行compile解析时,会自动为每个属性实例化一个watcher对象
// 此处我就略去watcher实例化过程
function reactive(data) {
if (Object.prototype.toString.call(data) == '[object Object]') {
Object.keys(data).forEach(key => {
if (Object.prototype.toString.call(data[key]) == '[object Object]') {
reactive(data[key])
}
const dep = new Dep()
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
// 依赖收集
Dep.target && dep.addWatcher(Dep.target)
return data[key]
},
set(newVal) {
if (newVal !== data[key]) {
//当改变的值为对象时,劫持其属性
if (Object.prototype.toString.call(newVal) == '[object Object]'){
reactive(newVal)
}
data[key] = newVal
//通知更新
dep.notify()
}
}
})
})
}
}
在数据劫持时,每个属性都对应一个订阅者,当获取属性值时,将对应的观察者存入订阅者中;当数据更新时,订阅者通知其收集的所有观察者进行更新。
关于Dep.target
关于Dep.target 为什么要设置为watcher实例,以及为什么Dep.target 在后面设置为null;这是一个不太好理解的点,涉及到源码中compile阶段,如果只看上述代码是不太好理解的,我就尽我所能再讲的清晰一点
data中的每个属性都有一个订阅者,在数据劫持的时候可以看到为每个属性都创建了订阅者。然后通过闭包的形式实现收集和更新。data中一个属性在View层可能绑定很多次,不是一次,所以dep中的依赖收集为数组。而每次VIew层的使用都对应一个watcher,在View层使用时(compile阶段)会生成watcher实例,并且获取属性值,触发对应的getter,添加对应的watcher。如果不设置为null的话,在数据改变时会一直向dep中添加watcher,大概就是这个样子
批量异步更新(nexTtick)
假设我们有如此代码
<div id="app">
<div>{{number}}</div>
<div @click="handleClick">click</div>
</div>
new Vue({
el: "#app",
data {
number: 0
},
methods: {
clickHandle() {
for(let i = 0; i < 1000; i++) {
this.number++;
}
}
}
})
假设我们触发了点击事件之后,number会进行累加,如果按照数据更新带动视图更新,那么View层也会被更新1000次,但是vue为了防止这种情况出现,实现了一个nextTick方法(批量异步更新),就是说每次修改时会将对应的watcher push 进一个队列中,然后在下一个tick 触发队列中的watcher更新。
vue循环中 key 属性的作用
我觉得 key 属性的作用是 diff 时判断两个VNode是否属于 sameVNode ,如果属于sameVNode就会进行节点复用。所以当渲染大量节点更新时,带key(唯一id)比不带key的效率低性能开销大,因为每次对比时,带key(唯一id)时总会判断为不同节点,会进行重新创建VNode,不会进行复用。而如果用index作为 key ,和不带是一样的,因为每次更新的index可能是不变的。 两种方式适用场景:
- 不带key 适用于渲染无状态组件,因为没有自己的状态,所以就无需使用key
- 带 key (唯一id) 适用于有状态组件:例如说有两个tab栏,对应于不同的列表。如果在第一个tab栏中选中了某一项,在切换到第二个状态栏,不带key时,在对比时会认为是相同节点然后复用,这样第二个tab栏中的那一项也会被选中;如果带key(唯一id),对比时会认为不同节点,就会重新创建VNode,这样第一tab栏的状态就影响不到第二个tab栏。
VUE3
- 数据劫持采用Proxy,对新增的属性也实现了劫持
- Diff 算法更新,添加 isStatic 标记,patch时只对比有标记的虚拟节点,vue2是全量对比
- 静态提升(牺牲内存换取效率?) vue2中,不论元素是否参与更新,都会被创建 vue3中,不参与更新的元素只会被创建一次
- 监听缓存 onclick事件绑定的是同一个函数,就会去掉静态标记,不会进行追踪对比,直接复用