Vue3中如何监测数据变动

vue3使用Proxy来进行数据拦截(关于Proxy的相关介绍,可以参见MDN,这里不做更多说明),但是在使用Proxy的时候,有几个比较头痛的问题需要处理。

1、如何解决多次触发setget问题。

2、Proxy只能代理一层数据,对于多层数据如何处理。

1. 如何解决多次触发setget问题

Proxy代理一个数组时,会引起多次getset的问题,看如下示例。

const p = new Proxy([1, 2, 3], {
  get(target, p, receiver) {
    console.log(`get ${p}`)
    return Reflect.get(target, p, receiver)
  },
  set(target, p, value, receiver) {
    console.log(`set ${p} ${value}`)
    return Reflect.set(target, p, value, receiver)
  }
})

p.push(10)

打印结果

get push
get length
set 3 10
set length 4

可以看到getset分别执行了2次,一次是当前设置的值,还有一次是length的改变触发了set,执行2次set就意味着会引起多次render。那么,如何解决这个问题呢?

办法有很多,比如可以使用定时器setTimeout来控制执行次数。

function reactive(data, fn) {
  let timer
  return new Proxy(data, {
    get(target, p, receiver) {
      return Reflect.get(target, p, receiver)
    },
    set(target, p, value, receiver) {
      clearTimeout(timer)
      timer = setTimeout(() => {
        typeof fn === 'function' && fn()
      })
      return Reflect.set(target, p, value, receiver)
    }
  })
}

const array = [1, 2, 3, 4]
let proxyArray = reactive(array, () => {
  console.log('trigger')
})
proxyArray.push(10)

// 输出:trigger

结果只输出一次trigger,这就解决了多次触发的问题。当然,这只是其中一种解决办法。

那么,vue3是怎么做的呢?

/******节选baseHandlers中的部分代码******/
function set(
  target: object,
  key: string | symbol,
  value: unknown,
  receiver: object
): boolean {
  value = toRaw(value)
  const hadKey = hasOwn(target, key)
  const oldValue = (target as any)[key]
  if (isRef(oldValue) && !isRef(value)) {
    oldValue.value = value
    return true
  }
  const result = Reflect.set(target, key, value, receiver)
  if (target === toRaw(receiver)) {
    if (__DEV__) {
      const extraInfo = { oldValue, newValue: value }
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key, extraInfo)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, OperationTypes.SET, key, extraInfo)
      }
    } else {
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, OperationTypes.SET, key)
      }
    }
  }
  return result
}

vue3的做法比较巧妙,首先通过hadKey判断当前对象中是否包含要设置的key。从上面已经知道,set会执行一次当前赋值的一次set操作,还会执行一个length改变时的set操作。

举个栗子,假如执行如下操作

const p = reactive([1, 2, 3]);
p.push(4);

首先hadKey检测肯定为false,因为在set之前,key肯定是不存在的,所以就会进入!hadKey逻辑,成功trigger

第二次length改变触发set的时候,首先拿到oldValue,也就是target[key],此时keylength,所以target[key]的值是4,而此时value也为4,所以oldValue === value,那么hasChanged(value, oldValue)这个逻辑就进不来了,所以就会执行一次trigger。

基于vue3的这个实现思路,我写了一个精简版的。

function reactive(data, fn) {
  return new Proxy(data, {
    get(target, key, receiver) {
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const hadKey = Object.hasOwnProperty.call(target, key)
      const oldValue = target[key]
      if (!hadKey || oldValue !== value) {
        typeof fn === 'function' && fn()
      }
      return Reflect.set(target, key, value, receiver)
    }
  })
}

let array = [1, 2]
let p = reactive(array, () => {
  console.log('trigger')
})
p.push(3)

// 输出trigger

2. 如何解决Proxy只能代理一层的问题

const p = new Proxy(
  { a: { b: 1 } },
  {
    get(target, p, receiver) {
      return Reflect.get(target, p, receiver)
    },
    set(target, p, value, receiver) {
      console.log('set')
      return Reflect.set(target, p, value, receiver)
    }
  }
)
p.a.b = 3

运行后控制台没有任何输出,而执行p.a = 3后会输出set,可见Proxy只能代理第一层数据,对于嵌套的数据是无法感知的。如何解决这个问题呢?

首先想到的肯定是递归Proxy,简单实现一下。

function reactive(data) {
  const results = Array.isArray(data) ? [] : {}
  if (typeof data === 'object') {
    for (let key in data) {
      results[key] = reactive(data[key])
    }
    return new Proxy(results, {
      get(target, k, receiver) {
        return Reflect.get(target, k, receiver)
      },
      set(target, k, value, receiver) {
        console.log('set')
        return Reflect.set(target, k, value, receiver)
      }
    })
  }
  return data
}
let data = { a: { b: 1 }, c: { d: 2 } }
let p = reactive(data)
p.c.d = 2

// 输出:set

通过递归,给每一个对象用Proxy包装一下,这样即使给内层数据赋值,也能拦截到set操作。但是,缺点也很明显,当数据量变大时,进行递归肯定会带来很大的性能问题。说到这里,vue3中是如何进行深度代理的?

不得不说,vue3的实现方式很巧妙,首先祭上vue3的代码

function createGetter(isReadonly: boolean, shallow = false) {
  return function get(target: object, key: string | symbol, receiver: object) {
    const res = Reflect.get(target, key, receiver)
    if (isSymbol(key) && builtInSymbols.has(key)) {
      return res
    }
    if (shallow) {
      track(target, OperationTypes.GET, key)
      return res
    }
    if (isRef(res)) {
      return res.value
    }
    track(target, OperationTypes.GET, key)
    return isObject(res)
      ? isReadonly
        ? readonly(res)
        : reactive(res)
      : res
  }
}

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
export function reactive(target: object) {
  if (readonlyToRaw.has(target)) {
    return target
  }
  if (readonlyValues.has(target)) {
    return readonly(target)
  }
  return createReactiveObject(
    target,
    rawToReactive,
    reactiveToRaw,
    mutableHandlers,
    mutableCollectionHandlers 
  )
}

function createReactiveObject(
  target: any,
  toProxy: WeakMap<any, any>,
  toRaw: WeakMap<any, any>,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  let observed = toProxy.get(target)
  if (observed !== void 0) {
    return observed
  }
  if (toRaw.has(target)) {
    return target
  }
  if (!canObserve(target)) {
    return target
  }
  const handlers = collectionTypes.has(target.constructor)
    ? collectionHandlers
    : baseHandlers
  observed = new Proxy(target, handlers)
  toProxy.set(target, observed)
  toRaw.set(observed, target)
  if (!targetMap.has(target)) {
    targetMap.set(target, new Map())
  }
  return observed
}

因为每次set被拦截之前都会拦截到get操作,所以vue3在get中直接对数据进行reactive,这样就大大减少了递归reactive带来的性能消耗。

isObject(res) ? (isReadonly ? readonly(res) : reactive(res)) : res

首先判断是否为object类型,如果是一个Object,就对其包装一层Proxy,否则直接返回原始值。同时,它还维护了几个WeakMap,通过这些WeakMap可以通过原始对象快速找到已经包装过Proxy的对象,或者通过已经包装过Proxy的对象找到原始对象,这将大大地提升了性能。

// WeakMaps that store {raw <-> observed} pairs.
const rawToReactive = new WeakMap<any, any>()
const reactiveToRaw = new WeakMap<any, any>()
const rawToReadonly = new WeakMap<any, any>()
const readonlyToRaw = new WeakMap<any, any>()

模拟vue的实现思路,可以实现一个精简版的大深度Proxy

function reactive(data) {
  return new Proxy(data, {
    get(target, k, receiver) {
      const result = Reflect.get(target, k, receiver)
      if (typeof result === 'object') {
        return reactive(result)
      }
      return result
    },
    set(target, k, value, receiver) {
      console.log('set')
      return Reflect.set(target, k, value, receiver)
    }
  })
}
let data = { a: { b: 1 }, c: { d: 2 } }
let p = reactive(data)
p.c.d = 2

// 输出:set

本文只是针对vue3中如何解决Proxy的两个痛点做出总结,当然,这里只是粗略的理解,源码的实现更加地复杂,如有不正之处欢迎不吝指正。

如果您觉得本文对您有用,欢迎捐赠或留言~
微信支付
支付宝

2条评论

  1. if (!hadKey || oldValue !== value) {
    typeof fn === ‘function’ && fn()
    }你这个例子举的好像没有第二个条件也可以满足只执行一次

    1. oldValue !== value判断数据变更的时候才执行操作,如果是一个对象话,去掉这个条件就不行了,比如:

      const a = { name: "1" };
      const p = reactive(a, () => {
        console.log("trigger");
      });
      p.name = "2";
      

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注