VUE框架及其响应式原理介绍

205 阅读6分钟

由于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官网有这么一句话

image.png

原因是vue有个 refs 属性,可以直接获取到页面的DOM元素,破环了View与Model无关联的要求,所以vue严格上不属于MVVM模型

VUE基本原理

image.png

数据劫持

当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事件绑定的是同一个函数,就会去掉静态标记,不会进行追踪对比,直接复用