简析vue 3 的数据响应系统

1,975 阅读8分钟

在10月5日尤大大Vue3的源代码正式发布了,闲暇之余也简单研究了下源码。

vue3 目前的版本是Pre-Alpha,源码仓库地址vue-next,有需要的朋友可以自行下载

Vue 的核心之一就是响应式系统,通过监听数据的变化,来驱动更新视图。因此,一拿到源码,就先研究了下它的数据监听机制。

当然,在介绍数据监听知识之前,还需要了解一些其他东西

第一个就是实现数据监听的核心Proxy,第二个就是WeakMap,如果已经了解了这两个知识点,可直接看数据监听部分内容

Proxy API 简介

我们知道在vue 2.x版本中,数据监听的实现核心是defineProperty,defineProperty在处理数组和对象时需要对应不同的方式,而在处理监听的深度时,需要递归处理对象的每一个key,这样在一定程度上存在一些性能问题。

Proxy API提供了更加强大的功能

  • 不仅可以代理Object,还能代理Array
  • 提供了很多traps,包括get和set
  • proxy只能代理一层

基本结构

/**
*  target: 目标对象,即要被代理的对象(可以是对象、数组、函数甚至另一个Proxy)
*  handler: 一个对象,其属性是当执行一个操作时定义代理的默认的行为的函数,其中包括get和set
**/
let p = new Proxy(target, handler);

get 和 set

在vue3里主要用到了Proxy里的两个traps,get和set

  • get : 当读取代理对象的属性时执行
  • set : 当为代理对象的属性设置值时执行
let data = { age: 1 }
let p = new Proxy(data, {
  // target: 目标对象
  // key:属性
  // receiver:
  get(target, key, receiver) {
    // 读取属性时执行
    console.log(target, key, receiver)
    return target[key]
  },
  set(target, key, value, receiver) {
    // 设置值时执行
    console.log(target, key, receiver)
    console.log('set value')
    target[key] = value
  }
})

p.age = 12
// set value

当然,若被代理的对象是数组,则需要修改部分代码

let data = [1,2]
let p = new Proxy(data, {
  get(target, key, receiver) {
    // 读取属性时执行
    console.log('get value:', key)
    return target[key]
  },
  set(target, key, value, receiver) {
    // 设置值时执行
    console.log('set value')
    target[key] = value
    return true
  }
})

p.push(3)

// get value: push
// get value: length
// set value
// set value

控制台执行以上代码,会看到如上输出。 why?

那是因为当我们执行数组的push方法时会获取数组的push属性和length属性,当我们为数组赋值时,我们会为数组下标2设置值3,同时将数组的length设置为3。所以我们执行了两次get和两次set。

此外,在Proxy里我们还可以使用一个叫Reflect的东西,他的作用就是来规范我们trap的默认的行为,不需要你自己写一堆代码,是js提供的一种最"标准"的行为。使用Reflect来修改我们的代码

let data = [1,2]
let p = new Proxy(data, {
  get(target, key, receiver) {
    // 读取属性时执行
    console.log('get value:', key)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    // 设置值时执行
    console.log('set value')
    return Reflect.set(target, key, receiver)
  }
})

p.push(3)

// get value: push
// get value: length
// set value
// set value

只能代理一层

let data = { foo: 'foo', bar: { key: 1 }, ary: ['a', 'b'] }
let p = new Proxy(data, {
  get(target, key, receiver) {
    console.log('get value:', key)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log('set value:', key, value)
    return Reflect.set(target, key, value, receiver)
  }
})

p.bar.key = 2
// get value: bar

运行以上代码,可以发现并没有执行set方法,那是因为Proxy只能代理一层。

那么现在我们就发现了使用Proxy的两个问题:

  • 执行一次操作时可能触发多次set或get
  • Proxy只能代理一次

关于这两个问题,我们稍后分析vue3数据监听机制时会一一解答,看看vue3是如何解决这两个问题的。

WeakMap

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的

由于WeakMap的键是若引用的,所以在没有其他引用存在时垃圾回收能正确进行,这也是vue3中使用它的原因,可以防止内存泄漏。

WeakMap提供了几个方法

  • get(key): 获取某个wm对象的键值
  • set(key,value): 为wm上某个键设置值
  • has(key): 判断wm上的某个键是否有值,返回一个Boolean
var wm = new WeakMap();
wm.set(window, "foo");

wm.get(window); // 返回 "foo".
wm.get("baz");  // 返回 undefined.

wm.has(window) // true

vue3 中的数据监听

vue3 中的源码采用了 TS 的形式。而我们的数据监听主要在vue-next\packages\reactivity\src\reactive.ts文件。

reactive.ts 文件提供了 reactive 函数,该函数是实现响应式的核心

// 这里对源码进行了一定程度的简化

// 
const rawToReactive: WeakMap<any, any> = new WeakMap() // 用来存放代理数据的对象
const reactiveToRaw: WeakMap<any, any> = new WeakMap() // 用来存放原始数据的对象

// reactive函数
export function reactive(target: object) {
  // 这里有一些处理只读属性的逻辑,这里省略
  
  return createReactiveObject(
    target,
    rawToReactive,
    reactiveToRaw,
    mutableHandlers,
    mutableCollectionHandlers
  )
}

// 创建Reactive的方法,在这里执行new Proxy()
/**
 * 
 * @param target 目标对象
 * @param toProxy 保存proxy对象的weakMap
 * @param toRaw  保存原始数据的WeakMap
 * @param baseHandlers  Proxy的handler
 * @param collectionHandlers Proxy的handler
 */
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
  }
  // 目标对象已经被代理
  // target already has corresponding Proxy
  let observed = toProxy.get(target)
  if (observed !== void 0) {
    return observed
  }
  // 目标对象已经是一个被代理的对象
  // target is already a Proxy
  if (toRaw.has(target)) {
    return target
  }
  // 是否可被observe
  // only a whitelist of value types can be observed.
  if (!canObserve(target)) {
    return target
  }
  const handlers = collectionTypes.has(target.constructor)
    ? collectionHandlers
    : baseHandlers
  // 执行Proxy
  observed = new Proxy(target, handlers)
  // 保存proxy对象和原始对象
  toProxy.set(target, observed)
  toRaw.set(observed, target)
  return observed
}

我们先来分析以上代码,首先,rawToReactivereactiveToRaw是两个weakMap类型的结构,分别保存了被代理的数据和原始的数据,这两个值会作为参数传给createReactiveObject函数

我们可以通过这两个结构确定传入的target是否被代理或者是否是一个proxy对象,之后执行new Proxy()方法,这里的重点就是传入Proxy的baseHandlers对象。

// 这里简化部分源码

// 这里的mutableHandlers就是传入Proxy的baseHandlers对象
export const mutableHandlers: ProxyHandler<any> = {
  get: createGetter(false),
  set,
  deleteProperty,
  has,
  ownKeys
}

// createGetter方法
function createGetter(isReadonly: boolean) {
  return function get(target: any, key: string | symbol, receiver: any) {
    const res = Reflect.get(target, key, receiver)
    return isObject(res)
        reactive(res)
      : res
  }
}

// 判断key是否是val的属性
const hasOwnProperty = Object.prototype.hasOwnProperty
export const hasOwn = (
  val: object,
  key: string | symbol
): key is keyof typeof val => hasOwnProperty.call(val, key)

// 返回被监听对象的原始数据
export function toRaw<T>(observed: T): T {
  return reactiveToRaw.get(observed) || readonlyToRaw.get(observed) || observed
}

// set方法
function set(
  target: any,
  key: string | symbol,
  value: any,
  receiver: any
): boolean {
  value = toRaw(value)
  const hadKey = hasOwn(target, key)
  const oldValue = target[key]
  const result = Reflect.set(target, key, value, receiver)
  // 如果目标在原型链上,不要触发,
  // 如果receiver存在于taRaw里,即receiver是proxy,即不再原型链上
  if (target === toRaw(receiver)) {
    /* istanbul ignore else */
    // 如果没有属性值,则执行add方法
    if (!hadKey) {
        trigger(target, OperationTypes.ADD, key)
    } else if (value !== oldValue) { // 否则如果新旧值不同,则执行SET方法
        trigger(target, OperationTypes.SET, key)
    }
    // 通过以上判断可以解决数组重复执行set问题
    
  }
  return result
}

回到之前我们遇到过的问题

  • proxy只能代理一层的问题
  • 重复执行set的问题

先看第一个问题

function createGetter(isReadonly: boolean) {
  return function get(target: any, key: string | symbol, receiver: any) {
    const res = Reflect.get(target, key, receiver)
    return isObject(res)
        reactive(res)
      : res
  }
}

可以看到这里判断了 Reflect 返回的数据是否还是对象,如果是对象,则再走一次 proxy ,从而获得了对对象内部的侦测,这样就解决了只能代理一层的问题。

注意proxy在这里的处理并不是递归。而是在使用这个数据的时候会返回多个res,这时候执行多次reactive,在vue 2.x中先递归整个data,并为每一个属性设置set和get。vue3中使用proxy则在使用数据时才设置set和get.

并且,每一次的 proxy 数据,都会保存在 Map 中,访问时会直接从中查找,从而提高性能。

举个🌰

假设现在传入 reactive 函数的数据是

const origin = {
  count: 0,
  a: { b: 0}
}
const state = reactive(origin)

当我对这个对象内部属性操作时,例如 state.a.b = 6,这个时候,get 会被触发两次,而 Reflect.get 会返回两个 res ,分别是data 的内层结构 {b: 0}0,这两个res,若是对象会重新 new Proxy 来代理,并且存入 map 中。

如果是递归proxy,那么 data 的每一层都会是 proxy 对象。而这里,proxy 对象是两个 {b: 0}{count: 0, a: { b: 0}}每个代理对象只有外层是 proxy 的

再来看第二个问题

if (!hadKey) {
    // 如果没有属性值,则执行add方法
    console.log('trigger add')
    trigger(target, OperationTypes.ADD, key)
} else if (value !== oldValue) { // 否则如果新旧值不同,则执行SET方法
    console.log('trigger set')
    trigger(target, OperationTypes.SET, key)
}

例如执行

let data = ['a', 'b']
let r = reactive(data)
r.push('c')

// 打印一次 trigger add

执行r.push('c'),会触发两次set,第一次是设置新值'c',第二次修改数组的length

当第一次触发时,这时侯key2,hadKeyfalse,所以打印trigger add

当第二次触发时,这时候keylength,hadKeytrue,oldValue3,value为3,所以只执行一次trigger.