手动实现Vue3 & 原理解析(二) —— Refs相关 & Computed 实现

前言

本篇解析参阅 vue3源码、崔大的mini-vue、霍春阳大佬的《Vuejs设计与实现》尽可能记录我的Vue3源码阅读学习过程。我会结合自己的思考,提出问题,找到答案,附在每一篇的底部。希望大家能在我的文章中也能一起学习,一起进步,有 get 到东西的可以给作者一个小小的赞作为鼓励吗?谢谢大家!

手写简易vue3 Refs相关 & Computed相关 实现

Refs

我们都知道 Proxy 是使用来代理对象的,那对于原始值比如 (Boolean, Number, BigInt, String, Symbol, undefined 和 null 等类型的值) 我们没有办法将它变成响应式,那么这时候就有了 ref

注意:这里的 ref 和我们在 vue2 里常用的 ref 是有区别的

初步实现

对于 ref 的定义如下:

我们需要有一个 ref方法 ,可以接收一个值,这个值

  1. 是对象,要用 reactive 将它变成响应式对象
  2. 是原始值,那么返回一个响应式的 ref对象

这个 ref对象 有 value 属性,也就是说通过 ref.value 能拿到对应的值

我们来看测试用例:

it('happy path', () => {
  // 这里用 const 是因为他是对象
  // 创建一个 ref 对象 value 是 1
  const a = ref(1)
  // 访问 a.value 他应该得到 1
  expect(a.value).toBe(1)
});
复制代码

条件应该是非常明确的了,我们可以直接创建一个 Ref 实例:

class RefImpl {
  private _value: any
  constructor(value) {
    // 如果 value 是对象 需要处理成 reactive
    this._value = convert(value)
  }
  // 访问 value 属性就直接返回这个 _value
  get value() {
    return this._value
  }
}

// 判断新值是不是对象 是的话要 reactive 一下,否则直接给
function convert(value) {
  // isObject 其实就是 return value !== null && typeof value === 'object'
  return isObject(value) ? reactive(value) : value
}

export function ref(value) {
  // 调用 ref 方法我们直接生成一个 实例
  return new RefImpl(value)
}
复制代码

经过上边的实现以后我们只是实现了一个 .value 可以返回值的 ref对象,但是这时候还不是响应式的

ref 对象实现响应式

接下来我们要实现 ref对象 响应式的功能,响应式肯定是我们触发 .value 的一个修改之后会有其他的影响

我们可以简单通过以下测试用例来具体化我们要实现的功能:

it('should be reactive', () => {
  // 创建一个 ref 对象 value 是 1
  const a = ref(1)
  let dummy
  let calls = 0
  // 创建一个副作用函数,他会自己执行一次,这时候 dummy 应该等于 1,calls由于函数被调用也会 + 1
  effect(() => {
    calls++
    dummy = a.value
  })
  expect(calls).toBe(1)
  expect(dummy).toBe(1)
  // 到这里 我们要操作 a.value 也即是触发 set value
  a.value = 2
  // 那 副作用函数就应该被 trigger 触发,两个变量都会更新
  expect(calls).toBe(2)
  expect(dummy).toBe(2)
  // 优化:赋值和之前的一样 不应该触发响应式
  a.value = 2
  expect(calls).toBe(2)
  expect(dummy).toBe(2)
});
复制代码

我们总结一下通过上面的测试用例我们需要注意哪些点?

  1. a.value = 2 应该触发 ref实例 的 set value
  2. set value 要检查是不是一个新的值
  3. set value 需要触发 trigger 来让副作用函数执行
  4. 有 trigger 那就肯定需要 track 来收集依赖

那么我们先对原有的 track 和 trigger 来针对 ref 做一下处理拿到以下这两个函数:

export function trackEffects(dep) {
  // 已经在 dep 中就不用 add 了
  if (dep.has(activeEffect)) return
  dep.add(activeEffect) // 把对应的 effect 实例加入 set 里
  activeEffect.deps.push(dep)
}
export function triggerEffects(dep) {
  for (const effect of dep) {
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect.run()
    }
  }
}
复制代码

细心的你可以发现这两个方法中的代码 其实就是原有 tarck原有trigger 中的代码,一模一样没有任何变化,我们只是将这两段代码单独抽离出来复用。

我们两个新的方法只接收一个依赖 dep (set类型)

接下来我们修改刚才创建的 class RefImp,新增一个 trackRefValue 方法:

class RefImpl {
  // 保留原有的 value,需要用来比较 set 的新的值,因为_value有可能是响应式的对象所以不能用 _value 来比较
  private _rawValue: any
  private _value: any
  // 在 track 中收集依赖,在 trigger 中触发依赖
  public dep
  constructor(value) {
    this._rawValue = value
    // 如果 value 是对象 需要处理成 reactive
    this._value = convert(value)
    // 初始化 Set
    this.dep = new Set()
  }
  get value() {
    // 去收集依赖,但是在那之前要判断是否有 effect
    trackRefValue(this)
    return this._value
  }
  set value(newValue) {
    // 语义化 Obejct.js  即没有变化 直接 return
    if (!hasChanged(this._rawValue, newValue)) return
    // 一定是先修改 value 再去 调用 trigger 才能保证_value准确
    this._rawValue = newValue
    this._value = convert(newValue)
    triggerEffects(this.dep)
  }
}
// ref get 依赖收集
function trackRefValue(ref) {
  // isTraking 其实就是 shouldTrack && activeEffect !== undefined
  if (isTracking()) trackEffects(ref.dep)
}
复制代码

通过上面的一个实现,我们也顺带实现了对于对象的响应式,我们已经可以直接通过以下的测试用例了

it('should make nested properties reactive', () => {
  const a = ref({
    count: 1
  })
  let dummy
  // 调用 副作用函数 给 dummy 赋值 注意是 a.value.count
  effect(() => {
    dummy = a.value.count
  })
  expect(dummy).toBe(1)
  // 修改 a.value.count 也能触发响应式,因为这时候的 this._value 实际是通过 reactive() 创建的响应式对象
  a.value.count = 2
  expect(dummy).toBe(2)
});
复制代码

isRef 和 unRef(脱ref)

之前有 isReactive 、 isReadonly ,那现在要有 isRef 也不是很过分嘛!

isRef 的定义:判断当前的值是不是一个 ref 对象

实现方法非常简单:

class RefImpl {
  // ...
  // 定义一个 __v_isRef 属性即可
  public __v_isRef = true
  // constructor() ....
}

// 判断是不是一个 ref
export function isRef(ref) {
  // 如果传入的不是一个 ref对象 那他是没有__v_isRef属性的,那就是 undefined ,所以需要 !! 将它转成一个 boolean 类型
  return !!ref.__v_isRef
}
复制代码

unRef:也是我们常说的 脱ref,他的一个作用实际上就是当我们传入一个 ref对象 的时候直接返回他的value,如果不是 ref对象 就直接返回传入的值。

unRef(脱ref)的应用场景是什么?为什么设计这个东西?

// 是 ref 返回 ref.value 否则 直接返回
export function unRef(ref) {
  return isRef(ref) ? ref.value : ref
}
复制代码

可以看到这个 unRef 的实现实际上是非常的简单,就是利用上边热乎的 isRef 来实现的。

ProxyRefs (自动脱ref)

我们实现完了以上的内容后,我们发现每次都需要 .value 来拿到对应的值,真的很麻烦很麻烦,不可能我写 template 的时候也让我 .value 吧!!也太坑啦!!!然后呢我们就有了 ProxyRefs

我们先通过测试用例来看一下我们需要实现什么:

it('proxyRefs', () => {
  // 创建一个用户 age字段是 ref对象
  const user = {
    age: ref(18),
    name: "Ben"
  }
  // 通过一个 proxyRefs 方法创建 proxyUser
  const proxyUser = proxyRefs(user)
  // 不影响 user.age.value 的使用
  expect(user.age.value).toBe(18)
  // proxyUser 可以直接 .age 拿到数值而不用 .value 了
  expect(proxyUser.age).toBe(18)
  expect(proxyUser.name).toBe("Ben")
  // 设置 proxyUser.age 的值变成为 非ref的原始值
  proxyUser.age = 20
  // user 的 age 仍然是ref对象,能够从 .value 获取到更新
  expect(user.age.value).toBe(20)
  // 依旧能正常访问更新后的 age
  expect(proxyUser.age).toBe(20)
  // 又给 proxyUser.age 设置成一个 ref对象
  proxyUser.age = ref(25)
  // user 的 age 仍然是ref对象,能够从 .value 获取到更新
  expect(user.age.value).toBe(25)
  // 依旧能正常访问更新后的 age
  expect(proxyUser.age).toBe(25)
});
复制代码

通过上面的测试用例,我们不难发现,通过 ProxyRefs方法 获取到的对象不再需要通过 .value 来获取值,且修改对象的 ref对象 属性的类型也不会有影响。

我们来写一下 ProxyRefs:

export function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, {
    // 触发 get 的时候我们帮它 脱ref
    get(target, key) {
      return unRef(Reflect.get(target, key))
    },
    set(target, key, newValue) {
      // 如果当前已经是 ref 而传入的 值不是 ref,那么要给 .value 赋值
      if (isRef(target[key]) && !isRef(newValue)) {
        return target[key].value = newValue
      } else {
        // 否则直接设置成 newValue即可
        return Reflect.set(target, key, newValue)
      }
    }
  })
}
复制代码

实现其实也是借助我们已经实现了的 unRef 来做脱ref的功能,要注意的是 set 可能有些绕,大家结合测试用例消化一下吧

toRefs

思来想去,我们还想实现一个 传入对象所有的属性都变成 ref对象,我们该怎么实现呢??

其实很简单,就是遍历对象的 key 然后挨个 ref 即可:

export function toRefs(obj) {
  const ret = {}
  // 用 for...in 遍历对象
  for (const key of obj) {
    ret[key] = ref(obj, key)
  }
  return ret
}
复制代码

这样就可以啦!!是不是很简单呢!!!

Computed

接下来到了我们比较好奇的 computed 的实现了!!我们知道 computed 他是具有缓存功能的,且他应该是懒执行的。

我们通过测试用例来看看我们需要做什么吧:

it('should compute lazily', () => {
  // lazy 懒执行 (不调用 cValue.value 我就不触发)
  const value = reactive({
    foo: 1
  })
  const getter = jest.fn(() => {
    return value.foo
  })
  // 创建 computed实例
  const cValue = computed(getter)
  // 懒执行 getter不应该被调用
  expect(getter).not.toHaveBeenCalled()
  // 访问 cValue.value 了要触发一次了
  expect(cValue.value).toBe(1)
  expect(getter).toHaveBeenCalledTimes(1)
  // 访问这个属性不应该再次触发 getter 缓存!!
  cValue.value
  expect(getter).toHaveBeenCalledTimes(1)
  // 修改属性 需要触发
  value.foo = 2
  expect(getter).toHaveBeenCalledTimes(1)
  // 访问这个属性不应该再次触发 getter 还是缓存!!
  cValue.value
  expect(getter).toHaveBeenCalledTimes(2)
});
复制代码

我们看到这个测试用例就已经很好的诠释了 computed 的两大特点:

  1. 懒执行
  2. 缓存

接下来我们来实现 computed :

class ComputedRefImpl {
  private _getter: any
  // 缓存变量 如果是 true 的时候说明有修改 需要更新 value
  private _dirty: boolean = true 
  private _value: any
  // 保存 ReactiveEffect 实例
  private _effect: any
  constructor(getter) {
    this._getter = getter
    // 利用第二个参数 scheduler 的特性,执行一次 getter 后触发 trigger 的时候只执行 scheduler
    this._effect = new ReactiveEffect(getter, () => {
      // 当 dirty 是 false 的时候 改为 true 即表示已更新过了
      if (!this._dirty) this._dirty = true
    })
  }
  get value() {
    // dirty 不是true 说明value没有改变 直接返回value
    if (this._dirty) {
      this._dirty = false
      // effect.run() 实际就是触发了 传入的 getter
      this._value = this._effect.run()
    }
    return this._value
  }
}
// 返回一个 ComputedRefImpl 实例
export function computed(getter) {
  return new ComputedRefImpl(getter)
}
复制代码

这样我们的 computed 就实现完成了,要注意两个细节:

  1. 我们是通过 dirty 这个 flag 来判断数据是否被更新了
  2. effect 的 scheduler 参数这时候就显得特别妙了,也就是说 computed 也是 scheduler 的重点应用场景

问题:Vue2 中的 ref 和 Vue3 中的 ref 是同一个东西吗?有什么区别?

实际上在 Vue2 中的 ref 是一个 attribute(属性),它的作用就是用来标记一个元素/子组件,让你可以在父组件中使用 $refs 来找到这个元素/组件,那么他是这么使用的:

<!-- `vm.$refs.p` 你就会得到 p 这个dom -->
<p ref="p">hello</p>

<!-- `vm.$refs.child` 你就会得到 chld-component 的实例 -->
<child-component ref="child"></child-component>
复制代码

而到了 Vue3 我们依然能够这样使用 ref。(来作为一个属性,用 $refs 拿到引用)

但 Vue3 还有一个 ref。这个 ref 实际上是由一个叫做 Refs 的响应式 api 携带的方法,通过 ref(xxx) 来让 xxx(原始值) 变成一个 ref对象具备响应式的功能。(如果 xxx 是一个对象,那么还是会调用 reactive 来变成一个代理对象)

这个 ref对象 仅有一个 .value property

我们是这样使用的:

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

count.value++
console.log(count.value) // 1
复制代码

问题:unRef(脱ref)的应用场景是什么?为什么设计这个东西?自动脱ref又是啥?

因为 ref 对象只能通过 ref.value 来访问对应的值,这会给我们带来非常严重的心智负担

我们使用 reactive 生成的响应式对象的时候:

const obj = reactive({ foo: 1, bar: 2 })
obj.foo // 1
obj.bar // 2
复制代码

而当我们使用 ref 生成的响应式数据的时候:

const a = ref(1)
a.value // 1

const b = {...toRefs({ foo: 1, bar: 2 })}
b.value.foo // 1
b.value.bar // 2
复制代码

那一比较你就很明显感觉到有一股很沉重的压力啊,什么?压力还不够大??

那。。。

<!-- 正常的 -->
<p>{{ foo }} / {{ bar }}</p>
<!-- ref -->
<p>{{ foo.value }} / {{ bar.value }}</p>
复制代码

现在呢?!!

所以说脱ref的存在就是为了降低用户使用的一个心智负担。

这也是为什么有了 自动脱ref

有了自动脱ref的能力后,用户在模板中使用响应式数据的时候,就不需要关系一个值是否是 ref 了。

猜你喜欢

转载自juejin.im/post/7085393997342081055