Vue 3.2 源码系列:03-看呆面试官的《手写响应式模块》

2,847 阅读11分钟

博客代码已上传至github

点击这里 即可访问

另提供:完整代码(ts+rollup)和视频教程

image.png


有一次,我去参加一个前端开发的面试,本来聊的一直都还挺好,心里觉得这次肯定十拿九稳。

直到面试官说:

那你说一下: 在 vue 中,通过 ref 声明的响应式数据,为什么需要通过 .value 来访问数据呢?

然后这次面试就凉了...

image.png

所以回来之后,我痛定思痛决定把 vue 响应式模块 好好的恶补一下...

前言

上一篇博客中,咱们聊了 响应式设计原则,凭借它可以应对部分的初级、中级的前端工程师面试。

但是如果面试官对问题更加深入(或许你可以趁机多要点钱)的时候,基本的设计原则可能就满足不了你的需求了。

那么这个时候,这篇博客或许可以帮助到你~~~~

本篇博客的核心主题是:手写响应式模块,主要涉及到以下几部分代码:

  1. effect 模块
  2. reactive 模块
  3. ref 模块
  4. 测试实例

博客中涉及到的所有代码,都可以 点击这里 进行访问。

正文开始

effect 模块

咱们先来看 effect 模块

啥是 effect ?

vue 中存在一个effect 模块,它的作用是:收集并且触发指定的方法。

啥意思呢?

我们来举个例子:

image.png

现在有一个数据 name = '张三',我们把它渲染到一个 div 中。

所以我们可以执行如下代码:

document.querySelector('#app').innerText = name

此时,当 name 的值发生变化时,在响应性的逻辑之下,div 中展示的内容需要同步跟随变化

也就是再次执行:document.querySelector('#app').innerText = name

在上面的例子中,重复被执行的 document.querySelector('#app').innerText = name 代码,就是被:收集并且触发指定的方法,也就是 effect 方法

image.png

effect 咋用 ?

想要知道effect 模块如何进行实现。咱首先得知道effect是怎么使用的。

我们可以通过如下方法去使用effect模块:

effect(() => {
  document.querySelector('#app').innerText = name
})

在上面的代码中,effect 接收一个回调函数作为参数。

根据咱们上面所说:回调方法存在两个执行时机

  1. 最初执行时
  2. 数据变化时

那也就是说,在咱们封装effect模块时,必须要保证它具有这两个执行时机。

封装 effect

咱们先列出effect的代码,然后再逐步分析:

// 当前需要执行的 effect
let activeEffect

/**
 * 2. 看完 effect 方法之后再看这里
 * 响应性触发依赖时的执行类
 */
class ReactiveEffect {
  constructor(fn) {
    this.fn = fn
  }

  run() {
    // 为 activeEffect 赋值
    activeEffect = this

    // 执行 fn 函数
    return this.fn()
  }
}

/**
 * 1. 代码先看这里
 * effect 函数
 * @param fn 执行方法
 * @returns 以 ReactiveEffect 实例为 this 的执行函数
 */
function effect(fn) {
  // 生成 ReactiveEffect 实例
  const _effect = new ReactiveEffect(fn)

  // 执行 run 函数
  _effect.run()
}

在上面的代码中,咱们主要做了两件事情:

1. effect 函数

  1. 咱们首先创建了一个effect 函数,该函数接收一个回调函数作为参数
  2. 在该函数中,创建了ReactiveEffect的实例_effect
  3. 并且触发了_effectrun 函数
  4. 那么这个run函数做了什么事情呢?

2. ReactiveEffect 类

  1. ReactiveEffect 的构造函数中,把fn赋值给了this.fn
  2. 提供了run 函数 3.run 函数主要做了两件事情:
    1. 为全局变量activeEffect赋值
    2. 执行this.fn(即:effect 的回调函数)

effect 代码总结

目前,咱们已经创建好了effect模块,并且利用effect做了两件事情:

  1. effect 函数接收回调函数
  2. ReactiveEffect 实例可以调用run函数,从而:
    1. activeEffect 变量赋值
    2. 执行回调函数

reactive 模块

vue 中提供 reactive 方法,可以用来创建响应性数据(仅限复杂数据类型)

那么接下来,咱就来看看这个reactive是咋弄的。

image.png

响应性咋弄?

每一个变量通常都有两个行为:

  1. getter 行为:访问变量的值时就会触发getter行为。比如:const name = person.name,那么此时就会触发persongetter 行为。
  2. setter 行为:为变量赋值时就会触发setter行为。比如person.name = '张三',那么此时就会触发personsetter 行为。

而我们想要实现一个响应性,则必须要从这两个行为入手。

该博客中 咱们已经知道:通过 proxy 可以监听对象的 getter 和 setter 行为。

咱们需要在:

  1. getter 行为时,保存被执行的effect 回调函数,以便在setter(数据改变) 行为时重新执行。这个过程被叫做:依赖收集
  2. setter 行为时,执行保存的 effect 回调函数,以便修改视图。这个过程被叫做:触发依赖

这两步就是响应性的核心逻辑。

反推 reactive 实现

想要实现 reactive 模块,那么首先得先明确reactive函数的用法,然后根据用法反推实现。

根据 官方文档reactive 用法非常简单:

const obj = reactive({
  name: '张三'
})

console.log(obj) // proxy 实例

reactive 接收一个对象,我们可以把该对象叫做target,然后得到一个返回值 obj

打印obj,可以发现得到的是一个proxy 实例。即:代理对象

那么由此可知:reactive 方法内部必然生成了 proxy 实例,并把它进行了返回

那么生成proxy实例的目的是什么呢?

咱知道proxy是可以监听getter、setter行为的,那么结合咱们上面所说,生成proxy实例的目的一定是:为了 监听 getter、setter行为,以便执行依赖收集和依赖触发

那么依赖收集和依赖触发的过程是怎么做的呢? 这里大家可以看下我之前写的这篇博客,它详细说明了整个依赖收集和依赖触发过程

那么咱们来总结下反推的结果:

  1. reactive 方法:接收一个对象target
  2. reactive 方法:生成了 proxy 实例,并把它进行了返回
  3. reactive 方法:利用proxy 监听targetgetter、setter 行为,以便执行依赖收集和依赖触发

封装 reactive 模块

reactive 模块的代码相对比较多,咱们分块去看。

reactive 方法封装

首先咱们来看reactive 方法的封装,代码如下:

function reactive(target) {
  const proxy = new Proxy(target, {
    get: (target, key, receiver) => {
      // 利用 Reflect 得到返回值,该代码可以简单理解为:target.key
      const res = Reflect.get(target, key, receiver)
      // 收集依赖
      track(target, key)
      return res
    },
    set: (target, key, value, receiver) => {
      // 利用 Reflect.set 设置新值,该代码可以简单理解为:target.key = value
      const result = Reflect.set(target, key, value, receiver)
      // 触发依赖
      trigger(target, key)
      return result
    }
  })
  return proxy
}

在上面的代码中,咱们创建了一个reactive 方法,在该方法中做了如下实现:

  1. 创建了proxy实例,并进行了返回
  2. 监听了gettersetter行为:
    1. getter行为时:触发了track方法,进行依赖收集
    2. setter行为时:触发了trigger方法,进行依赖触发

然后咱们来看下 依赖收集 track 的过程。

依赖收集:track 封装

所谓的依赖收集,说白了就是:把 activeEffect(effect 方法的参数)保存起来。

至于保存的方式在 这里<Vue 3 深入响应式原理 - 聊一聊响应式构建的那些经历> 进行了详细描述。

据此,咱们可以得到如下代码:

/**
 * 收集所有依赖的 WeakMap 实例:
 * 1. `key`:响应性对象
 * 2. `value`:`Map` 对象
 * 		1. `key`:响应性对象的指定属性
 * 		2. `value`:指定对象的指定属性的 执行函数
 */
const targetMap = new WeakMap()

/**
 * 收集依赖
 */
function track(target, key) {
  // 如果当前不存在执行函数,则直接 return
  if (!activeEffect) return
  // 尝试从 targetMap 中,根据 target 获取 map
  let depsMap = targetMap.get(target)
  // 如果获取到的 map 不存在,则生成新的 map 对象,并把该对象赋值给对应的 value
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  // 获取指定 key 的 dep
  let dep = depsMap.get(key)
  // 如果 dep 不存在,则生成一个新的 dep,并放入到 depsMap 中
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  // 把所有的 activeEffect 方法加入到 dep 中
  dep.add(activeEffect)
}

在上面的代码中,我们创建了track函数,利用WeaKMapactiveEffect进行了保存。

依赖触发:trigger 封装

收集依赖的目的是为了:触发依赖

所以下面我们就来看看触发依赖的逻辑。

咱们知道,触发依赖会通过trigger函数进行:

/**
 * 触发依赖
 */
function trigger(target, key) {
  // 依据 target 获取存储的 map 实例
  const depsMap = targetMap.get(target)
  // 如果 map 不存在,则直接 return
  if (!depsMap) {
    return
  }
  // 依据指定的 key,获取 dep 实例
  let dep = depsMap.get(key)
  // dep 不存在则直接 return
  if (!dep) {
    return
  }
  // 触发 dep
  triggerEffects(dep)
}

/**
 * 依次触发 dep 中保存的依赖
 */
function triggerEffects(dep) {
  // 把 dep 构建为一个数组
  const effects = Array.isArray(dep) ? dep : [...dep]
  // 依次触发
  for (const effect of effects) {
    effect.run()
  }
}

在上面的代码中,咱们创建了trigger函数,它会:

  1. WeakMap中,获取保存的activityEffect实例,命名为effect
  2. 然后执行effect.run,本质上是触发了effect 方法的参数

那么至此,咱们就可以在:数据发生变化时,重新触发effect,以达到响应式的目的。

试一下?

好不容易写好了reactive方法,怎么能不试试呢?

const obj = reactive({
  name: '张三'
})

// 调用 effect 方法
effect(() => {
  document.querySelector('#app').innerText = obj.name
})

setTimeout(() => {
  obj.name = '李四'
}, 2000);

创建如上代码,跑一跑吧~~~~~

image.png

ref 模块

reactive 模块是非常好的,但是存在一个问题,那就是:reactive 只能构建复杂数据类型的响应性

出现这个问题的原因本质上是因为:proxy 只能监听复杂数据类型的 getter 和 setter 行为

那简单数据类型咋办呢?

vue 为我们提供了ref 用来解决这个问题。

ref 咋弄的?

ref 方法的实现非常有意思,它利用了getset 标记语法。

咱们知道,reactive 之所以只能实现复杂数据类型响应性,是因为:proxy 无法监听简单数据类型的 getter 和 setter。

那么换句话来说:只要可以监听简单数据类型的 getter 和 setter,那么就可以实现简单数据的响应性。

恰好,getset 标记语法帮我们实现了这个功能。

反推 ref 模块

和实现reactive时一样,想要实现 ref 模块,那么首先得先明确ref函数的用法,然后根据用法反推实现。

根据 refref 使用起来和reactive 差不多:

const name = ref('张三')

console.log(name) // RefImpl 实例

ref 方法接收一个数据(可以是简单数据类型,也可以是复杂数据类型),我们可以把这个数据叫做value,然后得到一个返回值name

打印name (不要 .value 哦~~),可以发现得到的是一个 RefImpl 实例

那么由此可知:vue 内部必然存在一个 RefImpl 类,并且在 ref 中对它进行了实例化并返回。

那么生成RefImpl实例的目的又是什么呢?

结合上一趴所说,RefImpl实例一定是:用来监听 setter 和 getter 的。

想要访问RefImpl上的真实数据,那么必须通过.value进行访问,结合 getset 标记语法的作用,咱们可以大胆猜测下:.value 会不会是一个方法的调用?

当我们执行name.value = xxx 时,本质上是触发了set value(newVal) {} 方法。

当我们执行name.value 时,本质上是触发了get value() {} 方法。

那么我们来总结下反推的结果:

  1. ref 方法:接收任意数据
  2. ref 方法:生成RefImpl 实例,并进行返回
  3. ref 方法:利用 getset 监听getter、setter 行为,以便执行依赖收集和依赖触发

封装 ref 模块

明确好了以上内容之后,我们可以创建如下 ref 代码:

// 2. 先看 ref 方法,再看这里
class RefImpl {
  _value
  dep

  constructor(value) {
    this._value = value
  }

  /**
   * get 语法将对象属性绑定到:查询该属性时,将被调用的函数。
   * 即:xxx.value 时触发该函数
   */
  get value() {
    // 收集依赖
    if (activeEffect) {
      const dep = ref.dep || (ref.dep = new Set())
      dep.add(activeEffect)
    }
    return this._value
  }

  /**
   * set 语法将对象属性绑定到:赋值该属性时,将被调用的函数。
   * 即:xxx.value = xxx 时触发该函数
   */
  set value(newVal) {
    // 更新数据
    this._value = newVal
    // 触发依赖
    if (ref.dep) {
      triggerEffects(ref.dep)
    }
  }
}

/**
 * 1. 先看这里~~~~~~~~~~~~~~~~~~~
 * ref 函数
 * @param value unknown
 */
function ref(value) {
  return new RefImpl(value)
}

在上面的代码中,我们主要做了两块事情:

  1. ref 方法中,返回了RefImpl 的实例
  2. RefImpl 中,通过 getset 监听了 value 方法的gettersetter 行为,以便执行依赖收集和依赖触发

试一下?

创建个测试实例,试一下ref 方法:

const name = ref('张三')

// 调用 effect 方法
effect(() => {
  document.querySelector('#app').innerText = name.value
})

setTimeout(() => {
  name.value = '李四'
}, 2000);

代码成功运行,这可真是~~~~~~

good.gif

总结

这篇博客是咱们Vue 3.2 源码系列的第三趴了。

对了,还记不记得,一开始面试官问的问题?

在 vue 中,通过 ref 声明的响应式数据,为什么需要通过 .value 来访问数据呢?

现在根据咱们所学,你能回答出来了吗?

image.png