掘金 阅读 ( ) • 2024-04-02 17:41

highlight: a11y-dark theme: smartblue

前言

Vue3用久了,你会不会对自己手下的代码产生一些疑问?比如说:

  • 有了reactive,那还需要 ref 干啥?
  • 同样是响应式代理,为什么只有ref可以代理原始数据,reactive却不行?
  • 为什么使用ref的值要加上一个麻烦的.value
  • 为什么ref可以同时代理原始数据和对象数据?
  • 为什么只要修改响应式数据,视图就自动改变了呢?Vue它怎么就知道我修改了数据呢?

诸如此类的问题,是否令你感到有些困惑?不用担心,这些问题其实是多是由于Vue3的新特性和语法带来的变化导致的。下面会将上述问题一一捋顺,让我们开发的时候可以做到知其所以然。

本文旨在整理思路,让读者了解的整体流程,帮助读者对 Vue 的工作原理建立一个整体的认识和理解。而并非讨论详细的源码。因此,本文不会涉及到实际中大段的 Vue 源码。请安心阅读(开玩笑的!)

使用差异

reactive 的使用

在Vue3中,reactive是一个用于创建响应式对象的API。它接受一个普通的 JavaScript对象 作为参数,并返回一个响应式的代理。只要使用简单的几行代码就可以创建一个响应式数据,并轻松的使用它,普通版使用如下:

<script setup>
  // 引入
  import { reactive } from 'vue';

  // 创建响应式对象
  let reactiveInfo = reactive({
    name: '饿肚子游侠'
  });
</script>

<template>
  <div>
    // 页面使用
    {{ reactiveInfo }}
  </div>
</template>

好,很棒,完全没有问题。但当我们想往reactive里装一个原始数据类型的时候,就出问题了。Vue会给出提示:它无法获得响应式

image.png

这个提示是什么意思呢?其实也就是:后续如果再想对reactiveInfo的值进行更新赋值的时候,重新赋值后的值已经不能再响应式的更新到页面上了。那该怎么办呢?假设现在有个参数,一定要定义为number类型或者其它任何的原始数据类型怎么办?但凡有使用Vue3经验的小伙伴肯定都知道,该ref上场了。

ref 的使用

在Vue3中,ref是一个创建响应式的API,它可以将普通的 JavaScript数据 变为响应式数据。ref函数接受一个初始值作为参数,并返回一个响应式的引用对象。与reactive一样,ref使用起来也很简单。区别就在于,在script标签里对其进行获取的时候,需要加上一个.value,而在template里使用的时候则不用:

<script setup>
  // 引入
  import { ref } from 'vue';

  // 创建响应式对象
  let refTest = ref(0);
  
  // 修改测试
  setTimeout(() => {
    refTest.value = 2;
  }, 1000);
</script>

<template>
  <div>
    // 页面使用
    {{ refTest }}
  </div>
</template>

差异背后

在简单了解了reactiveref的使用之后,现在,不管是想要实现对象还是原始数据类型的响应式转换,都可以轻松做到了。可是却不禁会对它们的使用差异产生一些好奇,为什么!到底为什么ref可以代理原始数据,reactive不行!为什么使用ref的值还非要加上.value!为什么修改了我的响应式对象,视图也跟着变!不用担心,下面就来把这些问题一个一个的解决。

前置知识了解(简单了解)

在JavaScript中,Proxy是一个内置的对象,用于创建一个代理对象,可以拦截并定制目标对象上的操作。Proxy的构造函数接受两个参数:目标对象(Target)和处理器对象(Handler)。

参数解释如下:

  1. 目标对象(Target):即被代理的对象,可以是任何JavaScript对象(包括数组、函数、普通对象等)。Proxy会在目标对象上创建一个代理,拦截对目标对象的操作。
  2. 处理器对象(Handler):一个包含各种拦截方法的对象。处理器对象可以定义一系列拦截器(也称为陷阱或代理方法),这些拦截器在对代理对象进行操作时会被自动调用。处理器对象可以重写或定制这些操作,以实现自定义的行为。

处理器对象的常见拦截器方法包括:

  • get(target, property, receiver):拦截对代理对象属性的读取操作。
  • set(target, property, value, receiver):拦截对代理对象属性的赋值操作。
  • has(target, property):拦截in操作符的操作。
  • deleteProperty(target, property):拦截delete操作符的操作。
  • apply(target, thisArg, argumentsList):拦截对代理对象的函数调用操作。
  • construct(target, argumentsList, newTarget):拦截对代理对象的new操作符的操作。

为什么 reactive 不能监听原始数据类型?

要理解为什么reactive无法监听原始数据类型,我们需要提到前置知识里的Proxy。实际上,reactive底层是通过Proxy来实现数据劫持的。因此,只要了解了Proxy的特性,就能明白为什么reactive不支持原始数据类型了(代码可以跟着敲一下,便于理解也可以加深印象)。

先来实验一下使用Proxy监听对象,可以注意看代码注释:

// 定义一个对象
let testObj = {
  name: '11'
};

// 将对象传入Proxy中,并传入处理器对象来重写它的get和set方法
const proxyObj = new Proxy(testObj, {
  get: () => {
    // 获取数据的时候输出‘get’
    console.log('get');
  },

  set: () => {
    // 设置数据的时候输出‘set’
    console.log("set");

    return 'set';
  }
});

// 实验数据是否正常被监听了
proxyObj.name = '22';
proxyObj.name

如果你运行了上述代码,会发现控制台正常输出了" set "和" get ",表明我们成功使用Proxy对数据进行了监听。接下来,如果我们将testObj改成原始数据类型,并再次测试,你会发现控制台输出了如下报错信息:无法使用非对象作为目标或处理程序创建代理。这意味着Proxy是无法对原始数据类型进行代理的。而reactive正是基于Proxy封装而成的,所以reactive不能监听原始数据类型也就不难理解了。

image.png

那么下一个问题紧接着就来了:ref,为什么可以监听原始数据类型?

为什么 ref 可以监听原始数据类型?

在探究这个问题之前,让我们先来了解一下ref这个API的封装思路是什么,以及它与reactive之间的异同。同时也会解答为什么ref需要使用.value

其实通过前文的实验,我们已经观察到:Proxy是只能监听对象,而无法直接处理原始数据类型的数据。考虑到这一点,我们是不是可以尝试将原始数据放置在对象中,以便Proxy进行监听呢?接下来我们就按照这一思路,模拟实现对原始数据类型的监听。

ref函数的封装原理如下:

  1. 首先,创建一个对象,这个对象包含一个名为value的属性,用于存储我们传入的初始值(对象类型或者原始数据类型皆可)。
  2. 接下来,就和reactiveAPI的封装基本一致了,使用Proxy来创建一个代理对象。代理对象会拦截对其属性的读取和赋值操作。
  3. 在代理对象的拦截器方法中,对于读取操作,会返回对象的value属性的值。而对于赋值操作,会将新的值赋给响应式对象的value属性。
  4. 最后,将代理过的对象返回。这样,ref函数返回的值实际上就是由代理对象包装过的对象了。
function ref(value) {
  // 将传入的初始值存在reactiveObj中
  const reactiveObj = { value };

  // 返回一个Proxy代理
  return new Proxy(reactiveObj, {
    // 读取数据
    get(target, property, receiver) {
      console.log('触发了获取操作');
      return target.value;
    },
    
    // 更新数据
    set(target, property, value, receiver) {
      target.value = value;
      
      console.log('触发了更新操作');
      return 'set';
    },
  });
}

// 测试
const count = ref(100000);
count.value // 输出'get'
count.value = 1; // 输出'set'

这样操作之后,控制台已经可以正常输出了" 触发了获取操作 "和" 触发了更新操作 "了。

ref的封装过程中,我们将值包装在一个对象的value属性中,这个过程就解释了为什么我们需要使用.value来访问和修改ref封装返回的值。同样的,这里不管传入的数据是对象类型还是原始数据类型,都会经过这个包装将其放在一个对象里,然后再使用Proxy监听包装后的对象,所以才让ref具备同时代理原始数据和对象数据的能力。

为什么只要修改响应式数据 视图就会自动改变

当修改数据后,视图会立即更新,这是Vue的常见特性。然而,你是否曾思考过其中的原理?Vue是如何知道我们修改了哪个数据的呢?

实际在Vue源码中,这个过程涉及到了更多的内容和考虑,还要考虑模板编译等内容。由于这些内容并非本文的重点,我们不会过多讨论。为了更清晰地展示原理而不涉及源码细节,本文采用了下面这样一个简化的方法来代替复杂的过程。

从上面小节中已经了解到,在Vue中,我们使用Proxy来代理数据对象,使其成为可观察的。也就是当我们修改代理对象的属性时,能够在Proxy中感知到这种改变并触发相关操作,从而来做一些我们想做的事的,比如:数据改变的时候,是不是可以将对应的视图也一起改掉?

所以接下来要对上面的监听做一点点改造,具体就是在setter函数中通过id获取到对应的DOM元素,并直接替换更新后的数据,从而达到修改数据后视图改变的效果:

  <div id="app"></div>
  <button onclick="count.value++">加一</button>
  <div id="updateTest"></div>

  <script>
    function ref(value) {
      const reactiveObj = { value };

      return new Proxy(reactiveObj, {
        // 读取数据
        get(target, property, receiver) {
          console.log('get', property);
          return target.value;
        },
        
        // 更新数据
        set(target, property, value, receiver) {
          target.value = value;
          console.log('set');
          document.querySelector("#updateTest").innerHTML = value;
          return 'set';
        },
      });
    }

    const count = ref(100000);

    document.querySelector("#updateTest").innerHTML = count.value;
  </script>

现在,点击加一按钮,页面就可以随着数据改变更新啦!接下来只需要添加一套依赖收集来跟踪count属性的变化,就可以在在数据更新时自动更新相关的视图啦。

注释写的较全,所以代码不过多解释,可以多注意注释哦!

首先,我们需要创建一个Dep类来管理依赖的收集和通知。

  // 创建依赖管理类 Dep
  class Dep {
    constructor() {
      // 用于存储依赖的订阅者
      this.subscribers = new Set();
    }

    // 添加订阅者
    depend() {
      if (activeWatcher) {
        this.subscribers.add(activeWatcher);
      }
    }

    // 通知所有订阅者进行更新
    notify() {
      this.subscribers.forEach((watcher) => watcher.update());
    }
  }

然后,我们需要将Dep实例与count属性关联起来,再对上面的ref函数进行一下改造。

// 先将activeWatcher设为null 确保在初始化阶段没有活动的Watcher对象
let activeWatcher = null;

// 创建 ref 函数
function ref(value) {
  const reactiveObj = { value };
  // 创建依赖管理实例
  const dep = new Dep();

  return new Proxy(reactiveObj, {
    // 读取数据
    get(target, property, receiver) {
      console.log('get', property);
      dep.depend(); // 把依赖收集一下
      return target.value;
    },
    
    // 更新数据
    set(target, property, value, receiver) {
      target.value = value;
      console.log('set');
      dep.notify(); // 通知依赖更新
      return 'set';
    },
  });
}

最后,我们在按钮的点击事件监听器中创建了一个Watcher实例,在数据变化时更新视图。

// 调用ref函数
const count = ref(100000);

// 将初始值放到页面上
document.querySelector("#updateTest").innerHTML = count.value;

// 创建 Watcher 类
class Watcher {
  constructor(updateFn) {
    this.updateFn = updateFn;
  }

  // 执行更新操作
  update() {
    this.updateFn();
  }
}

activeWatcher = new Watcher(() => {
  // 更新视图
  document.querySelector("#updateTest").innerHTML = count.value;
});

至此,我们已经完成了依赖的收集。下面是整个依赖收集触发的具体流程,可以跟着再整理一下:

  1. 当用户点击按钮时,按钮的点击事件被触发。在点击事件中,每点击一次就将count的值加一。
  2. count的值改变了,在数据改变后更新的过程中,Proxy的监听起效了,countset函数被调用,而其中包括了通知依赖更新的逻辑,于是触发了通知依赖更新的操作。
  3. 而在通知依赖更新的过程中,activeWatcher又被触发,调用了它的update方法。
  4. update方法中执行了更新视图的函数,将count.value的值更新到#updateTest元素中,从而更新了视图。

总结

实际在Vue3的源码中肯定不会封装的如此简单,实际上要考虑的事情还有很多,比如Vue怎么知道哪里使用了数据,更新后的数据该更新到哪里?Vue是怎么把script里定义的响应式数据正确的放到页面上的?等诸如此类的问题。本文这里只是提供一个整体流程和思考路径,如果有对细致的源码感兴趣的小伙伴,可以在评论区提出,后续会陆续更新数据劫持模板编译等相关的源码解读,届时也会本文遗留的疑惑解开。