Vue3源码解读-ref原始值响应式原理


💡 [本系列Vue3源码解读文章基于3.3.4版本](https://github.com/vuejs/core/tree/v3.3.4) 欢迎关注公众号:《前端Talkking》

1、前言

原始值指的是Boolean、Number、BigInt、String、undefined和null等类型的值。在JavaScript中,原始是按值传递的,而非按引用传递,这意味着,如果一个函数接受原始值作为参数,那么实参和形参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。另外,JavaScript中的Proxy无法提供对原始值的代理,因此,想要将原始值变成响应式数据,旧必须要对其做一层包装,也就是接下来我们要介绍的ref。

2、源码实现

2.1 ref函数

既然Proxy只能代理对象,无法代理原始数据类型,那么我们将原始数据类型的数据包装一层,不就可以用Proxy代理的吗?我们来看看源码实现:

ref源码实现

typescript 复制代码
export function ref(value?: unknown) {
  return createRef(value, false)
}

根据上面的源码,ref接收了一个可选的参数,这个参数就是要变成响应式数据的原始数据。它底层调用了 createRef函数来创建 ref对象。

2.2 createRef函数实现

createRef函数源码实现

typescript 复制代码
function createRef(rawValue: unknown, shallow: boolean) {
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

从上面的源码得知,首先通过 isRef方法判断传入的值是否是一个 ref对象,如果是,则返回原始值,否则使用 RefImpl类创建一个 ref对象。

2.3 RefImpl类实现

RefImpl类实现

typescript 复制代码
class RefImpl<T> {
  private _value: T
  private _rawValue: T
  // 依赖收集
  public dep?: Dep = undefined
  // ref标识
  public readonly __v_isRef = true

  constructor(
    value: T,
    public readonly __v_isShallow: boolean
  ) {
    // 保存原始值到_rawValue
    this._rawValue = __v_isShallow ? value : toRaw(value)
    // 如果是对象,使用reactive将对象转为响应式的,因此将一个对象传入ref,实际上也是调用了reactive
    this._value = __v_isShallow ? value : toReactive(value)
  }

  get value() {
    // 取值的时候收集依赖
    trackRefValue(this)
    return this._value
  }

  set value(newVal) {
    const useDirectValue =
      this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)
    newVal = useDirectValue ? newVal : toRaw(newVal)
    // 如果值改变,才会触发依赖
    if (hasChanged(newVal, this._rawValue)) {
      // 更新值
      this._rawValue = newVal
      // 判断是否是对象,进行赋值
      this._value = useDirectValue ? newVal : toReactive(newVal)
      // 派发通知
      triggerRefValue(this, newVal)
    }
  }
}

上面的代码拆解如下:

  1. 定义两个私有变量 :_value_rawValue,其中, _value用来存储将原始值转换为响应式数据的值,_rawValue用来存储原始值;
  2. 定义两个公共变量:dep__v_isRef,其中,dep是Set类型,用来收集副作用函数,__v_isRef是一个只读对象,用来标识这个对象是否是 ref对象;
  3. 初始化的时候,在构造函数中初始化 value_rawValue值;
  4. getter函数中,使用 trackRefValue函数收集当前 ref对象的依赖,并将 ref对象的 _value值返回;
  5. setter函数中,比较旧值和新值是否发生了变化,如果发生了变化,则将新值更新到私有变量 _value属性上,然后调用 triggerRefValue函数触发副作用函数的执行。

3、ref其他方法源码实现

3.1 isRef源码实现

从2.3节可知,RefImpl内部定义了一个私有变量 __v_isRef来判断是否是 ref对象,因此,我们, 可以借助 __v_isRef变量来实现 isRef方法,如下代码所示:

判断是否是ref源码实现

typescript 复制代码
export function isRef<T>(r: Ref<T> | unknown): r is Ref<T>
export function isRef(r: any): r is Ref {
  return !!(r && r.__v_isRef === true)
}

3.2 解决响应式丢失问题源码实现

首先我们看一个例子:

typescript 复制代码
// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 })

// 将响应式数据展开到一个新的对象 newObj 中
const newObj = {
  ...obj
}

effect(() => {
  // 在副作用函数内通过新的对象 newObj 读取 foo 属性值
  console.log(newObj.foo)
})

// 很显然,此时修改 obj.foo 并不会触发响应
obj.foo = 100

以上示例的表现是,当我们修改响应式数据 obj.foo的值时,不会触发副作用函数重新执行。请问为什么呢?

这时因为:

typescript 复制代码
const newObj = {
  ...obj
}

其实相当于:

typescript 复制代码
const newObj = {
   foo: 1,
   bar: 2
}

可以发现,其实就是返回了一个普通的对象,因此不具有响应式的能力。为了解决改问题,我们可以封装 toRef函数,将响应式数据的某个属性创建成一个新的 ref

3.2.1 toRef源码实现

toRef源码实现

typescript 复制代码
function propertyToRef(
  source: Record<string, any>,
  key: string,
  defaultValue?: unknown
) {
  const val = source[key]
  return isRef(val)
    ? val
    : (new ObjectRefImpl(source, key, defaultValue) as any)
}

该函数根据传入的键key获取对应的键值,如果该键值已经是一个 ref对象,则直接放回,否则调用 ObjectRefImpl类创建一个新的 ref对象并返回。

ObjectRefImpl源码实现

typescript 复制代码
class ObjectRefImpl<T extends object, K extends keyof T> {
  public readonly __v_isRef = true

  constructor(
    private readonly _object: T,
    private readonly _key: K,
    private readonly _defaultValue?: T[K]
  ) {}

  get value() {
    const val = this._object[this._key]
    return val === undefined ? this._defaultValue! : val
  }

  set value(newVal) {
    this._object[this._key] = newVal
  }

  get dep(): Dep | undefined {
    return getDepFromReactive(toRaw(this._object), this._key)
  }
}

该函数的功能是为响应式对象的某个属性创建一个 ref对象,和 ref内部实现类似,它也定义了一个只读属性 __v_isRef,用来标识对象的某个属性是否是 ref对象。该函数同时实现了 gettersetter函数。

3.2.2 toRefs源码实现

toRef函数可以将对象的某个key转换为 ref对象,但是如果想讲对象所有的key转换为 ref对象该如何处理呢?答案是:借助循环。

toRefs源码实现

typescript 复制代码
export function toRefs<T extends object>(object: T): ToRefs<T> {
  if (__DEV__ && !isProxy(object)) {
    console.warn(`toRefs() expects a reactive object but received a plain one.`)
  }
  const ret: any = isArray(object) ? new Array(object.length) : {}
  for (const key in object) {
    ret[key] = propertyToRef(object, key)
  }
  return ret
}

该函数处理的拆解过程如下:

  1. 如果入参是普通的对象,则开发环境会发出警告:入参只能是reactive object;
  2. 初始化ret:如果对象是一个数组,则初始化为 object.length长度的数组,否则初始化为一个空对象;
  3. 使用for...in循环遍历响应式对象object的key值,调用 toRef依次将该key对应的值转换成 ref,并存储到普通对象ret;
  4. 返回ret;

3.3 自动脱ref源码实现

3.3.1 问题

我们看以下示例:

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

const newObj = { ...toRefs(obj) }
// 必须使用 value 访问值
newObj.foo.value // 1
newObj.bar.value // 2

以上例子中,newObj.foo是一个 ref对象,如果要访问它的值,还需要通过 newObj.foo.value访问。如果我们只想通过 newObj.foo`访问呢?

3.3.1 如何解决?

在Vue3源码中,定义了一个 proxyRefs函数来实现自动脱 ref能力。 proxyRefs源码实现

typescript 复制代码
const shallowUnwrapHandlers: ProxyHandler<any> = {
  get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
  set: (target, key, value, receiver) => {
    const oldValue = target[key]
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value
      return true
    } else {
      return Reflect.set(target, key, value, receiver)
    }
  }
}

export function proxyRefs<T extends object>(
  objectWithRefs: T
): ShallowUnwrapRef<T> {
  return isReactive(objectWithRefs)
    ? objectWithRefs
    : // 如果不是响应式对象,则创建一个代理对象,拦截get操作
      // 通过__v_isRef属性判断读取的值是否是ref,从而返回它的value属性值
      new Proxy(objectWithRefs, shallowUnwrapHandlers)
}

在上面的函数中,处理流程如下:

  1. 判断了是否是响应式对象,如果不是响应式对象,则通过 Proxy创建了一个代理对象,拦截get和set操作,
  2. 在get函数中,如果读取的值是 ref ,则调用 unref 函数返回它的value属性值从而实现自动脱 ref能力。
  3. 在set函数中:如果旧值为 ref并且新值不是 ref,那么将新值设置为旧值的value属性,从而实现自动为ref设置值的能力。

通过以上的处理,proxyRefs函数实现了自动脱 ref能力。

在Vue3组件中,有以下示例:

typescript 复制代码
const MyComponent = {
  setup() {
    const count = ref(0)
  
    // 返回的这个对象会传递给 proxyRefs
    return { count }
  }
}

setup函数中返回的数据经过了 handleSetupResult函数处理,如下所示:

handleSetupResult源码处理

typescript 复制代码
export function handleSetupResult(
  instance: ComponentInternalInstance,
  setupResult: unknown,
  isSSR: boolean
) {
  if (isFunction(setupResult)) {
    // 省略部分代码
  } else if (isObject(setupResult)) {
    // 省略部分代码
    // 将 setup 函数所返回的数据传递给 proxyRefs 函数进行处理
    // 使组件数据自动脱 ref
    instance.setupState = proxyRefs(setupResult)
  } else if (__DEV__ && setupResult !== undefined) {
    // 省略部分代码
  }
  finishComponentSetup(instance, isSSR)
}

setupResult结果经过了 proxyRefs函数处理,实现了组件数据自动脱 ref能力。

4、总结

ref本质上是一个"包裹对象",由于Proxy无法对原始值进行代理,因此我们需要使用一层对象进行包裹,间接实现原始值的响应式处理。

ref除了能够提供原始值的响应式方案,还能用来解决响应式丢失的问题。为了解决该问题,实现了 toRef toRefs这两个方法。

最后,为了减轻用户使用的心智负担,提供了 proxyRefs方法来实现自动脱ref能力,这样,用户在模板中使用响应式数据时,就不需要关心一个值是不是 ref了。

5、参考资料

1\] [Vue官网](https://link.juejin.cn?target=https%3A%2F%2Fcn.vuejs.org%2F "https://cn.vuejs.org/") \[2\] [Vuejs设计与实现](https://link.juejin.cn?target=https%3A%2F%2Fwww.ituring.com.cn%2Fbook%2F2953 "https://www.ituring.com.cn/book/2953") \[3\] [Vue3源码](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fvuejs%2Fcore%2Fblob%2Fv3.3.4 "https://github.com/vuejs/core/blob/v3.3.4")

相关推荐
IT_陈寒19 分钟前
Python 3.12性能优化实战:5个让你的代码提速30%的新特性
前端·人工智能·后端
赛博切图仔20 分钟前
「从零到一」我用 Node BFF 手撸一个 Vue3 SSR 项目(附源码)
前端·javascript·vue.js
爱写程序的小高20 分钟前
npm ERR! code ERESOLVE npm ERR! ERESOLVE unable to resolve dependency tree
前端·npm·node.js
loonggg20 分钟前
竖屏,其实是程序员的一个集体误解
前端·后端·程序员
程序员爱钓鱼30 分钟前
Node.js 编程实战:测试与调试 - 单元测试与集成测试
前端·后端·node.js
码界奇点37 分钟前
基于Vue.js与Element UI的后台管理系统设计与实现
前端·vue.js·ui·毕业设计·源代码管理
时光少年43 分钟前
Android KeyEvent传递与焦点拦截
前端
踢球的打工仔1 小时前
typescript-引用和const常量
前端·javascript·typescript
OEC小胖胖1 小时前
03|从 `ensureRootIsScheduled` 到 `commitRoot`:React 工作循环(WorkLoop)全景
前端·react.js·前端框架
时光少年1 小时前
ExoPlayer MediaCodec视频解码Buffer模式GPU渲染加速
前端