听说你很了解 Vue3 响应式?(上)

简介: 听说你很了解 Vue3 响应式?

image.png


前言

<< 温馨提醒 >>本文内容偏干,建议边喝水边食用,如有不适请及时点赞

【A】:能不能说说 Vue3 响应式都处理了哪些数据类型?都怎么处理的呀?

【B】:能,只能说一点点...

【A】:...

只要问到 Vue 相关的内容,似乎总绕不过 响应式原理 的话题,随之而来的回答必然是围绕着 Object.definePropertyProxy 来展开(即 Vue2Vue3),但若继续追问某些具体实现是不是就仓促结束回答了(你跑我追,你不跑我还追)。

本文就不再过多介绍 Vue2 中响应式的处理,感兴趣可以参考 从 vue 源码看问题 —— 如何理解 Vue 响应式?,但是会有简单提及,下面就来看看 Vue3 中是如何处理 原始值、Object、Array、Set、Map 等数据类型的响应式。

Object.definePropertyProxy

一切的一切还得从 Object.defineProperty 开始讲起,那是一个不一样的 API ... (bgm 响起,自行体会

Object.defineProperty

Object.defineProperty(obj, prop, descriptor) 方法会直接在一个对象上定义一个 新属性,或修改一个 对象现有属性,并返回此对象,其参数具体为:

  • obj:要定义属性的对象
  • prop:要定义或修改的 属性名称Symbol
  • descriptor:要定义或修改的 属性描述符

从以上的描述就可以看出一些限制,比如:

  • 目标是 对象属性,不是 整个对象
  • 一次只能 定义或修改一个属性
  • 当然有对应的一次处理多个属性的方法 Object.defineProperties(),但在 vue 中并不适用,因为 vue 不能提前知道用户传入的对象都有什么属性,因此还是得经过类似 Object.keys() + for 循环的方式获取所有的 key -> value,而这其实是没有必要使用 Object.defineProperties()

在 Vue2 中的缺陷

Object.defineProperty() 实际是通过 定义修改对象属性 的描述符来实现 数据劫持,其对应的缺点也是没法被忽略的:

  • 只能拦截对象属性的 getset 操作,比如无法拦截 deletein方法调用 等操作
  • 动态添加新属性(响应式丢失)
  • 保证后续使用的属性要在初始化声明 data 时进行定义
  • 使用 this.$set() 设置新属性
  • 通过 delete 删除属性(响应式丢失)
  • 使用 this.$delete() 删除属性
  • 使用数组索引 替换/新增 元素(响应式丢失)
  • 使用 this.$set() 设置新元素
  • 使用数组 push、pop、shift、unshift、splice、sort、reverse原生方法 改变原数组时(响应式丢失)
  • 使用 重写/增强 后的 push、pop、shift、unshift、splice、sort、reverse 方法
  • 一次只能对一个属性实现 数据劫持,需要遍历对所有属性进行劫持
  • 数据结构复杂时(属性值为 引用类型数据),需要通过 递归 进行处理

【扩展】Object.definePropertyArray

它们有啥关系,其实没有啥关系,只是大家习惯性的会回答 Object.defineProperty 不能拦截 Array 的操作,这句话说得对但也不对。

使用 Object.defineProperty 拦截 Array

Object.defineProperty 可用于实现对象属性的 getset 拦截,而数组其实也是对象,那自然是可以实现对应的拦截操作,如下:

image.png

image.png

Vue2 为什么不使用 Object.defineProperty 拦截 Array?

尤大在曾在 GitHubIssue 中做过如下回复:

image.png

说实话性能问题到底指的是什么呢?

下面是总结了一些目前看到过的回答:

  • 数组 和 普通对象 在使用场景下有区别,在项目中使用数组的目的大多是为了 遍历,即比较少会使用 array[index] = xxx 的形式,更多的是使用数组的 Api 的方式
  • 数组长度是多变的,不可能像普通对象一样先在 data 选项中提前声明好所有元素,比如通过 array[index] = xxx 方式赋值时,一旦 index 的值超过了现有的最大索引值,那么当前的添加的新元素也不会具有响应式
  • 数组存储的元素比较多,不可能为每个数组元素都设置 getter/setter
  • 无法拦截数组原生方法如 push、pop、shift、unshift 等的调用,最终仍需 重写/增强 原生方法

Proxy & Reflect

由于在 Vue2 中使用 Object.defineProperty 带来的缺陷,导致在 Vue2 中不得不提供了一些额外的方法(如:Vue.set、Vue.delete())解决问题,而在 Vue3 中使用了 Proxy 的方式来实现 数据劫持,而上述的问题在 Proxy 中都可以得到解决。

Proxy

Proxy 主要用于创建一个 对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等),本质上是通过拦截对象 内部方法 的执行实现代理,而对象本身根据规范定义的不同又会区分为 常规对象异质对象(这不是重点,可自行了解)。

  • new Proxy(target, handler) 是针对整个对象进行的代理,不是某个属性
  • 代理对象属性拥有 读取、修改、删除、新增、是否存在属性 等操作相应的捕捉器,更多可见

Reflect

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法,这些方法与 Proxy handlers 提供的的方法是一一对应的,且 Reflect 不是一个函数对象,即不能进行实例化,其所有属性和方法都是静态的。

更多方法点此可见

Proxy 为什么需要 Reflect 呢?

Proxyget(target, key, receiver)、set(target, key, newVal, receiver) 的捕获器中都能接到前面所列举的参数:

  • target 指的是 原始数据对象
  • key 指的是当前操作的 属性名
  • newVal 指的是当前操作接收到的 最新值
  • receiver 指向的是当前操作 正确的上下文

怎么理解 Proxy handlerreceiver 指向的是当前操作正确上的下文呢?

  • 正常情况下,receiver 指向的是 当前的代理对象
    image.png
  • 特殊情况下,receiver 指向的是 引发当前操作的对象
  • 通过 Object.setPrototypeOf() 方法将代理对象 proxy 设置为普通对象 obj 的原型
  • 通过 obj.name 访问其不存在的 name 属性,由于原型链的存在,最终会访问到 proxy.name 上,即触发 get 捕获器
  • image.png

Reflect 的方法中通常只需要传递 target、key、newVal 等,但为了能够处理上述提到的特殊情况,一般也需要传递 receiver 参数,因为 Reflect 方法中传递的 receiver 参数代表执行原始操作时的 this 指向,比如:Reflect.get(target, key , receiver)Reflect.set(target, key, newVal, receiver)

总结Reflect 是为了在执行对应的拦截操作的方法时能 传递正确的 this 上下文

Vue3 如何使用 Proxy 实现数据劫持?

Vue3 中提供了 reactive()ref() 两个方法用来将 目标数据 变成 响应式数据,而通过 Proxy 来实现 数据劫持(或代理) 的具体实现就在其中,下面一起来看看吧!

reactive 函数

从源码来看,其核心其实就是 createReactiveObject(...) 函数,那么继续往下查看对应的内容

源码位置:packages\reactivity\src\reactive.ts

export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  // 若目标对象是响应式的只读数据,则直接返回
  if (isReadonly(target)) {
    return target
  }
  // 否则将目标数据尝试变成响应式数据
  return createReactiveObject(
    target,
    false,
    mutableHandlers, // 对象类型的 handlers
    mutableCollectionHandlers, // 集合类型的 handlers
    reactiveMap
  )
}
复制代码

createReactiveObject() 函数

源码的体现也是非常简单,无非就是做一些前置判断处理:

  • 若目标数据是 原始值类型,直接向返回 原数据
  • 若目标数据的 __v_raw 属性为 true,且是【非响应式数据】或 不是通过调用 readonly() 方法,则直接返回 原数据
  • 若目标数据已存在相应的 proxy 代理对象,则直接返回 对应的代理对象
  • 若目标数据不存在对应的 白名单数据类型 中,则直接返回原数据,支持响应式的数据类型如下:
  • 可扩展的对象,即是否可以在它上面添加新的属性
  • __v_skip 属性不存在或值为 false 的对象
  • 数据类型为 Object、Array、Map、Set、WeakMap、WeakSet 的对象
  • 其他数据都统一被认为是 无效的响应式数据对象
  • 通过 Proxy 创建代理对象,根据目标数据类型选择不同的 Proxy handlers

看来具体的实现又在不同数据类型的 捕获器 中,即下面源码的 collectionHandlersbaseHandlers ,而它们则对应的是在上述 reactive() 函数中为 createReactiveObject() 函数传递的 mutableCollectionHandlersmutableHandlers 参数。

源码位置:packages\reactivity\src\reactive.ts

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  // 非对象类型直接返回
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  // 目标数据的 __v_raw 属性若为 true,且是【非响应式数据】或 不是通过调用 readonly() 方法,则直接返回
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  // 目标对象已存在相应的 proxy 代理对象,则直接返回
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  // 只有在白名单中的值类型才可以被代理监测,否则直接返回
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target     
  }
  // 创建代理对象
  const proxy = new Proxy(
    target,
    // 若目标对象是集合类型(Set、Map)则使用集合类型对应的捕获器,否则使用基础捕获器
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers 
  )
  // 将对应的代理对象存储在 proxyMap 中
  proxyMap.set(target, proxy)
  return proxy
}
复制代码

捕获器 Handlers

对象类型的捕获器 — mutableHandlers

这里的对象类型指的是 数组普通对象

源码位置:packages\reactivity\src\baseHandlers.ts

export const mutableHandlers: ProxyHandler<object> = {
  get,
  set,
  deleteProperty,
  has,
  ownKeys
}
复制代码

以上这些捕获器其实就是我们在上述 Proxy 部分列举出来的捕获器,显然可以拦截对普通对象的如下操作:

  • 读取,如 obj.name
  • 设置,如 obj.name = 'zs'
  • 删除属性,如 delete obj.name
  • 判断是否存在对应属性,如 name in obj
  • 获取对象自身的属性值,如 obj.getOwnPropertyNames()obj.getOwnPropertySymbols()

get 捕获器

具体信息在下面的注释中,这里只列举核心内容:

  • 若当前数据对象是 数组,则 重写/增强 数组对应的方法
  • 数组元素的 查找方法includes、indexOf、lastIndexOf
  • 修改原数组 的方法:push、pop、unshift、shift、splice
  • 若当前数据对象是 普通对象,且非 只读 的则通过 track(target, TrackOpTypes.GET, key) 进行 依赖收集
  • 若当前数据对象是 浅层响应 的,则直接返回其对应属性值
  • 若当前数据对象是 ref 类型的,则会进行 自动脱 ref
  • 若当前数据对象的属性值是 对象类型
  • 若当前属性值属于 只读的,则通过 readonly(res) 向外返回其结果
  • 否则会将当前属性值以 reactive(res) 向外返回 proxy 代理对象
  • 否则直接向外返回对应的 属性值
function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    // 当直接通过指定 key 访问 vue 内置自定义的对象属性时,返回其对应的值
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    } else if (key === ReactiveFlags.IS_SHALLOW) {
      return shallow
    } 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)) {
      // 重写/增强数组的方法: 
      //  - 查找方法:includes、indexOf、lastIndexOf
      //  - 修改原数组的方法:push、pop、unshift、shift、splice
      return Reflect.get(arrayInstrumentations, key, receiver)
    }
    // 获取对应属性值
    const res = Reflect.get(target, key, receiver)
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }
    // 依赖收集
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }
    // 浅层响应
    if (shallow) {
      return res
    }
    // 若是 ref 类型响应式数据,会进行【自动脱 ref】,但不支持【数组】+【索引】的访问方式
    if (isRef(res)) {
      const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
      return shouldUnwrap ? res.value : res
    }
    // 属性值是对象类型:
    //  - 是只读属性,则通过 readonly() 返回结果,
    //  - 且是非只读属性,则递归调用 reactive 向外返回 proxy 代理对象
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)
    }
    return res
  }
}


目录
相关文章
|
9天前
vue3【实战】语义化首页布局
vue3【实战】语义化首页布局
28 2
|
9天前
|
存储 容器
vue3【实战】来回拖拽放置图片
vue3【实战】来回拖拽放置图片
17 2
|
9天前
|
JavaScript 开发工具 开发者
vue3【提效】使用 VueUse 高效开发(工具库 @vueuse/core + 新增的组件库 @vueuse/components)
vue3【提效】使用 VueUse 高效开发(工具库 @vueuse/core + 新增的组件库 @vueuse/components)
32 1
|
9天前
|
API
Pinia 实用教程【Vue3 状态管理】状态持久化 pinia-plugin-persistedstate,异步Action,storeToRefs(),修改State的 $patch,$reset
Pinia 实用教程【Vue3 状态管理】状态持久化 pinia-plugin-persistedstate,异步Action,storeToRefs(),修改State的 $patch,$reset
18 1
|
9天前
|
JavaScript
vue3 【提效】自动注册组件 unplugin-vue-components 实用教程
vue3 【提效】自动注册组件 unplugin-vue-components 实用教程
17 1
|
9天前
|
JavaScript API
vue3【实用教程】组件(含父子组件传值 defineProps,自定义事件 defineEmits,defineProps,插槽 slot,动态组件 :is 等)
vue3【实用教程】组件(含父子组件传值 defineProps,自定义事件 defineEmits,defineProps,插槽 slot,动态组件 :is 等)
17 1
|
9天前
|
JavaScript 网络架构
vue3 【提效】自动路由(含自定义路由) unplugin-vue-router 实用教程
vue3 【提效】自动路由(含自定义路由) unplugin-vue-router 实用教程
48 0
vue3 【提效】自动路由(含自定义路由) unplugin-vue-router 实用教程
|
3天前
【vue3】Argumnt of type ‘history:RouterHistory;}is not assignable to paraeter of type ‘RouterOptions‘.
【vue3】Argumnt of type ‘history:RouterHistory;}is not assignable to paraeter of type ‘RouterOptions‘.
6 0
|
3天前
|
JavaScript
【vue3】vue3中路由hash与History的设置
【vue3】vue3中路由hash与History的设置
8 0
|
3天前
|
编解码 前端开发
【Vue3】解决电脑分辨率125%、150%及缩放导致页面变形的问题
【Vue3】解决电脑分辨率125%、150%及缩放导致页面变形的问题
10 0