参考答案:
Vue 3 中的响应式原理可谓是非常之重要,通过学习 Vue3 的响应式原理,不仅能让我们学习到 Vue.js 的一些设计模式和思想,还能帮助我们提高项目开发效率和代码调试能力。
当我们在学习 Vue 3 的时候,可以通过一个简单示例,看看什么是 Vue 3 中的响应式:
1<!-- HTML 内容 --> 2<div id="app"> 3 <div>Price: {{price}}</div> 4 <div>Total: {{price * quantity}}</div> 5 <div>getTotal: {{getTotal}}</div> 6</div>
1const app = Vue.createApp({ // ① 创建 APP 实例 2 data() { 3 return { 4 price: 10, 5 quantity: 2 6 } 7 }, 8 computed: { 9 getTotal() { 10 return this.price * this.quantity * 1.1 11 } 12 } 13}) 14app.mount('#app') // ② 挂载 APP 实例
通过创建 APP 实例和挂载 APP 实例即可,这时可以看到页面中分别显示对应数值:
当我们修改 price
或 quantity
值的时候,页面上引用它们的地方,内容也能正常展示变化后的结果。这时,我们会好奇为何数据发生变化后,相关的数据也会跟着变化,那么我们接着往下看。
在普通 JS 代码执行中,并不会有响应式变化,比如在控制台执行下面代码:
1let price = 10, quantity = 2; 2const total = price * quantity; 3console.log(`total: ${total}`); // total: 20 4price = 20; 5console.log(`total: ${total}`); // total: 20
从这可以看出,在修改 price
变量的值后, total
的值并没有发生改变。
那么如何修改上面代码,让 total
能够自动更新呢?我们其实可以将修改 total
值的方法保存起来,等到与 total
值相关的变量(如 price
或 quantity
变量的值)发生变化时,触发该方法,更新 total
即可。我们可以这么实现:
1let price = 10, quantity = 2, total = 0; 2const dep = new Set(); // ① 3const effect = () => { total = price * quantity }; 4const track = () => { dep.add(effect) }; // ② 5const trigger = () => { dep.forEach( effect => effect() )}; // ③ 6 7track(); 8console.log(`total: ${total}`); // total: 0 9trigger(); 10console.log(`total: ${total}`); // total: 20 11price = 20; 12trigger(); 13console.log(`total: ${total}`); // total: 40
上面代码通过 3 个步骤,实现对 total
数据进行响应式变化:
① 初始化一个 Set
类型的 dep
变量,用来存放需要执行的副作用( effect
函数),这边是修改 total
值的方法;
② 创建 track()
函数,用来将需要执行的副作用保存到 dep
变量中(也称收集副作用);
③ 创建 trigger()
函数,用来执行 dep
变量中的所有副作用;
在每次修改 price
或 quantity
后,调用 trigger()
函数执行所有副作用后, total
值将自动更新为最新值。
(图片来源:Vue Mastery)
通常,我们的对象具有多个属性,并且每个属性都需要自己的 dep
。我们如何存储这些?比如:
1let product = { price: 10, quantity: 2 };
从前面介绍我们知道,我们将所有副作用保存在一个 Set
集合中,而该集合不会有重复项,这里我们引入一个 Map
类型集合(即 depsMap
),其 key
为对象的属性(如: price
属性), value
为前面保存副作用的 Set
集合(如: dep
对象),大致结构如下图:
实现代码:
1let product = { price: 10, quantity: 2 }, total = 0; 2const depsMap = new Map(); // ① 3const effect = () => { total = product.price * product.quantity }; 4const track = key => { // ② 5 let dep = depsMap.get(key); 6 if(!dep) { 7 depsMap.set(key, (dep = new Set())); 8 } 9 dep.add(effect); 10} 11 12const trigger = key => { // ③ 13 let dep = depsMap.get(key); 14 if(dep) { 15 dep.forEach( effect => effect() ); 16 } 17}; 18 19track('price'); 20console.log(`total: ${total}`); // total: 0 21effect(); 22console.log(`total: ${total}`); // total: 20 23product.price = 20; 24trigger('price'); 25console.log(`total: ${total}`); // total: 40
上面代码通过 3 个步骤,实现对 total
数据进行响应式变化:
① 初始化一个 Map
类型的 depsMap
变量,用来保存每个需要响应式变化的对象属性(key
为对象的属性, value
为前面 Set
集合);
② 创建 track()
函数,用来将需要执行的副作用保存到 depsMap
变量中对应的对象属性下(也称收集副作用);
③ 创建 trigger()
函数,用来执行 dep
变量中指定对象属性的所有副作用;
这样就实现监听对象的响应式变化,在 product
对象中的属性值发生变化, total
值也会跟着更新。
如果我们有多个响应式数据,比如同时需要观察对象 a
和对象 b
的数据,那么又要如何跟踪每个响应变化的对象?
这里我们引入一个 WeakMap 类型的对象,将需要观察的对象作为 key
,值为前面用来保存对象属性的 Map 变量。代码如下:
1let product = { price: 10, quantity: 2 }, total = 0; 2const targetMap = new WeakMap(); // ① 初始化 targetMap,保存观察对象 3const effect = () => { total = product.price * product.quantity }; 4const track = (target, key) => { // ② 收集依赖 5 let depsMap = targetMap.get(target); 6 if(!depsMap){ 7 targetMap.set(target, (depsMap = new Map())); 8 } 9 let dep = depsMap.get(key); 10 if(!dep) { 11 depsMap.set(key, (dep = new Set())); 12 } 13 dep.add(effect); 14} 15 16const trigger = (target, key) => { // ③ 执行指定对象的指定属性的所有副作用 17 const depsMap = targetMap.get(target); 18 if(!depsMap) return; 19 let dep = depsMap.get(key); 20 if(dep) { 21 dep.forEach( effect => effect() ); 22 } 23}; 24 25track(product, 'price'); 26console.log(`total: ${total}`); // total: 0 27effect(); 28console.log(`total: ${total}`); // total: 20 29product.price = 20; 30trigger(product, 'price'); 31console.log(`total: ${total}`); // total: 40
上面代码通过 3 个步骤,实现对 total
数据进行响应式变化:
① 初始化一个 WeakMap
类型的 targetMap
变量,用来要观察每个响应式对象;
② 创建 track()
函数,用来将需要执行的副作用保存到指定对象( target
)的依赖中(也称收集副作用);
③ 创建 trigger()
函数,用来执行指定对象( target
)中指定属性( key
)的所有副作用;
这样就实现监听对象的响应式变化,在 product
对象中的属性值发生变化, total
值也会跟着更新。
大致流程如下图:
在上一节内容中,介绍了如何在数据发生变化后,自动更新数据,但存在的问题是,每次需要手动通过触发 track()
函数搜集依赖,通过 trigger()
函数执行所有副作用,达到数据更新目的。
这一节将来解决这个问题,实现这两个函数自动调用。
这里我们引入 JS 对象访问器的概念,解决办法如下:
track()
函数自动收集依赖;trigger()
函数执行所有副作用;那么如何拦截 GET 和 SET 操作?接下来看看 Vue2 和 Vue3 是如何实现的:
Object.defineProperty()
函数实现;Proxy
和 Reflect
API 实现;需要注意的是:Vue3 使用的 Proxy
和 Reflect
API 并不支持 IE。
Object.defineProperty()
函数这边就不多做介绍,可以阅读文档,下文将主要介绍 Proxy
和 Reflect
API。
通常我们有三种方法读取一个对象的属性:
.
操作符:leo.name
;[]
: leo['name']
;Reflect
API: Reflect.get(leo, 'name')
。这三种方式输出结果相同。
Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。语法如下:
1const p = new Proxy(target, handler)
参数如下:
p
的行为。我们通过官方文档,体验一下 Proxy API:
1let product = { price: 10, quantity: 2 }; 2let proxiedProduct = new Proxy(product, { 3 get(target, key){ 4 console.log('正在读取的数据:',key); 5 return target[key]; 6 } 7}) 8console.log(proxiedProduct.price); 9// 正在读取的数据: price 10// 10
这样就保证我们每次在读取 proxiedProduct.price
都会执行到其中代理的 get 处理函数。其过程如下:
然后结合 Reflect 使用,只需修改 get 函数:
1 get(target, key, receiver){ 2 console.log('正在读取的数据:',key); 3 return Reflect.get(target, key, receiver); 4 }
输出结果还是一样。
接下来增加 set 函数,来拦截对象的修改操作:
1let product = { price: 10, quantity: 2 }; 2let proxiedProduct = new Proxy(product, { 3 get(target, key, receiver){ 4 console.log('正在读取的数据:',key); 5 return Reflect.get(target, key, receiver); 6 }, 7 set(target, key, value, receiver){ 8 console.log('正在修改的数据:', key, ',值为:', value); 9 return Reflect.set(target, key, value, receiver); 10 } 11}) 12proxiedProduct.price = 20; 13console.log(proxiedProduct.price); 14// 正在修改的数据: price ,值为: 20 15// 正在读取的数据: price 16// 20
这样便完成 get 和 set 函数来拦截对象的读取和修改的操作。为了方便对比 Vue 3 源码,我们将上面代码抽象一层,使它看起来更像 Vue3 源码:
1function reactive(target){ 2 const handler = { // ① 封装统一处理函数对象 3 get(target, key, receiver){ 4 console.log('正在读取的数据:',key); 5 return Reflect.get(target, key, receiver); 6 }, 7 set(target, key, value, receiver){ 8 console.log('正在修改的数据:', key, ',值为:', value); 9 return Reflect.set(target, key, value, receiver); 10 } 11 } 12 13 return new Proxy(target, handler); // ② 统一调用 Proxy API 14} 15 16let product = reactive({price: 10, quantity: 2}); // ③ 将对象转换为响应式对象 17product.price = 20; 18console.log(product.price); 19// 正在修改的数据: price ,值为: 20 20// 正在读取的数据: price 21// 20
这样输出结果仍然不变。
通过上面代码,我们已经实现一个简单 reactive()
函数,用来将普通对象转换为响应式对象。但是还缺少自动执行 track()
函数和 trigger()
函数,接下来修改上面代码:
1const targetMap = new WeakMap(); 2let total = 0; 3const effect = () => { total = product.price * product.quantity }; 4const track = (target, key) => { 5 let depsMap = targetMap.get(target); 6 if(!depsMap){ 7 targetMap.set(target, (depsMap = new Map())); 8 } 9 let dep = depsMap.get(key); 10 if(!dep) { 11 depsMap.set(key, (dep = new Set())); 12 } 13 dep.add(effect); 14} 15 16const trigger = (target, key) => { 17 const depsMap = targetMap.get(target); 18 if(!depsMap) return; 19 let dep = depsMap.get(key); 20 if(dep) { 21 dep.forEach( effect => effect() ); 22 } 23}; 24 25const reactive = (target) => { 26 const handler = { 27 get(target, key, receiver){ 28 console.log('正在读取的数据:',key); 29 const result = Reflect.get(target, key, receiver); 30 track(target, key); // 自动调用 track 方法收集依赖 31 return result; 32 }, 33 set(target, key, value, receiver){ 34 console.log('正在修改的数据:', key, ',值为:', value); 35 const oldValue = target[key]; 36 const result = Reflect.set(target, key, value, receiver); 37 if(oldValue != result){ 38 trigger(target, key); // 自动调用 trigger 方法执行依赖 39 } 40 return result; 41 } 42 } 43 44 return new Proxy(target, handler); 45} 46 47let product = reactive({price: 10, quantity: 2}); 48effect(); 49console.log(total); 50product.price = 20; 51console.log(total); 52// 正在读取的数据: price 53// 正在读取的数据: quantity 54// 20 55// 正在修改的数据: price ,值为: 20 56// 正在读取的数据: price 57// 正在读取的数据: quantity 58// 40
在上一节代码中,还存在一个问题: track
函数中的依赖( effect
函数)是外部定义的,当依赖发生变化, track
函数收集依赖时都要手动修改其依赖的方法名。
比如现在的依赖为 foo
函数,就要修改 track
函数的逻辑,可能是这样:
1const foo = () => { /**/ }; 2const track = (target, key) => { // ② 3 // ... 4 dep.add(foo); 5}
那么如何解决这个问题呢?
接下来引入 activeEffect
变量,来保存当前运行的 effect 函数。
1let activeEffect = null; 2const effect = eff => { 3 activeEffect = eff; // 1. 将 eff 函数赋值给 activeEffect 4 activeEffect(); // 2. 执行 activeEffect 5 activeEffect = null;// 3. 重置 activeEffect 6}
然后在 track
函数中将 activeEffect
变量作为依赖:
1const track = (target, key) => { 2 if (activeEffect) { // 1. 判断当前是否有 activeEffect 3 let depsMap = targetMap.get(target); 4 if (!depsMap) { 5 targetMap.set(target, (depsMap = new Map())); 6 } 7 let dep = depsMap.get(key); 8 if (!dep) { 9 depsMap.set(key, (dep = new Set())); 10 } 11 dep.add(activeEffect); // 2. 添加 activeEffect 依赖 12 } 13}
使用方式修改为:
1effect(() => { 2 total = product.price * product.quantity 3});
这样就可以解决手动修改依赖的问题,这也是 Vue3 解决该问题的方法。完善一下测试代码后,如下:
1const targetMap = new WeakMap(); 2let activeEffect = null; // 引入 activeEffect 变量 3 4const effect = eff => { 5 activeEffect = eff; // 1. 将副作用赋值给 activeEffect 6 activeEffect(); // 2. 执行 activeEffect 7 activeEffect = null;// 3. 重置 activeEffect 8} 9 10const track = (target, key) => { 11 if (activeEffect) { // 1. 判断当前是否有 activeEffect 12 let depsMap = targetMap.get(target); 13 if (!depsMap) { 14 targetMap.set(target, (depsMap = new Map())); 15 } 16 let dep = depsMap.get(key); 17 if (!dep) { 18 depsMap.set(key, (dep = new Set())); 19 } 20 dep.add(activeEffect); // 2. 添加 activeEffect 依赖 21 } 22} 23 24const trigger = (target, key) => { 25 const depsMap = targetMap.get(target); 26 if (!depsMap) return; 27 let dep = depsMap.get(key); 28 if (dep) { 29 dep.forEach(effect => effect()); 30 } 31}; 32 33const reactive = (target) => { 34 const handler = { 35 get(target, key, receiver) { 36 const result = Reflect.get(target, key, receiver); 37 track(target, key); 38 return result; 39 }, 40 set(target, key, value, receiver) { 41 const oldValue = target[key]; 42 const result = Reflect.set(target, key, value, receiver); 43 if (oldValue != result) { 44 trigger(target, key); 45 } 46 return result; 47 } 48 } 49 50 return new Proxy(target, handler); 51} 52 53let product = reactive({ price: 10, quantity: 2 }); 54let total = 0, salePrice = 0; 55// 修改 effect 使用方式,将副作用作为参数传给 effect 方法 56effect(() => { 57 total = product.price * product.quantity 58}); 59effect(() => { 60 salePrice = product.price * 0.9 61}); 62console.log(total, salePrice); // 20 9 63product.quantity = 5; 64console.log(total, salePrice); // 50 9 65product.price = 20; 66console.log(total, salePrice); // 100 18
思考一下,如果把第一个 effect
函数中 product.price
换成 salePrice
会如何:
1effect(() => { 2 total = salePrice * product.quantity 3}); 4effect(() => { 5 salePrice = product.price * 0.9 6}); 7console.log(total, salePrice); // 0 9 8product.quantity = 5; 9console.log(total, salePrice); // 45 9 10product.price = 20; 11console.log(total, salePrice); // 45 18
得到的结果完全不同,因为 salePrice
并不是响应式变化,而是需要调用第二个 effect
函数才会变化,也就是 product.price
变量值发生变化。
代码地址: https://github.com/Code-Pop/vue-3-reactivity/blob/master/05-activeEffect.js
熟悉 Vue3 Composition API 的朋友可能会想到 Ref,它接收一个值,并返回一个响应式可变的 Ref 对象,其值可以通过 value
属性获取。
ref:接受一个内部值并返回一个响应式且可变的 ref 对象。ref 对象具有指向内部值的单个 property .value。
官网的使用示例如下:
1const count = ref(0) 2console.log(count.value) // 0 3 4count.value++ 5console.log(count.value) // 1
我们有 2 种方法实现 ref 函数:
reactive
函数1const ref = intialValue => reactive({value: intialValue});
这样是可以的,虽然 Vue3 不是这么实现。
1const ref = raw => { 2 const r = { 3 get value(){ 4 track(r, 'value'); 5 return raw; 6 }, 7 8 set value(newVal){ 9 raw = newVal; 10 trigger(r, 'value'); 11 } 12 } 13 return r; 14}
使用方式如下:
1let product = reactive({ price: 10, quantity: 2 }); 2let total = 0, salePrice = ref(0); 3effect(() => { 4 salePrice.value = product.price * 0.9 5}); 6effect(() => { 7 total = salePrice.value * product.quantity 8}); 9console.log(total, salePrice.value); // 18 9 10product.quantity = 5; 11console.log(total, salePrice.value); // 45 9 12product.price = 20; 13console.log(total, salePrice.value); // 90 18
在 Vue3 中 ref 实现的核心也是如此。
代码地址: https://github.com/Code-Pop/vue-3-reactivity/blob/master/06-ref.js
用过 Vue 的同学可能会好奇,上面的 salePrice
和 total
变量为什么不使用 computed
方法呢?
没错,这个可以的,接下来一起实现个简单的 computed
方法。
1const computed = getter => { 2 let result = ref(); 3 effect(() => result.value = getter()); 4 return result; 5} 6 7let product = reactive({ price: 10, quantity: 2 }); 8let salePrice = computed(() => { 9 return product.price * 0.9; 10}) 11let total = computed(() => { 12 return salePrice.value * product.quantity; 13}) 14 15console.log(total.value, salePrice.value); 16product.quantity = 5; 17console.log(total.value, salePrice.value); 18product.price = 20; 19console.log(total.value, salePrice.value);
这里我们将一个函数作为参数传入 computed
方法,computed
方法内通过 ref
方法构建一个 ref 对象,然后通过 effct
方法,将 getter
方法返回值作为 computed
方法的返回值。
这样我们实现了个简单的 computed
方法,执行效果和前面一样。
这一节介绍如何去从 Vue 3 仓库打包一个 Reactivity 包来学习和使用。
准备流程如下:
1git clone https://github.com/vuejs/vue-next.git
1yarn install
1yarn build reactivity
上一步构建完的内容,会保存在 packages/reactivity/dist
目录下,我们只要在自己的学习 demo 中引入该目录的 reactivity.cjs.js 文件即可。
1const { reactive, computed, effect } = require("./reactivity.cjs.js");
在源码的 packages/reactivity/src
目录下,有以下几个主要文件:
effect
/ track
/ trigger
;reactive
方法并创建 ES6 Proxy;最近更新时间:2024-08-10