前言

在上篇文章中——Vue3响应式原理],我们基本了解了响应式的核心reactiveeffect,话不多说,我们这节课将剩下的内容写完。

其实watchwatchEffect并不在reactivity响应式模块里,而是在runtime-dom模块里,那为啥还要在reactivity这个响应式模块中,来介绍这两个API呢?一是因为这两个API我们在项目中太常见,二才是最主要的,是因为watchwatchEffect都是基于上篇文章说的effect进行了封装,从而得到的。所以说么,effect是最底层的方法,弄懂了上篇文章的内容,那么这篇文章就显得相对好理解很多。

watch的实现

我们先在reactive.ts/shared/src/index.ts中完善两个工具方法,方便我们在实现watch时进行导入调用。

// reactive.ts文件
// 判断传入的值是不是一个响应式的值
export function isReactive(value) {
  return !!(value && value[ReactiveFlags.IS_REACTIVE])
}
// shared/src/index.ts 文件
// 判断传入的值,是不是一个函数
export const isFunction = value => {
  return value && typeof value === 'function'
}

然后在/reactivity/src目录下新建apiWatch.ts文件,来写watch的主逻辑。首先我们简单回顾下Vue3watch的常见用法:

const state = reactive({name: '张三', age: 18})
// 用法1:
watch(() => state.name, (newV, oldV) => {
  console.log(newV, oldV)
})
// 用法2:
watch(state, (newV, oldV) => {
  console.log(newV, oldV)
})

那么在用到watch的时候,第一个参数我们可以传入一个函数(如用法1)来监听某个属性的变化,有朋友可能会问,为啥要写成一个函数,我直接把第一个参数传入state.name不行么?醒醒,快醒醒!在这个案例中state.name就是个定死的值张三,监听常量,肯定是不会发生变化的啊;

同样,第一个参数还可以传入一个对象(如方法2)但是这种有几个问题,一般不推荐,比如当第一个参数传入的是对象,实际上watch监听的是这个对象的引用地址,所以,无法区分newVoldV,引用的地址是一直不变的,所以打印的结果会发现,这俩值是一样的,都是最新的值。还有个小问题就是,虽然你传入参数的是一个对象,但是在watch方法的内部,依旧是遍历了这个对象所有的key,并且进行取值操作(为的是触发依赖收集)。所以会对性能有所损耗,不过有时候为了方便,还是可以这么去干的(反正内部针对这种情况做了处理,代码写的爽就行了,管他呢)。

我们接下来实现watch的逻辑:

// /reactivity/src/apiWatch.ts 文件
import { isReactive } from './reactive'
import { isFunction, isObject } from '@vue/shared'
import { ReactiveEffect } from './effect'
function traverse(value, seen = new Set()) {
  if (!isObject(value)) {
    return value
  }
  if (seen.has(value)) {
    return value
  }
  seen.add(value)
  for (const key in value) {
    if (value.hasOwnProperty(key)) {
      // 就是单纯的取了下值,比如state.name,为了触发reactive对象中属性的getter
      traverse(value[key], seen)
    }
  }
  return value
}
// 1. 首先导出watch方法
export function watch(source, cb, { immediate } = {} as any) {
  // 2. 分两种情况判断,source是一个响应式对象和source是一个函数
  // 这个getter就相当于是effect中的回调函数
  let getter
  if (isReactive(source)) {
    // 3. 如果source是一个响应式对象,应该对source递归进行取值
    getter = () => traverse(source)
  } else if (isFunction(source)) {
    getter = source
  }
  let oldValue
  // 6. 当触发更新的时候,也就是属性改变的时候,才会执行这个job方法
  const job = () => {
    const newValue = effect.run()
    cb(newValue, oldValue)
    oldValue = newValue
  }
  // 4. 当new的时候,就会执行getter方法,开始进行依赖收集,数据变化后,会执行cb的回调方法
  const effect = new ReactiveEffect(getter, job)
  // 需要立即执行,那么就先执行一次任务
  if (immediate) {
    job()
  }
  // 5. 将立即执行的effect.run()的结果作为oldValue
  oldValue = effect.run()
}

首版代码就算是完成了,代码虽然不多,但是为了方便理解,我们还是需要拆分每个步骤,来进行一一讲解。

  • 步骤1:很好理解,导出的watch,放入传参,这里第三个参数options我们只实现immediate的功能;
  • 步骤2:就是上文提到的,对于传入的source,需要进行类型判断,如果是一个函数的话,那就让getter赋值为这个函数;如果是对象的话,那就用函数包一层。
  • 步骤3:但是单独包一层,并不会触发依赖收集,所以就需要对这个响应式对象source进行遍历,然后对每个key进行取值,从而触发依赖收集;代码看上去的效果就是,只是取了下值,实际没有进行其他任何操作。为什么要包装成一个函数呢?别急,看到第4步就明白了。
  • 步骤4:这步是不是非常熟?没错,在上篇写effect原理的时候,我们就是通过 new ReactiveEffect(fn, options.scheduler)进行生成的,所以,此步骤中,我们把getter当成第一个参数进行传参,把job当成第二个参数,也就是当响应式对象的属性发生变化时候,就会主动来调用job方法,如果忘了,可以再去复习下上篇文章。
  • 步骤5:new完后,得到的effect,我们先执行一次effect.run方法,就能拿到最开始的返回值,记为oldValue
  • 步骤6:就是步骤4中需要传入的job方法,当响应式对象的属性,发生变化,才会执行这个方法,我们在其中调用cb,并且传入oldValuenewValue,大功告成。

是不是发现,当我们理解了effect方法原理之后,再去写watch的实现,就变得非常简单了呢?所以说嘛effect是底层方法,很多方法都是基于它进行封装的。

接下来,我们再介绍一个Vue3watch提供的一个功能,所谓新功能,不是无缘无故就出来的,一定是为了解决相关的场景,所以才会提出的新功能,我们改动下index.html中的示例代码,先看看如下场景,该用什么方法来解决:

<script>
    const state = reactive({ name: '张三', age: 18 })
    let timmer = 4000
    function getData(data) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve(data)
        }, timmer -= 1000)
      })
    }
    watch(() => state.age, async (newV, oldV) => {
      const result = await getData(newV)
      console.log(result)
      app.innerHTML = result
    })
    // 我们这里直接改变响应式对象属性,模拟用户输入
    state.age = 20
    state.age = 30
    state.age = 40
</script>

我们来简单说一下上边代码的含义,设想一下页面里有个输入框,每次输入内容,都会发送一个请求,我们这边模拟用户改变了3次值,所以一共发送了3次请求;第一个请求历时4秒钟能拿到返回结果,第二个请求历时3秒能拿到结果,第三个请求历时2秒能拿到结果,那么我们期望的页面显示内容,是以最后一次输入的结果为准,即页面上显示的是state.age = 40的结果。但是根据我们现在的逻辑,会发现,页面上过2秒后确实显示的是state.age = 40的结果,但是又过了1秒钟,state.age = 30这个请求的结果又被显示到页面上,又过了1秒state.age = 20的结果最终显示在了页面上,那显然不合理,我们的输入框中,最后明明是40,但是页面显示的结果却是20的请求结果。

所以我们此时需要来解决这个问题,我们第一反应就是,能不能在每次触发新请求的时候,屏蔽上次请求的结果呢?(注意,请求已经发送了,不能取消),这样,就能保证就算之前的请求,过了很久才拿到返回值,也不会覆盖最新的结果。那我们来在当前代码中,修改下吧!

<script>
const state = reactive({ name: '张三', age: 18 })
let timmer = 4000
// 1. 新建数组,用于存放上一次请求需要的方法
let arr = []
function getData(data) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(data)
    }, timmer -= 1000)
  })
}
watch(() => state.age, async (newV, oldV) => {
  let fn
  // 2. 每次发送请求前,利用闭包,将上次的结果flag改为false,从而屏蔽结果
  while(arr.length){
    fn = arr.shift()
    fn()
  }
  // 3. 新建一个标识,为true才改变app.innerHTML的内容
  let flag = true
  // 4. 将flag = false的函数,存在arr数组中,方便下次请求前进行调用
  arr.push(function(){ flag = false})
  const result = await getData(newV)
  console.log(result)
  flag && (app.innerHTML = result)
})
// 我们这里直接改变响应式对象属性,模拟用户输入
state.age = 20
state.age = 30
state.age = 40
</script>

之后,我们在页面上再次打印结果,发现,页面上始终显示的是40,也就是最后state.age = 40对应的结果。那么,我们通过在业务逻辑中,的一些代码改良,成功的解决了请求结果顺序错乱的问题。那么在Vue3watch中提供了新的参数,可以把一些逻辑放在watch的内部,从而达到和上述代码相同的效果,同样,我们先看用法,进而推导下在watch源码中是如何实现的。

<script>
    const state = reactive({ name: '张三', age: 18 })
    let timmer = 4000
    function getData(data) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve(data)
        }, timmer -= 1000)
      })
    }
    // 第三个参数提供了onCleanup,用户可以传入回调
    watch(() => state.age, async (newV, oldV, onCleanup) => {
      let flag = true
      onCleanup(() => {
        flag = false
      })
      const result = await getData(newV)
      console.log(result)
      flag && (app.innerHTML = result)
    })
    // 我们这里直接改变响应式对象属性,模拟用户输入
    state.age = 20
    state.age = 30
    state.age = 40
</script>

是不是发现,代码精简了很多?我们接下来实现一下吧!

// /reactivity/src/apiWatch.ts 文件
import { isReactive } from './reactive'
import { isFunction, isObject } from '@vue/shared'
import { ReactiveEffect } from './effect'
function traverse(value, seen = new Set()) {
  if (!isObject(value)) {
    return value
  }
  if (seen.has(value)) {
    return value
  }
  seen.add(value)
  for (const key in value) {
    if (value.hasOwnProperty(key)) {
      // 就是单纯的取了下值,比如state.name,为了触发reactive对象中属性的getter
      traverse(value[key], seen)
    }
  }
  return value
}
// 1. 首先导出watch方法
export function watch(source, cb, { immediate } = {} as any) {
  // 2. 分两种情况判断,source是一个响应式对象和source是一个函数
  // 这个getter就相当于是effect中的回调函数
  let getter
  if (isReactive(source)) {
    // 3. 如果source是一个响应式对象,应该对source递归进行取值
    getter = () => traverse(source)
  } else if (isFunction(source)) {
    getter = source
  }
  let oldValue
  // 8. 创建cleanup变量,和onCleanup方法
  let cleanup
  const onCleanup = fn => {
    cleanup = fn
  }
  // 6. 当触发更新的时候,也就是属性改变的时候,才会执行这个job方法
  const job = () => {
    // 9. 当cleanup存在,就调用我们onCleanup中传入的回调方法
    if (cleanup) cleanup()
    const newValue = effect.run()
    // 7. 首先在cb中添加这个onCleanup参数
    cb(newValue, oldValue, onCleanup)
    oldValue = newValue
  }
  // 4. 当new的时候,就会执行getter方法,开始进行依赖收集,数据变化后,会执行cb的回调方法
  const effect = new ReactiveEffect(getter, job)
  // 需要立即执行,那么就先执行一次任务
  if (immediate) {
    job()
  }
  // 5. 将立即执行的effect.run()的结果作为oldValue
  oldValue = effect.run()
}

看7、8、9三个步骤,其实就是类似于刚才我们写在外边的逻辑,只不过我们现在把这些逻辑写在了watch内部,多读几遍,非常巧妙。

至此为止,关于watch的核心逻辑,我们就已经写完了,是不是看起来,没有想象中的那么难呢?接下来我们还要实现下watchEffect,莫慌,只需要改动几行代码,便可轻松实现。首先,我们将刚才导出的watch改个名字换为doWatch,变成一个通用函数,因为前文说过,watchwatchEffect都是基于effect方法进行封装的,所以二者的逻辑可以说是非常相似的,所以我们没必要再写一遍,那么只要调用通用函数,根据传参不同,即可快速实现:

watchEffect的实现

// /reactivity/src/apiWatch.ts 文件
import { isReactive } from './reactive'
import { isFunction, isObject } from '@vue/shared'
import { ReactiveEffect } from './effect'
function traverse(value, seen = new Set()) {
  if (!isObject(value)) {
    return value
  }
  if (seen.has(value)) {
    return value
  }
  seen.add(value)
  for (const key in value) {
    if (value.hasOwnProperty(key)) {
      // 就是单纯的取了下值,比如state.name,为了触发reactive对象中属性的getter
      traverse(value[key], seen)
    }
  }
  return value
}
// 1. 首先导出doWatch方法
export function doWatch(source, cb, { immediate } = {} as any) {
  // 2. 分两种情况判断,source是一个响应式对象和source是一个函数
  // 这个getter就相当于是effect中的回调函数
  let getter
  if (isReactive(source)) {
    // 3. 如果source是一个响应式对象,应该对source递归进行取值
    getter = () => traverse(source)
  } else if (isFunction(source)) {
    getter = source
  }
  let oldValue
  // 8. 创建cleanup变量,和onCleanup方法
  let cleanup
  const onCleanup = fn => {
    cleanup = fn
  }
  // 6. 当触发更新的时候,也就是属性改变的时候,才会执行这个job方法
  const job = () => {
    // 10. 根据传参不同,判断如果有回调函数的话,那么就是watch,如果没有cb那就是watchEffect
    if (cb) {
      // 9. 当cleanup存在,就调用我们onCleanup中传入的回调方法
      if (cleanup) cleanup()
      const newValue = effect.run()
      // 7. 首先在cb中添加这个onCleanup参数
      cb(newValue, oldValue, onCleanup)
      oldValue = newValue
    }else {
      effect.run()
    }
  }
  // 4. 当new的时候,就会执行getter方法,开始进行依赖收集,数据变化后,会执行cb的回调方法
  const effect = new ReactiveEffect(getter, job)
  // 需要立即执行,那么就先执行一次任务
  if (immediate) {
    job()
  }
  // 5. 将立即执行的effect.run()的结果作为oldValue
  oldValue = effect.run()
}
// 导出watch和watchEffect方法
export function watch(source, cb, options) {
  return doWatch(source, cb, options)
}
export function watchEffect(source, options) {
  return doWatch(source, null, options)
}

改动点仅仅是第10步骤,加了一个判断,那么这样doWatch就是一个通用函数,只需要根据传参不同,在外边再包一层,就是我们平时中项目常用的watchwatchEffect了!怎样,是不是很容易?那我们继续往下看吧

computed的实现

我们还是简单用一下computed,看看有哪几种用法:

<script>
const state = reactive({ name: '张三', age: 18 })
// 1. 可以传入对象,里边自定义get和set的逻辑
const info = computed({
  get() {
    console.log('我触发啦!')
    return state.name + state.age
  }
  set(val){
    console.log(val)
  }
})
// 虽然取了2次值,但是只会打印一次'我触发了',因为computed有缓存的效果,依赖的值不变化,就不会多次触发get,要通过.value来取值
console.log(info.value)
console.log(info.value)
// 2. 传入函数,默认就相当于返回了一个get,取值要通过.value来取
const info = computed(() => {
  return state.name + state.age
})
</script>

回顾了下基本用法后,我们还是在reactivity/src目录下,新建computed.ts文件,然后在reactivity/src/index.tsexport * from '.computed',进行导出。接下来,我们便可以在computed.ts中来实现computed的逻辑了。

// reactivity/src/computed.ts 文件
import { isFunction } from '@vue/shared'
import { ReactiveEffect } from './effect'
class ComputedRefImpl {
  public effect
  public _value
  public __v_isRef = true
  constructor(getter, public setter) {
    // 3. 还是通过new ReactiveEffect方法
    this.effect = new ReactiveEffect(getter, () => {
    })
  }
  // 4. 给value属性,创建get和set,再取值和赋值的时候,触发相应逻辑
  get value() {
    this._value = this.effect.run()
    return this._value
  }
  set value(newV){
    this.setter(newValue)
  }
}
export function computed(getterOrOptions) {
  // 1. 对传入的参数进行分类处理,对函数和对象进行不同的处理。
  const isGetter = isFunction(getterOrOptions)
  let getter, setter
  if (isGetter) {
    getter = isGetter
    setter = () => ({})
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  // 2. 创建计算属性,返回一个响应式对象,访问的时候,通过.value的方式来访问
  return new ComputedRefImpl(getter, setter)
}

我们来按步骤一一讲解下

  • 步骤1:没错,非常熟悉的套路,和watch处理参数的方式几乎是一模一样;
  • 步骤2:返回一个响应式对象,获取computed的值,需要通过.value的方法;
  • 步骤3:依旧是通过new ReactiveEffect,传入getter进行依赖收集,生成effect实例对象;
  • 步骤4:因为computed返回的对象,是通过.value来访问的,所以要创建get set,执行相应逻辑;

至此,我们的computed就可以简单的用起来了,我们先运行一下,其他的问题,我们后边再来解决,我们改变下index.html的代码,查看打印结果:

<script>
const state = reactive({ name: '张三', age: 18 })
const info = computed({
  get() {
    console.log('我调用啦!')
    return state.name + state.age
  },
  set (val) {
    console.log(val) 
  }
})
// 对info.value取两次值,查看结果
console.log(info.value)
console.log(info.value)
</script>

此时,我们会发现,控制台中打印的结果是:

复制代码我调用啦!
张三18
我调用啦!
张三18

这和我们平时用的computed好像哪里有些不同?没错,info中依赖的响应式对象state中的属性,并没有变化,但是却触发了两次computed,并没有实现缓存的效果,那么我们接下来就来实现一下吧!

// reactivity/src/computed.ts 文件
import { isFunction } from '@vue/shared'
import { ReactiveEffect } from './effect'
class ComputedRefImpl {
  public effect
  public _value
  // 5. 创建一个_dirty变量,为true的时候就代表可以重新执行取值操作
  public _dirty = true
  constructor(getter, public setter) {
    // 3. 还是通过new ReactiveEffect方法
    this.effect = new ReactiveEffect(getter, () => {
      // 7. 依赖的值变了,判断_dirty是否为false,为false的话,就把_dirty改为true 
      this_dirty = true
    })
  }
  // 4. 给value属性,创建get和set,再取值和赋值的时候,触发相应逻辑
  get value() {
    // 6. 如果_dirty是true的话,才会重新执行`run`方法,重新取值,否则,直接返回原值
    if (this._dirty) {
      this._dirty = false
      this._value = this.effect.run()
    }
    return this._value
  }
  set value(newV){
    this.setter(newValue)
  }
}
export function computed(getterOrOptions) {
  // 1. 对传入的参数进行分类处理,对函数和对象进行不同的处理。
  const isGetter = isFunction(getterOrOptions)
  let getter, setter
  if (isGetter) {
    getter = isGetter
    setter = () => ({})
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  // 2. 创建计算属性,返回一个响应式对象,访问的时候,通过.value的方式来访问
  return new ComputedRefImpl(getter, setter)
}

我们看捕捉5~7,是不是通过一个_dirty属性,就实现了如果依赖不发生变化,那么就不会多次触发computed对象中的get了呢?还是那句话,computed的实现,依旧是依赖于effect,所以理解effect才是重中之重。

看起来是没啥问题了,但是在有一种场景下,存在着问题,我们改一下index.html代码,来看一下:

<script>
const state = reactive({ name: '张三', age: 18 })
const info = computed({
  get() {
    console.log('我调用啦!')
    return state.name + state.age
  },
  set (val) {
    console.log(val) 
  }
})
effect(() => {
  app.innerHTML = info.value
})
console.log(info.value)
setTimeout(() => {
  state.age = 22
  console.log(info.value)
}, 2000)
</script>

没错,就是当我们在effect方法中,使用了computed计算属性,那么页面就不会更新,因为effect中并没有对计算属性进行依赖收集,而computed计算属性中也没有对应的effect方法。那怎么实现呢?我们想一想,是不是很类似于之前写的依赖收集track和触发更新trigger方法呢?没错,我们只需要在computed中增加进行依赖收集和触发更新的逻辑就好了,而这两个逻辑,我们之前也写过,所以可以把通用的代码直接copy过来:

// reactivity/src/computed.ts 文件
import { isFunction } from '@vue/shared'
import { ReactiveEffect, activeEffect, trackEffects, triggerEffects } from './effect'
class ComputedRefImpl {
  public effect
  public _value
  public dep = new Set()
  // 5. 创建一个_dirty变量,为true的时候就代表可以重新执行取值操作
  public _dirty = true
  constructor(getter, public setter) {
    // 3. 还是通过new ReactiveEffect方法
    this.effect = new ReactiveEffect(getter, () => {
      // 7. 依赖的值变了,判断_dirty是否为false,为false的话,就把_dirty改为true 
      this_dirty = true
      // 9. 触发更新
      triggerEffects(this.dep)
    })
  }
  // 4. 给value属性,创建get和set,再取值和赋值的时候,触发相应逻辑
  get value() {
    // 8. 如果计算属性在effect中使用的话,那也要做依赖收集
    if (activeEffect) {
      trackEffects(this.dep)
    }
    // 6. 如果_dirty是true的话,才会重新执行`run`方法,重新取值,否则,直接返回原值
    if (this._dirty) {
      this._dirty = false
      this._value = this.effect.run()
    }
    return this._value
  }
  set value(newV){
    this.setter(newValue)
  }
}
export function computed(getterOrOptions) {
  // 1. 对传入的参数进行分类处理,对函数和对象进行不同的处理。
  const isGetter = isFunction(getterOrOptions)
  let getter, setter
  if (isGetter) {
    getter = isGetter
    setter = () => ({})
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  // 2. 创建计算属性,返回一个响应式对象,访问的时候,通过.value的方式来访问
  return new ComputedRefImpl(getter, setter)
}
// reactivity/src/effect.ts 文件
export function triggerEffects(dep) {
  const effects = [...dep]
  effects && effects.forEach(effect => {
    // 正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) {
      // 如果用户传入了scheduler,那么就执行用户自定义逻辑,否则还是执行run逻辑
      if(effect.scheduler) {
        effect.scheduler()
      }else {
        effect.run()
      }
    }
  })
}
// computed中收集effect的依赖
export function trackEffects(dep) {
  let shouldTrack = !dep.has(activeEffect)
  if(shouldTrack) {
    // 依赖和effect多对多关系保存
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

我们看步骤8,9,依赖收集和触发更新的方法,我们依旧写在effect.ts文件中(可以对比下triggertrack方法,逻辑几乎一模一样)。我们再运行刚才index.html中的代码,发现页面成功的更新了,那么至此,computed的核心逻辑我们就写完啦!

ref的实现

我们在reactivity/src目录下创建ref.ts文件

import { isObject } from '@vue/shared'
import { activeEffect, trackEffects, triggerEffects } from './effect'
import { reactive } from './reactive'
function toReactive(value) {
  return isObject(value) ? reactive(value) : value
}
class RefImpl {
  public _value
  public dep = new Set()
  public __v_isRef = true
  constructor(public rawValue) {
    // 1. 判断传入的值,如果是对象类型,那么就将其包装成响应式对象
    this._value = toReactive(rawValue)
  }
  get value () {
    if(activeEffect) {
      // 2. 进行依赖收集
      trackEffects(this.dep)
    }
    return this._value
  }
  set value (newVal) {
    if(newVal !== this.rawValue) {
      this.rawValue = newVal
      this._value = toReactive(newVal)
      // 3. 进行触发更新
      triggerEffects(this.dep)
    }
  }
}

有了前边的基础,写起ref来,就显得非常得心应手,核心其实就这几行代码,通过注释,我们就不难发现,如果传入的是对象,那么就是利用了之前写的reactive进行包装处理,如果传入了其他类型的数据,那么就和computed中的方法一模一样,需要进行依赖收集和触发更新。

实现toReftoRefs

这两个方法,其实我们开发中,用的会比较少,所以还是先简单介绍下用法,然后再思考下如何实现,最后再来写一下它们的原理:

<script>
const state = reactive({name: '张三'}))
// 单独把name取出来
let name = state.name
effect(() => {
  app.innerHTML = name
})
setTimeout(() => {
  state.name = '李四'
}, 1000)
</script>

这是上边的代码可以看到,当我们将let name = state.name单独取出来之后,再修改state.name的值之后,name的值就不会再发生变化了,页面上的名字也不会随之发生变化,也就是所谓的丢失响应式,那么利用toRef就可以解决这种问题:

<script>
const state = reactive({name: '张三'}))
// 单独把name取出来
let name = toRef(state, 'name')
effect(() => {
  app.innerHTML = name.value
})
setTimeout(() => {
  state.name = '李四'
}, 1000)
</script>

我们来思考一下如何实现呢?为了不丢失响应式,所以就需要联系,那么肯定就是在namestate.name之间存在某种联系,当改变state.name值的时候,从而能使得name同步进行变动。既然这样,那不就可以做一层代理,当访问和修改name的时候,实际是去访问和修改state.name的值么?思路有了,我们便可以通过代码来实现:

// reactivity/src/ref.ts
import { isObject } from '@vue/shared'
import { activeEffect, trackEffects, triggerEffects } from './effect'
import { reactive } from './reactive'
function toReactive(value) {
  return isObject(value) ? reactive(value) : value
}
class RefImpl {
  public _value
  public dep = new Set()
  public __v_isRef = true
  constructor(public rawValue) {
    // 1. 判断传入的值,如果是对象类型,那么就将其包装成响应式对象
    this._value = toReactive(rawValue)
  }
  get value () {
    if(activeEffect) {
      // 2. 进行依赖收集
      trackEffects(this.dep)
    }
    return this._value
  }
  set value (newVal) {
    if(newVal !== this.rawValue) {
      this.rawValue = newVal
      this._value = toReactive(newVal)
      // 3. 进行触发更新
      triggerEffects(this.dep)
    }
  }
}
// 导出ref
export function ref(value) {
  return new RefImpl(value)
}
class ObjectRefImpl {
  public __v_isRef = true
  constructor(public _object, public _key) {
  }
  get value() {
    return this._object[this._key]
  }
  set value(newVal) {
    this._object[this._key] = newVal
  }
}
// 导出toRef
export function toRef(object, key) {
  return new ObjectRefImpl(object, key)
}
// 导出toRefs
export function toRefs(object) {
  // 如果传入数组,就创建一个空数组,如果是对象,那就创建一个新对象
  const ret = isArray(object) ? new Array(object.length) : {}
  for (const key in object) {
    ret[key] = toRef(object, key)
  }
  return ret
}

代码非常简单,就是进行了一次代理转化,而我们项目中常用的是toRefs,也是遍历每个属性,并借助toRef来实现的。

proxyRef的实现

这个方法可能听起来很陌生,但是只要写过Vue3的项目,就一定会用到这个方法,举个例子就明白了:

<template>
  <div>{{ name }}</div>
</template>
<script>
let name = ref('张三')
</script>

当我们在代码中,用ref声明了一个字符串类型的数据后,如果在代码中使用这个值,是不是需要通过name.value的方式来调用呢?但是当我们在模板中使用的时候,却可以直接来用这个name而并不需要再.value来取值,诶,这就是Vue3在模板编译的时候,内部调用了这个方法,帮助我们对ref声明变量,进行自动脱钩,那么细心的朋友也发现了,不管是在computed,还是ref代码中,都有一样public __v_isRef = true这个标识,没错,接下来就要用到这个标识了,这个标识就是为了在自动脱钩的时候,来进行分辨的。那么我们来实现这个proxyRef方法吧~

// reactivity/src/ref.ts
import { isObject } from '@vue/shared'
import { activeEffect, trackEffects, triggerEffects } from './effect'
import { reactive } from './reactive'
function toReactive(value) {
  return isObject(value) ? reactive(value) : value
}
class RefImpl {
  public _value
  public dep = new Set()
  public __v_isRef = true
  constructor(public rawValue) {
    // 1. 判断传入的值,如果是对象类型,那么就将其包装成响应式对象
    this._value = toReactive(rawValue)
  }
  get value () {
    if(activeEffect) {
      // 2. 进行依赖收集
      trackEffects(this.dep)
    }
    return this._value
  }
  set value (newVal) {
    if(newVal !== this.rawValue) {
      this.rawValue = newVal
      this._value = toReactive(newVal)
      // 3. 进行触发更新
      triggerEffects(this.dep)
    }
  }
}
// 导出ref
export function ref(value) {
  return new RefImpl(value)
}
class ObjectRefImpl {
  public __v_isRef = true
  constructor(public _object, public _key) {
  }
  get value() {
    return this._object[this._key]
  }
  set value(newVal) {
    this._object[this._key] = newVal
  }
}
// 导出toRef
export function toRef(object, key) {
  return new ObjectRefImpl(object, key)
}
// 导出toRefs
export function toRefs(object) {
  // 如果传入数组,就创建一个空数组,如果是对象,那就创建一个新对象
  const ret = isArray(object) ? new Array(object.length) : {}
  for (const key in object) {
    ret[key] = toRef(object, key)
  }
  return ret
}
export function isRef(value) {
  return !!(value && value.__v_isRef === true)
}
// 如果是ref则取ref.value
export function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, {
    get(target, key, receiver) {
      let v = Reflect.get(target, key, receiver)
      return isRef(v) ? v.value : v
    },
    set(target, key, value, receiver) {
      const oldValue = target[key]
      // 老的值如果是个ref,那么实际上赋值的时候应该给他的.value进行赋值
      if(oldValue.__v_isRef) {
        oldValue.value = value
        return true
      }else {
        // 其他情况,正常赋值
        return Reflect.set(target, key, value, receiver)
      }
    }
  })
}

这个proxyRef方法,在后续文章中,会用到,这里只是提前介绍下这个方法。

结语

那么至此,我们reactivity响应式模块中的一些个核心的方法,基本上已经实现了最核心的逻辑,这样,我们再去阅读源码的时候,就不会变得一头雾水了,好好再熟悉一遍reactivity模块的方法吧,然后再去看下Vue3源码中的reactivity逻辑;我们接下来会继续分析Vue3,其他模块的核心代码。

作者:柠檬soda水
链接:juejin.cn/post/720326…
来源:稀土
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。