Vue 怎么监听 Set,WeakSet,Map,WeakMap 变化?

2,184 阅读9分钟

该系列是本人准备面试的笔记,或许有描述不当的地方,请在评论区指出,感激不尽。

其他篇章:

  1. Promise.try 和 Promise.withResolvers,你了解多少呢?
  2. 从 babel 编译看 async/await
  3. 挑战ChatGPT提供的全网最复杂“事件循环”面试题
  4. Vue.nextTick 从v3.5.13追溯到v0.7.0
  5. Vue 是怎么从<HelloWorld />、<component is='HelloWorld'>找到HelloWorld.vue

前言

  • Set:值成组,独一存,去重快,操作频。
  • WeakSet:弱引用,只存对,自动清,不留迹。
  • Map:键值对,遍历易,键不限,存储灵。
  • WeakMap:弱键值,隐私守,键对象,防泄漏。

594c58d9fbb5468ab7ab35724cc4e60d.gif

为什么会引入 Set、WeakSet、Map 和 WeakMap?

Set 和 WeakSet

SetWeakSet 的设计初衷是解决 Array 在以下场景中的不足:

  • 去重功能Array 无法直接去重,需要使用额外逻辑或工具函数。而 Set 天然支持去重。
  • 性能问题:当数组元素较多时,Array 的查找、删除操作效率较低(时间复杂度为 O(n)),而 Set 基于哈希表实现,查找和删除的时间复杂度为 O(1)。

WeakSet 的特点

WeakSet 是一种特殊的集合,仅存储对象引用,且这些引用是弱引用(weakly referenced)。
当对象在其他地方没有引用时,垃圾回收机制会自动清除 WeakSet 中的引用,避免内存泄漏。

Map 和 WeakMap

MapWeakMap 的出现则是为了解决 Object 的以下不足:

  1. 键的局限性:在 Object 中,键只能是字符串或 Symbol,而 Map 支持任何类型作为键,包括对象。
  2. 性能优化:对于较复杂的操作(如使用非字符串类型的键、频繁的插入和删除),Map 提供了更好的性能和灵活性。
  3. 键值对的存储意图更明确:相比于 ObjectMap 更适合用作键值对存储,而非仅仅作为原型链和属性的载体。

WeakMap 的特点

WeakMap 是一种键值对集合,键必须是对象且为弱引用。其主要优势包括:

  • 内存管理:当某个键对象没有其他引用时,键值对会自动被垃圾回收机制清理。
  • 隐私性WeakMap 的键值对无法被遍历,因此可以用来存储私有数据。

与 Object 和 Array 的对比

功能对比

数据结构是否支持去重是否支持任意键是否支持弱引用是否支持遍历性能表现(查找/插入)
Object字符串/SymbolO(1)(理论上)
Array数字/字符串/SymbolO(n)
Set-O(1)
WeakSet-O(1)
Map任意O(1)
WeakMap对象或非全局注册的符号O(1)

基准测试

本地测试结果

测试结果与每个人的硬件,运行环境(浏览器/Node)相关。 大家也可自行测试,在评论区给出你的测试结果。

添加操作
数据结构平均时间 (ms)最小时间 (ms)最大时间 (ms)标准差 (ms)
Object8.485.4333.368.30
Map5.293.6910.742.41
Array3.520.929.772.78
Set6.254.329.041.54
查找操作
数据结构平均时间 (ms)最小时间 (ms)最大时间 (ms)标准差 (ms)
Object0.100.040.440.12
Map0.080.050.300.08
Array2760.952733.922783.7615.06
Set0.110.060.430.11
更新操作
数据结构平均时间 (ms)最小时间 (ms)最大时间 (ms)标准差 (ms)
Object0.080.060.250.06
Map0.100.050.340.08
Array9.579.2510.680.40
删除操作
数据结构平均时间 (ms)最小时间 (ms)最大时间 (ms)标准差 (ms)
Object0.090.050.300.07
Map0.090.050.400.10
Array3085.052842.653282.08144.71
Set0.090.040.370.09

结果分析

  • 对于频繁查询和修改的场景,ObjectMap 是优选。
  • 对于需要快速唯一性检查或插入的场景,Set 表现出色。
  • 尽量避免在大规模数据中频繁对 Array 进行删除或查找操作。

注意

  • 对于 Set,它是一个值的集合,只存储唯一值。没有类似 MapObject 的键值对,因此无法像 map.set(key, value) 那样直接更新。如果需要修改一个值,只能先删除原值再插入新值。所以不对 Set 进行 update 测试。
  • 对于 Array,删除操作使用 splice 是常见的实现方式,但它的时间复杂度较高,特别是在删除数组开头或中间元素时。

可以完全取代 Object 吗?

尽管 Map 的功能比 Object 更强大,但两者并非可以完全互相替代:

  • 语义化场景Object 更适合用作存储数据属性的载体(例如:user.nameuser.age)。
  • 继承机制Object 支持原型链继承,而 Map 则没有这个功能。
  • 内置方法支持Object 拥有丰富的内置方法(如 Object.keysObject.values),而 Map 实例也支持 .keys.values,但返回的是一个迭代器对象。

结论:MapWeakMap 更适合用作键值对存储,而 Object 在结构化数据建模中仍然不可或缺。

使用场景

  • Set 和 WeakSet

    • 去重数组Set 是处理重复数据的利器,快速过滤重复值。
    • 快速集合操作:判断元素是否存在或处理动态集合。
    • 弱引用集合WeakSet):用于存储不需要强引用的对象集合,比如跟踪 DOM 元素。
  • Map 和 WeakMap

    • 复杂键值对映射Map 能更方便地将对象作为键,存储复杂对象之间的映射关系。
    • 缓存管理WeakMap 是构建内存敏感缓存的理想选择,例如存储与 DOM 元素关联的数据。
    • 私有数据存储:使用 WeakMap 模拟类的私有属性。

Vue 如何监听变化?

测试 demo 如下:

<script setup>
import { reactive, watch } from "vue";

// Map 示例
const map = reactive(new Map());
const addToMap = () => {
    const key = `item${map.size + 1}`;
    map.set(key, `value${map.size + 1}`);
};
const deleteFromMap = () => {
    const firstKey = Array.from(map.keys())[0];
    map.delete(firstKey);
};

// Set 示例
const set = reactive(new Set());
const addToSet = () => {
    set.add(`item${set.size + 1}`);
};
const deleteFromSet = () => {
    const firstItem = Array.from(set)[0];
    set.delete(firstItem);
};

// 监听 Map 变化
watch(
    () => Array.from(map.entries()),
    (newValue) => {
        console.log("Map 已更新:", newValue);
    }
);
watch(
    () => map.size,
    (newValue) => {
        console.log("Map.size 已更新:", newValue);
    }
);

// 监听 Set 变化
watch(
    () => Array.from(set),
    (newValue) => {
        console.log("Set 已更新:", newValue);
    }
);
watch(
    () => set.size,
    (newValue) => {
        console.log("Set.size 已更新:", newValue);
    }
);
</script>

源码逻辑:

function createIterableMethod(
    method: string | symbol
) {
    return function (
        this: IterableCollections,
        ...args: unknown[]
    ): Iterable<unknown> & Iterator<unknown> {
        const target = this[ReactiveFlags.RAW]
        const rawTarget = toRaw(target)
        const targetIsMap = isMap(rawTarget)
        const isPair =
            method === 'entries' || (method === Symbol.iterator && targetIsMap)
        const isKeyOnly = method === 'keys' && targetIsMap
        const innerIterator = target[method](...args)
        const wrap = toReactive
        track(
            rawTarget,
            TrackOpTypes.ITERATE,
            isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY,
        )
        return {
            next() {
                const { value, done } = innerIterator.next()
                return done
                    ? { value, done }
                    : {
                        value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
                        done,
                    }
            },
            [Symbol.iterator]() {
                return this
            },
        }
    }
}

function createInstrumentations(): Instrumentations {
    const instrumentations: Instrumentations = {
        get(this: MapTypes, key: unknown) {
            const target = this[ReactiveFlags.RAW]
            const rawTarget = toRaw(target)
            const rawKey = toRaw(key)
            if (hasChanged(key, rawKey)) {
                track(rawTarget, TrackOpTypes.GET, key)
            }
            track(rawTarget, TrackOpTypes.GET, rawKey)
            const { has } = getProto(rawTarget)
            const wrap = toReactive
            if (has.call(rawTarget, key)) {
                return wrap(target.get(key))
            } else if (has.call(rawTarget, rawKey)) {
                return wrap(target.get(rawKey))
            } else if (target !== rawTarget) {
                target.get(key)
            }
        },
        get size() {
            const target = (this as unknown as IterableCollections)[ReactiveFlags.RAW]
            track(toRaw(target), TrackOpTypes.ITERATE, ITERATE_KEY)
            return Reflect.get(target, 'size', target)
        },
        has(this: CollectionTypes, key: unknown): boolean {
            const target = this[ReactiveFlags.RAW]
            const rawTarget = toRaw(target)
            const rawKey = toRaw(key)
            if (hasChanged(key, rawKey)) {
                track(rawTarget, TrackOpTypes.HAS, key)
            }
            track(rawTarget, TrackOpTypes.HAS, rawKey)
            return key === rawKey
                ? target.has(key)
                : target.has(key) || target.has(rawKey)
        },
        forEach(this: IterableCollections, callback: Function, thisArg?: unknown) {
            const observed = this
            const target = observed[ReactiveFlags.RAW]
            const rawTarget = toRaw(target)
            const wrap = toReactive
            track(rawTarget, TrackOpTypes.ITERATE, ITERATE_KEY)
            return target.forEach((value: unknown, key: unknown) => {
                return callback.call(thisArg, wrap(value), wrap(key), observed)
            })
        },
    }

    extend(
        instrumentations, {
        add(this: SetTypes, value: unknown) {
            if (!isShallow(value) && !isReadonly(value)) {
                value = toRaw(value)
            }
            const target = toRaw(this)
            const proto = getProto(target)
            const hadKey = proto.has.call(target, value)
            if (!hadKey) {
                target.add(value)
                trigger(target, TriggerOpTypes.ADD, value, value)
            }
            return this
        },
        set(this: MapTypes, key: unknown, value: unknown) {
            if (!isShallow(value) && !isReadonly(value)) {
                value = toRaw(value)
            }
            const target = toRaw(this)
            const { has, get } = getProto(target)

            let hadKey = has.call(target, key)
            if (!hadKey) {
                key = toRaw(key)
                hadKey = has.call(target, key)
            }

            const oldValue = get.call(target, key)
            target.set(key, value)
            if (!hadKey) {
                trigger(target, TriggerOpTypes.ADD, key, value)
            } else if (hasChanged(value, oldValue)) {
                trigger(target, TriggerOpTypes.SET, key, value, oldValue)
            }
            return this
        },
        delete(this: CollectionTypes, key: unknown) {
            const target = toRaw(this)
            const { has, get } = getProto(target)
            let hadKey = has.call(target, key)
            if (!hadKey) {
                key = toRaw(key)
                hadKey = has.call(target, key)
            }

            const oldValue = get ? get.call(target, key) : undefined
            const result = target.delete(key)
            if (hadKey) {
                trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
            }
            return result
        },
        clear(this: IterableCollections) {
            const target = toRaw(this)
            const hadItems = target.size !== 0
            const oldTarget = undefined
            const result = target.clear()
            if (hadItems) {
                trigger(
                    target,
                    TriggerOpTypes.CLEAR,
                    undefined,
                    undefined,
                    oldTarget,
                )
            }
            return result
        },
    }
    )

    const iteratorMethods = [
        'keys',
        'values',
        'entries',
        Symbol.iterator,
    ] as const

    iteratorMethods.forEach(method => {
        instrumentations[method] = createIterableMethod(method)
    })

    return instrumentations
}

1. createIterableMethod 方法

createIterableMethod 是一个工厂函数,用于创建 SetMap 上的迭代方法(如 keys, values, entries, Symbol.iterator)的代理。这个方法的目标是将迭代方法包装成一个响应式的迭代器,并在访问时触发数据追踪 (track)。

  • 目标: 使得对 MapSet 数据结构的迭代操作(如 .keys(), .values(), .entries(), Symbol.iterator)能够触发响应式的数据追踪。

  • 如何做:

    • createIterableMethod 方法根据传入的 method 参数,分别处理 SetMap 上的四种常见迭代方法。
    • 对于 Map,方法还会判断是否需要处理 key-value 对(isPair)或者单独的 keyisKeyOnly)。
    • rawTarget 进行 track 调用来追踪数据访问。
    • 返回一个 Iterator 对象,并且在 next 方法中封装响应式数据,确保 value 是经过 toReactive 包装的。

2. createInstrumentations 方法

createInstrumentations 方法定义了对 SetMap 数据结构的各种操作的响应式拦截,并通过 instrumentations 对象暴露这些拦截方法。这些方法会在访问或修改 SetMap 时触发响应式追踪。

  • 目标:SetMap 数据结构的常见操作(如 get, set, add, delete, clear 等)进行响应式包装,确保对这些操作的每一次修改都能触发依赖的更新。

  • 具体操作:

    1. get 方法:

      • 在获取 Map 中的值时,会先进行 toRaw 处理,确保操作的是原始数据。
      • 使用 track 方法记录 GET 操作的变化,确保依赖能够被追踪。
      • 如果键不存在,它会检查原始数据的 key 是否存在,并处理返回的值。
    2. size getter:

      • 在访问 size 属性时,会追踪 ITERATE 操作(即大小变化),并返回集合的大小。
    3. has 方法:

      • 检查 MapSet 是否包含某个键/值,使用 track 记录该操作。
    4. forEach 方法:

      • 对集合的每一项调用回调函数,并将每个项包装成响应式数据。
    5. add 方法 (针对 Set):

      • 如果值不是浅层的,且没有被只读修饰,则对值进行 toRaw 转换。
      • 如果值已经存在于集合中,则不会触发添加操作。
      • 如果值不存在,触发 ADD 操作来更新集合,并发出响应式变更。
    6. set 方法 (针对 Map):

      • 检查 Map 是否已经包含某个键,若不存在,则触发 ADD 操作。
      • 如果值有变化,触发 SET 操作。
    7. delete 方法:

      • 删除集合中的某项,并触发 DELETE 操作,发出数据变更通知。
    8. clear 方法:

      • 清空集合时,触发 CLEAR 操作,确保清空操作被响应式系统正确记录。

3. 数据代理和追踪

通过 Proxytrack 函数,数据结构上的每一次访问和修改都会触发响应式系统的更新。这些追踪信息使得 Vue 3 的响应式系统能够在数据变化时自动更新依赖的视图或其他相关数据。

  • 数据修改时触发的响应式操作:

    • 任何对 MapSet 数据结构的操作(例如:添加、删除、修改、获取等),都会通过 tracktrigger 方法被追踪和记录。
    • track 用于记录依赖,trigger 用于在数据变化时触发更新。

结语

欢乐的时光到这里就要结束了。喜欢这篇文章的朋友不要忘了点赞收藏评论!

1-1720751939.jpeg