Vue响应式系统的作用与实现(三)

响应式系统的作用与实现(三)

前面讨论了非原始值的响应式实现,接下来这节将讨论原始值的响应式实现。原始值指的是:Boolean、String、Number、BigInt、Symbol、undefined和null等类型的值。

在JS中,原始值是按值传递的,而非按引用传递。这意味着如果一个函数接收原始值作为参数,那么形参和实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。

另外JS中的Proxy无法提供对原始值的修改,因此想要将原始值变成响应式数据,就需要做一层包裹,vue.js里面是借助ref实现的,接下来就让我们来认识它。

1.引入ref的概念:

由于Proxy的代理目标必须是非原始值,所以我们没有任何手段拦截对原始值的操作,例如:

let str = 'hello world';
// 无法拦截对值的修改
str = 'hhh'

对于这个问题,我们能够想到的唯一办法:使用一个非原始值去包裹原始值,例如使用一个对象包裹原始值:

const wrapper = {
    value: 'hello world'
}
// 可以使用Proxy代理 warpper,间接实现对原始值的拦截
const name = reactive(wrapper);
// 修改值可以触发响应
name.value = 'hhh'

但是这样做有两个问题:

  • 用户为了创建一个响应式的原始值,不得不顺带创建一个包裹对象;
  • 包裹对象由用户定义,这意味着不规范。用户命名将变得随意,如 wrapper.valuewrapper.val都是可以的。

为了解决这两个问题,我们封装一个ref函数,将包裹对象的创建工作都封装到该函数中:

// 封装一个ref函数
function ref(val) {
    // 在 ref函数内部创建包裹对象
    const wrapper = {
        value: val
    }
    // 将包裹对象变成响应式数据
    return reactive(wrapper);
}

这样便解决了上面的两个问题,但是还不够完善。

比如:如何区分refVal到底是原始值的包裹对象,还是一个非原始值的响应式数据?

const refVal1 = ref(1);
const refVal2 = reactive({ value: 1 })

从结果实现来看,它们没有任何区别,但是有必要区分下,一个数据到底是不是ref。

那么该如何区分一个数据是否是ref呢?

// 封装一个ref函数
function ref(val) {
    // 在 ref函数内部创建包裹对象
    const wrapper = {
        value: val
    }
    // 使用Object.defineProperty再wrapper对象上定义一个不可枚举的属性
    // __v_isRef,并且返回值为true
    Object.defineProperty(wrapper, '__v_isRef', {
        value: true
    })
    // 将包裹对象变成响应式数据
    return reactive(wrapper);
}

通过检查 __v_isRef属性便知道一个数据是否是ref了。

2.响应丢失问题:

ref除了能够用于原始值的响应式之外,还能用来解决响应丢失问题。

什么是响应丢失问题:

const obj = reactive({ foo: 1, bar2 })
// 将响应式数据展开到一个新的对象newObj里
const newObj = {
    ...obj
}
effect(() => {
    // 在副作用函数内通过新的对象 newObj读取foo的值
    console.log(newObj.foo);
})
// 此时修改obj.foo不会触发响应
obj.foo = 100;

使用拓展运算符展开的新对象newObj是一个普通对象,不具备任何响应能力,所以当我们修改 obj.foo的值时,不会触发副作用函数重新执行。

那么有什么办法可以让我们实现:在副作用函数内,即使通过普通对象 newObj来访问属性值,也能够建立响应联系?

const obj = reactive({ foo: 1, bar2 })
// 将响应式数据展开到一个新的对象newObj里
const newObj = {
    foo: {
        get value() {
            return obj.foo
        }
    },
    bar: {
        get value() {
            return obj.bar
        }
    }
}
effect(() => {
    // 在副作用函数内通过新的对象 newObj读取foo的值
    console.log(newObj.foo.value);
})
// 这样就能够触发响应了
obj.foo = 100;
  • newObj对象通过访问器读取value的值时,读取到的其实是obj下的同名属性值。即当在副作用函数内读取 newObj.foo时,等价于间接读取了 obj.foo的值。这样响应式数据自然能够与副作用函数建立响应联系。当修改值时,也能触发副作用函数重新执行了。
  • foo和bar的结构类似,这时我们可以将它抽象出来进行封装。
function toRef(obj, key) {
    const wrapper = {
        get value() {
            return obj[key];
        }
    }
    // 定义 __v_isRef 属性,表面这是一个ref
    Object.defineProperty(wrapper, '__v_isRef', {
        value: true
    })
    return wrapper;
}
// 借助toRef重新实现newObj对象
const newObj = {
    foo: toRef(obj, 'foo'),
    bar: toRef(obj, 'bar')
}

进一步实现,如果obj的键非常多,我们可以封装toRefs函数,来批量完成转换:

function toRefs(obj, key) {
    const ret = {};
    // 使用 for...in 循环遍历对象
    for (const key in obj) {
        // 逐个调用toRef完成转换
        ret[key] = toRef(obj, key);
    }
    return ret;
}

这样一步操作便可完成对一个对象多个键的转换了:

const newObj = { ...toRefs(obj) }

如此一来,响应丢失问题就彻底解决了。思路是:将响应式数据转换成类似于ref结构的数据,并且为了概念上的统一,将通过toRef或toRefs转换后得到的结果视为真正的ref数据。

上文实现的toRef函数还存在缺陷,即通过toRef函数创建的ref是只读的,因为我们只添加了getter,没有添加setter,最终实现如下:

function toRef(obj, key) {
    const wrapper = {
        get value() {
            return obj[key];
        },
        // 允许设置值
        set value(newValue) {
            obj[key] = newValue;
        }
    }
    // 定义 __v_isRef 属性,表面这是一个ref
    Object.defineProperty(wrapper, '__v_isRef', {
        value: true
    })
    return wrapper;
}

3.自动脱Ref:

toRefs函数解决了响应丢失的问题,但是toRefs会把响应式数据的第一层属性值转换为ref,因此必须通过 value属性访问之,如:

const obj = reactive({ foo: 1, bar2 });
// obj.foo , obj.bar 即可访问属性值
const newObj = { ...toRefs(obj) };
newObj.foo.value;
newObj.bar.value;

这明显写起来很繁琐,增加了心智负担,毕竟通常是在模板里面访问数据,肯定不希望写得太烦吧, {{foo.value}} 、 {{bar.value}}

因此我们需要自动脱ref的能力。所谓自动脱ref,指的是属性的访问行为,即如果读取的属性是一个ref,则直接将该ref对应的value属性值返回即可:

newObj.foo.value  ->  newObj.foo

要实现这个功能,需要使用Proxy为newObj创建一个代理对象,通过代理来实现最终目标,这时就需要用到上文中的ref标识:

function proxyRefs(target) {
    return new Proxy(target, {
        get(target, key, receiver) {
            const value = Reflect.get(target, key, receiver)
            // 自动脱ref实现:如果读取的值是ref,则返回它的value属性值
            return value.__v_isRef ? value.value : value;
        }
    })
}
// 调用proxyRefs 函数创建代理
const newObj = proxyRefs({ ...toRefs(obj) })

这样,当我们读取的属性是一个ref时,直接返回该ref的value属性值,就实现了自动脱ref了。

console.log(newObj.foo);
console.log(newObj.bar);

在Vue3中,我们用到的setup函数所返回的数据其实是给 proxyRefs处理的。(script setup真的好香

const myComponent = {
    setup() {
        const count = ref(0);
        // 返回的这个对象会传递给 proxyRefs
        return { count };
    }
}

这便是在访问直接访问ref值时,无需通过value属性来访问。上面我们只做了读取的,还没做设置的,添加对应的set拦截函数即可:

function proxyRefs(target) {
    return new Proxy(target, {
        get(target, key, receiver) {
            const value = Reflect.get(target, key, receiver)
            // 自动脱ref实现:如果读取的值是ref,则返回它的value属性值
            return value.__v_isRef ? value.value : value;
        },
        set(target, key, newValue, receiver) {
            // 通过target读取真实值
            const value = target[key];
            // 如果值是Ref,则设置其对应的value属性值
            if (value.__v_isRef) {
                value.value = newValue;
                return true;
            }
            return Reflect.set(target, key, newValue, receiver);
        }
    })
}

在Vue.js中,其实reactive也有自动脱ref的能力,比如:

const count = ref(0);
const obj = reactive({ count });
obj.count; //0

这样我们不用知道一个值到底是不是ref,在模板做使用响应式数据时,无需关心它是不是ref。

4.总结:

在本章中,介绍了ref的概念,它本质就是一个 “包裹对象”,因为JS的Proxy无法提供对原始值的代理,所以需要使用一层对象作为包裹,间接实现原始值的响应式方案,并且定义一个属性 __v_isRef作为ref的标识。以及解决响应丢失,自动脱ref的能力,减轻了心智负担,暴露到模板中的响应式数据不用写 xxx.value了。

至此,响应式系统的作用与实现便介绍到这里。

你可能感兴趣的:(Vue,前端,javascript,vue,响应式系统)