从应用到原理看vue响应式

579 阅读7分钟

前言

在我们正式地进入响应式的学习前,我们需要先明确mvvm,双向绑定,响应式这三者的概念和关系。

mvvm

分开了数据和视图,通过中间层viewmodel将两者结合在一起,实现当数据改变时页面变化,当页面改变时数据也发生改变。viewmodel通常要实现一个数据监听器observer,当数据改变时,viewmodel能够监听到数据的变化,然后通知相应的视图做自动更新,而当用户操作视图的时候,viewmodel也能监听到视图的变化,然后通知数据做改动。

双向绑定

基于mvvm模式实现数据更新,视图变化。视图变化,数据也能同样得到更新。

响应式

数据发生变化重新对页面进行渲染。

应用

vue2和vue3中关于响应式的区别

以下会直接介绍vue2vue3关于响应式的一些不同表现。

  1. vue2中不能监听动态添加的属性,举个例子来看
let vm = new Vue({
  el:'#app',
  data:{
    msg:'hello'
  }
})
vm.person = 'Mary'
console.log(vm.msg)
console.log(vm.person)

请问person会是响应式吗?

可以发现只有msg是响应式的,当更改vm.person时也没有任何变化。官方文档中也特别说明了vue不允许动态添加根级别的响应式属性,但是可以通过Vue.set来设置,但是有时候我们还是会迷惑什么时候使用Vue.set

  1. vue2中不能监听属性的删除操作

页面也没有任何变化,msg依旧被渲染出来,再看vue3

const app = Vue.createApp({
  data() {
    return { msg: 'hello' }
  }
})

const vm = app.mount('#app')
console.log(vm.$data.msg) 

  1. vue2中不能监听数组索引和length属性

    具体来说就是直接通过索引去修改数组,虽然数组里更新了,但是,它不会响应到DOM里,虽然把整个数组更新了,但是,它的样式依旧没变。有两种解决办法,一种同样使用Vue.set,一种是数组的方法,如pop,pushshiftunshift,splice,sort, reverse这七个方法。为什么这七个方法可以呢?因为vue2内部将这些方法做了处理,在保留这些方法功能的同时,也就是每次调用了该方法后进行派发更新.

    看起来vue2只实现一部分的响应式,而vue3的响应式解决了这些问题,怎么解决呢?

原理

proxy VS defineProperty

先来看看proxydefineProperty的基本使用,关于详细使用可以mdn上自行学习

先来看defineProperty

        // 遍历data中的所有属性 this是vue实例
        Object.keys(data).forEach(key =>{
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get(){
                    return data[key]
                },
                set(newValue){
                    if(newValue == data[key]) return
                    data[key] = newValue
                }
            })
        })

再看proxy

        // proxy代理的是整个对象 不用循环遍历属性,性能更好
        let vm = new Proxy(data, {
            get(target, key){
                return target[key]
            },
            set(target, key, newValue){
                if(target[key] == newValue) return
                target[key] = newValue
            }
        })

可以看到区别,vue2中需要去遍历属性,拦截的是修改obj[key]和读取obj[key],而proxy代理的是整个对象,不需要给定key值,这就是为什么能够动态新增属性的原因。还可以在Proxy中拦截更多的操作符,定义一些其他被代理对象上的自定义行为,如deleteProperty拦截delete操作符,这样便可以监听属性的删除操作。

实现

实现核心

不论是vue2还是vue3,响应式的实现离不开两个核心

  • 数据劫持(在vue3中称为数据代理更为贴切,这里暂不做区分)
  • 观察者模式

数据劫持我们已经分析过definePropertyproxy的区别以及它们所产生的现象不同了,下面再来说说观察者模式。我一开始总是分不清观察者模式和订阅模式,在实现eventbus以及vue2响应式原理时才略知一二。

观察者模式与发布订阅模式

  • 共同点:都有发布者和订阅者
  • 不同点:观察者模式中没有信号中心,而发布订阅模式中有一个信号中心

发布订阅模式

有一个信号中心,某个任务执行完毕,就向信号中心发布一个信号,其他任务可以向信号中心订阅到这个信号,从而知道什么时候自己可以执行。

  • 订阅者
  • 发布者
  • 信号中心

vue中自定义事件使用的就是发布订阅模式,兄弟组件通信中也使用到eventbus事件中心,隔离了发布者和订阅者,使用起来更加灵活。下面是一个简单的eventbus模拟实现。

        // 事件中心
        class eventEmitter{
            constructor(){
                this.subs = Object.create(null)
            }
            // 注册事件
            $on(eventType, handler){
                this.subs[eventType]= this.subs[eventType] || []
                this.subs[eventType].push(handler)
            }
            // 触发事件
            $emit(eventType){
                if(this.subs[eventType]){
                    this.subs[eventType].forEach(handler => {
                        handler()
                    });
                }
            }
        }

        let em = new eventEmitter()
        em.$on('click',()=>{
            console.log('click1')
        })
        em.$on('click',()=>{
            console.log('click2')
        })
        em.$emit('click')

观察者模式

  • 订阅者:watcher
  • 发布者:dep,需要知道订阅者的存在

没有事件中心,订阅者和发布者之间有依赖关系

        //发布者
        class Dep{
            constructor(){
                // 记录所有的订阅者
                this.subs = []
            }
            // 收集依赖
            addSub(sub){
                if(sub && sub.update){
                    this.subs.push(sub)
                }
            }
            // 派发更新,调用所有订阅者的update方法
            notify(){
                this.subs.forEach((sub) => {
                  // 触发订阅者更新
                    sub.update()
                })
            }
        }
        //订阅者
        class Watcher{
            update(){
                console.log('update')
            }
        }

        let dep = new Dep()
        let watcher  = new Watcher()
        dep.addSub(watcher)
        dep.notify()

正式实现

一个简单的响应式vue2

总体来说,实现一个响应式主要需要借助数据劫持和观察者模式。实现过程中主要关注两个方面:

  1. 页面首次渲染的过程
  2. 数据改变更新视图的过程

响应式

这是官方vue响应式的原理图,我们先简化一下它,抓住这几个几个核心,watcherdepdefineReactive,还有一个进行模版编译功能的compiler。分别来看看这几个部分需要实现什么。

  1. observer
  • data选项中的属性转换成响应式数据
  • 如果data中的某个属性也是对象,把子属性也变成响应式
  • get时触发依赖收集,收集依赖于属性watcher
  • set时触发派发更新,数据变化时发送通知
  1. compiler
  • 负责编译模版
  • 时机1:首次渲染
  • 时机2:数据变化后重新渲染视图
  1. Dep
  • 收集的是依赖于属性的订阅者
  • 当属性变换的时候,会去通知所有订阅者
  1. watcher

用一张图来理解订阅者与发布者的关系。

  • 当数据变化触发依赖,dep通知所有的watcher更新视图
  • 自身实例化的时候向dep对象中添加自己

实现步骤

  1. 将数据代理到vue实例上;
  2. 通过一个数据监听器中的数据劫持将数据赋予gettersetter,设置收集依赖和派发更新后使其变成响应式对象。在vue中会把propsdata等变成响应式对象,在创建过程中,发现子属性也为对象则递归把该对象变成响应式;
  3. 编译dom节点,区分不同的节点处理方式,将数据能够展现在视图上;
  4. 编译dom节点的时候,要定义一个watcher对象,当数据改变时,会通知watcher进行更新,设置订阅器中当前target为该watcher,访问属性时将订阅器会添加这个订阅者;
  5. 当数据改变时,遍历属性的subs,也就是订阅器,去执行它们的update方法。由此实现了数据驱动视图,也就是响应式。

关于实现源码这里不再详细分析,如果抓住这几个核心就没问题,万变不离其宗。

一个简单的响应式vue3

这里只实现reactive,它能够把一个对象变成响应式对象。

实现核心

  1. proxy数据劫持
  2. 观察者模式:收集依赖与派发更新,依赖是依赖于每个属性变化的 effect 函数,即收集订阅者,数据变化时通知订阅者进行更新

实现步骤

  1. reactive将一个对象变成响应式对象,类似于vue2实现中的observer类实现的数据劫持
  2. 实现依赖收集 track 和 派发更新 trigger
  3. tracktrigger的实现过程中使用到SetMap数据结构存储依赖属性的操作函数

主要部分

  1. reactive
const isObject = obj => { return  obj != null && typeof obj == 'object'}
const convert = obj => { return isObject(obj) ? reactive(obj) : obj}
// 代理的是整个对象,不需要循环taregt,提升了性能
export function reactive(target){
    // 判断target是否为对象,如果是继续,如果不是直接return
    if(!isObject(target)) return
    // 创建proxy实例
    const proxy = new Proxy(target, {
        get(target, key, receiver){
            // 收集依赖
            track(target, key)
            const result = Reflect.get(target, key, receiver)
            // 有子对象也要变成响应式
            return convert(result)
        },
        set(target, key ,newValue, receiver){
            let result = true
            // 当新老不相等时才需要更新
            if(Reflect.get(target, key, receiver) != newValue){
                result = Reflect.set(target, key ,newValue, receiver)
                // 派发更新
                trigger(target,key)
            }
            return result
            // 这里为什么要保存一个result值呢
            // 因为需要要set之后需要派发更新(可以理解为额外的功能),而set本身必须返回一个布尔值
        },
        deleteProperty(target, key){
            const hasOwn = target.hasOwnProperty(key)
            const result = Reflect.deleteProperty(target, key)
            if(hasOwn && result){
              // 派发更新
              trigger(target,key)
            }
            // deleteProperty本身也必须返回一个布尔值
            return result
        }
    })
    // 最后返回proxy实例
    return proxy
}
  1. effect

使用effect来观测变化,相当于watcher

const product = reactive({
    name:'iphone',
    price:5000,
    count:3
})
let total = 0
effect(() => {
   total = product.price * product.count
})
console.log(total) //15000
product.price = 8000
console.log(total)//24000
  1. tracktrigger

proxyget中需要track收集依赖,setdeleteProperty中需要派发更新,那么需要考虑的是收集依赖的时候如何存放依赖呢?这里和vue2defineProperty最大的区别是,vue2中循环遍历对象每个key时,在getter中收集依赖;vue3中对应的是整个对象,同样地,要收集对应属性的依赖。这里使用这样的结构来存储。

  • 首先全局会创建一个 targetMap,用来建立 数据 -> 依赖 的映射,它是一个 WeakMap 数据结构。
  • targetMap 通过键target,可以获取到 depsMap
  • depsMap的键是属性名称,值dep用来存放这个数据对应的所有响应式依赖。
  • dep是一个 Set 数据结构,存放着对应 key 的更新函数,也就是effect的回调函数。
  • track时,传入targetkey,举一个例子

target = {price: 190, count: 10}
// 此时targetMap.get(target)有值depsMap,是一个map结构
// 以两个属性为键,value是一个Set结构
depsMap = {'price' => Set(1), 'count' => Set(1)}
// 向Set添加依赖函数
// Set中是effect的回调函数,它依赖响应式属性的变化而变化

trigger时,同样根据该结构一层层找到对应属性的Set结构,执行变化

function trigger (target, key){
    const depsMap = targetMap.get(target)
    if(!depsMap) return
    const dep = depsMap.get(key)
    // 执行dep中的依赖函数
    if(dep){
        dep.forEach(effect => {
            effect()
        });
    }
}

说完原理,让我们再回到表现上来。上文在对比vue2vue3响应式时,提到了vue2中不能监听数组索引和length属性,那么现在vue3使用proxy后可以实现吗?大家可以自行试一试。

总结

  1. vue2响应式的实现核心:利用defineProperty数据劫持+观察者模式
  2. vue3响应式的实现核心:利用proxy数据代理+观察者模式,WeakMapSetMap数据结构作为辅助
  3. vue3解决的问题:
  • proxy对象实现属性监听,不需要去遍历每个属性,性能更好
  • 多层属性嵌套时只有访问某个属性时才会递归处理下一级属性,不再像vue2那样递归对所有的子数据进行响应式定义
  • 默认监听动态添加的属性
  • 默认监听属性的删除操作
  • 默认监听数组索引和length属性