From 46ee7c73a6b89d562517286d56e7c2b1e658848d Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Thu, 11 Jul 2024 20:45:13 -0400 Subject: [PATCH] feat: add `cloneDeep` function (#81) --- benchmarks/object/cloneDeep.bench.ts | 14 ++ docs/object/cloneDeep.mdx | 65 +++++++++ src/mod.ts | 1 + src/object/cloneDeep.ts | 190 +++++++++++++++++++++++++++ tests/object/cloneDeep.test.ts | 127 ++++++++++++++++++ 5 files changed, 397 insertions(+) create mode 100644 benchmarks/object/cloneDeep.bench.ts create mode 100644 docs/object/cloneDeep.mdx create mode 100644 src/object/cloneDeep.ts create mode 100644 tests/object/cloneDeep.test.ts diff --git a/benchmarks/object/cloneDeep.bench.ts b/benchmarks/object/cloneDeep.bench.ts new file mode 100644 index 00000000..4ea889ca --- /dev/null +++ b/benchmarks/object/cloneDeep.bench.ts @@ -0,0 +1,14 @@ +import * as _ from 'radashi' +import { bench } from 'vitest' + +describe('cloneDeep', () => { + const objects: any = _.list(0, 5, i => { + const object: any = {} + _.set(object, 'a.b.c.d.e.f.g.h.i.k.l.m.n.o.p', i) + return object + }) + + bench('dozens of nested plain objects', () => { + _.cloneDeep(objects) + }) +}) diff --git a/docs/object/cloneDeep.mdx b/docs/object/cloneDeep.mdx new file mode 100644 index 00000000..369fe265 --- /dev/null +++ b/docs/object/cloneDeep.mdx @@ -0,0 +1,65 @@ +--- +title: cloneDeep +description: Create a deep copy of an object or array +--- + +### Usage + +Deeply clone the given object or array. The only nested objects that get cloned by default are: plain objects, arrays, `Map` instances, and `Set` instances. + +The default behavior aims to support the most popular use cases. See “Customized cloning” below if you need more control. + +By default, non-enumerable properties and computed properties are copied losslessly. Note that you can opt out of this behavior if you need better performance (see “Faster cloning” below). + +```ts +import * as _ from 'radashi' + +_.cloneDeep() +``` + +### Faster cloning + +You can pass the `FastCloningStrategy` for better performance, but bear in mind the following tradeoff. + +All plain objects and class instances are cloned with `{...obj}`. This means that the original prototype, computed properties, and non-enumerable properties are not preserved. + +Also note that built-in, complex objects like `RegExp` and `Date` are still not cloned with this cloning strategy. You can override the `cloneOther` function if you need to clone these object types. + +### Customized cloning + +“Cloning strategies” control how certain object types are handled by `cloneDeep`. You can pass in a custom strategy, which may even be a partial strategy. Any undefined methods in your strategy will inherit the default logic. Your custom methods can return `null` to use the default logic, or they can return the received object to skip cloning. + +```ts +import * as _ from 'radashi' + +_.cloneDeep(obj, { + // Clone arrays with default logic if they are not frozen. + cloneArray: array => (Object.isFrozen(array) ? array : null), +}) +``` + +If you clone the object in your custom method, make sure to pass the clone into the `track` function before cloning the nested objects. Here's an example with `cloneOther` that handles a custom class instance. + +```ts +import * as _ from 'radashi' + +_.cloneDeep(obj, { + cloneOther: (obj, track, clone) => { + if (obj instanceof MyClass) { + // 1. Create a new instance and track it. + const clone = track(new MyClass()) + + // 2. Copy over the properties of the original instance. + for (const key in obj) { + clone[key] = clone(obj[key]) + } + + // 3. Return the cloned instance. + return clone + } + + // Use default logic for anything else. + return null + }, +}) +``` diff --git a/src/mod.ts b/src/mod.ts index bbf41042..ff8d6be0 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -62,6 +62,7 @@ export * from './number/toInt.ts' export * from './object/assign.ts' export * from './object/clone.ts' +export * from './object/cloneDeep.ts' export * from './object/construct.ts' export * from './object/crush.ts' export * from './object/filterKey.ts' diff --git a/src/object/cloneDeep.ts b/src/object/cloneDeep.ts new file mode 100644 index 00000000..76f588aa --- /dev/null +++ b/src/object/cloneDeep.ts @@ -0,0 +1,190 @@ +import { isArray, isMap, isObject, isSet } from 'radashi' + +/** + * A strategy for cloning objects with `cloneDeep`. + * + * Methods **must** call the `track` function with the new parent + * object **before** looping over the input object's + * properties/elements for cloning purposes. This protects against + * circular references. + * + * Methods may return the input object to indicate that cloning should + * be skipped. + * + * Methods may return null to indicate that the default cloning logic + * should be used. + */ +export interface CloningStrategy { + cloneMap: ( + parent: Map, + track: (newParent: Map) => Map, + clone: (value: T) => T, + ) => Map | null + cloneSet: ( + parent: Set, + track: (newParent: Set) => Set, + clone: (value: T) => T, + ) => Set | null + cloneArray: ( + parent: readonly T[], + track: (newParent: T[]) => T[], + clone: (value: T) => T, + ) => T[] | null + cloneObject: ( + parent: T, + track: (newParent: T) => T, + clone: (value: T) => T, + ) => T | null + cloneOther: ( + parent: T, + track: (newParent: T) => T, + clone: (value: T) => T, + ) => T | null +} + +export const DefaultCloningStrategy = { + cloneMap( + input: Map, + track: (newParent: Map) => Map, + clone: (value: T) => T, + ): Map { + const output = track(new Map()) + for (const [key, value] of input) { + output.set(key, clone(value)) + } + return output + }, + cloneSet( + input: Set, + track: (newParent: Set) => Set, + clone: (value: T) => T, + ): Set { + const output = track(new Set()) + for (const value of input) { + output.add(clone(value)) + } + return output + }, + cloneArray( + input: readonly T[], + track: (newParent: T[]) => T[], + clone: (value: T) => T, + ): T[] { + // Use .forEach for correct handling of sparse arrays + const output = track(new Array(input.length)) + input.forEach((value, index) => { + output[index] = clone(value) + }) + return output + }, + cloneObject( + input: T, + track: (newParent: T) => T, + clone: (value: T) => T, + ): T { + const output = track(Object.create(Object.getPrototypeOf(input))) + for (const key of Reflect.ownKeys(input)) { + // By copying the property descriptors, we preserve computed + // properties and non-enumerable properties. + const descriptor = Object.getOwnPropertyDescriptor(input, key)! + if ('value' in descriptor) { + descriptor.value = clone(descriptor.value) + } + Object.defineProperty(output, key, descriptor) + } + return output + }, + cloneOther(input: T, track: (newParent: T) => T): T { + return track(input) + }, +} + +/** + * If you don't need support for non-enumerable properties or computed + * properties, and you're not using custom classes, you can use this + * strategy for better performance. + */ +export const FastCloningStrategy = { + cloneObject: ( + input: T, + track: (newParent: T) => T, + clone: (value: T) => T, + ): T => { + const output: any = track({ ...input }) + for (const key of Object.keys(input)) { + output[key] = clone(input[key as keyof object]) + } + return output + }, +} + +/** + * Clone the given object and possibly other objects nested inside. + * + * By default, the only objects that get cloned are plain objects, + * class instances, arrays, `Set` instances, and `Map` instances. If + * an object is not cloned, any objects nested inside are also not + * cloned. + * + * You may define a custom cloning strategy by passing a partial + * implementation of the `CloningStrategy` interface to the + * `cloneDeep` function. Any undefined methods will fall back to the + * default cloning logic. Your own methods may return null to indicate + * that the default cloning logic should be used. They may also return + * the input object to indicate that cloning should be skipped. + * + * ```ts + * const obj = { a: 1, b: { c: 2 } } + * const clone = cloneDeep(obj) + * + * assert(clone !== obj) + * assert(clone.b !== obj.b) + * assert(JSON.stringify(clone) === JSON.stringify(obj)) + * ``` + */ +export function cloneDeep( + root: T, + customStrategy?: Partial, +): T { + const strategy = { ...DefaultCloningStrategy, ...customStrategy } + + const tracked = new Map() + const track = (parent: unknown, newParent: unknown) => { + tracked.set(parent, newParent) + return newParent + } + + const clone = (value: T): T => + value && typeof value === 'object' + ? ((tracked.get(value) ?? cloneDeep(value, strategy)) as T) + : value + + const cloneDeep = (parent: unknown, strategy: CloningStrategy): unknown => { + const cloneParent = ( + isObject(parent) + ? strategy.cloneObject + : isArray(parent) + ? strategy.cloneArray + : isMap(parent) + ? strategy.cloneMap + : isSet(parent) + ? strategy.cloneSet + : strategy.cloneOther + ) as ( + newParent: unknown, + track: (newParent: unknown) => unknown, + clone: (value: unknown) => unknown, + ) => unknown + + const newParent = cloneParent(parent, track.bind(null, parent), clone) + if (!newParent) { + // Use the default strategy if null is returned. + return cloneDeep(parent, DefaultCloningStrategy) + } + + tracked.set(parent, newParent) + return newParent + } + + return cloneDeep(root, strategy) as T +} diff --git a/tests/object/cloneDeep.test.ts b/tests/object/cloneDeep.test.ts new file mode 100644 index 00000000..027ebaa5 --- /dev/null +++ b/tests/object/cloneDeep.test.ts @@ -0,0 +1,127 @@ +import * as _ from 'radashi' + +describe('cloneDeep', () => { + test('simple object with no nested objects', () => { + const source = { a: 1, b: 'test' } + const result = _.cloneDeep(source) + expect(result).toEqual(source) + expect(result).not.toBe(source) + }) + + test('object with nested objects', () => { + const source = { a: 1, b: { c: 2 } } + const result = _.cloneDeep(source) + expect(result).toEqual(source) + expect(result.b).not.toBe(source.b) + }) + + test('object with multiple levels of nested objects', () => { + const source = { a: 1, b: { c: { d: 2 } } } + const result = _.cloneDeep(source) + expect(result).toEqual(source) + expect(result.b).not.toBe(source.b) + expect(result.b.c).not.toBe(source.b.c) + }) + + test('object with arrays and nested arrays', () => { + const source = { a: [1, [2]], b: { c: [3, 4] } } + const result = _.cloneDeep(source) + expect(result).toEqual(source) + expect(result.a).not.toBe(source.a) + expect(result.a[1]).not.toBe(source.a[1]) + expect(result.b.c).not.toBe(source.b.c) + }) + + test('object with complex types of nested objects', () => { + const source = { a: { b: new Date(), c: /test/g, d: () => {} } } + const result = _.cloneDeep(source) + expect(result).toEqual(source) + expect(result).not.toBe(source) + expect(result.a).not.toBe(source.a) + expect(result.a.b).toBe(source.a.b) + expect(result.a.c).toBe(source.a.c) + expect(result.a.d).toBe(source.a.d) + }) + + test('object with non-enumerable properties', () => { + const source = { a: 1 } + Object.defineProperty(source, 'b', { value: 2, enumerable: false }) + const result = _.cloneDeep(source) + expect(result).toEqual(source) + }) + + test('object containing Set and Map instances', () => { + const source = { + a: new Set([{ a: 1 }, { a: 2 }]), + b: new Map([[{ b: 1 }, { b: 2 }]]), + } + + const result = _.cloneDeep(source) + expect(result).toEqual(source) + + expect(result.a).not.toBe(source.a) + expect(result.b).not.toBe(source.b) + + // Objects inside sets are cloned. + expect([...result.a].every(item => !source.a.has(item))).toBe(true) + + // Object keys in maps are *not* cloned. + // Object values in maps are cloned. + expect( + [...result.b].every( + ([key, value]) => source.b.has(key) && source.b.get(key) !== value, + ), + ).toBe(true) + }) + + test('handle circular references', () => { + const source: any = { a: 1 } + source.b = source + const result = _.cloneDeep(source) + expect(result).not.toBe(source) + expect(result).toEqual(source) + expect(result.b).toBe(result) + }) + + test('avoid cloning an object more than once', () => { + const source: any = { a1: { b: 1 } } + source.a2 = source.a1 + const result = _.cloneDeep(source) + expect(result).toEqual(source) + expect(result.a1).toBe(result.a2) + expect(result.a1).not.toBe(source.a1) + }) + + test('avoid calling the mapper for a skipped object more than once', () => { + const source: any = { a1: { b: 1 } } + source.a2 = source.a1 + + const cloneObject = vi.fn(obj => (obj !== source ? obj : null)) + _.cloneDeep(source, { + cloneObject, + }) + + // Once for the root object, another for the nested object that + // appears twice. + expect(cloneObject).toHaveBeenCalledTimes(2) + }) + + describe('FastCloningStrategy', () => { + test('object with non-enumerable and computed properties', () => { + const source = { + a: 1, + get b() { + return this.a + 1 + }, + } + Object.defineProperty(source, 'c', { value: 2, enumerable: false }) + + const result = _.cloneDeep(source, _.FastCloningStrategy) + + expect(result.a).toBe(source.a) + expect(result.b).toBe(source.b) + expect('get' in Object.getOwnPropertyDescriptor(result, 'b')!).toBe(false) + expect('c' in result).toBe(false) + }) + }) +})