Vue3响应式对象Reactive和Ref的用法解读

 更新时间:2022年09月07日 10:49:43   作者:SunsetFeng  
这篇文章主要介绍了Vue3响应式对象Reactive和Ref的用法,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教

一、内容简介

本篇文章着重结合源码版本V3.2.20介绍Reactive和Ref。前置技能需要了解Proxy对象的工作机制,以下贴出的源码均在关键位置备注了详细注释。

备注:本篇幅只讲到收集依赖和触发依赖更新的时机,并未讲到如何收集依赖和如何触发依赖。响应式原理快捷通道。

二、Reactive

1. 关键源码

/*源码位置:/packages/reactivity/src/reactive.ts*/
/**
 * 创建响应式代理对象
 * @param target 被代理对象
 * @param isReadonly 是否只读
 * @param baseHandlers 普通对象的拦截操作
 * @param collectionHandlers 集合对象的拦截操作
 * @param proxyMap 代理Map
 * @returns 
 */
function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  //如果不是对象,则警告,Proxy代理只支持对象
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  //如果被代理对象已经是一个proxy对象且是响应式的并且此次创建的新代理对象不是只读的,则直接返回被代理对象
  //这儿存在一种情况需要重新创建,即被代理对象已经是一个代理对象了,且可读可写。但新创建的代理对象是只读的
  //那么,本次生成的那个代理对象最终是只读的。响应式必须可读可写,只读的代理对象是非响应式的。
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  //从map中找,如果对象已经被代理过,则直接从map中返回,否则生成代理
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  // 获取代理类型,即采用集合类型的代理还是普通对象类型的代理
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  // 生成代理对象并存入map中
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

2. 源码流程分析

Vue中创建响应式代理对象都是通过createReactiveObject方法创建。这个方法里面的主要逻辑很简单,就是生成一个目标对象的代理对象,代理对象最为核心的操作拦截则由外部根据是否只读和是否浅响应传入,然后将这个代理对象存起来以备下次快捷获取。

三、代理拦截操作

1. 数组操作

(1).关键源码

//源码位置: /packages/reactivity/src/baseHandlers.ts
function createArrayInstrumentations() {
  const instrumentations: Record<string, Function> = {}
  // instrument identity-sensitive Array methods to account for possible reactive
  // values
  ;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
    instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
      //获取原始数组
      const arr = toRaw(this) as any
      for (let i = 0, l = this.length; i < l; i++) {
        //收集依赖 键值为索引 i
        track(arr, TrackOpTypes.GET, i + '')
      }
      // 调用数组的原始方法
      const res = arr[key](...args)
      if (res === -1 || res === false) {
        // 如果不存在,则将参数参数转换为原始数据在试一次(这儿可能是防止传入的是代理对象导致获取失败)
        return arr[key](...args.map(toRaw))
      } else {
        return res
      }
    }
  })
  // instrument length-altering mutation methods to avoid length being tracked
  // which leads to infinite loops in some cases (#2137)
  ;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
    instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
      //由于上面的方法会改变数组长度,因此暂停收集依赖,不然会导致无限递归
      pauseTracking()
      //调用原始方法
      const res = (toRaw(this) as any)[key].apply(this, args)
      //复原依赖收集
      resetTracking()
      return res
    }
  })
  return instrumentations
}

(2).源码流程分析

上述源码其实就是重写了对于数组方法的操作,在通过数组的代理对象访问以上数组方法时,就会执行重写后的数组方法。

内部逻辑很简单,对于改变了数组长度的方法,先暂停依赖收集,调用原始数组方法,然后复原依赖收集。

对于判断元素是否存在的数组方法,执行依赖收集并调用数组原始方法。

总结来说最终都是调用了数组的原始方法,只不过在调用前后添加了关于依赖收集相关的行为。

2.Get操作

(1).关键源码

//源码位置: /packages/reactivity/src/baseHandlers.ts
/**
 * 创建并且返回一个Get方法
 * @param isReadonly 是否只读
 * @param shallow 是否浅响应
 * @returns 
 */
function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    //这儿不重要,其实就是通过代理对象访问这几个特殊属性时,返回相应的值,和响应式无关
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    } else if (
      key === ReactiveFlags.RAW &&
      receiver ===
        (isReadonly
          ? shallow
            ? shallowReadonlyMap
            : readonlyMap
          : shallow
          ? shallowReactiveMap
          : reactiveMap
        ).get(target)
    ) {
      return target
    }
  
    const targetIsArray = isArray(target)
    //如果是调用的数组方法,则调用重写后的数组方法,前提不是只读的
    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }
    //调用原始行为获取值
    const res = Reflect.get(target, key, receiver)
    //访问Symbol对象上的属性和__proto__,__v_isRef,__isVue这3个属性,直接返回结果值
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }
    if (!isReadonly) {
      //不是只读,则收集依赖
      track(target, TrackOpTypes.GET, key)
    }
    if (shallow) {
      //如果对象是浅响应的 则返回结果
      return res
    }
    if (isRef(res)) {
      //如果值是Ref对象且是通过数组代理对象的下标访问的,则不做解包装操作,否则返回解包装后的值
      // ref unwrapping - does not apply for Array + integer key.
      const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
      return shouldUnwrap ? res.value : res
    }
    if (isObject(res)) {
      //走到这儿需要满足非浅响应。如果结果是一个对象,则将改对象转换为只读代理对象或者响应式代理对象返回
      //e.g. 
      // test:{
      //   a:{
      //     c:10
      //   }
      // }
      //以上测试对象当访问属性a时,此时res是一个普通对象,如果不转换为代理对象,则对a.c的操作不会被拦截处理,导致无法响应式处理
      // Convert returned value into a proxy as well. we do the isObject check
      // here to avoid invalid value warning. Also need to lazy access readonly
      // and reactive here to avoid circular dependency.
      return isReadonly ? readonly(res) : reactive(res)
    }
    
    return res
  }
}

(2).源码流程分析

上述Get方法是在通过代理对象获取某一个值时触发的。流程很简单,就是对几个特殊属性做了特殊返回。

如果是数组方法,则调用重写后的数组方法,不是则调用原始行为获取值。

如果不是只读,则收集依赖,对返回结果进行判断特殊处理。其中最关键的地方在于收集依赖和将获取到的嵌套对象转换为响应式对象。

3. Set操作

(1).关键源码

//源码位置: /packages/reactivity/src/baseHandlers.ts
/**
 * 创建并返回一个Set方法
 * @param shallow 是否浅响应
 * @returns 
 */
function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    //获取改变之前的值
    let oldValue = (target as any)[key]
    if (!shallow) {
      value = toRaw(value)
      oldValue = toRaw(oldValue)
      //对Ref类型值的特殊处理
      //比较2个值,如果旧值是Ref对象,新值不是,则直接变Ref对象的value属性
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
        //这儿看似没有触发依赖更新,其实Ref对象的value进行赋值会触发Ref对象的写操作,在那个操作里面会触发依赖更新
        oldValue.value = value
        return true
      }
    } else {
      // in shallow mode, objects are set as-is regardless of reactive or not
    }
    const hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key)
    const result = Reflect.set(target, key, value, receiver)
    // don't trigger if target is something up in the prototype chain of original
    // 这个判断其实是处理一个代理对象的原型也是代理对象的情况,以下是测试代码
    // let hiddenValue: any
    // const obj = reactive<{ prop?: number }>({})
    // const parent = reactive({
    //   set prop(value) {
    //     hiddenValue = value
    //   },
    //   get prop() {
    //     return hiddenValue
    //   }
    // })
    // Object.setPrototypeOf(obj, parent)
    // obj.prop = 4
    // 当存在上述情形,第一次设置值时,由于子代理没有prop属性方法,会触发父代理的set方法。父代理的这个判断此时是false,算是一个优化,避免2个触发更新
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        //触发add类型依赖更新
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        //触发set类型依赖更新
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

(2).源码流程分析

当设置时,首先对旧值是Ref类型对象做了个特殊处理,如果满足条件,则走Ref对象的set方法逻辑触发依赖更新。

否则根据是否存在key值,判断是新增属性,还是修改属性,触发不同类型的依赖更新。

之所以要区分依赖类型,是因为某些属性会连带别的属性更改,比如数组直接设置下标,会导致length的更改,这个时候需要收集length为键值的依赖,以便连带更新依赖的length属性的地方。

4. 其余行为拦截操作

(1).关键源码

//源码位置: /packages/reactivity/src/baseHandlers.ts
/**
 * delete操作符时触发
 * @param target 目标对象
 * @param key 键值
 * @returns 
 */
function deleteProperty(target: object, key: string | symbol): boolean {
  const hadKey = hasOwn(target, key)
  const oldValue = (target as any)[key]
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    //触发依赖更新
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
  }
  return result
}
/**
 * in 操作符时触发
 * @param target 目标对象
 * @param key 键值
 * @returns 
 */
function has(target: object, key: string | symbol): boolean {
  const result = Reflect.has(target, key)
  if (!isSymbol(key) || !builtInSymbols.has(key)) {
      //收集依赖
    track(target, TrackOpTypes.HAS, key)
  }
  return result
}
/**
 * Object.keys()等类似方法时调用
 * @param target 目标对象
 * @returns 
 */
function ownKeys(target: object): (string | symbol)[] {
  //收集依赖
  track(target, TrackOpTypes.ITERATE, isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}

(2).源码流程分析

上述源码其实就是在对一些特殊操作符或者特定API时的特殊处理,本质还是收集依赖和触发依赖更新,没什么好讲的。

四、Ref对象

1. 思考一个问题

为什么存在了Reactive代理对象后,已经可以进行依赖收集和依赖更新了,还要设计一个Ref类型。

测试一:针对以下测试代码,Ref对象值的改变正确触发了更新

    //源码位置 /packages/reactivity/__test__/ref.spec.ts
    const a = ref(1)
    let dummy
    let calls = 0
    effect(() => {
      calls++
      dummy = a.value;
    })
    a.value = 2;
    //此时dummy = 2,a对象值的改变触发了依赖更新

测试二:修改以上代码,更新失败

    const a = 1
    let dummy
    let calls = 0
    effect(() => {
      calls++
      dummy = a;
    })
    a= 2;
    //此时dummy = 1,a的改变没有触发依赖更新

上述2个示例很明显的表明出了,对于非响应式对象的改变,不会触发依赖更新。Reactive是通过代理实现的,代理只支持对象,不支持非对象的基础类型。所以需要设计一个Ref类型来包装这些类型数据,以便拥有响应式状态

2. 简要说明

既然设计了Ref来支持非对象属性,那么也一定需要兼容对象属性。内部其实很简单,如果是对象,则直接转为Reactive代理对象。

3. 关键源码

class RefImpl<T> {
  private _value: T
  private _rawValue: T
  public dep?: Dep = undefined
  public readonly __v_isRef = true
  constructor(value: T, public readonly _shallow: boolean) {
    //原始数据
    this._rawValue = _shallow ? value : toRaw(value)
    //外部访问到的数据,转换为响应式
    this._value = _shallow ? value : toReactive(value)
  }
  get value() {
    //跟踪依赖
    trackRefValue(this)
    return this._value
  }
  set value(newVal) {
    newVal = this._shallow ? newVal : toRaw(newVal)
    if (hasChanged(newVal, this._rawValue)) {
      //如果原始数据之间的比较不一样,则赋值
      this._rawValue = newVal
      //把新值转换为响应式对象
      this._value = this._shallow ? newVal : toReactive(newVal)
      //触发依赖
      triggerRefValue(this, newVal)
    }
  }
}
//转换响应式对象方法
export const toReactive = <T extends unknown>(value: T): T =>
  isObject(value) ? reactive(value) : value
type CustomRefFactory<T> = (
  track: () => void,
  trigger: () => void
) => {
  get: () => T
  set: (value: T) => void
}
//收集依赖
export function trackRefValue(ref: RefBase<any>) {
  //是否可以收集
  if (isTracking()) {
    //获取原始数据
    ref = toRaw(ref)
    if (!ref.dep) {
      //如果不存在依赖,就创建一个依赖对象
      ref.dep = createDep()
    }
    //收集依赖
    if (__DEV__) {
      trackEffects(ref.dep, {
        target: ref,
        type: TrackOpTypes.GET,
        key: 'value'
      })
    } else {
      trackEffects(ref.dep)
    }
  }
}
//触发依赖更新
export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
  ref = toRaw(ref)
  if (ref.dep) {
    if (__DEV__) {
      triggerEffects(ref.dep, {
        target: ref,
        type: TriggerOpTypes.SET,
        key: 'value',
        newValue: newVal
      })
    } else {
      triggerEffects(ref.dep)
    }
  }
}
/**
 * 自定义响应式对象
 */
class CustomRefImpl<T> {
  public dep?: Dep = undefined
  private readonly _get: ReturnType<CustomRefFactory<T>>['get']
  private readonly _set: ReturnType<CustomRefFactory<T>>['set']
  public readonly __v_isRef = true
  constructor(factory: CustomRefFactory<T>) {
    const { get, set } = factory(
      () => trackRefValue(this),
      () => triggerRefValue(this)
    )
    this._get = get
    this._set = set
  }
  get value() {
    return this._get()
  }
  set value(newVal) {
    this._set(newVal)
  }
}

四. 源码解析

Ref对象实际是代理的简化版,针对value设置了一个getter,setter读取器。

这个读取器可以对读写操作进行拦截,因此可以进行依赖的收集和更新。

同时又巧妙了对reactive做了一层封装,假如传入的是一个多层嵌套的复杂对象,最终是类似ref.value.a其实操作的已经是reactive代理对象上的属性,已经和ref无关了。对于CustomRefImpl类型,其实核心和RefImpl是一样的,更加精简,只不过将Get方法和Set方法交给程序员自己去实现了。

只需要在这个Get方法里面调用track方法进行依赖收集和在Set方法里面调用依赖更新即可。

示例代码如下:

    let value = 1
    const custom = customRef((track, trigger) => ({
      get() {
        track()
        return value
      },
      set(newValue: number) {
        value = newValue
        trigger()
      }
    }))
    let dummy
    effect(() => {
      dummy = custom.value 
    })
    custom.value = 2
    //此时dummy = 2;

五、总结

1. 收集依赖和触发依赖的本质

export const enum TrackOpTypes {
  GET = 'get',
  HAS = 'has',
  ITERATE = 'iterate'
}
export const enum TriggerOpTypes {
  SET = 'set',
  ADD = 'add',
  DELETE = 'delete',
  CLEAR = 'clear'
}

以上时源码中定义的收集依赖的和触发依赖的类型。其实也就是当涉及读操作时收集依赖,当设计写操作时触发依赖更新。

2. 响应式对象本质是对数据进行了包装,拦截了读写操作。

3. 上述篇幅并未讲到集合类型代理的处理,原理其实一样,有兴趣的可以自行翻阅源码。

4. 本篇幅只讲到收集依赖和触发依赖的时机,并未讲到如何收集和如何触发。

这些仅为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

相关文章

  • vue element-ui el-table组件自定义合计(summary-method)的坑

    vue element-ui el-table组件自定义合计(summary-method)的坑

    这篇文章主要介绍了vue element-ui el-table组件自定义合计(summary-method)的坑及解决,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-02-02
  • Vue dialog模态框的封装方法

    Vue dialog模态框的封装方法

    这篇文章主要为大家详细介绍了Vue dialog模态框的封装方法,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-07-07
  • vue中使用input[type=

    vue中使用input[type="file"]实现文件上传功能

    这篇文章主要介绍了vue中使用input[type="file"]实现文件上传功能,实现代码简单易懂,非常不错,具有一定的参考借鉴价值,需要的朋友可以参考下
    2018-09-09
  • vue-cli2打包前和打包后的css前缀不一致的问题解决

    vue-cli2打包前和打包后的css前缀不一致的问题解决

    这篇文章主要介绍了vue-cli2打包前和打包后的css前缀不一致的问题解决,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-08-08
  • vue调用高德地图实例代码

    vue调用高德地图实例代码

    本篇文章主要介绍了vue调用高德地图实例代码,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-04-04
  • Vue 构造选项 - 进阶使用说明

    Vue 构造选项 - 进阶使用说明

    这篇文章主要介绍了Vue 构造选项 - 进阶使用说明,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-08-08
  • vue恢复初始数据this.$data,this.$options.data()解析

    vue恢复初始数据this.$data,this.$options.data()解析

    这篇文章主要介绍了vue恢复初始数据this.$data,this.$options.data()解析,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2022-03-03
  • 分分钟玩转Vue.js组件(二)

    分分钟玩转Vue.js组件(二)

    这篇文章教大家如何分分钟玩转Vue.js组件,完善了vue.js组件的学习资料,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2017-03-03
  • vue自定义树状结构图的实现方法

    vue自定义树状结构图的实现方法

    这篇文章主要给大家介绍了关于vue自定义树状结构图的实现方法,文中通过图文介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-10-10
  • Vue pinia模块化全局注册详解

    Vue pinia模块化全局注册详解

    这篇文章主要介绍了Vue pinia模块化全局注册,Pinia是Vue.js团队成员专门为Vue开发的一个全新的状态管理库,并且已经被纳入官方github
    2023-02-02

最新评论