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官网

[2] Vuejs设计与实现

[3] Vue3源码

相关推荐
不悔哥13 分钟前
vue 案例使用
前端·javascript·vue.js
工业互联网专业22 分钟前
毕业设计选题:基于ssm+vue+uniapp的捷邻小程序
vue.js·小程序·uni-app·毕业设计·ssm·源码·课程设计
陈无左耳、37 分钟前
Vue.js 与后端配合:打造强大的现代 Web 应用
vue.js
anyup_前端梦工厂43 分钟前
Vuex 入门与实战
前端·javascript·vue.js
你挚爱的强哥1 小时前
【sgCreateCallAPIFunctionParam】自定义小工具:敏捷开发→调用接口方法参数生成工具
前端·javascript·vue.js
喝旺仔la1 小时前
Element 表格相关操作
javascript·vue.js·elementui
繁依Fanyi1 小时前
使用 Spring Boot + Redis + Vue 实现动态路由加载页面
开发语言·vue.js·pytorch·spring boot·redis·python·算法
米老鼠的摩托车日记1 小时前
【vue element-ui】关于删除按钮的提示框,可一键复制
前端·javascript·vue.js
forwardMyLife1 小时前
element-plus的菜单组件el-menu
javascript·vue.js·elementui
猿饵块2 小时前
cmake--get_filename_component
java·前端·c++