Vue 3 中的响应式原理堪称是十分之重要,通过学习 Vue3 的响应式原理,不仅能让咱们学习到 Vue.js 的一些设计模式和思维,还能帮忙咱们进步我的项目开发效率和代码调试能力

在这之前,我也写了一篇《摸索 Vue.js 响应式原理》 ,次要介绍 Vue 2 响应式的原理,这篇补上 Vue 3 的。

于是最近在 Vue Mastery 上重新学习 Vue3 Reactivity 的常识,这次播种更大。本文将带大家从头开始学习如何实现简略版 Vue 3 响应式,帮忙大家理解其外围,前面浏览 Vue 3 响应式相干的源码可能更加得心应手。

一、Vue 3 响应式应用

1. Vue 3 中的应用

当咱们在学习 Vue 3 的时候,能够通过一个简略示例,看看什么是 Vue 3 中的响应式:

<!-- HTML 内容 --><div id="app">    <div>Price: {{price}}</div>    <div>Total: {{price * quantity}}</div>    <div>getTotal: {{getTotal}}</div></div>
const app = Vue.createApp({ // ① 创立 APP 实例    data() {        return {            price: 10,            quantity: 2        }    },    computed: {        getTotal() {            return this.price * this.quantity * 1.1        }    }})app.mount('#app')  // ② 挂载 APP 实例

通过创立 APP 实例和挂载 APP 实例即可,这时能够看到页面中别离显示对应数值:

当咱们批改 price 或 quantity 值的时候,页面上援用它们的中央,内容也能失常展现变动后的后果。这时,咱们会好奇为何数据发生变化后,相干的数据也会跟着变动,那么咱们接着往下看。

2. 实现单个值的响应式

在一般 JS 代码执行中,并不会有响应式变动,比方在控制台执行上面代码:

let price = 10, quantity = 2;const total = price * quantity;console.log(`total: ${total}`); // total: 20price = 20;console.log(`total: ${total}`); // total: 20

从这能够看出,在批改 price 变量的值后, total 的值并没有产生扭转。

那么如何批改下面代码,让 total 可能自动更新呢?咱们其实能够将批改 total 值的办法保存起来,等到与 total 值相干的变量(如 price 或 quantity 变量的值)发生变化时,触发该办法,更新 total 即可。咱们能够这么实现:

let price = 10, quantity = 2, total = 0;const dep = new Set(); // ① const effect = () => { total = price * quantity };const track = () => { dep.add(effect) };  // ②const trigger = () => { dep.forEach( effect => effect() )};  // ③track();console.log(`total: ${total}`); // total: 0trigger();console.log(`total: ${total}`); // total: 20price = 20;trigger();console.log(`total: ${total}`); // total: 40

下面代码通过 3 个步骤,实现对 total 数据进行响应式变动:

① 初始化一个 Set 类型的 dep 变量,用来寄存须要执行的副作用( effect 函数),这边是批改 total 值的办法;

② 创立 track() 函数,用来将须要执行的副作用保留到 dep 变量中(也称收集副作用);

③ 创立 trigger() 函数,用来执行 dep 变量中的所有副作用;

在每次批改 pricequantity 后,调用 trigger() 函数执行所有副作用后, total 值将自动更新为最新值。

(图片起源:Vue Mastery)

3. 实现单个对象的响应式

通常,咱们的对象具备多个属性,并且每个属性都须要本人的 dep。咱们如何存储这些?比方:

let product = { price: 10, quantity: 2 };

从后面介绍咱们晓得,咱们将所有副作用保留在一个 Set 汇合中,而该汇合不会有反复项,这里咱们引入一个 Map 类型汇合(即 depsMap ),其 key 为对象的属性(如: price 属性), value 为后面保留副作用的 Set 汇合(如: dep 对象),大抵构造如下图:


(图片起源:Vue Mastery)

实现代码:

let product = { price: 10, quantity: 2 }, total = 0;const depsMap = new Map(); // ① const effect = () => { total = product.price * product.quantity };const track = key => {     // ②    let dep = depsMap.get(key);  if(!dep) {        depsMap.set(key, (dep = new Set()));  }    dep.add(effect);}const trigger = key => {  // ③    let dep = depsMap.get(key);  if(dep) {        dep.forEach( effect => effect() );  }};track('price');console.log(`total: ${total}`); // total: 0effect();console.log(`total: ${total}`); // total: 20product.price = 20;trigger('price');console.log(`total: ${total}`); // total: 40

下面代码通过 3 个步骤,实现对 total 数据进行响应式变动:

① 初始化一个 Map 类型的 depsMap 变量,用来保留每个须要响应式变动的对象属性(key 为对象的属性, value 为后面 Set 汇合);

② 创立 track() 函数,用来将须要执行的副作用保留到 depsMap 变量中对应的对象属性下(也称收集副作用);

③ 创立 trigger() 函数,用来执行 dep 变量中指定对象属性的所有副作用;

这样就实现监听对象的响应式变动,在 product 对象中的属性值发生变化, total 值也会跟着更新。

4. 实现多个对象的响应式

如果咱们有多个响应式数据,比方同时须要察看对象 a 和对象 b  的数据,那么又要如何跟踪每个响应变动的对象?

这里咱们引入一个 WeakMap 类型的对象,将须要察看的对象作为 key ,值为后面用来保留对象属性的 Map 变量。代码如下:

let product = { price: 10, quantity: 2 }, total = 0;const targetMap = new WeakMap();     // ① 初始化 targetMap,保留察看对象const effect = () => { total = product.price * product.quantity };const track = (target, key) => {     // ② 收集依赖  let depsMap = targetMap.get(target);  if(!depsMap){      targetMap.set(target, (depsMap = new Map()));  }    let dep = depsMap.get(key);  if(!dep) {        depsMap.set(key, (dep = new Set()));  }    dep.add(effect);}const trigger = (target, key) => {  // ③ 执行指定对象的指定属性的所有副作用  const depsMap = targetMap.get(target);  if(!depsMap) return;    let dep = depsMap.get(key);  if(dep) {        dep.forEach( effect => effect() );  }};track(product, 'price');console.log(`total: ${total}`); // total: 0effect();console.log(`total: ${total}`); // total: 20product.price = 20;trigger(product, 'price');console.log(`total: ${total}`); // total: 40

下面代码通过 3 个步骤,实现对 total 数据进行响应式变动:

① 初始化一个 WeakMap 类型的 targetMap 变量,用来要察看每个响应式对象;

② 创立 track() 函数,用来将须要执行的副作用保留到指定对象( target )的依赖中(也称收集副作用);

③ 创立 trigger() 函数,用来执行指定对象( target )中指定属性( key )的所有副作用;

这样就实现监听对象的响应式变动,在 product 对象中的属性值发生变化, total 值也会跟着更新。

大抵流程如下图:


(图片起源:Vue Mastery)

二、Proxy 和 Reflect

在上一节内容中,介绍了如何在数据发生变化后,自动更新数据,但存在的问题是,每次须要手动通过触发 track() 函数收集依赖,通过 trigger() 函数执行所有副作用,达到数据更新目标。

这一节未来解决这个问题,实现这两个函数主动调用。

1. 如何实现主动操作

这里咱们引入 JS 对象拜访器的概念,解决办法如下:

  • 在读取(GET 操作)数据时,主动执行 track() 函数主动收集依赖;
  • 在批改(SET 操作)数据时,主动执行 trigger() 函数执行所有副作用;

那么如何拦挡 GET 和 SET 操作?接下来看看 Vue2 和 Vue3 是如何实现的:

  • 在 Vue2 中,应用 ES5 的 Object.defineProperty() 函数实现;
  • 在 Vue3 中,应用 ES6 的 ProxyReflect API 实现;

须要留神的是:Vue3 应用的 ProxyReflect API 并不反对 IE。

Object.defineProperty() 函数这边就不多做介绍,能够浏览文档,下文将次要介绍 ProxyReflect API。

2. 如何应用 Reflect

通常咱们有三种办法读取一个对象的属性:

  1. 应用 . 操作符:leo.name
  2. 应用 []leo['name']
  3. 应用 Reflect API: Reflect.get(leo, 'name')

这三种形式输入后果雷同。

3. 如何应用 Proxy

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

const p = new Proxy(target, handler)

参数如下:

  • target : 要应用 Proxy 包装的指标对象(能够是任何类型的对象,包含原生数组,函数,甚至另一个代理)。
  • handler : 一个通常以函数作为属性的对象,各属性中的函数别离定义了在执行各种操作时代理 p 的行为。

咱们通过官网文档,体验一下 Proxy API:

let product = { price: 10, quantity: 2 };let proxiedProduct = new Proxy(product, {    get(target, key){      console.log('正在读取的数据:',key);    return target[key];  }})console.log(proxiedProduct.price); // 正在读取的数据: price// 10

这样就保障咱们每次在读取 proxiedProduct.price 都会执行到其中代理的 get 处理函数。其过程如下:


(图片起源:Vue Mastery)

而后联合 Reflect 应用,只需批改 get 函数:

    get(target, key, receiver){      console.log('正在读取的数据:',key);    return Reflect.get(target, key, receiver);  }

输入后果还是一样。

接下来减少 set 函数,来拦挡对象的批改操作:

let product = { price: 10, quantity: 2 };let proxiedProduct = new Proxy(product, {    get(target, key, receiver){      console.log('正在读取的数据:',key);    return Reflect.get(target, key, receiver);  },  set(target, key, value, receiver){      console.log('正在批改的数据:', key, ',值为:', value);      return Reflect.set(target, key, value, receiver);  }})proxiedProduct.price = 20;console.log(proxiedProduct.price); // 正在批改的数据: price ,值为: 20// 正在读取的数据: price// 20

这样便实现 get 和 set 函数来拦挡对象的读取和批改的操作。为了不便比照 Vue 3 源码,咱们将下面代码形象一层,使它看起来更像 Vue3 源码:

function reactive(target){    const handler = {  // ① 封装对立处理函数对象      get(target, key, receiver){      console.log('正在读取的数据:',key);      return Reflect.get(target, key, receiver);    },    set(target, key, value, receiver){      console.log('正在批改的数据:', key, ',值为:', value);      return Reflect.set(target, key, value, receiver);    }  }    return new Proxy(target, handler); // ② 对立调用 Proxy API}let product = reactive({price: 10, quantity: 2}); // ③ 将对象转换为响应式对象product.price = 20;console.log(product.price); // 正在批改的数据: price ,值为: 20// 正在读取的数据: price// 20

这样输入后果依然不变。

4. 批改 track 和 trigger 函数

通过下面代码,咱们曾经实现一个简略 reactive() 函数,用来将一般对象转换为响应式对象。然而还短少主动执行 track() 函数和 trigger() 函数,接下来批改下面代码:

const targetMap = new WeakMap();let total = 0;const effect = () => { total = product.price * product.quantity };const track = (target, key) => {   let depsMap = targetMap.get(target);  if(!depsMap){      targetMap.set(target, (depsMap = new Map()));  }    let dep = depsMap.get(key);  if(!dep) {        depsMap.set(key, (dep = new Set()));  }    dep.add(effect);}const trigger = (target, key) => {  const depsMap = targetMap.get(target);  if(!depsMap) return;    let dep = depsMap.get(key);  if(dep) {        dep.forEach( effect => effect() );  }};const reactive = (target) => {    const handler = {      get(target, key, receiver){      console.log('正在读取的数据:',key);      const result = Reflect.get(target, key, receiver);      track(target, key);  // 主动调用 track 办法收集依赖      return result;    },    set(target, key, value, receiver){      console.log('正在批改的数据:', key, ',值为:', value);      const oldValue = target[key];      const result = Reflect.set(target, key, value, receiver);      if(oldValue != result){         trigger(target, key);  // 主动调用 trigger 办法执行依赖      }      return result;    }  }    return new Proxy(target, handler);}let product = reactive({price: 10, quantity: 2}); effect();console.log(total); product.price = 20;console.log(total); // 正在读取的数据: price// 正在读取的数据: quantity// 20// 正在批改的数据: price ,值为: 20// 正在读取的数据: price// 正在读取的数据: quantity// 40


(图片起源:Vue Mastery)

三、activeEffect 和 ref

在上一节代码中,还存在一个问题: track 函数中的依赖( effect 函数)是内部定义的,当依赖发生变化, track 函数收集依赖时都要手动批改其依赖的办法名。

比方当初的依赖为 foo 函数,就要批改 track 函数的逻辑,可能是这样:

const foo = () => { /**/ };const track = (target, key) => {     // ②  // ...    dep.add(foo);}

那么如何解决这个问题呢?

1. 引入 activeEffect 变量

接下来引入 activeEffect 变量,来保留以后运行的 effect 函数。

let activeEffect = null;const effect = eff => {    activeEffect = eff; // 1. 将 eff 函数赋值给 activeEffect  activeEffect();     // 2. 执行 activeEffect  activeEffect = null;// 3. 重置 activeEffect}

而后在 track 函数中将 activeEffect 变量作为依赖:

const track = (target, key) => {    if (activeEffect) {  // 1. 判断以后是否有 activeEffect        let depsMap = targetMap.get(target);        if (!depsMap) {            targetMap.set(target, (depsMap = new Map()));        }        let dep = depsMap.get(key);        if (!dep) {            depsMap.set(key, (dep = new Set()));        }        dep.add(activeEffect);  // 2. 增加 activeEffect 依赖    }}

应用形式批改为:

effect(() => {    total = product.price * product.quantity});

这样就能够解决手动批改依赖的问题,这也是 Vue3 解决该问题的办法。欠缺一下测试代码后,如下:

const targetMap = new WeakMap();let activeEffect = null; // 引入 activeEffect 变量const effect = eff => {    activeEffect = eff; // 1. 将副作用赋值给 activeEffect  activeEffect();     // 2. 执行 activeEffect  activeEffect = null;// 3. 重置 activeEffect}const track = (target, key) => {    if (activeEffect) {  // 1. 判断以后是否有 activeEffect        let depsMap = targetMap.get(target);        if (!depsMap) {            targetMap.set(target, (depsMap = new Map()));        }        let dep = depsMap.get(key);        if (!dep) {            depsMap.set(key, (dep = new Set()));        }        dep.add(activeEffect);  // 2. 增加 activeEffect 依赖    }}const trigger = (target, key) => {    const depsMap = targetMap.get(target);    if (!depsMap) return;    let dep = depsMap.get(key);    if (dep) {        dep.forEach(effect => effect());    }};const reactive = (target) => {    const handler = {        get(target, key, receiver) {            const result = Reflect.get(target, key, receiver);            track(target, key);            return result;        },        set(target, key, value, receiver) {            const oldValue = target[key];            const result = Reflect.set(target, key, value, receiver);            if (oldValue != result) {                trigger(target, key);            }            return result;        }    }    return new Proxy(target, handler);}let product = reactive({ price: 10, quantity: 2 });let total = 0, salePrice = 0;// 批改 effect 应用形式,将副作用作为参数传给 effect 办法effect(() => {    total = product.price * product.quantity});effect(() => {    salePrice = product.price * 0.9});console.log(total, salePrice);  // 20 9product.quantity = 5;console.log(total, salePrice);  // 50 9product.price = 20;console.log(total, salePrice);  // 100 18

思考一下,如果把第一个 effect 函数中 product.price 换成 salePrice 会如何:

effect(() => {    total = salePrice * product.quantity});effect(() => {    salePrice = product.price * 0.9});console.log(total, salePrice);  // 0 9product.quantity = 5;console.log(total, salePrice);  // 45 9product.price = 20;console.log(total, salePrice);  // 45 18

失去的后果齐全不同,因为 salePrice 并不是响应式变动,而是须要调用第二个 effect 函数才会变动,也就是 product.price 变量值发生变化。

代码地址:
https://github.com/Code-Pop/vue-3-reactivity/blob/master/05-activeEffect.js

2. 引入 ref 办法

相熟 Vue3 Composition API 的敌人可能会想到 Ref,它接管一个值,并返回一个响应式可变的 Ref 对象,其值能够通过 value 属性获取。

ref:承受一个外部值并返回一个响应式且可变的 ref 对象。ref 对象具备指向外部值的单个 property .value。

官网的应用示例如下:

const count = ref(0)console.log(count.value) // 0count.value++console.log(count.value) // 1

咱们有 2 种办法实现 ref 函数:

  1. 应用 rective 函数
const ref = intialValue => reactive({value: intialValue});

这样是能够的,尽管 Vue3 不是这么实现。

  1. 应用对象的属性拜访器(计算属性)

属性形式去包含:getter 和 setter。

const ref = raw => {    const r = {      get value(){        track(r, 'value');      return raw;    },        set value(newVal){        raw = newVal;      trigger(r, 'value');    }  }    return r;}

应用形式如下:

let product = reactive({ price: 10, quantity: 2 });let total = 0, salePrice = ref(0);effect(() => {    salePrice.value = product.price * 0.9});effect(() => {    total = salePrice.value * product.quantity});console.log(total, salePrice.value); // 18 9product.quantity = 5;console.log(total, salePrice.value); // 45 9product.price = 20;console.log(total, salePrice.value); // 90 18

在 Vue3 中 ref 实现的外围也是如此。

代码地址:
https://github.com/Code-Pop/vue-3-reactivity/blob/master/06-ref.js

四、实现繁难 Computed 办法

用过 Vue 的同学可能会好奇,下面的 salePricetotal 变量为什么不应用 computed 办法呢?

没错,这个能够的,接下来一起实现个简略的 computed 办法。

const computed = getter => {    let result = ref();    effect(() => result.value = getter());    return result;}let product = reactive({ price: 10, quantity: 2 });let salePrice = computed(() => {    return product.price * 0.9;})let total = computed(() => {    return salePrice.value * product.quantity;})console.log(total.value, salePrice.value);product.quantity = 5;console.log(total.value, salePrice.value);product.price = 20;console.log(total.value, salePrice.value);

这里咱们将一个函数作为参数传入 computed 办法,computed 办法内通过 ref 办法构建一个 ref 对象,而后通过 effct 办法,将 getter 办法返回值作为 computed 办法的返回值。

这样咱们实现了个简略的 computed 办法,执行成果和后面一样。

五、源码学习倡议

1. 构建 reactivity.cjs.js

这一节介绍如何去从 Vue 3 仓库打包一个 Reactivity 包来学习和应用。

筹备流程如下:

  1. 从 Vue 3 仓库下载最新 Vue3 源码;
git clone https://github.com/vuejs/vue-next.git
  1. 装置依赖:
yarn install
  1. 构建 Reactivity 代码:
yarn build reactivity
  1. 复制 reactivity.cjs.js 到你的学习 demo 目录:

上一步构建完的内容,会保留在 packages/reactivity/dist目录下,咱们只有在本人的学习 demo 中引入该目录的  reactivity.cjs.js 文件即可。

  1. 学习 demo 中引入:
const { reactive, computed, effect } = require("./reactivity.cjs.js");

2. Vue3 Reactivity 文件目录

在源码的 packages/reactivity/src目录下,有以下几个次要文件:

  1. effect.ts:用来定义 effect / track / trigger ;
  2. baseHandlers.ts:定义 Proxy 处理器( get 和 set);
  3. reactive.ts:定义 reactive 办法并创立 ES6 Proxy;
  4. ref.ts:定义 reactive 的 ref 应用的对象拜访器;
  5. computed.ts:定义计算属性的办法;


(图片起源:Vue Mastery)

六、总结

本文带大家从头开始学习如何实现简略版 Vue 3 响应式,实现了 Vue3 Reactivity 中的外围办法( effect / track / trigger / computed /ref 等办法),帮忙大家理解其外围,进步我的项目开发效率和代码调试能力

参考文章

  • Vue Mastery

往期举荐

  1. 摸索 React 合成事件
  2. 摸索 Vue.js 响应式原理
  3. 摸索 Snabbdom 模块零碎原理

我是王平安,如果我的文章对你有帮忙,请点个 赞 反对我一下

我的公众号:前端自习课,每日凌晨,享受一篇前端优良文章。欢送大家退出我的前端群,一起分享和交换技术,vx: pingan8787