Vue3 响应式原理 reactive、ref 详解(对比vue2)

304 阅读9分钟

Reactive 与 Ref

reactiveref
❌只支持对象和数组(引用数据类型)✅支持基本数据类型+引用数据类型
✅在 <script> <template>中无差别使用❌在 <script><template> 使用方式不同(script中要.value)
❌重新分配一个新对象会丢失响应性✅重新分配一个新对象不会失去响应
能直接访问属性需要使用 .value 访问属性
❌将对象传入函数时,失去响应✅传入函数时,不会失去响应
❌解构时会丢失响应性,需使用toRefs❌解构对象时会丢失响应性,需使用toRefs

参考:juejin.cn/post/727051…

reactive 解构

直接给解构后的a赋值,a不会被改变,需要使用toRefs

// index.tsx
<template>
  <div @click="change">
    值a: {{ value.a }} 
  </div>
</template>

import { useTest } from './hook';
const value = reactive({
  a: 1,
});
const change = () => {
   // 这里直接给解构后的a赋值,a不会被改变
   let { a } = value
   a++
   
   // 使用toRefs将value的属性改为ref,即a改为了ref
   let { a } = toRefs(value);
   a.value++
};

reactive 的属性传入函数

将reactive的属性值直接传入useTest这个组合式函数,更改value.a时会发现aPlus没有改变

// index.tsx
<template>
  <div @click="change">
    值a: {{ value.a }} 
    aPlus: {{ aPlus }}
  </div>
</template>

import { useTest } from './hook';
const value = reactive({
  a: 1,
});
// 这里直接将reactive对象或reactive对象的属性传入函数,会丢失响应式
const { aPlus } = useTest(value.a);
// 使用toRef将value变为ref类型传入函数
const { aPlus3 } = useTest(toRef(value.a));
// 将整个对象传入函数不会丢失响应
const { aPlus } = useTest2(value);

const change = () => {
  value.a++;
};

// hook.ts
export const useTest = (a) => {
  const aPlus = computed(() => {
    return a + 1;
    // 使用toRef后,newObj接受的是一个ref类型的值,需要在后面加value
    return a.value + 1
  });
  return {
    aPlus,
  };
};

export const useTest2 = (obj) => {
  const aPlus = computed(() => {
    return obj.a + 1
  });
  return {
    aPlus,
  };
};

toRef 与 toRefs

toRef

toRef 可以将值、refs 或 getters 规范化为 refs,它可以接受基本数据类型与对象。

也可以基于响应式对象上的一个属性,创建一个对应的 ref。这样创建的 ref 与其源属性保持同步:改变源属性的值将更新 ref 的值,反之亦然。

const state = reactive({
  foo: 1,
  bar: 2
})

// 双向 ref,会与源属性同步
const fooRef = toRef(state, 'foo')

// 更改该 ref 会更新源属性
fooRef.value++
console.log(state.foo) // 2

// 更改源属性也会更新该 ref
state.foo++
console.log(fooRef.value) // 3

toRefs

它接受的是一个对象,可以是数组,不可接受基本数据类型

将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的 ref。每个单独的 ref 都是使用 toRef() 创建的。

比如:

const value1 = reactive({
  a: 1,
  b: {
    c: 2,
  },
});
console.log(toRefs(value1)); 
// 返回值是 {a: ObjectRefImpl, b: ObjectRefImpl} 将对象的每一个属性都变为了Ref类型
console.log(toRefs(value1).value.b);
// 返回值是 Proxy(Object) {c: 2} 仅将对象的第一层属性改为Ref,后面的是Proxy类型

const value2 = reactive([1, 2, 3]);
console.log(toRefs(value2)); 
//返回值是 [ObjectRefImpl, ObjectRefImpl, ObjectRefImpl],将数组的每一项都变为了Ref类型

深入响应式原理

什么是响应式

let A0 = 1
let A1 = 2

let A2
function update() {  
    A2 = A0 + A1
}
// 在A0或A1变化时,需要执行update更新 A2
  • update() 函数会产生一个副作用,或者就简称为作用 (effect),因为它会更改程序里的状态。
  • A0A1 被视为这个作用的依赖 (dependency),因为它们的值被用来执行这个作用。因此这次作用也可以说是一个它依赖的订阅者 (subscriber)。

如何能够在 A0A1 (这两个依赖) 变化时调用 update() (产生作用)?

  1. 当一个变量被读取时开启监听。例如我们执行了update,触发了 A0 + A1 的计算,则 A0A1 都被读取到了。

  2. 如果一个变量在副作用中被读取了,即执行了update,就将该副作用update设为此变量的一个订阅者。

  3. 监听一个变量的变化。例如当我们给 A0 赋了一个新的值后,应该通知其所有订阅了的副作用重新执行。

如何实现响应式

我们无法直接追踪对上述示例中局部变量的读写,原生 JavaScript 没有提供任何机制能做到这一点。但是,我们是可以追踪对象属性的读写的。

在 JavaScript 中有两种劫持 property 访问的方式:getter / settersProxiesVue 2 使用 getter / setters 完全是出于支持旧版本浏览器的限制。而在 Vue 3 中则使用了 Proxy 来创建响应式对象,仅将 getter / setter 用于 ref

defineProperty

使用 defineProperty 为当前对象定义 getter

var o = { a: 0 };

Object.defineProperty(o, "b", {
  get: function () {
    return this.a + 1;
  },
});

console.log(o.b); // Runs the getter, which yields a + 1 (which is 1)

使用 defineProperty 为当前对象定义 setter

const o = { a: 0 };

Object.defineProperty(o, "b", {
  set: function (x) {
    this.a = x / 2;
  },
});

o.b = 10; // Runs the setter, which assigns 10 / 2 (5) to the 'a' property
console.log(o.a); // 5
使用defineProperty的缺点

defineProperty在对象新增、删除属性没有响应式,数组新增、删除元素没有响应式;通过下标修改某个元素没有响应式;通过.length改变数组长度没有响应式。

Proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

const handler = {
  get: function (target, key) {
    return target[key];
  },
  set: function (target, key, value) {
    target[key] = value
  }
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b); // 1, undefined
proxy的优点
  1. proxy性能整体上优于Object.defineProperty
  2. vue3支持更多数据类型的劫持(vue2只支持Object、Array;vue3支持Object、Array、Map、WeakMap、Set、WeakSet)
  3. vue3支持更多时机来进行依赖收集和触发通知(vue2只在get时进行依赖收集,vue3在get/has/iterate时进行依赖收集;vue2只在set时触发通知,vue3在set/add/delete/clear时触发通知),所以vue2中的响应式缺陷vue3可以实现
  4. vue3做到了“精准数据”的数据劫持(vue2会把整个data进行递归数据劫持,而vue3只有在用到某个对象时,才进行数据劫持,所以响应式更快并且占内存更小)
  5. vue3的依赖收集器更容易维护(vue3监听和操作的是原生数组;vue2是通过重写的方法实现对数组的监控)

参考:juejin.cn/post/726074…

vue3 中的ref与reactive

reactive:

reactive是基于proxy实现的,其行为就和普通对象一样,不同的是Vue 能够拦截对响应式对象所有属性的访问和修改,以便进行依赖追踪和触发更新。

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)
      return target[key]
    }
  })
}

但是reactive有其局限性,

  1. 有限的值类型:它只能用于对象类型 (对象、数组和如 MapSet 这样的集合类型)。它不能持有如 stringnumberboolean 这样的原始类型

Proxy 的target是对象类型

  1. 当我们将响应式对象的原始类型属性解构为本地变量时,将丢失响应性连接

这是因为 ,解构赋值是一种从数组或对象中提取值并赋值给变量的语法,所以它会创建一个新的对象,而Proxy对象只能拦截已存在的属性,无法拦截新创建的属性或对象的赋值操作,

  1. 当我们将响应式对象的属性传递给函数时,我们将丢失响应性连接:

这是因为,state.count作为参数传递的时候,传递的是proxy代理的值(state的原始值)的属性,与proxy失去了联系,必须传入整个对象以保持响应性

// 该函数接收到的是一个普通的数字
// 并且无法追踪 state.count 的变化
// 我们必须传入整个对象以保持响应性
callSomeFunction(state.count)
  1. 由于 Vue 的响应式跟踪是通过属性访问实现的,因此我们必须始终保持对响应式对象的相同引用。这意味着我们不能轻易地“替换”响应式对象,因为这样的话与第一个引用的响应性连接将丢失。

这个不太好理解,我们来一段伪代码帮助理解。

<template>
  <div @click="change">赋值一个新的reactive</div>
  <div @click="change2">赋值一个新的object</div>
  <div @click="change3">解构赋值后更改</div>
</template>
<script lang="ts" setup>
let trackMap = new Map();
const track = (target, key) => {
  trackMap.set(target, key);
};

const trigger = (target, key) => {
  if (trackMap.has(target)) {
    publishChange();
  }
};

const publishChange = () => {
  update();
};

const reactive = (obj) => {
  return new Proxy(obj, {
    get(target, key) {
      debugger;
      track(target, key);
      return target[key];
    },
    set(target, key, value) {
      target[key] = value;
      debugger;
      trigger(target, key);
      return target[key];
    },
  });
};

let test = reactive({ a: 1 });

let value2Comp = test.a + 1; // 此处触发get, trackMap中存储了当前test, a
function update() {
  value2Comp = test.a + 100;
}
const change = () => {
  test = reactive({ a: 2 }); // 重新赋值的时候,不会触发get,所以不会触发track,新赋值的对象也就不会被跟踪
  test.a = 100; // 此处触发set,触发trigger,target为{a:2,b:2}, value为100,trackMap中没有这个target,所以不会触发update
  console.log('test', test.a); // 此处打印为100
  console.log('value2Comp', value2Comp); //没有触发update,所以打印为2
};

const change2 = () => {
  test = { a: 2 }; // 这样赋值的时候,也不会触发get,所以不会触发track,新赋值的对象也就不会被跟踪
  test.a = 100; // 此处触发set,触发trigger,target为{a:2,b:2}, value为100,trackMap中没有这个target,所以不会触发update
  console.log('test', test.a); // 此处打印为100
  console.log('value2Comp', value2Comp); //没有触发update,所以打印为2
};

const change3 = () => {
  let { a } = test; // 此处可以触发get,
  a = 100; // 但此处不会触发set,因为a是一个新的变量,不是test的属性
  console.log('test', a); // 此处打印为100
  console.log('value2Comp', value2Comp); //没有触发update,所以打印为2
};
</script>

总结:

  1. 在创建proxy的时候是不会触发get,只有读取属性的时候才触发get,所以vue3的文档上说【 Vue 的响应式跟踪是通过属性访问实现的
  2. 整体赋值对象的时候,实际上是赋值了一个对象的引用地址,此时的test对象指向的也就是这个新的对象的引用地址了,他就不是一个proxy对象了,自然也就没有响应式
Ref

ref依然是使用的getter/setter的模式,对于基本数据类型,在读写时会触发get和set方法,以此来追踪变量,实现响应式,对于引用数据类型,有着与defineProperty相同的问题,对于对象的新增删除,数组的新增、删除元素等无效,所以需要将它们转化成Proxy,实现深层响应式。

const toReactive = (value) =>
  isObject(value) ? reactive(value) : value
  
class RefImpl {
  constructor(value) {
    this._value = toReactive(value) // value的值是reactive包裹的
  }

  get value() {
    track(this)
    return this._value
  }

  set value(newVal) {
    this._value = toReactive(newVal)
    trigger(this, newVal)
  }
}