总结vue3响应式原理与ref、reactive

571 阅读8分钟

前言

vue2的响应式通过Object.defineProperty将data转为响应式,而vue3则通过Proxy实现,性能得到显著提升,那么vue3的响应式系统做了什么呢?发生了哪些变化?

vue3通过什么api实现响应式数据声明,怎么实现?

对比vue2,响应式数据都声明在data(){}中,但是vue3则需要我们通过api自己声明,如下就是声明vue3响应式的代码。

setup() {
    const msg = ref('想吃炸鸡')
    const obj = reactive({
      name:"深圳不怕影子斜"
    })
    console.log(obj,msg);
    return { msg,obj }
  },

看完这个案例之后,是不是想了解怎么实现一个简单的响应式数据结构和理解他的原理呢?

第一步:实现一个简单的reactive

let reactive = function(obj){
    //返回劫持obj的Proxy对象
    return new Proxy(obj, {
        //劫持get
        get(target, key) {
            return target[key]
        },
        //劫持set
        set(target, key, value) {
            target[key] = value
        }
    })
}
//声明一个reactive
let reactive_data = reactive({age:30})
a.age = 40
console.log(a.age)

接下来我们可以看到我们修改了值改成了40,我的reactive方法通过Proxy劫持对象,实现了一个简单的响应式方法,这就是Vue 3 使用了 Proxy 来创建响应式对象的原理,当然vue3考虑的会更全面,我们这只是一个简单的案例,理解他怎么实现的。

01.png

既然有了reactive,为什么还会有ref呢?

接下来我们看一段代码

  setup() {
    const obj = reactive(1)
    console.log('打印A',obj);
    obj++
    console.log('打印B',obj);
    return { obj }
  },

image.png 再看上面控制台打印的,你会发现通过reactive直接声明number,string,boolean等值类型的响应式数据,是不支持的,直接提示了错误,原因是Proxy只能劫持对象,那我们需要写成下面这样,打印结果正常了。

  setup() {
    const obj = reactive({value:1})
    console.log('打印A',obj)
    obj.value++
    console.log('打印B',obj)
    return { obj }
  },

image.png

第二步:先实现一个简单的ref

<script>
function ref(value) {
    const refObject = {
        get value() {
          return value
        },
        set value(newValue) {
          value = newValue
        }
    }
    return refObject
}
//声明响应式
const age = ref(18)
//打印出18
console.log('ref',age.value)
//赋值加1
age.value ++
//打印出19
console.log('ref',age.value)
</script>

运行结果如下

image.png

通过上面这个案例,我们去传入一个对象时,发现又报错了,如下

let ref = ref({name:"小明",age:18})
console.log('ref',refa.value.age);
refa.value.age ++
console.log('ref',refa.value.age);

image.png

这是因为,真正的ref是通过RefImpl类实现的时候,在构造器和set参数时动态的判断了是否是object对象,如果是则通过reactive实现,那么我们按照他的思路写一个简单的案例,继续改造一下我们的ref

<div>
    <button onclick="age_click()">点我增加年龄</button>
</div>
<script>
//首先简单的实现一下isObject
const isObject = (value)=> typeof value === 'object'
//第二步实现toReactive,是否为对象,如果是则调用之前写好的reactive方法
const toReactive = (value) => isObject(value) ? reactive(value) : value
//第三步实现一下RefImpl
class RefImpl {
    _value =''
    constructor(value) {
        //在这里初始化值,真实的RefImpl类构造器里面还初始化了很多东西
        this._value = isObject(value) ? toReactive(value) : value;
    }
    get value() { // getter方法 获取value值
        console.log('get了',this._value);
        return this._value;
    }
    set value(newVal) { // setter方法 设置value值
        this._value = isObject(newVal) ?  toReactive(newVal):newVal;  // 判断是否为object,是则调用toReactive
        console.log('set了',newVal);
    }
}
//那么我们的ref就可以简化如下了
let ref =function(value){
    return new RefImpl(value)
}
const obj = ref({age:18})
let age_click =()=>{
    console.log('对象的age',obj.value.age)
    obj.value.age ++
    console.log('对象的age',obj.value.age)
}
</script>

这个时候我们能看到的结果如下,至此,ref实现了可以传入对象进行响应式的操作。

image.png

实现了数据响应,怎么实现数据变了页面也跟着变?

我们上面虽然实现了数据的响应,可是他什么时候会更新呢?这个就涉及到vue的track和trigger,我们劫持了数据的get和set,在get的时候track收集依赖(发布订阅),在set的时候触发trigger(通知订阅)的update进行更新。思路有了那我们来写一个简单的案例理解一下

<div>
    <button onclick="age_click()">点我增加年龄</button>
    <div >我的age现在是<span id="update_span"></span></div>
</div>
<script>
// 首先先将activeWatcher设为null 确保在初始化阶段没有活动的Watcher对象
let activeWatcher = null;
// 创建 Watcher 类,通过构造函数绑定update函数,并实现update更新回调
class Watcher {
    constructor(updateFn) {
        this.updateFn = updateFn;
    }
    // 执行更新操作
    update() {
        this.updateFn();
    }
}
//创建更新操作方法,更新页面数据
let update = ()=>{
    console.log('更新页面元素开始');
    document.querySelector("#update_span").innerHTML = age.value.age;
    console.log('更新页面元素结束');
}
//实例化一个Watcher
activeWatcher = new Watcher(update)
// 创建依赖管理类Dep,通过构造函数初始化依赖收集器,实现track收集依赖
class Dep {
    constructor() {
        // 用于存储依赖的订阅者
        this.subscribers = new Set();
    }

    // 添加订阅者
    track=(key) =>  activeWatcher && this.subscribers.add(activeWatcher)

    // 通知所有订阅者进行更新
    trigger=()=> this.subscribers.forEach((watcher) => watcher.update())
}
//new 一个依赖收集器
const dep = new Dep();
//基于上面的reactive方法,添加了dep.track() //发布订阅和dep.trigger() //通知订阅
let reactive = function(obj){
    return new Proxy(obj, {
        get(target, key) {
            // track(target, key)//vue的发布订阅
            dep.track() //发布订阅
            // return Reflect.get(target,key) 
            return target[key]
        },
        set(target, key, value) {
            console.log('set了',value);
            target[key] = value
            // vue3底层的对象响应式的雏形是这样的,利用proxy代理,利用reflect反射
            // Reflect.set(target,key,value)
            // trigger(target, key)
            dep.trigger() //通知订阅
        }
    })
}

//简单的实现isObject
let isObject = (value)=> typeof value === 'object'
// 是否为对象,如果是则调用reactive
const toReactive = (value) => {return isObject(value) ? reactive(value) : value}; 

//根据上面的RefImpl,添加dep.track()和dep.trigger()
class RefImpl {
    _value =''
    constructor(value) {
        //在这里初始化值,真实的RefImpl类构造器里面还初始化了很多东西
        this._value = isObject(value) ? toReactive(value) : value;
    }
    get value() { // getter方法 获取value值
        dep.track()
        console.log('get了',this._value);
        return this._value;
    }
    set value(newVal) { // setter方法 设置value值
        this._value = isObject(newVal) ?  toReactive(newVal):newVal;  // 判断是否为object,是则调用toReactive
        console.log('set了',newVal);
        dep.trigger()
    }
}
//实现ref
let ref =function(value){
    return new RefImpl(value)
}
//声明一个ref
const age = ref({age:18,name:'aaa'});
//实现click
let age_click =()=>{
    age.value.age++
}
//把值初始化挂载在页面
update()
</script>

这个时候我们看到了初始化把age挂载在页面上的效果,模拟成功

image.png 再看看点击一下按钮之后的效果

image.png 至此,模拟成功,这个是一个简单的例子,来帮助我们理解响应式的原理,实际的vue发布订阅以及响应更新页面元素会更复杂。

ref和reactive的注意事项

  • reactive() 返回的是一个原始对象的 Proxy,它和原始对象是不相等的
const raw = {}
const proxy = reactive(raw)
// 代理对象和原始对象不是全等的
console.log(proxy === raw) // false
  • 只有代理对象是响应式的,更改原始对象不会触发更新。因此,使用 Vue 的响应式系统的最佳实践是仅使用你声明对象的代理版本
const raw = {age:18}//非响应式数据
const proxy = reactive(raw)//响应式数据
raw.age =19  //不会触发页面更新
proxy.age =19 //会触发页面更新
  • reactive使用时不能替换整个对象:由于 Vue 的响应式跟踪是通过属性访问实现的,因此我们必须始终保持对响应式对象的相同引用。
let state = reactive({ count: 0 })
// 上面的 ({ count: 0 }) 引用将不再被追踪
// (响应性连接已丢失!)
state = reactive({ count: 1 })
  • reactive对解构操作不友好:当我们将响应式对象的原始类型属性解构为本地变量时,或者将该属性传递给函数时,我们将丢失响应性连接,在对象需要解构的时候,建议配合toRefs使用
const state = reactive({ count: 0 }) 
// 当解构时,count 已经与 state.count 断开连接 
let { count } = state 
// 不会影响原始的 
state count++ 
// 该函数接收到的是一个普通的数字 
// 并且无法追踪 state.count 的变化 // 我们必须传入整个对象以保持响应性 callSomeFunction(state.count)
//基于这个问题所以官方建议我们使用ref
  • 额外的 ref 解包细节
const count = ref(0) 
//一个 ref 会在作为响应式对象的属性被访问或修改时自动解包,count在reactive中被解包
const state = reactive({ count })
//只有当嵌套在一个深层响应式对象内时,才会发生 ref 解包。当其作为shallowReactiv浅层响应式的属性被访问时不会解包
//如果将一个新的 ref 赋值给一个关联了已有 ref 的属性,那么它会替换掉旧的 ref
//当 ref 作为响应式数组或原生集合类型 (如 `Map`) 中的元素被访问时,它不会被解包
//ref在template模板中可以自动解包

扩展

上次在博客上看说vue3的响应式底层使用到了Reflect工具类,所以我去看了一下,这篇文章写的不错推荐给大家一起看看,这篇文章讲述了Proxy和Reflect,并且描述了通过劫持对象的get或者set方法实现表单校验和请求拦截,扩展了我的编程思路。 揭秘:Proxy 与 Reflect,为何总是形影不离

总结

  • ref判断了对象是原始类型还是object,是object则通过reactive new Proxy后再赋值给value,否则直接赋值给value,ref支持声明number,布尔等原始类型的值和对象的响应式数据
  • reactive只支持声明object响应式数据,返回的是一个Proxy代理对象
  • 通过Weak容器,实现观察者角色,ref和reactive都实现了通过get的track收集依赖到容器和set参数的trigger触发容易中的依赖的更新,下面是vue官方的一段话。

image.png

最后,如果有写的不好的,欢迎大家指点。