Vue3 源码解析系列 - 响应式原理(reactive 篇)

1,577 阅读19分钟

前言

在 2019.10.05 Vue3 的源代码正式发布了,来自官方的消息:

Vue 3 源码公开

我自己也是在阅读源码,而且提了几个 PR:

本篇文章算是 Vue3 源码解析系列-响应式系统原理的开篇吧,后续会给大家带来整个响应式系统原理的源码解析以及流程图

仓库

响应式系统的源码是在仓库的 packages/reactivity 中,看源码第一步就是去看仓库的 README,翻译后大致意思如下:

这个包会被嵌入到面向框架使用者的渲染器中,例如 @vue/runtime-dom,但是也可以单独作为一个独立的包(@vue/reactivity)使用。 而且这个独立的包不应该和面向框架使用者的渲染器一起使用,也就是说不要这个文件是从@vue/reactivity导入响应式 API,另一个文件就从 @vue/runtime-dom 里导入,而应该统一从渲染器里再导入响应式的所有 API 来使用

所以,从 README 里我们能获取到什么信息呢?

  • 响应式系统是独立与编译或者渲染的
  • 单独使用响应式包来实现一些状态管理之类的功能

然后先来撸一眼响应式系统核心目录:

packages/reactivity/src
├── baseHandlers.ts          // Proxy handler,针对于常规对象(Array,Object)
├── collectionHandlers.ts     // Proxy handler,针对于 Set,Map,WeakMap,WeakSet 这类集合数据
├── computed.ts              // 功能同 vue2 的 computed
├── effect.ts               // 数据变动响应的处理,以及依赖收集
├── index.ts                // 入口文件,导出 API
├── lock.ts                 // 锁控制(其实控制 只读的响应式变量的 set 和 delete 操作)
├── operations.ts            // 对数据操作类型的枚举
├── reactive.ts              // 响应式入口,针对于对象,下方会有介绍
└── ref.ts                  // 响应式入口,针对于基本类型,下方会有介绍

Api 概览

对于不熟悉新的响应式 API 的同学,可以直接看下面的示例:

import { reactive, ref, computed, effect } from '@vue/reactivity'

const original = { foo: 0 }
const observed = reactive(original)
const oRef = ref(0)

const getter = computed(() => observed.foo + oRef.value)

effect(() => {
    console.log('trigger effect', getter.value)
})

// 打印 trigger effect 0

observed.foo++ // 打印 trigger effect 1

oRef.value++ // 打印 trigger effect 2

根据上面的目录以及 API 示例,其实响应式系统中主要包含了 4 个 关键API,分别是:

  • reactive:响应式关键入口 API,作用同 Vue2 组件的 data 选项
  • ref:响应式关键入口 API,作用同 reactivity,不过是用于针对基本数据类型的响应式包装,因为基本数据类型在对象结构或者函数参数传递时会丢失引用
  • computed:响应式计算 API,同 Vue2 的 computed 选项
  • effect:作用同 Vue2 的 watcher,是用来进行依赖收集的 API,computed 和 后续文章的 watch API都是基于 effect

介绍完 4 个关键的 API 后,先简单地给出它们之间的联系:

关键 API 间的关联

接下来就对这几个 API 以及辅助工具的源码以及原理来进行解析

大家如果不熟悉 API,也可以先从单测的测试用例开始看起,比如相老板的 Vue3响应式系统源码解析-单测篇

reactive

核心入口

话不多说,我们先来看下核心的 reactive 的源码,先看下有哪些依赖:

// 工具方法,isObject 是判断是否是对象,toTypeString 获取数据类型
import { isObject, toTypeString } from '@vue/shared'
// Proxy 的 handlers
// mutableHandlers:可变数据的 handler
// readonlyHandlers:只读数据的 handler
import { mutableHandlers, readonlyHandlers } from './baseHandlers'
import {
  mutableCollectionHandlers, // 可变集合数据的 handler
  readonlyCollectionHandlers // 只读集合数据的 handler
} from './collectionHandlers'
// effect 泛型类型
import { ReactiveEffect } from './effect'
// ref 泛型类型
import { UnwrapRef, Ref } from './ref'
// 工具方法,将字符串转化成 Map,返回 function 来判断是否 这个Map 上包含所给的 key
// 这个在 vue2 里也有
import { makeMap } from '@vue/shared'

可以很清楚的看到,重点依赖项就是那一堆 handler 了,其他都是一些工具方法和泛型类型

接下来的源码里是一堆变量的定义,不过我们先跳过,先来看下 reactive 的方法和类型:

// 上面还有一大坨变量定义,很关键,但是我们先跳过,先看下有哪些方法,类型是什么样的

// 依赖集合类型
export type Dep = Set<ReactiveEffect>
// 看名字就知道,是 key 和 Dep 集合的对应关系集合
// key 其实就是我们响应式数据上的 key,Dep 则是有哪些地方依赖到了这个 key
// 比如 const a = { foo: 1 },如果在其他两处都用到了 a.foo,那么
// 这里的 key 就是 foo,Dep 就是这两处的 依赖集合
export type KeyToDepMap = Map<string | symbol, Dep>

// 判断对象能不能被观察的
const canObserve = (value: any): boolean

// only unwrap nested ref
// 解套 Ref 类型
type UnwrapNestedRefs<T> = T extends Ref ? T : UnwrapRef<T>

// 响应式入口方法,入参是泛型,继承 object,返回值就是上面的解套 Ref 类型
export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

// 跟 reactive 作用相同,只不过返回值是 Readonlyexport function readonly<T extends object>(
  target: T
): Readonly<UnwrapNestedRefs<T>>

// 创建响应式对象的关键方法,reactiveonly 都调用了这个方法
function createReactiveObject(
  target: any,
  toProxy: WeakMap<any, any>,
  toRaw: WeakMap<any, any>,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
): any

// 是否是响应式数据
export function isReactive(value: any): boolean

// 是否是只读的响应式数据
export function isReadonly(value: any): boolean

// 将响应式数据转化为原始数据
export function toRaw<T>(observed: T): T

// 将 value 标记为 Readonly,在 reactive 方法里会判断是否是 Readonly 的原始数据
export function markReadonly<T>(value: T): T

// 将 value 标记为不可响应数据,这个将会影响 canObserve 方法的判断
export function markNonReactive<T>(value: T): T

看完方法和类型,大致有以下几个问题:

  1. Dep 依赖是如何追踪的?
  2. UnwrapRef 是如何展开嵌套的响应式数据类型的(俗称解套),比如 reactive({ name: reactive(ref('Jooger')) })
  3. 如何判断是否是(只读的)响应式数据?往响应式数据上面挂载一个标记位来标记?还是用一个对象来存储响应式数据?
  4. 如何将响应式数据转化为原始数据?proxy 数据如何转化成 object?

问题 1,后面的 effect 会讲,这里先不讨论

问题 2,后面的 ref 会讲,这里先不讨论

问题 3,4 就需要看下我刚才跳过的一堆变量的定义了:

// The main WeakMap that stores {target -> key -> dep} connections.
// Conceptually, it's easier to think of a dependency as a Dep class
// which maintains a Set of subscribers, but we simply store them as
// raw Sets to reduce memory overhead.

// target 和 KeyToDepMap 的映射关系集合
// 一句话理解,有多个 target,每个 target 上有多个 key,每个 key 都有多个依赖
// 至于为什么要把映射关系存到 WeakMap 里,根据上面注释所述,是为了减少内存开销
// 这个在后续的 effect 部分会讲
export const targetMap = new WeakMap<any, KeyToDepMap>()

// WeakMaps that store {raw <-> observed} pairs.
// 下面这四个变量就是为了解答 问题 3 和 4 的
// 根据上面的原英文注释,这四个变量是 raw 和 observed 的对应关系集合
// raw 是原始数据,observed 则是响应式数据

// 原始数据 -> 响应式数据
const rawToReactive = new WeakMap<any, any>()
// 响应式数据 -> 原始数据
const reactiveToRaw = new WeakMap<any, any>()
// 原始数据 -> 只读响应式数据
const rawToReadonly = new WeakMap<any, any>()
// 只读响应式数据 -> 原始数据
const readonlyToRaw = new WeakMap<any, any>()

// WeakSets for values that are marked readonly or non-reactive during
// observable creation.
// 前面提到过的 markReadonly 和 markNonReactive 方法用到的
// 用来存储我们标记的特定数据,以便在创建响应式数据是来检查是否被上面两个方法标记过
const readonlyValues = new WeakSet<any>()
const nonReactiveValues = new WeakSet<any>()

// 判断是否是集合类型(Set, Map, WeakMap, WeakSet)
// 因为集合类型的代理 handler 和普通对象是不同的,需要特殊处理
const collectionTypes = new Set<Function>([Set, Map, WeakMap, WeakSet])
// 判断是否是可观察类型,有以下 6 类,在 canObserve 方法里会用到
const isObservableType = /*#__PURE__*/ makeMap(
  ['Object', 'Array', 'Map', 'Set', 'WeakMap', 'WeakSet']
    .map(t => `[object ${t}]`)
    .join(',')
)

看完上面这些变量定义,我们来解答一下问题 3,4:

问题 3:如何判断是否是(只读的)响应式数据?往响应式数据上面挂载一个标记位来标记?还是用一个对象来存储响应式数据?

readonlyToRaw 来存储只读响应式数据的,参见下面代码:

export function isReadonly(value: any): boolean {
  return readonlyToRaw.has(value)
}

问题 4:如何将响应式数据转化为原始数据?proxy 数据如何转化成 object?

reactiveToRawreadonlyToRaw 来存储响应式数据 -> 原始数据 的映射关系,然后:

export function toRaw<T>(observed: T): T {
  return reactiveToRaw.get(observed) || readonlyToRaw.get(observed) || observed
}

总的来讲,就是利用各种集合来存储原始数据和响应式数据的映射关系,以便快速根据这种映射关系拿到对应的数据,至于为什么用 WeakMap,上面说过了是为了减少内存开销,不清楚原理的同学可以移步 MDN WeakMap

再回头看下 canObserve 方法,来看看到底有哪些数据是可以观察的:

const canObserve = (value: any): boolean => {
  return (
    // Vue 实例不可观察,目前库里还没有 _isVue 的逻辑,不过猜测应该是内部在 setup 方法中挂载
    !value._isVue &&
    // virtual dom 不可观察
    !value._isVNode &&
    // 'Object', 'Array', 'Map', 'Set', 'WeakMap', 'WeakSet' 类型以外的不可观察
    isObservableType(toTypeString(value)) &&
    // 已经标记为不可响应数据的不可观察
    !nonReactiveValues.has(value)
  )
}

相比于 Vue2 的是否可观察判断,则少了很多条件:

// 我就不解析 Vue2 中的这段判断代码了
// 相比于 Vue2,少了 __ob__ ,ssr 以及 Object.isExtensible 的判断
// 这都是得益于 Proxy
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
  ob = value.__ob__
} else if (
  shouldObserve &&
  !isServerRendering() &&
  (Array.isArray(value) || isPlainObject(value)) &&
  Object.isExtensible(value) &&
  !value._isVue
) {
  ob = new Observer(value)
}

接下来就讲一下重点的 reactivereadonly 这两个核心方法的实现:

// 前面讲过返回值就是上面的解套 Ref 类型
export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  // 如果是一个只读响应式数据,直接返回,因为已经是响应式的了
  if (readonlyToRaw.has(target)) {
    return target
  }
  // target is explicitly marked as readonly by user
  // 如果曾经被标记为只读数据,直接调用 readonly 方法生成只读响应式对象
  if (readonlyValues.has(target)) {
    return readonly(target)
  }
  
  // 创建响应式对象
  return createReactiveObject(
    target, // 原始数据
    rawToReactive, // raw -> observed
    reactiveToRaw, // observed -> raw
    mutableHandlers, // 可变数据的 proxy handle
    mutableCollectionHandlers // 可变集合数据的 proxy handler
  )
}

export function readonly<T extends object>(
  target: T
): Readonly<UnwrapNestedRefs<T>> {
  // value is a mutable observable, retrieve its original and return
  // a readonly version.
  // 如果是响应式数据,那么获取原始数据来进行观察
  if (reactiveToRaw.has(target)) {
    target = reactiveToRaw.get(target)
  }
  // 创建响应式数据,同样
  return createReactiveObject(
    target, // 原始数据
    rawToReadonly, // raw -> readonly observed
    readonlyToRaw, // readonly ovserved -> raw
    readonlyHandlers, // 只读数据的 proxy handler
    readonlyCollectionHandlers // 只读集合数据的 proxy handler
  )
}

其实我们看 Vue3 的源码会发现,很多入口方法都变得短小精简,不像 Vue2 里的一些 exposed function 那样写的很长,这两个核心方法也一样,逻辑很简单,主要是进行一些原始数据检查和转换,核心实现逻辑都是放在 createReactiveObject 里的

下面继续看下核心实现方法 createReactiveObject

// 创建响应式对象
function createReactiveObject(
  target: any,  // 原始数据
  toProxy: WeakMap<any, any>, // raw -> (readonly) observed
  toRaw: WeakMap<any, any>, // (readonly) observed -> raw
  baseHandlers: ProxyHandler<any>, // 只读/可变 数据的 proxy handler
  collectionHandlers: ProxyHandler<any> // 只读/可变 集合数据的 proxy handler
) {
  // 如果不是对象,则直接返回自身,包括 null,reactive(null) => null
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  // target already has corresponding Proxy
  // 如果原始数据已经被观察过,直接通过 raw -> observed 映射,返回响应式数据
  let observed = toProxy.get(target)
  if (observed !== void 0) {
    return observed
  }
  // target is already a Proxy
  // 如果原始数据本身就是响应式的,直接返回自身
  if (toRaw.has(target)) {
    return target
  }
  // only a whitelist of value types can be observed.
  // 如果是不可观察对象,直接返回自身
  if (!canObserve(target)) {
    return target
  }
  // 判断是采用基础数据(object|array)handler 还是集合数据 handler
  const handlers = collectionTypes.has(target.constructor)
    ? collectionHandlers
    : baseHandlers
  // Proxy 创建代理对象,即响应式对象
  observed = new Proxy(target, handlers)
  // 创建后,设置好 raw <-> observed 的双向映射关系
  toProxy.set(target, observed)
  toRaw.set(observed, target)
  // 上面讲到了 targetMap 的作用,这里是创建默认依赖追踪集合
  if (!targetMap.has(target)) {
    targetMap.set(target, new Map())
  }
  return observed
}

我们可以看到:

  • 目前来看 reactivereadonly 的区别仅有两点:映射关系存储集合不同 and proxy handler 不同
  • object``array 和集合类型 Set``Map``WeakSet``WeakMapproxy handler 是不同的

所以下面再来依次看下响应式核心中的核心 - 各种 proxy handler

baseHandler

方法跟上面一样,先看依赖:

// 上面讲过,不过现在来看感觉像是是在 get set 这些 trap 方法里会调用
import { reactive, readonly, toRaw } from './reactive'
// 操作类型枚举,对应于 proxy handler 里的 trap 方法
import { OperationTypes } from './operations'
// 依赖收集和触发依赖回调
import { track, trigger } from './effect'
// 全局锁,用来禁止 set 和 delete
import { LOCKED } from './lock'
// 工具方法,类型判断
import { isObject, hasOwn, isSymbol } from '@vue/shared'
// 判断是否是 ref,后面会讲到
import { isRef } from './ref'

这里有两个疑问:

  1. tracktrigger 的实现
  2. LOCKED 的作用?为什么会有这个全局锁?

问题 1 在后面的 effect 部分会讲到,现在只需要知道是用来追踪依赖和触发依赖回调方法就行

问题 2 现在我也不是特别了解,只知道是在组件 mountupdate 的时候会对组件的 props 的代理进行修改,因为我们都知道单向数据流中,子组件内部是不能更改 props 的,但是子组件更新,进行 vnode patch 后需要更新子组件的 props,包括一些动态 props

再来看下变量和方法概览:

// JS 内部语言行为描述符集合,比如 Symbol.iterator 这些,在 get 里会用到
const builtInSymbols = new Set(
  Object.getOwnPropertyNames(Symbol)
    .map(key => (Symbol as any)[key])
    .filter(isSymbol)
)

function createGetter(isReadonly: boolean) {}

function set(
  target: any,
  key: string | symbol,
  value: any,
  receiver: any
): boolean

function deleteProperty(target: any, key: string | symbol): boolean

function has(target: any, key: string | symbol): boolean

function ownKeys(target: any): (string | number | symbol)[]

export const mutableHandlers: ProxyHandler<any> = {
  get: createGetter(false),
  set,
  deleteProperty,
  has,
  ownKeys
}

export const readonlyHandlers: ProxyHandler<any> = {
  get: createGetter(true),

  set(target: any, key: string | symbol, value: any, receiver: any): boolean {
    if (LOCKED) {
      if (__DEV__) {
        console.warn(
          `Set operation on key "${String(key)}" failed: target is readonly.`,
          target
        )
      }
      return true
    } else {
      return set(target, key, value, receiver)
    }
  },

  deleteProperty(target: any, key: string | symbol): boolean {
    if (LOCKED) {
      if (__DEV__) {
        console.warn(
          `Delete operation on key "${String(
            key
          )}" failed: target is readonly.`,
          target
        )
      }
      return true
    } else {
      return deleteProperty(target, key)
    }
  },

  has,
  ownKeys
}

可以看到,mutableHandlersreadonlyHandlers 都是定义了 5 个 trap 方法:getsetdeletePropertyhasownKeys,前 3 个不用多家介绍,has trap 针对与 in 操作符,而 ownKeys 针对于 for inObject.keys 这些遍历操作的

readonlyHandlers 相比于 mutableHandlers 其实只是在 getsetdeleteProperty 这三个 trap 方法里有区别,而对于可能改变数据的 setdeleteProperty 方法,则是利用 LOCKED 来锁定,不让修改数据,这个变量我在上面也提了一下

下面来一个一个的看下各个 trap 方法

get

// 创建 get trap 方法
// 如果是可变数据, isReadonly 是 false
// 如果是只读数据,那么 isReadonly 就是 true
function createGetter(isReadonly: boolean) {
  return function get(target: any, key: string | symbol, receiver: any) {
    // 利用 Reflect 反射来获取原始值
    const res = Reflect.get(target, key, receiver)
    // 如果是 JS 内置方法,不进行依赖收集
    if (isSymbol(key) && builtInSymbols.has(key)) {
      return res
    }
    // 如果是 ref 类型数据,则直接返回其 value
    // TODO 后面 ref 部分我们会讲到,ref(target) 其实在 get value 的时候做了依赖收集了,
    // 就不需要下面重复收集依赖
    if (isRef(res)) {
      return res.value
    }
    
    // get 类型操作的依赖收集
    track(target, OperationTypes.GET, key)
    
    // 这里其实很简单就是递归返回响应式对象
    return isObject(res)
      ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
          // circular dependency
          readonly(res)
        : reactive(res)
      : res
  }
}

看完 get trap 其实很简单,但是也会有写疑问:

1. 为什么用 Reflect.get,而不是直接 target[key] 返回呢?

MDN Reflect.get 我们可以看它的第三个参数:

receiver:如果target对象中指定了getter,receiver则为getter调用时的this值

举个例子:

const target = {
    foo: 24,
    get bar () {
        return this.foo
    }
}

const observed = reactive(target)

此时,如果不用 Reflect.get,而是 target[key],那么 this.foo 中的 this 就指向的是 target,而不是 observed,此时 this.foo 就不能收集到 foo 的依赖了,如果 observed.foo = 20 改变了 foo 的值,那么是无法触发依赖回调的,所以需要利用 Reflect.get 将 getter 里的 this 指向代理对象

2. 为什么在结尾 return 的时候还要调用 reactive 或者 readoonly 呢?

原注释是这样写的:

need to lazy access readonly and reactive here to avoid circular dependency 翻译过来是:需要延迟地使用 readonly 和 readtive 来避免循环引用

为什么这样说呢?这里不得不说一下 Proxy 的特性:只能代理一层,对于嵌套的深层对象,如果不按源码中的方法,那就需要一层层递归来代理劫持对象,即每次递归都判断是否是对象,如果是对象,那么再调用 reactive 来响应式化

但是问题又来了,JS 里是有循环引用这个概念的,就像下面这样:

const a = {
    b: {}
}

a.b.c = a

这样的话,如果每次递归调用 reactive 的话,会造成调用栈溢出 Maximum call stack size exceeded,但是我们只需要加上一个判断条件即可解决,在上面解析的 createReactiveObject 方法里我们知道如果原始数据已经被观察过,则直接返回对应的响应式数据,那么我们可以在递归调用 reactive 的时候判断 toProxy.get(target) 是否存在,如果存在就不往下递归了,我写了一个例子代码:

// 循环引用对象
const target = { b: { c: 1 }}
target.b.d = target

// 这个就是上面讲的 原始数据 -> 响应式数据的集合
const rawToReactive = new WeakMap()

function reactive (data) {
  const observed = new Proxy(data, {
    get (target, key, receiver) {
      const res = Reflect.get(target, key, receiver)
      const observed = rawToReactive.get(res)
      return observed || res
    }
  })
  rawToReactive.set(data, observed)

  for (let key in data) {
    const child = data[key]
    // 这里判断如果没有被观察过,那么继续 reactive 递归观察
    if (typeof child === 'object' && !rawToReactive.get(child)) {
      reactive(child)
    }
  }

  return observed
}

const p = reactive(target)

console.log(p.b.c) // 1

console.log(p.b.d.b) // Proxy {c: 1, d: {…}}
 
// 我试了一下。跟源码里的 reactive 的 get 结果是一样的

可以去看下我在 vue3响应式源码解析-Reactive篇 这篇文章下的评论部分

而源码中的 lazy access 方式很取巧,只代理一层,当用到某个属性值对象时,再进行响应式观察这一层

所以相比于初始化时递归劫持,延迟访问劫持的方式更能提升初始化性能,也有利于对数据劫持做更细的控制,特别是针对于数据对象比较大时(比如接口返回数据嵌套过深),有些数据并非需要劫持,所以按需劫持代理我们用到的数据这种方式更好

set

// set trap 方法
function set(
  target: any,
  key: string | symbol,
  value: any,
  receiver: any
): boolean {
  // 如果是观察过响应式数据,那么获取它映射的原始数据
  value = toRaw(value)
  // 获取旧值
  const oldValue = target[key]
  // 如果旧值是 ref 类型数据,而新的值不是 ref,那么直接赋值给 oldValue.value
  // 因为 ref 数据在 set value 的时候就已经 trigger 依赖了,所以直接 return 就行
  if (isRef(oldValue) && !isRef(value)) {
    oldValue.value = value
    return true
  }
  // 对象上是否有这个 key,有则是 set,无则是 add
  const hadKey = hasOwn(target, key)
  // 利用 Reflect 来执行 set 操作
  const result = Reflect.set(target, key, value, receiver)
  // don't trigger if target is something up in the prototype chain of original
  // 如果 target 原型链上的数据,那么就不触发依赖回调
  if (target === toRaw(receiver)) {
    /* istanbul ignore else */
    if (__DEV__) {
      // 开发环境操作,只比正式环境多了个 extraInfo 的调试信息
      const extraInfo = { oldValue, newValue: value }
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key, extraInfo)
      } else if (value !== oldValue) {
        trigger(target, OperationTypes.SET, key, extraInfo)
      }
    } else {
      // 上面讲过,有这个 key 则是 set,无则是 add
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key)
      } else if (value !== oldValue) {
        // 只有当 value 改变的时候才触发
        trigger(target, OperationTypes.SET, key)
      }
    }
  }
  return result
}

同样,set trap 看起来也很简单,但是同时也会有一些问题:

1. target === toRaw(receiver) 是什么鬼逻辑?

首先看下 MDN handler.set() 的关于第三个参数的说明:

最初被调用的对象。通常是proxy本身,但handler的set方法也有可能在原型链上或以其他方式被间接地调用(因此不一定是proxy本身)。 比如,假设有一段代码执行 obj.name = "jen",obj不是一个proxy且自身不含name属性,但它的原型链上有一个proxy,那么那个proxy的set拦截函数会被调用,此时obj会作为receiver参数传进来

上面已经给出例子了,这里我再写一下:

const child = { name: 'child' }

const parent = new Proxy({ name: 'parent' }, {
	set (target, key, value, receiver) {
    console.log(target, receiver)
    return Reflect.set(target, key, value, receiver)
	}
})

Object.setPrototypeOf(child, parent)

child.foo = 1

// 会打印出
// {name: "parent"} {name: "child"}

这里有两个先决条件:

  1. child 的原型链是一个 Proxy
  2. child 在设置值的时候,本身不包含 key 的

可以看到,当满足上面两个条件的时候,设置 child 的值,会触发原型链上的 set trap 方法,并且 target 是原型链数据,而 receiver 则是真实数据

所以,源码中的那个条件逻辑也就不难看懂了,当满足上述两个条件时,我们当然不希望触发 parent 的 set trap

2. 像数组的 unshiftsplice 这些操作是如何触发 set trap 方法的呢?

// 在 set 里加上这么一个 log
console.log(!hadKey ? 'add' : value !== oldValue ? 'set' : 'unknow', target, key, value, oldValue)

然后

a = reactive([1, 2, 3])
a.unshift(0)

// 会打印
// add [1, 2, 3, 3] 3 3 undefined
// set [1, 2, 2, 3] 2 2 3
// set [1, 1, 2, 3] 1 1 2
// set [0, 1, 2, 3] 0 0 1
// unknow [0, 1, 2, 3] length 4 4

一共打印了 5 次,根据打印内容我们可以看到 unshift 的实际操作过程,即把数组的每一项依次都往后移动一位,然后再把首位设置成 0,至于为什么这么操作,ECMA-262 Array.property.unshift 标准中有原理介绍,我就不赘述了,还有像 shiftsplice 也是一样的操作步骤

可以看到 unshift 或者 splice 是会带来多次的 trigger 的,当然这些会有批量跟新优化的,有时间我再展开讲一下

细心的同学可能会发现,还触发了 length 属性的 set,而且 valueoldValue 是一样的,那么根据源码所示,就不会触发 set 类型的回调了呀,那我们如果在 template 里用到了 a.length 那也不会更新了么?

肯定是会更新的,解决办法就在 trigger 这个方法里,后续 effect 部分会讲到,先简单说一下,对于会导致数组 length 改变的操作,比如 add 和 delete,在 effecttrigger 方法里会单独处理,来触发 length 属性的依赖回调的

其他 trap 方法

还有 deletePropertyhasownKeys 这几个 trap,代码不多,都很简单,直接看下面的源码就能明白,我就不在赘述了

// deleteProperty trap 方法
function deleteProperty(target: any, key: string | symbol): boolean {
  const hadKey = hasOwn(target, key)
  const oldValue = target[key]
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    /* istanbul ignore else */
    if (__DEV__) {
      trigger(target, OperationTypes.DELETE, key, { oldValue })
    } else {
      trigger(target, OperationTypes.DELETE, key)
    }
  }
  return result
}

// has trap 方法
function has(target: any, key: string | symbol): boolean {
  const result = Reflect.has(target, key)
  track(target, OperationTypes.HAS, key)
  return result
}

// ownKey trap 方法
function ownKeys(target: any): (string | number | symbol)[] {
  track(target, OperationTypes.ITERATE)
  return Reflect.ownKeys(target)
}

总结

  • baseHandler 是针对于数组和对象类型的数据的 proxy handler
  • 每个 trap 方法都是用 Reflect 来反射到原始数据上的
  • 对于 gethasownKeys 这一类读操作,会进行 track 来收集依赖,而对于 setdeleteProperty 这类写操作,则是会进行 trigger 来触发依赖回调
  • 响应式数据的读取是 lazy 的,即初始化的时候不会对嵌套对象全盘观察,而是只有用到了每个值才会生成对应的响应式数据

collectionHandler

还记得我们在看 reactive 方法那里有个 collectionTypes 的判断对吧,collectionHandler 就是专门来处理 Set|Map|WeakSet|WeakMap 这类集合类型数据的

这里可以参考相学长的vue3响应式源码解析-Reactive篇 collectionHandler 这篇文章,写的很详细,我这里也不再赘述了

总结

最开始阅读 reactive 源码时,总体的逻辑是比较清晰的,但是仍然有几个地方当时有疑惑:

  • rawToReactive|rawToReadonly 等这几个变量是干嘛的?
  • targetMap 的是干什么的?为什么是 WeakMap<any, KeyToDepMap> 类型
  • LOCKED 是用来干嘛的?
  • baseHandlerget trap 为什么又返回了一个 reactive(res)
  • collectionHandler 里的 trap 方法为什么只有 get?为什么跟 baseHandler 不一样?

在读完源码后,除了 LOCKED 那个疑惑,其他几个问题我都已经找到答案,并且也在上面解惑了,我相信大家看完这篇文章后也应该都有自己的答案了

最后再来个源码里的知识点总结吧:

  • reactive 是利用 Proxy 来进行数据观察,Reflect 相关操作来反射到原始数据的,并且数据的访问是一个 lazy reactive 方式,即按需观察
  • 普通对象、数组和集合类型数据的代理 handler 是不同的,这是因为 Proxy 的一些限制,参考Proxy limitations
  • 利用几个 WeakMap 来存储原始数据 <-> 响应式数据的双向映射关系,以便在响应式入口方法里判断是否原始数据已经被观察过,这个相比于 Vue2 的直接在原始数据上挂载 __ob__ 要少一些冗余数据,并且由于 WeakMap 的 GC 特性,在运行时会有一定的内存优化
  • 响应式数据的读操作会 track 来收集依赖,写操作则是会 trigger 来触发依赖回调

整个 reactive|readonly 的流程如下:

reactive|readonly 流程

后续文章将会介绍 refcomputedeffect 这几个核心 API,以及将响应式系统流程串联起来,文章里的几个 TODO 后续都会讲到

参考文章


关联博客文章:Vue3 源码解析系列 - 响应式原理(reactive 篇)

也欢迎关注我的博客:Jooger.me