在开发复杂的前端应用或后端服务时,我们经常需要处理数据的复制操作。你有没有遇到过这样的场景:当你修改了一个对象的“副本”时,原本的对象竟然也被意外修改了?这通常是因为我们在 JavaScript 中进行了浅拷贝,仅仅复制了对象的引用,而不是对象本身的数据。
为了彻底解决这个问题,我们需要使用“深度克隆”。不仅如此,作为一个 TypeScript 开发者,我们还非常看重代码的健壮性,因此如何确保克隆后的对象依然保留原始对象的类型信息,避免类型系统的报错,是我们今天要解决的核心问题。
在这篇文章中,我们将深入探讨几种在 TypeScript 中实现深度克隆并保留类型的方法,从简单的内置函数到健壮的工具库,再到完全手写的高性能递归实现,带你领略不同方案背后的技术细节与权衡。
目录
- 为什么 JSON.stringify 和 JSON.parse 是“双刃剑”?
- 深入 Object.assign 与递归复制的结合
- 借助 Lodash 实现工业级克隆
- 完美掌控:手写通用递归克隆函数
目录
为什么 JSON.stringify 和 JSON.parse 是“双刃剑”?
最常见、也是最容易想到的深度克隆方案,莫过于利用 JavaScript 内置的 JSON 对象。这个方法的原理非常直观:先将对象序列化为字符串,再从字符串解析回一个新的对象。因为中间隔了一个字符串,所以新对象在内存中与原对象完全独立。
类型安全的泛型实现
在 TypeScript 中,为了让这个过程类型安全,我们可以定义一个泛型函数。为了演示类型保留的效果,我们定义一个 DeepClone 工具类型,它利用映射遍历对象的属性,递归地构建出深度克隆后的类型结构。
语法基础
JSON.stringify(value, replacer, space);
JSON.parse(string, reviver);
代码示例:基础实现与类型保留
让我们先看一个基础的例子。我们将定义一个接口 INLINECODE38ea8b1b,并尝试克隆它。请注意观察 INLINECODEcb0aefee 类型是如何工作的,以及我们如何验证结果是否仍然是一个对象实例。
// 定义一个递归类型,用于推导深度克隆后的类型结构
type DeepClone = T extends object ? {
[K in keyof T]: DeepClone
} : T;
/**
* 方法1:使用 JSON 方法进行深度克隆
* 注意:这种方法会丢失 undefined、function、Date 等非 JSON 数据
*/
function cloneWithJson(obj: T): DeepClone {
// 第一步:序列化;第二步:解析
return JSON.parse(JSON.stringify(obj));
}
interface Person {
name: string;
age: number;
address?: string; // 可选属性
}
const developer: Person = {
name: ‘前端技术专家‘,
age: 28,
address: ‘科技园‘
};
// 执行克隆
const clonedPerson = cloneWithJson(developer);
console.log(‘克隆后的对象:‘, clonedPerson);
// 验证类型是否保留:检查它是否是 Object 的实例
const isTypePreserved = clonedPerson instanceof Object;
console.log(‘类型是否保留:‘, isTypePreserved); // 输出: true
方法的局限性
虽然这种方法简单快捷,但在实际工程中,你必须非常小心。INLINECODE9f8bb9b2 有一个致命的缺陷:它会忽略所有值为 INLINECODEe67443cf 的属性,并且无法处理函数、INLINECODE414005bb 对象以及 INLINECODE6e5f085d/Set 等特殊结构。
interface ComplexObject {
id: number;
date: Date;
func: () => void;
undef: undefined;
}
const obj: ComplexObject = {
id: 1,
date: new Date(),
func: () => console.log(‘Hello‘),
undef: undefined
};
// 请尝试运行这段代码,你会发现 func 消失了,date 变成了字符串
const clonedObj = cloneWithJson(obj);
console.log(clonedObj);
// 输出类似于:{ id: 1, date: ‘2023-10-27T...‘ }
// func 和 undef 都丢失了!
因此,这种方法仅适用于纯数据对象(POJO),如果你需要处理更复杂的业务对象,我们需要寻找更健壮的方案。
深入 Object.assign 与递归复制的结合
为了解决 JSON 方法的局限性,我们可以尝试构建自己的克隆逻辑。INLINECODE389f9427 是 ES6 引入的一个方法,用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。然而,仅仅使用 INLINECODEa76aae60 只能实现浅拷贝。如果我们想要实现深拷贝,就必须结合递归思想。
实现思路
我们的策略是:遍历对象的每一个属性。如果属性的值也是对象,我们就递归调用克隆函数;如果是基本类型,就直接赋值。同时,我们需要处理数组的情况。
代码示例:手动递归克隆
下面这个例子展示了如何手动实现一个递归克隆器。请注意,这里我们使用了 as T 作为类型断言,告诉 TypeScript 编译器:“相信我,返回的对象结构和输入是一样的”。
/**
* 方法2:结合 Object.assign 和递归实现深度克隆
* 这种方法可以处理函数和 undefined,但对 Date 等内置对象仍需特殊处理
*/
function cloneWithRecursiveAssign(obj: T): T {
// 基本类型或 null 直接返回
if (typeof obj !== ‘object‘ || obj === null) {
return obj;
}
// 初始化结果对象:如果是数组则初始化为空数组,否则为空对象
const result: any = Array.isArray(obj) ? [] : {};
// 遍历对象属性
for (const key in obj) {
// 确保属性是对象自身的,而非从原型链继承的
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// 递归克隆每个属性值,然后赋值给结果对象
result[key] = cloneWithRecursiveAssign(obj[key]);
}
}
// 使用 Object.assign 可以确保属性描述符被正确复制(虽然这里主要是覆盖值)
// 在此场景下,主要是为了演示组合使用,实际上 result 已经构建完毕
return Object.assign(result, obj) as T;
}
interface WebsiteConfig {
name: string;
domain: string;
settings: {
debug: boolean;
maxUsers: number;
};
}
const config: WebsiteConfig = {
name: ‘技术博客‘,
domain: ‘www.example.com‘,
settings: {
debug: true,
maxUsers: 5000
}
};
// 执行深度克隆
const configClone = cloneWithRecursiveAssign(config);
// 修改克隆对象的深层属性
configClone.settings.debug = false;
// 验证原对象是否受影响
console.log(‘原对象配置:‘, config.settings.debug); // 应该输出 true,证明是深拷贝
console.log(‘克隆对象配置:‘, configClone.settings.debug); // 输出 false
const isPreserved = configClone instanceof Object;
console.log(‘类型结构保持完整:‘, isPreserved);
潜在陷阱
虽然这个方法比 JSON 方法更进了一步,但它依然有盲区。例如,它会把 Date 对象当作普通对象处理,导致克隆出来的是一个包含数字属性的对象,而不是真正的日期对象。此外,对于循环引用(对象 A 引用对象 B,对象 B 又引用对象 A),这种简单的递归会导致“栈溢出”错误。
借助 Lodash 实现工业级克隆
在工程实践中,如果我们不想重复造轮子,最好的选择往往是使用经过实战检验的第三方库。Lodash 提供的 cloneDeep 方法是处理深度克隆的黄金标准。它不仅考虑了类型推断,还完美处理了数组、Buffer、TypedArray、Date、Map、Set 甚至循环引用等复杂场景。
为什么选择 Lodash?
- 稳健性:它处理了各种边界情况。
- 类型支持:INLINECODE524f2b4b 提供了完美的 TypeScript 类型定义,通常 INLINECODE446f3406 的返回类型会自动推断为输入类型的深度克隆版本。
代码示例:处理复杂嵌套结构
下面的例子模拟了一个包含多层嵌套的对象,展示了 Lodash 的强大之处。你会发现代码非常简洁,不需要我们手写复杂的递归逻辑。
import _ from ‘lodash‘;
// 确保安装了类型定义:npm install @types/lodash
/**
* 方法3:使用 Lodash 的 cloneDeep
* 这是目前最推荐的工程化方案
*/
function cloneWithLodash(obj: T): T {
return _.cloneDeep(obj);
}
interface UserDetail {
name: string;
profile: {
age: number;
location: {
city: string;
country: string;
};
};
}
const user: UserDetail = {
name: ‘全栈开发者‘,
profile: {
age: 30,
location: {
city: ‘上海‘,
country: ‘中国‘
}
}
};
// 一行代码完成深度克隆,且保留类型
const clonedUser = cloneWithLodash(user);
// 修改嵌套属性
clonedUser.profile.location.city = ‘北京‘;
// 输出结果以验证
console.log(‘原始城市:‘, user.profile.location.city); // 上海
console.log(‘克隆后城市:‘, clonedUser.profile.location.city); // 北京
const preserveCheck = clonedUser instanceof Object;
console.log(‘使用 Lodash 类型保留状况:‘, preserveCheck);
性能优化建议
虽然 Lodash 很强大,但如果你的项目中仅仅是为了克隆一个很小的对象,引入整个 Lodash 库可能会导致打包体积过大。在这种情况下,你可以考虑只引入 lodash.clonedeep 这个单独的包,或者使用下面我们要介绍的手写方案。
完美掌控:手写通用递归克隆函数
为了彻底理解深度克隆的机制,并减少对第三方库的依赖,我们最后来实现一个“终极版”的手写克隆函数。这个函数将涵盖对 Date、数组、普通对象以及基本类型的处理。
实现细节
- 类型守卫:使用 INLINECODE6cbef9d3 检查 INLINECODE01f44747 对象。
- 数组处理:使用
Array.isArray区分数组和普通对象。 - 递归逻辑:对于普通对象,遍历键值并递归调用。
代码示例:生产级别的克隆函数
这个示例展示了如何处理 INLINECODE6d2f3dd9 对象,这是之前的 INLINECODEb05cdcfb 方法做不到的。通过这种方式,我们可以确保不仅数据结构一致,连数据的实际类型也保持原样。
/**
* 方法4:完善的递归克隆函数
* 能够处理 Date、数组、普通对象及其嵌套组合
*/
function deepClone(obj: T): T {
// 1. 处理基本类型 和 null
if (obj === null || typeof obj !== ‘object‘) {
return obj;
}
// 2. 处理 Date 对象
if (obj instanceof Date) {
// 创建一个新的 Date 对象,复制时间戳
return new Date(obj.getTime()) as T;
}
// 3. 处理数组
if (Array.isArray(obj)) {
// 创建一个空数组,断言为 any 类型以便赋值(后续会修正)
const arrCopy = [] as any;
for (let i = 0; i < obj.length; i++) {
// 递归拷贝数组中的每一项
arrCopy[i] = deepClone(obj[i]);
}
return arrCopy;
}
// 4. 处理普通对象
const clonedObj = {} as T;
for (const key in obj) {
// 确保键是对象自身的属性
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// 递归拷贝每一个属性值
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
interface SystemAdmin {
id: number;
username: string;
roles: string[];
lastLogin: Date;
metadata: {
active: boolean;
notes: string;
};
}
const admin: SystemAdmin = {
id: 101,
username: 'root_admin',
roles: ['admin', 'editor'],
lastLogin: new Date(), // 当前时间
metadata: {
active: true,
notes: '超级管理员'
}
};
// 执行我们的终极克隆函数
const clonedAdmin = deepClone(admin);
// 修改 Date 对象和深层属性
clonedAdmin.lastLogin.setDate(clonedAdmin.lastLogin.getDate() + 1);
clonedAdmin.metadata.active = false;
// 验证克隆结果
console.log('原始登录时间:', admin.lastLogin.toDateString());
console.log('修改后登录时间:', clonedAdmin.lastLogin.toDateString());
// 验证类型安全:clonedAdmin 依然被识别为 SystemAdmin
const typeCheck = clonedAdmin instanceof Object;
console.log('自定义函数类型保留状况:', typeCheck);
常见错误与最佳实践
在编写上述代码时,有一个常见的错误是直接使用 INLINECODE666a98d9 或 INLINECODE2f6231d7 来初始化所有情况。如果对象的原型链上有重要的方法,这种简单初始化会切断原型链。不过,对于绝大多数基于接口的数据传输对象(DTO),上述方案已经足够。
如果你需要保留对象的类(例如由 INLINECODEbc9a0f75 定义的实例),你还需要特殊处理 INLINECODE663b6c12 检查并调用构造函数,这会大大增加代码的复杂度。通常对于数据处理场景,我们只需要数据的“纯粹克隆”,而不需要保留类的方法,因此上述方案是性价比最高的选择。
总结
在这篇文章中,我们一起探讨了 TypeScript 中深度克隆并保留类型的四种主要方案。
- 如果你处理的是简单的 JSON 数据,JSON 方法 是最快的选择,但要警惕它的数据丢失风险。
- 如果你不想引入外部依赖,Object.assign 配合递归 是一个不错的中间方案,但需要注意特殊对象的处理。
- 在企业级开发中,Lodash 提供了最稳健、功能最全面的解决方案。
- 而对于追求极致性能和控制的场景,手写递归函数 让你能够精确地处理
Date、数组等特定类型,同时保持代码的轻量级。
选择哪种方法,最终取决于你的具体需求——是追求开发效率,还是追求运行时的绝对性能?希望这些知识能帮助你在未来的开发中写出更安全、更高效的代码。下次当你需要复制一个对象时,别再犹豫,选出最适合你的那一招吧!