Vue 3 响应式变化初看:Proxy 带来的不只是写法变化

同样是给一个对象加新属性,Vue 2 里视图不会更新,Vue 3 里会。这不是巧合也不是 bug,是两代响应式实现的分野。Vue 3 已经正式发布,趁着在一个内部小工具项目里试跑的机会,把这个行为差异在控制台里复现了一遍,顺便把两边的实现思路对照着看了一次。

Vue 2 项目里这样写没有效果:

1this.user.nickname = 'Su';

如果 nickname 是新增字段,界面不会更新,得改成:

1this.$set(this.user, 'nickname', 'Su');

Vue 3 里同样的操作不需要额外处理:

1import { reactive } from 'vue';
2
3const user = reactive({
4  name: 'Su',
5});
6
7user.nickname = '代码工匠';

这一行为差异的根子在于两边拦截数据的方式完全不同。

Object.defineProperty 为什么拦不住新增属性

Vue 2 的响应式系统在初始化时,会遍历 data 对象的每一个已存在的 key,用 Object.defineProperty 把它改造成带 getter/setter 的访问器属性:

1Object.defineProperty(obj, 'name', {
2  get() {
3    // 收集依赖
4    return val;
5  },
6  set(newVal) {
7    val = newVal;
8    // 触发更新
9  },
10});

这个改造是逐个属性做的,前提是这个属性在初始化时就已经存在。user.nickname = 'Su' 这句话,如果 nickname 之前没在 data 里声明过,Vue 2 压根没机会给它装 getter/setter——对象上凭空多出来的 key,不会经过任何拦截。数组的坑是同一个根源:arr[0] = 1 这种索引赋值、arr.length = 0 这种改长度的操作,Vue 2 也拦不住,只能靠重写 pushpopsplice 等七个变异方法去兜底,arr[0] = 1 这种写法官方文档直接建议改用 Vue.set(arr, 0, 1)arr.splice(0, 1, 1)

这套机制的局限不是实现疏忽,是 Object.defineProperty 本身能管到的范围决定的——它作用于单个属性,不作用于对象整体。

Vue 2 源码里做这件事的是 Observer 类里的 walk 方法,思路大致是这样一段伪代码:

1function Observer(value) {
2  this.value = value;
3  if (Array.isArray(value)) {
4    // 数组走另一套处理
5    protoAugment(value, arrayMethods);
6    this.observeArray(value);
7  } else {
8    this.walk(value);
9  }
10}
11
12Observer.prototype.walk = function (obj) {
13  var keys = Object.keys(obj);
14  for (var i = 0; i < keys.length; i++) {
15    defineReactive(obj, keys[i]);
16  }
17};
18
19function defineReactive(obj, key) {
20  var val = obj[key];
21  var childOb = observe(val); // 递归处理嵌套对象
22
23  Object.defineProperty(obj, key, {
24    enumerable: true,
25    configurable: true,
26    get: function () {
27      dep.depend();
28      if (childOb) {
29        childOb.dep.depend();
30      }
31      return val;
32    },
33    set: function (newVal) {
34      if (newVal === val) return;
35      val = newVal;
36      observe(newVal); // 新值如果是对象,也要递归转换
37      dep.notify();
38    },
39  });
40}

关键在 walk 这一步是同步、一次性、递归往下走的:data 上有多少层嵌套对象,初始化时就要把这些层级全部遍历完,每一层的每个 key 都跑一遍 defineReactive。项目里 data 稍微写得深一点、字段多一点,这个初始化开销是能在 Vue 2 项目冷启动时感觉到的,尤其是列表页一次性塞进来几百条记录、每条又是嵌套结构的时候。

数组这边 Vue 2 走的是完全不同的另一套路子,因为 Object.defineProperty 没法给数组下标批量装劫持(真要给每个索引都定义 getter/setter,性能代价也扛不住)。Vue 2 的做法是重写数组原型上会改变数组本身的那 7 个方法:pushpopshiftunshiftsplicesortreverse,思路是拦截调用、执行原生逻辑、再手动触发通知:

1var arrayProto = Array.prototype;
2var arrayMethods = Object.create(arrayProto);
3
4['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
5  var original = arrayProto[method];
6  Object.defineProperty(arrayMethods, method, {
7    value: function () {
8      var result = original.apply(this, arguments);
9      var ob = this.__ob__;
10      var inserted;
11      if (method === 'push' || method === 'unshift') {
12        inserted = arguments;
13      } else if (method === 'splice') {
14        inserted = Array.prototype.slice.call(arguments, 2);
15      }
16      if (inserted) {
17        ob.observeArray(inserted); // 新插入的元素也要变成响应式
18      }
19      ob.dep.notify();
20      return result;
21    },
22  });
23});

数组实例的 __proto__ 会被指向这个改造过的 arrayMethods,这样调用 arr.push(1) 走的就是打了补丁的版本。但这套补丁只覆盖这 7 个方法本身,arr[0] = 1 这种直接索引赋值和 arr.length = 0 这种改长度,都绕开了这 7 个方法,补丁完全拦不住——这也是文档里为什么反复强调这两种写法要换成 Vue.setsplice

Proxy 拦截的是操作,不是某个属性

Vue 3 换了个思路,不再一个个属性装劫持,而是用 Proxy 包一层壳,拦截对整个对象的操作。单独拎出来在控制台跑一下就看得很清楚:

1const p = new Proxy({}, {
2  get(target, key) {
3    console.log('get', key);
4    return target[key];
5  },
6  set(target, key, value) {
7    console.log('set', key, value);
8    target[key] = value;
9    return true;
10  },
11});
12
13p.a = 1; // 打印 set a 1
14p.a;     // 打印 get a

这里的 get/set 拦截的是"读"和"写"这两个动作本身,跟 key 是不是提前声明过的属性没关系。p.newKey = 1 一样会触发 set。Vue 3 的 reactive 就是在这个 set 拦截里做依赖触发、在 get 拦截里做依赖收集,新增属性、删除属性(deleteProperty 陷阱)、数组索引赋值、改 length,全都落在同一套拦截逻辑里,不需要再对数组方法单独打补丁。

reactive 的整体思路简化下来大概是这样:

1function reactive(target) {
2  return new Proxy(target, {
3    get(target, key, receiver) {
4      track(target, key); // 依赖收集
5      var result = Reflect.get(target, key, receiver);
6      if (typeof result === 'object' && result !== null) {
7        return reactive(result); // 惰性递归包装
8      }
9      return result;
10    },
11    set(target, key, value, receiver) {
12      var oldValue = target[key];
13      var result = Reflect.set(target, key, value, receiver);
14      if (oldValue !== value) {
15        trigger(target, key); // 触发更新
16      }
17      return result;
18    },
19    deleteProperty(target, key) {
20      var hadKey = Object.prototype.hasOwnProperty.call(target, key);
21      var result = Reflect.deleteProperty(target, key);
22      if (hadKey && result) {
23        trigger(target, key);
24      }
25      return result;
26    },
27  });
28}

跟 Vue 2 的 walk 对照着看,区别很直观:Vue 2 是初始化时就把整棵对象树递归改造完;Vue 3 是包一层壳,具体某个 key 要不要往下继续包,等真正 get 到的时候再判断。数组这边完全不需要额外处理,同一个 set 陷阱天然覆盖了索引赋值:

1const list = reactive([1, 2, 3]);
2
3list[0] = 100; // 触发 set,正常更新视图
4list.length = 0; // 触发 set(length 本身也是一个可被拦截的属性),数组清空、视图同步清空
5list.push(4); // push 内部依然是先读 length、再写索引、再写 length,每一步都经过 Proxy 陷阱

push 这种数组方法在 Vue 3 里完全不需要被"重写",因为它调用过程本身就是对 length 和索引的读写操作,Proxy 已经把这些读写都截住了。Vue 2 那一整套改写原型的补丁,在 Vue 3 里变成了不需要存在的历史包袱。

配合 Proxy 使用的 Reflect 也值得提一句。Vue 3 源码里 get 陷阱内部不是直接写 target[key],而是用 Reflect.get(target, key, receiver)

1const handler = {
2  get(target, key, receiver) {
3    track(target, key);
4    return Reflect.get(target, key, receiver);
5  },
6};

Reflect 提供的这组方法和 Proxy 的陷阱一一对应,行为更规范,尤其在处理原型链、this 指向这些边界情况时比直接操作 target[key] 更不容易出错——这也是为什么 Vue 3 源码里 ProxyReflect 基本是绑定出现的。

具体是什么样的场景,我在控制台里造了个原型链继承的例子才算看明白:base 对象上定义了一个 getter,obj 通过 Object.create(base) 继承它,再用 Proxy 包一层 obj。如果 get 陷阱里直接写 target[key]base 上那个 getter 执行时内部的 this 绑定的是原始的 target(也就是 obj),而不是外层的 Proxy 实例本身。这在简单场景下看不出问题,但涉及到继承链、this 要保持指向 Proxy 本身的场景就会出岔子。换成 Reflect.get(target, key, receiver)receiver 显式传入 Proxy 自己,this 的指向才是正确的。这类场景平时写业务代码基本碰不到,但框架源码里要保证在所有继承、代理嵌套的场景下都不出错,Reflect 就不是可有可无的装饰,是必须项。

嵌套对象的处理方式也变了。Vue 2 是在初始化阶段就递归遍历整个对象树,把每一层都提前转成响应式;Vue 3 的 reactive 是惰性的,包一层 Proxy 之后,只有真正访问到某个嵌套属性时,才会在 get 拦截里判断这个值是不是对象,是的话再包一层 Proxy 返回出去。这意味着 Vue 3 处理深层嵌套结构时初始化开销更小,但也意味着你从 reactive 对象里取出一个嵌套对象再单独保存,那个引用同样是响应式的(因为整个访问路径都经过了 Proxy),这点和解构基本类型时的"断连接"是两回事。

这个惰性包装还有一个细节我一开始没想到:同一个原始对象,每次被 get 访问到都会重新执行一遍"判断是不是对象、再包一层 Proxy"的逻辑,如果不做缓存,同一个嵌套对象被访问两次会得到两个不同的 Proxy 实例,用 === 比较会是 false。实际源码里 Vue 3 用一个 WeakMap 缓存原始对象到 Proxy 实例的映射,同一个 target 只会生成一次 Proxy,get 到就直接从缓存里取。WeakMap 是 ES2015 就有的结构,这里用它而不是普通 Map,是因为 key 是对象引用、不需要手动清理——原始对象没有其他地方引用了,WeakMap 里对应的条目会被垃圾回收自动清掉,不会造成内存泄漏。这个细节我是在看 toRaw 的实现时顺带弄明白的。

toRaw、markRaw 和 readonly

调试的时候经常想看一眼某个 reactive 对象背后的原始值到底长什么样,直接在控制台打印 Proxy 对象,看到的是一层套壳,不太直观。toRaw 就是干这个的,拿到 Proxy 包装之前的原始对象:

1import { reactive, toRaw } from 'vue';
2
3const state = reactive({ count: 0 });
4const raw = toRaw(state);
5
6console.log(raw === state); // false,raw 是原始对象,state 是 Proxy
7raw.count = 100; // 直接改原始对象,不会触发视图更新,因为这条路径没有经过 Proxy 的 set 陷阱

toRaw 平时业务代码里用得不多,但有一种场景很实用:往第三方库(比如某个图表库、地图 SDK)传参数时,如果直接把 reactive 对象传过去,第三方库内部可能会对这个对象做深度比较或者序列化,Proxy 包装有时候会让这些操作行为异常,这时候传 toRaw(state) 更保险。

markRaw 反过来,是告诉 Vue"这个对象永远不需要变成响应式",哪怕它被塞进某个 reactive 对象里,用它包一层之后再塞进去就不会被再包一层 Proxy。像图表实例、地图实例这类第三方库返回的复杂对象,本身有大量内部状态和方法,Vue 没必要、也没能力去正确追踪它内部的变化,用 markRaw 标记一下,可以省掉一层不必要的代理开销,还能避免 Proxy 包装后和第三方库自己的内部逻辑产生冲突。

readonly 是另一个方向的需求:有些数据只想让子组件读、不想让子组件改,比如通过 provide/inject 往下传的全局配置。用 readonly 包一层,尝试修改会在开发环境下报警告:

1import { reactive, readonly } from 'vue';
2
3const original = reactive({ theme: 'dark' });
4const copy = readonly(original);
5
6copy.theme = 'light'; // 开发环境下控制台会报警告,值实际上没有被改动

readonly 包装出来的对象,get 陷阱正常工作、依赖收集不受影响,但 set 陷阱里直接拦下写操作并打印警告,不会真的触发 Reflect.set。这跟 Vue 2 时代"只能靠约定告诉别人别改这个 prop"比起来,是实打实的运行时保护。

Proxy 不等于随便写

这几天试下来,我不太想把这次变化理解成"终于没有限制了"。追踪能力变强,不代表状态可以随便堆、随便改,反而框架能追踪的范围越大,状态一旦组织得乱,影响扩散得也更快。

写这个小工具项目时,我把状态分成三类:原始状态(接口返回、用户输入、页面内部控制值)、派生状态(从原始状态算出来的结果)、副作用(请求、存储、埋点、外部组件同步)。列表筛选是最直接的例子,过滤结果不手动维护一份,靠 computed 派生:

1import { computed, reactive, ref } from 'vue';
2
3const keyword = ref('');
4const state = reactive({
5  users: [],
6});
7
8const filteredUsers = computed(() => {
9  const value = keyword.value.trim().toLowerCase();
10
11  if (!value) {
12    return state.users;
13  }
14
15  return state.users.filter((user) => {
16    return user.name.toLowerCase().includes(value);
17  });
18});

userskeyword 是原始状态,filteredUsers 是派生状态。这个边界比"能不能被追踪到"重要得多。

computed 依赖收集的方式跟 get 拦截是同一套机制:filteredUsers 内部的函数体执行一次,访问到 state.userskeyword.value,这两个访问都会经过各自的 get 陷阱触发 trackcomputed 内部把这两个依赖记下来。之后 state.users 或者 keyword.value 任意一个变了,触发对应的 triggercomputed 收到通知,标记自己"脏了",下次被访问时才重新求值——这是个懒执行的缓存,不是 state 一变就立刻重新算一遍。

watch 和 watchEffect 的依赖收集方式不一样

Composition API 里除了 computed,另外两个直接和响应式数据打交道的 API 是 watchwatchEffect,这两个我一开始以为是同一个东西的两种写法,实际用起来发现依赖收集的方式完全不同。

watchEffect 是自动收集依赖,回调函数体内用到了哪些响应式数据,就自动订阅哪些,不需要显式声明:

1import { reactive, watchEffect } from 'vue';
2
3const state = reactive({
4  keyword: '',
5  page: 1,
6});
7
8watchEffect(() => {
9  console.log('搜索参数变化', state.keyword, state.page);
10  // 这个函数体第一次执行的时候,就会把 state.keyword 和 state.page 都记录为依赖
11});
12
13state.keyword = 'su'; // 触发回调
14state.page = 2; // 触发回调

这个自动收集的过程和 computed 内部的机制是一回事:回调函数先整体执行一次,执行过程中访问到的每一个响应式属性,都经过 get 陷阱被 track 记录下来,成为这个 watchEffect 的依赖列表。哪天回调函数体改了、里面访问的属性变了,依赖列表也会跟着重新收集——这也是它和 watch 最大的区别:watchEffect 的依赖是运行时动态发现的,不是提前声明的。

watch 则是显式指定要监听哪个数据源,不看回调函数体里用了什么:

1import { reactive, watch } from 'vue';
2
3const state = reactive({
4  keyword: '',
5  page: 1,
6});
7
8watch(
9  () => state.keyword, // 显式指定监听的数据源,用一个 getter 函数包一层
10  (newVal, oldVal) => {
11    console.log('keyword 变化', oldVal, '->', newVal);
12    state.page = 1; // 关键词变了,页码重置,这里访问 state.page 不会被这个 watch 记为依赖
13  }
14);

这里 state.page = 1 这行虽然也访问了响应式数据,但因为是在回调函数体里,不是在第一个参数(数据源函数)里,watch 不会把 state.page 当成自己的依赖去订阅——这跟 watchEffect 不一样,watchEffect 是不区分"数据源"和"回调"的,函数体里任何被访问到的响应式数据都算依赖。团队里挑 watch 还是 watchEffect,我目前的判断标准是:需要拿到变化前后的值做对比、或者只想监听某几个明确的字段而不是整个函数体里用到的所有数据,用 watch;只是想"某些数据一变就重新跑一遍副作用",不关心具体哪个字段变了,用 watchEffect 更省事。

ref 和 reactive 怎么选

refreactive 该用哪个,判断标准很简单:独立的值用 ref,一组强相关的状态用 reactive

1const loading = ref(false);
2const errorMessage = ref('');
3
4const form = reactive({
5  name: '',
6  phone: '',
7  address: '',
8});

不为了"少写 .value"就把所有东西塞进一个大 reactive 对象,那样短期省事,页面一旦变大,这个对象很容易变成新的全局垃圾桶:

1const state = reactive({
2  loading: false,
3  user: null,
4  list: [],
5  form: {},
6  dialogVisible: false,
7  chart: null,
8});

这不是 Vue 3 的问题,是状态组织方式又退回去了。

ref 的实现思路跟 reactive 不是同一套,这点在控制台里对比着看会更清楚。reactive 只能包对象(数组和普通对象都算),传一个基本类型进去是没有效果的:

1import { reactive } from 'vue';
2
3const count = reactive(0); // 控制台会有警告,基本类型不会被转成响应式

ref 是专门为了解决"基本类型也要有响应式能力"这个问题设计的,内部思路是包一层带 .value 的对象,靠这个对象自身的 getter/setter 去追踪依赖:

1function ref(value) {
2  const wrapper = {
3    get value() {
4      track(wrapper, 'value');
5      return value;
6    },
7    set value(newVal) {
8      if (newVal === value) return;
9      value = newVal;
10      trigger(wrapper, 'value');
11    },
12  };
13  return wrapper;
14}

如果 ref 包的值本身是对象,内部还会再调用一次 reactive 把这个对象也代理一层,所以 ref({ name: 'Su' }).value 拿到的其实是个 reactive 对象,嵌套结构一样能被追踪。这也是为什么业务里经常看到 ref([])ref({}) 这种写法而不觉得别扭——ref 不是只能装基本类型,只是对基本类型也有效,reactive 做不到这点。

数组响应式在 ref 里的表现

数组用 ref 还是 reactive 包,这几天写小工具时顺手对比了一下。reactive 包数组,直接对数组本身做操作(push、索引赋值、改 length)都能触发更新,前面已经验证过。ref 包数组时,因为内部会自动转一层 reactivelist.value.push(4)list.value = [9, 9, 9] 这两种写法都能正常触发更新,前者走的是 list.value 拿到的 reactive 数组本身的 set 陷阱,后者走的是 ref 自身的 set 拦截。唯一要注意的是不能把 list.value 解构出来单独存一份再操作,那样和 reactive 解构断连接是同一个坑,只是套了一层 .value 更容易被忽略。

解构会断开响应式连接

Vue 3 的响应式更顺手,但也有新坑,最常见的是解构:

1const state = reactive({
2  count: 0,
3});
4
5const { count } = state;

这样拿出来的 count 是个普通值,和 state 的响应式连接已经断了。用 isReactive/isRef 在控制台里能验证这个断开的过程:

1import { reactive, isReactive, isRef, toRefs } from 'vue';
2
3const state = reactive({ count: 0 });
4isReactive(state); // true,state 本身是响应式的
5
6const { count } = state;
7isRef(count); // false,解构出来的是个普通数字,连接断了
8
9const refs = toRefs(state);
10isRef(refs.count); // true,toRefs 把每个属性包成了 ref,连接保住了

refs.count 是 ref,在 JS 里取值要写 refs.count.value(模板里会自动解包,不用写 .value)——这也是 refreactive 手感不一样的根源:reactive 返回的是 Proxy 对象本身,直接访问属性;ref 返回的是一个带 .value 的包装对象,本质是靠这层包装在普通变量上模拟出 getter/setter。

需要解构时用 toRefs

1import { reactive, toRefs } from 'vue';
2
3function useCounter() {
4  const state = reactive({
5    count: 0,
6  });
7
8  function increment() {
9    state.count += 1;
10  }
11
12  return {
13    ...toRefs(state),
14    increment,
15  };
16}

从 Vue 2 迁移时要注意的响应式 API 变化

团队目前的判断是:核心业务项目暂时不动,还是 Vue 2.6.x,Vuex 4、Vue Router 4、Element UI 这些依赖的 Vue 3 版本大多还没跟上,贸然切换风险不小。真正会用到 Vue 3 响应式 API 的场景,一个是像这次一样写 demo 验证行为,另一个是今年二月发布的 @vue/composition-api 插件——这个插件让 Vue 2 项目提前用上 refreactivecomputed 这套写法,不用等 Vue 3 正式版。

用插件的 Vue 2 项目和真正的 Vue 3 项目,响应式行为不完全等价,这点得留意。@vue/composition-api 底层仍然依赖 Vue 2 的 Object.defineProperty,只是把响应式 API 的调用方式提前搬了过来,所以插件版本下,新增属性不被追踪的老问题照样存在,refreactive 只是换了个更顺手的入口,底层限制没有跟着变。等哪天真正切到 Vue 3,这部分行为差异反而要重新过一遍,不能想当然认为迁移前后写法一致、行为也一致。

具体验证了一下这个差异:在装了 @vue/composition-api 的 Vue 2 项目里跑下面这段代码,

1import { reactive } from '@vue/composition-api';
2
3export default {
4  setup() {
5    const state = reactive({
6      user: { name: 'Su' },
7    });
8
9    function addField() {
10      state.user.age = 18; // 新增字段,Vue 2 场景下不会触发视图更新
11    }
12
13    return { state, addField };
14  },
15};

state.user.age = 18 这一句在插件版本下和原生 Options API 里的 this.user.age = 18 是一模一样的结果——不会更新,因为插件内部把 reactive 实现成了对 Vue 2 Observer 机制的一层包装,age 这个新字段依然逃过了 defineReactive 的初始化遍历。同样的代码搬到真正的 Vue 3 项目里跑,state.user.age = 18 立刻生效,因为这时候 reactive 底层是 Proxy,新增属性天然落在 set 陷阱里。这个差异我专门写了个对照文档留在评估资料里,避免以后团队里有人真按这个插件的 API 表面去理解 Vue 3 的实际行为,被这种细节坑到。

另外几个响应式相关的 API 变化,是评估阶段整理出来打算记下来的:Vue.set/Vue.delete 在 Vue 3 里不再需要,因为 Proxy 天然能追踪新增和删除;Vue.observable(Vue 2.6 引入的独立响应式 API)对应 Vue 3 的 reactive;watch 的用法从 Options API 里的 watch 选项,多了一种 watchEffect/watch 的组合式写法,依赖收集方式也是自动追踪,不用像以前那样手写依赖字符串路径。这些点目前只在评估文档里记着,还没有大范围验证的机会。

$set/$delete 这两个 API 在迁移评估里单独拎出来说一下,因为它们是 Vue 2 项目里用得最频繁的"响应式补丁",理解它们存在的意义,反过来也能帮着理解 Proxy 到底省掉了什么:

1// Vue 2 写法
2this.$set(this.form, 'remark', '');
3this.$delete(this.form, 'tempField');
4
5// Vue 3 对应写法
6form.remark = '';
7delete form.tempField;

Vue 3 里这两行就是普通的 JS 赋值和 delete 操作符,不需要额外的 API,因为 Proxy 的 set 陷阱和 deleteProperty 陷阱本来就覆盖了这两种操作。评估文档里我把这条列为"迁移后能直接删掉的代码"——真到了迁移那天,全局搜 $set/$delete 应该是能批量清理掉一部分历史包袱的,当然实际搜出来的调用点会不会全部符合这个替换模式,得等真正动手迁移的时候再具体看,不能想当然。

响应式变强之后,框架帮你兜住的场景变多了,但状态设计这件事不会因此变得不重要——原始状态、派生状态和副作用的边界,还是得自己去分清楚。