在 Vue2 中的选项式 API 中会用到data
函数来声明变量的响应式状态,data
函数的返回值是一个对象,Vue2 在创建实例时调用该函数,并将函数返回的对象通过响应式系统进行包装来实现响应式。
在 Vue3 的组合式 API 中已经没有data
函数了,那又是如何实现数据响应式的呢?
响应式基础
在setup
函数中,如果直接定义变量,然后使用watch
监听变量,会发现watch
是不会起作用的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <template> <button @click="add">add</button> </template>
<script setup> import { watch } from 'vue';
var a = 0; var add = () => { a++ }
watch(a, () => { console.log("a changed"); }) </script>
|
并且控制台还会报出错误:
1
| [Vue warn]: Invalid watch source: 0 A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.
|
因为在setup
函数中直接定义变量的话,变量不是响应式的,要定义响应式的变量,要使用ref
/reactive
。
响应式变量
ref
方法用于创建一个响应式的值类型变量(但是其实里面是对象或数组也是没问题的)。下面用ref
来实现上面的自增响应式代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <template> <button @click="add">add</button> </template>
<script setup> import { watch, ref } from 'vue';
var a = ref(0); var add = () => { a.value++; }
watch(a, () => { console.log("a changed"); })
</script>
|
通过ref
创建的变量,是一个带有value
属性的RefImpl
对象。它的value
属性是响应式的。如果要修改ref
的值,要修改的是它的value
属性,而不是直接修改ref
,如上面代码中的a.value++;
。
ref
也可以创建响应式的对象或数组。当值是对象类型时,会用reactive
自动转化它的value
属性,将 Proxy 代理赋值给它的value
,因此在监听ref
创建的对象类型时,要监听它的value
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <template> <button @click="add">add</button> </template>
<script setup> import { watch, ref } from 'vue';
var a = ref([0]); var add = () => { a.value.push(Math.random() * 10 | 0) }
watch(a.value, (newValue) => { console.log("a changed", newValue); })
</script>
|
ref
创建的变量在模板中使用就不用使用.value
。
响应式对象或数组
可以使用reactive
创建响应式的对象或数组:
1 2 3 4 5 6 7 8 9 10
| import { watch, reactive } from "vue";
var a = reactive([0]); var add = () => { a.push((Math.random() * 10) | 0); };
watch(a, (newValue) => { console.log("a changed", newValue); });
|
响应式的对象就是 JavaScript 的 Proxy。它的表现和普通的对象行为相同,它是深层响应式的,意味着响应式对象下的所有属性都具有响应式:
1 2 3 4 5 6 7 8 9 10
| import { watch, reactive } from "vue";
var obj = reactive({ a: [0] }); var add = () => { obj.a.push((Math.random() * 10) | 0); };
watch(obj.a, (newValue) => { console.log("obj.a changed", newValue); });
|
上面代码中的watch
是可以正常监听到的。在修改它的值时也不需要加上.value
。
但是如果我们将响应式对象的属性解构或赋值给新的变量时,新的变量就不会具有响应式了,因为对象响应式是通过属性访问进行追踪的:
1 2 3 4 5 6 7 8 9 10 11
| import { watch, reactive } from "vue";
var obj = reactive({ a: 1 }); var { a } = obj; var add = () => { a++; };
watch(a, (newValue) => { console.log("a changed", newValue); });
|
响应式原理
原生 JavaScript 没有任何机制可以直接追踪变量的改变,但是我们可以追踪对象属性的读写。JavaScript 中有两种追踪对象属性读写的方法:getter
/setter
和 Proxy。在 Vue2 中出于对浏览器的兼容使用的是getter
/setter
,Vue3 中则使用了 Proxy 来创建响应式对象。仅将getter
/setter
用于 ref。
副作用函数和响应式数据
副作用函数是指会产生副作用的函数:
1 2 3
| function effect() { document.body.innerText = obj.a; }
|
effect
函数执行时,它会修改body
的文本内容,但除了effect
函数之外的任何函数都可以读取或设置body
的内容,也就是说,effect
函数的执行会直接或间接影响其他函数的执行。
1 2 3 4 5
| var obj = { a: 1 };
function effect() { document.body.innerText = obj.a; }
|
假设在一个函数中读取了某个对象的属性,当obj.a
的值改变时,我们希望副作用函数effect
会重新执行。如果能实现这个目标,那么对象就是响应式数据。
基本实现
如何让obj
变成响应式数据呢?我们知道:
- 当副作用函数
effect
执行时,会触发obj.a
的读取操作。
- 当修改
obj.a
的值时,会触发obj.a
的设置操作。
如果能拦截对象的读取/设置操作,事情就简单了。当读取obj.a
时,我们把副作用函数effect
存储到一个桶里。在设置obj.a
时,再把effect
从桶里面取出来并执行即可。
下面用 Proxy 来实现上面的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| var data = { a: 1 }; var bucket = new Set(); var obj = new Proxy(data, { get(target, key) { bucket.add(effect); return target[key]; }, set(target, key, value) { target[key] = value; bucket.forEach((fn) => fn()); return true; }, });
function effect() { document.body.innerText = obj.a; }
setInterval(() => { obj.a++; }, 1000);
|
运行页面后,可以看到页面上的数字会自动增加了。
但是还是存在缺陷,比如上面的代码直接通过硬编码的effect
函数名来获取副作用函数,副作用函数的名字应该可以任意取,因此我们要去掉这种硬编码的机制。
设计一个完善的响应式系统
一个响应式系统的工作流程只有两个:
- 当读取操作发生时,将副作用函数收集到桶中。
- 当设置操作发生时,从桶中取出副作用函数并执行。
在上一节中的代码中存在副作用函数硬编码的问题,而我们希望副作用函数可以随便命名甚至是一个匿名函数,为了实现这一点,我们需要一个用于注册副作用函数的机制:
1 2 3 4 5 6 7 8 9 10 11
| let activeEffect;
function whenDepsChange(update) { const effect = () => { activeEffect = effect; update(); activeEffect = null; }; effect(); }
|
首先定义了一个全局变量activeEffect
,初始值是undefined
,它的作用是存储未被注册的副作用函数,接着定义了whenDepsChange
函数,它用于注册副作用函数,我们可以像下面的方式使用whenDepsChange
:
1 2 3
| whenDepsChange(() => { document.body.innerText = obj.a; });
|
然后修改 Proxy 对象内的get
拦截函数:
1 2 3 4 5 6 7 8 9 10 11 12 13
| var obj = new Proxy(data, { get(target, key) { if (activeEffect) { bucket.add(activeEffect); } return target[key]; }, set(target, key, value) { target[key] = value; bucket.forEach((fn) => fn()); return true; }, });
|
由于副作用函数已经存储到了activeEffect
中, 所以在get
拦截内应该把activeEffect
收集到桶中了,因此响应式系统就不以来函数名字了。
但是如果我们使用下面的代码进行测试:
1 2 3 4 5 6 7 8
| whenDepsChange(() => { document.body.innerText = obj.a; console.log("obj.a is read."); });
setInterval(() => { obj.b++; }, 1000);
|
可以看到副作用函数读取的是obj.a
,在定时器中修改的obj.b
,但是运行页面时,控制台依然打印了obj.a is read.
。导致整个问题的原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。因此我们需要重新设计bucket
这个桶的数据结构。
我们把bucket
重新设计成WeakMap<target, Map<key, Set<effect>>>
这样的一个数据结构,它是一个WeakMap
,它可以看成一个树形的结构,它的顶层的键是响应式的对象,值是这个响应式对象的属性和它们的副作用组成的一个Map
。第二层的Map
的键是响应式对象的属性,它的值是对应的副作用。
接下来我们用代码来实现新的桶,首先需要用WeakMap
代替Set
作为桶:
1
| let bucket = new WeakMap();
|
然后修改 get/set 拦截代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| var obj = new Proxy(data, { get(target, key) { if (!activeEffect) { return target[key]; } let depsMap = bucket.get(target); if (!depsMap) { bucket.set(target, (depsMap = new Map())); } let deps = depsMap.get(key); if (!deps) { depsMap.set(key, (deps = new Set())); } deps.add(activeEffect); return target[key]; }, set(target, key, value) { target[key] = value; let depsMap = bucket.get(target); if (!depsMap) return; let effect = depsMap.get(key); effect && effect.forEach((fn) => fn()); return true; }, });
|
从代码中我们可以看到桶的数据结构:
WeakMap
由target
–> Map
构成
Map
由key
–> Set
构成
我们可以对上面的代码进行一些改进,我们可以把get
函数中把副作用函数收集到桶里的这部分逻辑封装成track
函数,同时也可以把触发副作用部分的逻辑封装到trigger
函数,在track
和trigger
函数中,我们可以把获取副作用集合的函数封装到getSubscribersForProperty
函数,最后,我们再封装一个reactive
函数,就类似于 Vue3 中的,接受一个对象,返回一个 Proxy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| let activeEffect; let bucket = new WeakMap();
function getSubscribersForProperty(target, key) { var depsMap = bucket.get(target); if (!depsMap) { bucket.set(target, (depsMap = new Map())); } let deps = depsMap.get(key); if (!deps) { depsMap.set(key, (deps = new Set())); } return deps; }
function track(target, key) { if (activeEffect) { const effects = getSubscribersForProperty(target, key); effects.add(activeEffect); } }
function trigger(target, key) { const effects = getSubscribersForProperty(target, key); effects.forEach((effect) => effect()); }
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); }, }); }
function whenDepsChange(update) { const effect = () => { activeEffect = effect; update(); activeEffect = null; }; effect(); }
|
测试一下效果:
1 2 3 4 5 6 7
| var obj = reactive({ a: 1 }); setInterval(() => { obj.a++; }, 1000); whenDepsChange(() => { document.body.innerText = obj.a; });
|
现在就很类似于 Vue 定义的响应式对象了。上面的代码分别把逻辑封装到track
和trigger
函数内,这能为我们带来极大的灵活性。
用类似 reactive
的方法,可以实现ref
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function ref(value) { var refObj = { get value() { track(refObj, "value"); return value; }, set value(newValue) { value = newValue; trigger(refObj, "value"); return true; }, }; return refObj; }
|