小码哥带你一窥 vue3 响应式原理

news/2024/7/19 14:43:27 标签: 队列, java, 编程语言, js, webgl
js_content">

前言

随着 Vue3 的登台,各大博客论坛铺天盖地的涌来各种文章。我们组也是率先把 Vue3 应用到了工作中,刮起了一波学习热潮。上个月组内一位大佬的分享也让我受益良多,那么就借着这波余热一起从 Vue3 的源码一探究竟。

1. ref 和 reactive 的关系

Vue3 的文档中主要提供了两种比较常用的方法来把你的数据转换成响应式,ref 和 reactive。这两种方法的区别就在于 ref在 reactive 的基础上对数据进行了二次分装,需要以 .value的形式获取值,可以从源码中看到:

class RefImpl<T> {
  private _value: T

  public readonly __v_isRef = true

  constructor(private _rawValue: T, public readonly _shallow = false) {
    this._value = _shallow ? _rawValue : convert(_rawValue)
  }

  get value() {
    track(toRaw(this), TrackOpTypes.GET, 'value')
    return this._value
  }

  set value(newVal) {
    if (hasChanged(toRaw(newVal), this._rawValue)) {
      this._rawValue = newVal
      this._value = this._shallow ? newVal : convert(newVal)
      trigger(toRaw(this), TriggerOpTypes.SET, 'value', newVal)
    }
  }
}
const convert = <T extends unknown>(val: T): T =>
  isObject(val) ? reactive(val) : val

重点在于 convert 方法,能看出最后还是会通过 reactive 方法来转换成响应式。也知道了因为 Proxy 不能监听简单数据类型所以 Vue 在基础数据类型上又加了一层对象来实现响应式。

2. reactive 做了什么

既然 ref 只是 reactive的一层伪装,那我们就直接看看 reactive 做了什么吧:

export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers
  )
}

在 reactive 方法中,Vue 首先判断了一下如果 target 是只读对象那么直接返回,否则创建一个响应式对象。

if (
  target[ReactiveFlags.RAW] &&
  !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
) {
  return target
}

...

// only a whitelist of value types can be observed.
const targetType = getTargetType(target)
if (targetType === TargetType.INVALID) {
  return target
}
const proxy = new Proxy(
  target,
  targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
)
proxyMap.set(target, proxy)

然后就进入了 createReactiveObject 方法,代码很清晰,先判断了是否已经绑定过,没有就绑定。最后,通过判断目标的类型,如果是普通的对象 Object 或 Array,处理器对象就使用 baseHandlers,如果是 Set, Map, WeakMap, WeakSet 中的一个,就使用 collectionHandlers

这里有一个小知识点,为什么 Vue 存储使用的是 WeakMap ?

export const reactiveMap = new WeakMap<Target, any>()

WeakMap 的键值必须是对象,而且 WeakMap 的键值是不可枚举的,是弱引用。方便垃圾回收。

弱引用对应的当然就是强引用了,强引用有一种计数机制。举个例子当一个对象被创建时,计数为1,每有一个变量引用该对象,计数都会加1,只有当计数为0时,对象才会被垃圾回收。所以一旦造成循环引用等问题,就会造成内存泄漏。而 WeakMap 当它的键所指对象没有被其他地方引用时,就会被垃圾回收了。

3. baseHandlers

baseHandlers 针对不同类型的响应式做了不同的处理,我们重点来看一下完全响应式 mutableHandlers 是如何处理的,完全响应式中 Vue 一共定义了五种方法:

  • get

  • set

  • deleteProperty

  • has

  • ownKeys

从 Vue2 中我们知道,如果 Vue 想要在数据更新时通知界面渲染那肯定要收集依赖,而在数据被修改后触发依赖,那么我们就知道:get、has、ownKeys 是收集依赖,set、deleteProperty 是触发依赖。先看一下 get 方法中是如何收集依赖的。

const res = Reflect.get(target, key, receiver)

if (
  isSymbol(key)
    ? builtInSymbols.has(key as symbol)
    : key === `__proto__` || key === `__v_isRef`
) {
  return res
}

if (!isReadonly) {
  // 只读不需要收集
  track(target, TrackOpTypes.GET, key)
}

if (shallow) {
  // 浅层响应,直接返回
  return res
}

if (isRef(res)) {
  const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
  return shouldUnwrap ? res.value : res
}

if (isObject(res)) {
  // 如果是深层对象需要递归绑定响应
  return isReadonly ? readonly(res) : reactive(res)
}

代码并不难理解,加了点注释。Vue 根据不同情况分别做了不同的处理方式,那么这是对象类型的,数组类型也做了针对性的处理。

if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
  return Reflect.get(arrayInstrumentations, key, receiver)
}

针对数组通过 arrayInstrumentations 方法来收集数组的依赖。我们来看看 arrayInstrumentations 方法做了什么:

const arrayInstrumentations: Record<string, Function> = {}
// instrument identity-sensitive Array methods to account for possible reactive
// values
;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
  const method = Array.prototype[key] as any
  arrayInstrumentations[key] = function(this: unknown[], ...args: unknown[]) {
    const arr = toRaw(this)
    for (let i = 0, l = this.length; i < l; i++) {
      track(arr, TrackOpTypes.GET, i + '')
    }
    // we run the method using the original args first (which may be reactive)
    const res = method.apply(arr, args)
    if (res === -1 || res === false) {
      // if that didn't work, run it again using raw values.
      return method.apply(arr, args.map(toRaw))
    } else {
      return res
    }
  }
})

在这里 Vue 通过封装了数组方法来达到收集依赖的目的。

再回到之前的 get 方法中,Vue 使用了 Reflect.get。Reflect 是一个内置对象,它与 Proxy 几乎共用方法。那为什么 Vue 要多此一举用 Reflect 呢?我们来看个例子:

const data = new Proxy([1,2,3], {
    get(target, key) {
        console.log('get', key)
        return target[key];
    },
    set(target, key, value) {
        console.log('set', key, value)
        return true;
    }
})
data.push(4)

这是一个普通写法,set 方法为什么要返回一个 true,如果不返回 true 则会抛出一个 typeError。看上去是不是不太优雅,那么我们用 Reflect 改造一下。

const data = new Proxy([1, 2, 3], {
  get(target, key, receiver) {
    console.log('get', key);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log('set', key, value);
    return Reflect.set(target, key, value, receiver);
  },
});
data.push(4);

改造完后,我们来看看打印结果,打印出来是这样的:

get push
get length
set 3 4
set length 4

这样达到的效果是一样的,不过还是有点问题。你会发现打印了两次 get 和 set,这是因为在 push 后,不仅改变了数组内的元素,同时操作了数组的 length 产生了两次 get 和 set,那么 Vue 是如何解决这种重复问题的?

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
if (target === toRaw(receiver)) {
  if (!hadKey) {
    // key 不存在说明是新增操作,触发依赖
    trigger(target, TriggerOpTypes.ADD, key, value)
  } else if (hasChanged(value, oldValue)) {
    // 新旧值不相等,触发依赖
    // 同时也防止了刚才提到的 push 后触发了两次依赖的问题,新旧值相同则不触发依赖
    trigger(target, TriggerOpTypes.SET, key, value, oldValue)
  }
}
return result;

在 set 方法中,Vue 通过判断 key 是否为 target 自身属性,以及设置 val 是否跟 target[key] 相等来阻止重复的响应。

继续看 set 和 get 方法中,用到了 trigger 和 track 方法。看一下源码中他们都来自于一个叫 effect 的文件,那么 effect 是做什么用的?

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  if (isEffect(fn)) {
    fn = fn.raw
  }
  const effect = createReactiveEffect(fn, options)
  if (!options.lazy) {
    effect()
  }
  return effect
}

其中创建 effect 的其实是 createReactiveEffect 方法,我们来看看这个方法:

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    if (!effect.active) {
      return options.scheduler ? undefined : fn()
    }
    if (!effectStack.includes(effect)) {
      // 清除依赖
      cleanup(effect)
      try {
        enableTracking()
        effectStack.push(effect)
        // 
        activeEffect = effect
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  } as ReactiveEffect
}

在 createReactiveEffect 方法中,首先对 effect 做了一些初始化,然后初次创建 effect 的时候如果当前的 effect 栈(effectStack)不包含当前 effect,那么清空之前的依赖,避免依赖的重复收集。然后执行传进来的 fn 方法,fn 方法是 vm 原型上的一个叫做 componentEffect。执行该方法后也就进入了渲染流程了。在这里,effect 最终赋值给了 activeEffect 中。

那我们先继续来看看 trigger 和 track 做了什么:

export function track(target: object, type: TrackOpTypes, key: unknown) {
  // activeEffect 为空代表没有依赖,直接返回
  if (!shouldTrack || activeEffect === undefined) {
    return
  }
  // targetMap 是一个 WeakMap,用于收集依赖
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 如果目标没有被追踪,添加一个
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    // 如果目标 key 没有被追踪,添加一个
    depsMap.set(key, (dep = new Set()))
  }
  if (!dep.has(activeEffect)) {
    // 如果没有添加 activeEffect,添加一个
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
    ...
  }
}

在 track 方法中,主要追踪并收集了依赖,再来看看 trigger:

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  const depsMap = targetMap.get(target)
  // 没有收集依赖的话直接返回
  if (!depsMap) {
    // never been tracked
    return
  }

  // 依赖队列,这里的依赖队列是 Set 类型避免重复
  const effects = new Set<ReactiveEffect>()
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => {
        if (effect !== activeEffect || effect.allowRecurse) {
          effects.add(effect)
        }
      })
    }
  }

  if (type === TriggerOpTypes.CLEAR) {
    ...
  } else if (key === 'length' && isArray(target)) {
  	// 这里就能监听到数组的 length 变化了
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        add(dep)
      }
    })
  } else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      add(depsMap.get(key))
    }

    // 在 新增/删除/编辑 的方法中,判断了 target 的类型然后添加 depsMap 中的不同依赖到 effect 中
    switch (type) {
      case TriggerOpTypes.ADD:
        ...

      case TriggerOpTypes.DELETE:
        ...
      case TriggerOpTypes.SET:
        ...
    }
  }

  const run = (effect: ReactiveEffect) => {
    if (__DEV__ && effect.options.onTrigger) {
      effect.options.onTrigger({
        effect,
        target,
        key,
        type,
        newValue,
        oldValue,
        oldTarget
      })
    }
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }

  // 触发依赖
  effects.forEach(run)
}

在 trigger 方法中,拿到了之前收集到的依赖(也就是之前添加好的 effect)并添加到了任务队列中。然后遍历触发依赖。

其实已经有 Vue2 内味儿了,其实就是一个改良版的发布订阅模式。get 时通过 track 收集依赖,而 set 时通过 trigger 触发了依赖,而 effect 收集了这些依赖并进行追踪,在响应后去触发相应的依赖。effect 也正是 Vue3 响应式的核心。

了解了这些后,那么剩下的三个方法就很明朗了,一看就能明白:

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
}

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
}

function ownKeys(target: object): (string | number | symbol)[] {
  track(target, TrackOpTypes.ITERATE, isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}

那么 Vue3 的响应式相关的比较核心的源码基本就是这些了,至于 collectionHandlers 方法,基本是换汤不换药,核心也是通过 trigger 和 track 实现,有兴趣的大佬可以自己看看。

4. 参考

  • https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect

  • https://www.vue3js.cn/docs/zh

  • https://github.com/vuejs/vue-next

全文完


以下文章您可能也会感兴趣:

  • 理清 Promise 的状态及使用

  • 逻辑思维:理清思路,表达自己的技巧

  • Actor 模型及 Akka 简介

  • 中文房间之争:浅论到底什么是智能

  • 从零搭建一个基于 lstio 的服务网格

  • 容器管理利器:Web Terminal 简介

  • 单元测试 -- 工程师 Style 的测试方法

  • 理解 RabbitMQ Exchange

  • iOS 下的图片处理与性能优化

  • 不懂产品的研发,不是好 CTO

  • 技术选型的艺术

  • 服务网格:微服务进入2.0时代

  • Apache Common Pool2 对象池应用浅析

  • 函数扩展

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。


http://www.niftyadmin.cn/n/1781384.html

相关文章

在 Delphi 下使用 DirectSound (2): 查看设备性能

为什么80%的码农都做不了架构师&#xff1f;>>> 使用 DirectSound 前应先建立 IDirectSound8 对象(之前的版本是 IDirectSound): function DirectSoundCreate8(pcGuidDevice: PGUID; //设备的 GUID; 指定 nil 表示使用默认声卡out ppDS8: IDirectSound8; //要…

两种方法解决mysql主从不同步

今天发现Mysql的主从数据库没有同步先上Master库&#xff1a;mysql>show processlist; 查看下进程是否Sleep太多。发现很正常。show master status; 也正常。mysql> show master status;--------------------------------------------------------------------------| Fi…

计算机组装与维修选择题详解,《计算机组装与维修》复习题(已修完整版).pdf

计算机组装与维修复习题计算机组装与维修复习题一、填空题 &#xff1a;一、填空题 &#xff1a;、主频、外频、倍频之间的关系为 主频 外频 倍频 。、主频、外频、倍频之间的关系为 主频 外频 倍频 。1 *1 *、家用计算机常用的硬盘接口有 接口和 接口。2 、家用计算机常用的…

打造前端监控系统之 SDK 实战篇

目录大纲前言收集哪些数据性能错误辅助信息小结客户端 SDK&#xff08;探针&#xff09;相关原理和 APIWeb微信小程序编写测试用例单元测试流程测试提供 Web 环境的方式Mock Web API 的方式结语一、前言随着前端的发展和被重视&#xff0c;慢慢的行业内对于前端监控系统的重视程…

GdiPlus[51]: 图像(三) 关于呈现

为什么80%的码农都做不了架构师&#xff1f;>>> 相关方法: IGPGraphics.DrawImage(); IGPImage.GetThumbnailImage(); IGPImage.RotateFlip();用 DrawImage 呈现图像时, 是否指定 Width 和 Height 的区别: //如果图像的分辨率与 Graphics 的分辨率不一致, 则指定 W…

西电_矩阵论_学习笔记

文章目录 【 第一章 线性空间 】【 第二章 范数 】【 第三章 矩阵函数 】【 第四章 矩阵分解 】【 第五章 矩阵特征值估计 】【 第六章 广义逆 】【 考试重点内容总结 】 这是博主2023春季西电所学矩阵论的思维导图&#xff08;软件是幕布&#xff09;&#xff0c;供大家参考&a…

内容更新了《网络规划设计师考试考点分析与真题详解》(2013年8月印刷版)...

小伙伴们&#xff1a;有福了&#xff01;《网络规划设计师考试考点分析与真题详解》&#xff08;2013年8月印刷版&#xff09;内容更新了,定价保持不变。你还在为2012年11月网络规划设计师考试真题中运筹学类及网络规划类试题的解答&#xff0c;发愁吗&#xff1f;你还在为网络…

使用 IntraWeb (19) - 基本控件之 TIWTreeView

为什么80%的码农都做不了架构师&#xff1f;>>> 这是个饱受非议的控件; 我通过尝试, 理解了非议, 也能理解作者. 总之向作者的思路靠拢吧, 还是不错的. TIWTreeView 所在单元及继承链: IWCompTreeview.TIWTreeView < TIWCustomControl < TIWBaseHTMLControl …