我的响应式是如何丢掉的

ref 还是 reactive ?

结论:二者本质是相同的,建议无脑 ref。

众所周知 vue(后文未特殊说明时皆指 vue3)的响应式模型是基于 Proxy 的发布订阅模式,而 Proxy 是不能够拦截字面量的,因此需要使用对象包裹字面量才能使用 Proxy 拦截实现响应式。

ref(someValue) 就是 reactive({value: someValue})

关于 ref 的 .value 后缀,VueConf(vue 开发者大会)曾经提出过使用$语法糖声明响应式变量,并且在编译时自动帮助开发者添加 .value 从而使开发者更加无感的使用响应式,后来这个提案被拒绝掉了没能成为正式标准,因为 vue 团队发现没有 .value 时,开发者不能够清晰地确定自己引用的值是否是响应式的,会带来一些困扰和问题,反而增加了开发者的心智负担,因此拒绝了这个提案。(因此当你不能很好地掌控 ref 与 reactive 时,建议使用 ref,通过 .value 可以更清晰的告诉你:“hey guys,i’m a ref!”) 更多阅读:知道尤雨溪为什么要放弃 $ 语法糖提案么? - 掘金

(就这?这谁都知道!退钱!)

客官莫急,正文开始~

字面量和引用类型有什么区别?

相信大部分人都清楚,但还是统一一下认识

在 javascript 中字面量和引用类型都是存储的指向值所在地址的指针,只不过字面量在使用时是按值传递(将值复制一份给使用目标),而引用类型的值仍然是一个地址,这使得引用类型的值发生变化后在所有被引用位置都会取得变化后的值。

通常是一个非常有用的特性,这可以保证我们查询到的值始终是最新的,从而保证所有引用数据的地方都是最新的值,避免数据不一致带来的问题。举个例子

const club = {
  count: 10,
};

function getMemberCount(club) {
  return club.count;
}
function addNewMember(club) {
  club.count += 10;
}

addNewMember(club);
getMemberCount(club); // 20

我们在所有使用count的地方都通过查询club来取值从而达到了所有引用位置都可以同步最新的值。

我前面使用了一个词语“通常”,那么肯定会有不常见的情况,接下来我们来讲“但是”

引用类型虽然可以很方便的保证数据的一致性,这非常棒,但是也打开了潘多拉的魔盒。当一个程序员只是为了方便而使用引用类型,而并不清楚引用类型的本质,那么混乱就会悄悄地侵入我们的程序,并且这是一个很难察觉的问题,直到某一天,产品经理提出了一个新的需求,他要对我们以前的业务逻辑进行一些修改和升级!此时我们面对自己一点一点迭代出来的庞然大物,却无从下手,因为我们无法知道每个值究竟是在什么地方被改掉了!

我们将这个潘多拉魔盒里跑出来的恶魔叫做——可变数据

因此我们应该十分谨慎的使用引用类型,我们需要清楚的知道,我们使用引用类型就是为了保持数据的一致性,并且它可能在任何时刻任何地方被其它使用者修改!(为了解决可变数据带来的问题,诞生出了基于不可变数据的编程范式——函数式编程)

事实上单是一个可变数据所带来的困扰,还不足以阻碍聪明的程序员们,尽管有时会带来一些困扰,但我们还有作用域、模块化等一系列手段限制着这个恶魔。直到有一天恶魔混进了 vue 的响应式对象里…

vue 响应式(ref)与引用类型

引用类型变量和 vue 的响应式特性有很多相似的地方(尤其是 reactive 使用语法完全一致但是特性完全不同),这就导致如果不能完全清晰引用类型和 ref 的区别,那就很容易混用导致产生一些很难察觉的问题。

来看例子吧

<template>
  <h1>hello {{ someone.name }}</h1>
  <h2>phone:{{ someone.info.phone }}</h2>
  <h2>address:{{ someone.info.address }}</h2>
</template>

<script setup>
import { ref, reactive, watch, nextTick } from 'vue';

const someone = reactive({
  name: 'Ginlon',
  info: {
    phone: '10086',
    address: 'Qingdao',
  },
});

someone.name = 'Sana';

const newInfo = {
  phone: '10010',
  address: 'Beijing',
};
someone.info = newInfo;

newInfo.address = 'HongKong';
</script>

执行结果

image.png

看起来非常正确,没有任何问题,但是潘多拉的魔盒已经打开了。

将上面代码中最后的赋值语句(30 行)改为异步操作就可以让他原形毕露

// info.address = 'HongKong'
Promise.resolve().then(() => {
  newInfo.address = 'HongKong';
  console.log(someone.info.address);
});

结果

image.png

image.png

哦天哪,这太可怕了,我们在程序中访问到的值和在页面中访问到的值不是同一个值! :::warning 事实上此时只要通过someone进行查询、修改而不是通过 newInfo,一切依然是正常的,后面再来解释原因 ::: 让我们来看看究竟发生了什么!

1. vue 调度执行(schedule)

首先在改为异步语句之前,一切都是正常的,这牵扯到了 vue 内部的一个优化机制,

众所周知 DOM 操作是极其昂贵的,如果我们的响应数据(ref)每次发生变化时都去更新 DOM,那会带来很多不必要的 DOM 操作,造成很大的性能损耗。(尽管有虚拟 DOM、标记更新一系列优化,但是仍然是非常巨大的开销)

再来个例子

<template>
  <div>count:{{ count }}</div>
</template>

<script setup>
import { ref, watch } from 'vue';

const count = ref(0);

watch(count, () => {
  console.log('响应式数据变化了!');
});

for (let i = 0; i < 10; i++) {
  count.value++;
}
</script>

我们写了一个循环对一个响应式的数据进行自增,并且使用watch监听这个响应式数据的变化

执行结果

image.png

image.png

可以看到watch只执行了一次,也就是说只监听到了一次响应式数据变化,为什么?

这是因为 vue 的内部优化将所有的副作用(响应式数据变化引起的更新)放到了一个微任务队列中,这样就可以避免掉无用的 DOM 更新,极大地节省性能消耗。

实现方法是使用Promise.resolve创建一个微任务队列,并将所有的副作用函数放到这个Promise.then中执行(没错就是 nextTick)。(事实上watchcomputed等一系列的可配置特性都与这个调度执行(scheduler)有关,此处不展开,感兴趣可以参考《Vue.js 设计与实现》4.7 调度执行 章节)。

const tick = Promise.resolve();
const queue: any[] = [];
let queued = false;

const scheduler = (fn: any) => {
  queue.push(fn);
  if (!queued) {
    queued = true;
    tick.then(flush);
  }
};

所以由于 Vue 内部的这个优化导致我们第一次的代码案例中使用info.address = 'HongKong'的方式赋值是可以更新到视图的,因为引用类型的数据总是可以拿到最新的值!

但这还不足以解释为什么异步更新不能够同步到视图中,即使响应式更新发生在下一个事件循环中,它也应该可以正确的触发视图更新才对,除非…

2. reactive 的本质是什么?

除非…响应式被丢掉了!

还记得前面我们提到过,通过newInfo修改值时会导致视图和代码中的数据不一致,而通过someone来修改值却可以维持响应式,接下来我们来看看这两者究竟有何区别。

someonenewInfo的区别其实就是声明方式的不同,前者是通过 vue 的reactive Api 创建,而后者则是普通的 javascript 对象,而对reactive(即 ref 类型数据)的访问是会被 vue 的响应式系统拦截的,正是这个拦截的处理使我们的访问产生了差异。

下面是对这个过程的简要描述,实际的源码处理了很多边界情况要复杂多

function reactive(value){ return Proxy(value, { get:()=>{ return isReactive(res)
? res : reactive(res) } }) }

可以看到当我们访问一个响应式数据时,vue 会将数据进行一次响应式的包装再返回给我们,因此我们总是可以拿到响应式的数据,所以在我们的代码中通过someone修改值是可以正确的处理响应式的,而通过普通的引用对象newInfo并没有经过 vue 的包装,因此不能够触发响应事件,但是由于引用类型本身的特性,我们仍可以访问到最新的值!破案了!

此外 vue 这样的处理还有一个好处是:不必在创建对象的时候就进行完全的遍历使用 proxy 包装,而是在真正访问的时候再进行处理,从而减少了不必要的开销和复杂的边界情况处理。

我们已经发现了问题的根源所在,但如果我们就是觉得每次取值都通过最开始的对象查找太繁琐了,有没有办法可以通过newInfo来取值又保持 vue 的响应式呢?那肯定是当然的

通过上面这段代码我们可以发现,newInfosomeone.info区别仅仅是有没有reactive包裹而已,那我们完全可以自己添加一个reactive来包裹这个数据!

const newInfo = reactive({
  phone: '10010',
  address: 'Beijing',
});

someone.info = newInfo;

newInfo.address = 'Honkong';

这样我们就可以方便的取值且保证响应式的正常执行了!

至于可能存在的重复的包装问题,vue 内部已经做了完善的处理,ref(ref(value))ref(value)是一致的。

我们用最开始的图在回顾一下我们的响应式是如何丢失的

3.开发实践

找到了问题的原因,那就来看看如何避免混用带来的问题:

  1. 首先不推荐使用reactive,建议无脑ref理由可以参考文章开始部分,只要是通过.value取到的值就是响应式的数据。
  2. 在使用reactive声明的对象时需要注意下面这些用法:
    1. 深拷贝 cloneDeep()
    2. 解构 {...reactive(value)}
    3. Obejct.assign(下面解释)
    4. 所有的响应式操作都应该从根对象出发查找
    5. 引用类型数据的地址更改
const someone = reactive({
  name: 'Ginlon',
  info: {
    phone: '10086',
    address: 'Qingdao',
  },
});
const newInfo = {
  phone: '10010',
  address: 'Beijing',
};

someone.info = newInfo;

解释一下Object.assign,这是个非常灵活的方法。当我们想要移除某个响应式对象的响应性获取其原始值时可以使用Object.assign({}, refObj)(注意只能移除最外层以及由外层传递到子层级的响应性,并不能处理深层的显式声明的响应式数据)。当我们想要更新某个响应式对象的值时,又不想丢失响应性的时候,可以使用

Object.assign(refObj, newObj)

【⚠️ 警告】不推荐使用该写法,对象内部字段的不可控可能会引起不可控的副作用,使用此方法传递一个字段非常多的对象时,会连续触发响应更新,一旦在页面中使用了该对象的字段则会导致高频率的刷新页面。

至于为什么可以维持响应式,我们可以看一下 ECMAScript 关于Object.assign的实现标准说明

image.png

可以看到最后值是通过set的方式添加给target的,而 vue 的响应式系统是可以拦截set的因此响应式系统是可以正确的执行的。

其实还可以看到Object.assign还访问了sourcesget,也就是说响应式系统是可以追踪到依赖的,但是不建议使用这个特性,会使数据流向变得难以追踪。

引用类型的使用

1.映射表

常见的一种情况是服务端返回给我们的数据经常会是一个有唯一标识的对象数组 {id,otherValue}[]

而很多操作往往是需要根据 id 来查找指定的记录的,项目里常见到

list.find(it=>it.id===Id)

类似的代码,每次find都会遍历数组,这会产生很多不必要的开销,而我们可以提前对数据做一个映射

const idToData = list.reduce((map, item) => {
  map[item.Id] = item;
}, {});

这样当我们需要根据 id 获取某个记录是可以直接查表idToData[id],避免遍历整个数组,将O(n)的时间复杂度降低为O(1),而由于我们存储的仅仅是一个引用地址表,并没有存储实际数据,所以空间代价是几乎可以忽略不计的。(这种用法在优化双层循环时屡试不爽)

2.缓存池

与映射表相似,我们也可以构建一个以 id 为键值的缓存池,维持对某个对象的引用从而避免被 GC(垃圾回收)回收,且下次使用时可以直接查表获取,从而避免重复加载或计算某些繁重的任务,webpack 的模块化部分就是这种设计的一个实现。

千辛万苦,终于搞定了响应式,可是很遗憾,解决了响应式的问题并不代表彻底解决了页面更新的问题,因为实际的开发中有些代码会非常庞大复杂,迭代多次后往往很难完善的处理响应式的流转,会造成有些极端情况下,即使在可追踪的范围内我们的数据完成了响应式的更新,我们的页面也有可能不会重新渲染。万不得已时可以尝试的解决方案:

给需要重新渲染的部分添加:key属性来明确的告诉 vue: “hey, guys! 这一部分有变化,需要重新渲染!”

附部分代码,可直接在 Vue SFC Playground 中执行并调试

<script setup>
import { ref, reactive, watch, nextTick } from 'vue';

const someone = reactive({
  name: 'Ginlon',
  info: {
    phone: '10086',
    address: 'Qingdao',
  },
});

watch(
  someone,
  newValue => {
    console.log(newValue);
  },
  { deep: true },
);

someone.name = 'Sana';

const newInfo = {
  phone: '10010',
  address: 'Beijing',
};

someone.info = newInfo;
setTimeout(() => {
  newInfo.address = 'HongKong';
}, 0);
</script>

<template>
  <h1>hello {{ someone.name }}</h1>
  <h2>phone:{{ someone.info.phone }}</h2>
  <h2>address:{{ someone.info.address }}</h2>
</template>

推荐阅读

最后更新:2025-07-27 11:45 星期日
备案号:鲁ICP备2024058644号