返回

Vue structuredClone克隆ref报错?用toRaw一招解决

vue.js

Vue 里 structuredClone() 克隆 ref 值为啥报错?咋整?

写 Vue 3 的时候,想搞个对象的深拷贝,structuredClone() 这玩意儿原生支持,多香啊!不用再折腾 JSON.parse(JSON.stringify()) 这种笨办法,也不想为了个深拷贝就专门引入 lodash 之类的库。

官方文档说 structuredClone() 能拷贝大多数类型,挺好使。我试了下普通对象,确实没毛病:

const a = {
  foo: {
    bar: "+"
  }
};
const b = structuredClone(a); // 妥妥的

console.log(b); // { foo: { bar: "+" } }
console.log(a.foo === b.foo); // false,深拷贝成功!

一切看起来都很美好,直到我把它用到了 Vue 的 ref 变量上。

问题不大,但挺烦人

当我想克隆一个 ref 包裹的对象时,比如这样:

import { ref } from "vue";

const a = ref({ foo: { bar: "+" } });
const b = structuredClone(a.value); // 想克隆 ref 里面的值

浏览器控制台直接给我甩了个大红脸:

Uncaught DOMException: Failed to execute 'structuredClone' on 'Window': #<Object> could not be cloned.

翻译过来就是:“兄弟,你传进来的这个对象,我 structuredClone 克隆不了啊!”

更坑的是,如果 ref 包裹的是个数组,我想遍历数组,克隆里面的对象,同样中招:

import { ref } from "vue";

const list = ref([{ id: 1, data: { value: "A" } }, { id: 2, data: { value: "B" } }]);

for (const item of list.value) {
  // const clonedItem = structuredClone(item); // 照样报错!
  console.log(item); // 这里 item 看起来就是个普通对象
}

这就奇了怪了,a.value 或者数组里的 item,打印出来看着就是个普通 JavaScript 对象,凭啥 structuredClone 就伺候不了它了呢?

为啥 structuredClone 跟 Vue 的 ref 八字不合?

要搞清楚这背后的小九九,咱们得先聊聊 Vue 的响应式系统和 structuredClone 的脾气。

Vue 的响应式魔法

Vue 3 里面,ref()reactive() 是实现响应式数据的两大护法。
当你用 ref() 包裹一个对象或者数组时,比如 ref({ foo: 'bar' }),Vue 实际上在内部会调用 reactive() 来把这个对象转换成响应式的。

这个 reactive() 干了啥?它会用 JavaScript 的 Proxy 对象把你的原始对象包一层。这个 Proxy 就像个忠心耿耿的管家,监视着你对对象的所有操作(比如读取属性、设置属性)。一旦数据有变动,它就能通知 Vue:“嘿,数据变啦,该更新视图啦!”

所以,当你访问 a.value 时,你拿到的其实是一个被 Proxy 包裹了的对象。虽然它用起来跟普通对象差不多,但“内心”已经打上了 Vue 响应式的烙印。这个烙印可能包含一些 Vue 内部管理用的、不可枚举的属性,或者是 Proxy 本身的一些特性。

structuredClone 的“洁癖”

structuredClone() 是一个比较新的 API,它遵循 HTML 规范定义的“结构化克隆算法”。这个算法能处理很多 JavaScript 类型,比 JSON.stringify/parse 强多了(比如 Date, RegExp, Map, Set, ArrayBuffer 等等)。

但是!structuredClone 也不是万能的。它不能克隆:

  1. 函数 (Function objects)
  2. DOM 节点 (DOM nodes)
  3. 某些内置类型的对象,比如 Error 对象、或者一些宿主环境提供的特殊对象。
  4. 对象的属性符、setter 和 getter 不会被克隆。它只克隆值。
  5. 原型链也不会被遍历和克隆。

关键就在于,Vue 通过 Proxy 给对象添加的那些响应式特性,或者一些内部标记,可能就属于 structuredClone 无法理解或处理的范畴。它一瞅:“嗯?这对象身上带的这些玩意儿,我的说明书上没写能克隆啊!” 于是,它就直接罢工,抛出那个 DOMException

简单说,Vue 为了实现响应式,给对象“化了妆”(加了 Proxy 和一些内部属性)。而 structuredClone 这个卸妆水不够强力,认不出“素颜”的对象,或者被某些“化妆品残留”卡住了。

药方来了:toRaw 出手,克隆不愁

既然问题出在 Vue 的响应式包装上,那解法就很自然了:在克隆之前,把这层包装去掉,拿到原汁原味的对象不就行了?

Vue 早就替咱们想到了这一点,提供了一个工具函数:toRaw

toRaw 是个啥?

toRaw 是一个 Vue 3 提供的 API,它的作用是返回由 reactivereadonly 创建的代理对应的原始对象。如果参数不是代理对象,toRaw 会原样返回。

你可以把它理解成一个“照妖镜”,能看穿 Proxy 的伪装,直接拿到里面未经任何修饰的原始数据。

实战演练:toRaw 搭配 structuredClone

有了 toRaw,解决上面的问题就变得手到擒来了。

1. 克隆 ref 包裹的对象:

import { ref, toRaw } from "vue";

const a = ref({ foo: { bar: "+" } });

// 先用 toRaw 拿到原始对象
const rawA = toRaw(a.value);

// 然后再用 structuredClone 克隆这个原始对象
const b = structuredClone(rawA);

console.log(b); // { foo: { bar: "+" } }
console.log(a.value.foo === b.foo); // false,深拷贝成功,且不报错!

瞧,问题解决了!先用 toRaw(a.value) 拿到不带响应式“壳”的原始对象,再把它喂给 structuredClone,一路畅通。

2. 克隆 ref 包裹的数组中的对象:

import { ref, toRaw } from "vue";

const list = ref([{ id: 1, data: { value: "A" } }, { id: 2, data: { value: "B" } }]);
const clonedList = [];

for (const item of list.value) {
  // 对数组中的每个响应式对象,先用 toRaw 获取原始对象
  const rawItem = toRaw(item);
  const clonedItem = structuredClone(rawItem);
  clonedList.push(clonedItem);
  console.log('Original item:', item);
  console.log('Cloned item:', clonedItem);
  console.log('Is data object same?', item.data === clonedItem.data); // false
}

console.log('Original list value:', list.value);
console.log('Cloned list:', clonedList);

对于数组中的每一个元素(它们也是被 reactive 处理过的),同样用 toRaw 获取其原始形态,然后再进行克隆。这样,structuredClone 就能愉快地工作了。

如果你想克隆整个数组,并且数组里的对象也是响应式的,可以这样做:

import { ref, toRaw } from "vue";

const originalRefArray = ref([
  { id: 1, nested: { name: "Alice" } },
  { id: 2, nested: { name: "Bob" } }
]);

// 获取原始数组,数组内的对象此时还是 Proxy
const rawArrayProxiedItems = toRaw(originalRefArray.value);

// structuredClone 可以处理包含 Proxy 的数组,只要 Proxy 的 target 是可克隆的
// 但是如果 Proxy 本身被 Vue 标记了某些特性,直接克隆 Proxy 可能失败
// 更保险的做法是,确保克隆的是纯净的原始数据结构

// 方案一:克隆原始数组,然后数组内部的每个对象也确保是原始的(如果它们是响应式对象)
// structuredClone 遇到 Proxy 时,会尝试克隆 Proxy 的 target。
// 如果 Vue Proxy 的 target 是普通对象,则能成功。
// 问题中,直接 structuredClone(a.value) 失败,说明 a.value 这个 Proxy 的 target
// 或 Proxy 本身,因为 Vue 的处理,使其无法被 structuredClone。
// toRaw(a.value) 返回的是 target,这个 target 对于 structuredClone 通常是可接受的。

// 克隆整个响应式数组(里面包含响应式对象)
// const clonedArray = structuredClone(toRaw(originalRefArray.value));
// 上面这行在某些 Vue 版本或复杂情况下可能依然会因为嵌套对象是 Proxy 而出问题。
// 这是因为 toRaw(array) 返回的是原始数组,但数组内的元素如果本身是独立创建的 reactive 对象,它们仍然是 Proxy。
// structuredClone 会尝试克隆这些 Proxy。如果这些 Proxy 的 target 是干净的,就没问题。

// 最稳妥的方式:深度 toRaw (如果需要)
// 如果数组内的对象也是 ref 或 reactive,需要确保它们也被转换为 raw
// 但通常 toRaw(array) 返回的数组,其元素已经是原始值或指向原始对象的引用(如果原始数组元素就是对象)
// Vue 的 toRaw 对于数组,会返回原始数组。如果数组内的对象是 reactive proxy, 那么原始数组里对应的是原始对象。

const completelyRawArray = toRaw(originalRefArray.value).map(item => toRaw(item));
const clonedDeepArray = structuredClone(completelyRawArray);

// 不过,在大多数情况下,下面这个更简洁的方式应该能行:
const smartClonedArray = structuredClone(toRaw(originalRefArray.value), {
    transfer: originalRefArray.value.map(item => toRaw(item)) // 这不是 structuredClone 的标准用法,是示意
});
// 正确的做法是确保输入给 structuredClone 的是纯净数据结构

// 如果 originalRefArray.value 是 Proxy<Array<Proxy<Object>>>
// toRaw(originalRefArray.value) -> Array<Proxy<Object>>
// structuredClone(Array<Proxy<Object>>) 依然可能报错,因为它要克隆里面的 Proxy<Object>

// 最可靠的做法还是先对最外层 toRaw,然后如果需要克隆的是其内容,确保内容也是 toRaw 后的
// 对数组进行 map,对每个元素调用 toRaw
const rawOuterArray = toRaw(originalRefArray.value); // 得到原始数组,元素可能还是 Proxy
const itemsToClone = rawOuterArray.map(entry => toRaw(entry)); // 对每个元素也 toRaw
const trulyClonedArray = structuredClone(itemsToClone);


console.log("Cloned Array (truly):", trulyClonedArray);
console.log(originalRefArray.value[0].nested === trulyClonedArray[0].nested); // false

// 演示一下为什么可能需要 map(item => toRaw(item))
const item1 = reactive({ name: "Item 1" });
const item2 = reactive({ name: "Item 2" });
const reactiveArray = reactive([item1, item2]);

const rawReactiveArray = toRaw(reactiveArray); // rawReactiveArray is [ Proxy<item1_target>, Proxy<item2_target> ] if item1, item2 were separately reactive
                                            // 或者 [ item1_target, item2_target ] 如果 reactiveArray 是通过 reactive([{name...}, {name...}]) 创建的
                                            // 这取决于 Proxy 的嵌套和创建方式,但 toRaw(reactive(arr)) 返回的是原始数组,其元素是原始对象。

// 证明:
const objA = { id: 1 };
const objB = { id: 2 };
const arr = ref([objA, objB]); // arr.value 会被 reactive() 包裹
                               // arr.value[0] 访问到的是 objA 的 Proxy

// console.log(isReactive(arr.value)); // true
// console.log(isReactive(arr.value[0])); // true

const rawArr = toRaw(arr.value); // rawArr 是 [objA, objB] (原始对象组成的数组)
// console.log(isReactive(rawArr)); // false
// console.log(isReactive(rawArr[0])); // false
// console.log(isProxy(rawArr[0])); // false (重要!)

// 所以,对于 ref([]) 或 reactive([]) 包裹的数组,其元素如果原始就是普通对象,
// toRaw(array.value) 后得到的数组,其元素就是那些普通对象,而不是它们的 Proxy。
// 这种情况下,直接 structuredClone(toRaw(array.value)) 是可以的。
const aValue = ref([{ foo: { bar: "+" } }]);
const bValueCloned = structuredClone(toRaw(aValue.value));
console.log('Cloned array from ref:', bValueCloned);
console.log(aValue.value[0].foo === bValueCloned[0].foo); // false

// 这说明了 `toRaw` 在处理数组时的行为:它返回原始数组,并且如果数组中的元素原本是普通对象(在被reactive包装前),
// 那么 `toRaw` 后的数组中,这些元素也是它们对应的原始普通对象。
// 这就解释了为什么 `structuredClone(toRaw(array.value))` 通常是有效的。
// 问题描述中的循环 `for (const b of a.value)`,`b` 是响应式对象。所以 `structuredClone(toRaw(b))` 是正确的。

关于 toRaw 的一个小提醒:
toRaw 拿到的对象是“赤裸裸”的原始数据。你对这个原始对象做的任何修改,都不会触发 Vue 的响应式更新。这是它的设计使然,通常在我们就是想获得一个干净快照或者临时跳出响应式系统时非常有用。但在深拷贝这个场景下,我们恰恰需要它这个特性。

安全提示?这里没啥特别的

使用 toRawstructuredClone 本身不涉及什么特别的安全风险。structuredClone 克隆的是数据,不是代码。toRaw 也只是获取原始数据引用。常规的数据安全和输入验证还是要做的,但这跟这两个 API 本身关系不大。

toRaw 进阶玩法 (可选)

除了解决 structuredClone 的问题,toRaw 还有些其他用武之地:

  1. 性能优化 :在一些需要频繁读取但不修改响应式对象属性的场景,如果 Proxy 的开销成为了瓶颈(非常罕见),可以考虑用 toRaw 获取原始对象进行只读操作。不过,得谨慎,别滥用,Vue 的 Proxy 性能通常足够好。
  2. 与外部库交互 :有些外部库可能不认识 Vue 的 Proxy 对象,或者会对 Proxy 做一些非预期的处理。这时,可以先用 toRaw 把数据“净化”一下再传给它们。
  3. 临时“冻结”状态 :获取原始对象后,对其修改不会触发视图更新。这在某些特定逻辑下可能有用,比如你想基于当前状态做一些复杂计算或变换,但不希望这个过程影响界面。

老法子 JSON.parse(JSON.stringify()):能用,但不完美

structuredClone 出现之前,或者在不支持它的环境(比如一些老的 Node.js 版本或浏览器),JSON.parse(JSON.stringify(obj)) 是实现深拷贝的常用技巧。

原理回顾

这法子简单粗暴:

  1. JSON.stringify(obj):把 JavaScript 对象序列化成一个 JSON 字符串。
  2. JSON.parse(jsonString):再把这个 JSON 字符串解析回一个新的 JavaScript 对象。

因为序列化和反序列化的过程会创建全新的对象和值,所以实现了深拷贝。

代码瞅瞅

import { ref, toRaw } from "vue"; // 依然建议对响应式对象先 toRaw

const a = ref({
  date: new Date(),
  num: 123,
  undef: undefined, // 会被忽略
  fn: function() { console.log('hello') }, // 会被忽略
  sym: Symbol('id'), // 会被忽略
  map: new Map([['key', 'value']]), // 会变空对象 {}
  set: new Set([1,2,3]), // 会变空对象 {} (在一些实现中会变数组)
  foo: { bar: "+" }
});

const rawValue = toRaw(a.value); // 同样,先拿到原始对象
const b = JSON.parse(JSON.stringify(rawValue));

console.log(b);
/*
输出可能类似(具体看环境对Map/Set等的处理):
{
  date: "2023-XX-XXTXX:XX:XX.XXX_Z_", // Date 变成了字符串
  num: 123,
  foo: { bar: "+" }
  // undefined, function, Symbol 属性都没了
  // Map, Set 可能变成 {} 或其他非预期结构
}
*/
console.log(rawValue.foo === b.foo); // false
console.log(typeof b.date); // string

为啥它这次不是最佳人选?

尽管 JSON.parse(JSON.stringify()) 能解决 structuredClone 对 Vue 响应式对象不兼容的问题(前提也是先 toRaw),但它有自身的硬伤:

  1. 类型丢失或转换
    • Date 对象会变成 ISO8601 格式的字符串。
    • RegExp, Error 对象会变成空对象 {}
    • Function, Symbol 值,以及值为 undefined 的属性,在序列化时会被直接忽略掉。
    • NaNInfinity 会变成 null
    • Map, Set 等现代数据结构,根据不同JS引擎的 JSON.stringify 实现,可能被转换成空对象 {} 或数组 []
  2. 不支持循环引用 :如果对象有循环引用(比如 obj.self = obj),JSON.stringify 会直接报错 (TypeError: Converting circular structure to JSON)。

structuredClone 被设计出来就是为了克服这些限制,它能正确处理更多数据类型,也支持循环引用。

所以,能用 structuredClone 的时候,尽量用它,它才是根正苗红的深拷贝方案。而 toRaw 则是打通 Vue 响应式对象与 structuredClone 之间通道的关键桥梁。

下次再遇到 Vue 里 refreactive 的值想用 structuredClone 拷贝却碰壁时,别慌,祭出 toRaw 大法,一般就能迎刃而解了!