From 509079319045a5e9d2f6e254deea70784d0b026a Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Thu, 4 Jul 2024 14:48:57 -0400 Subject: [PATCH] rewrite cloneDeep to not use traverse --- src/mod.ts | 1 + src/object/cloneDeep.ts | 216 +++++++++++++++++++++++++++------ tests/object/cloneDeep.test.ts | 119 +++++++++--------- 3 files changed, 244 insertions(+), 92 deletions(-) diff --git a/src/mod.ts b/src/mod.ts index 3ddbd8d6d..529798021 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -59,6 +59,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 index 47bbb05a8..76f588aa6 100644 --- a/src/object/cloneDeep.ts +++ b/src/object/cloneDeep.ts @@ -1,46 +1,190 @@ -import { traverse, type TraverseContext } from 'radashi' +import { isArray, isMap, isObject, isSet } from 'radashi' /** - * Traverse an object deeply, mapping the root object and any objects - * nested within. + * A strategy for cloning objects with `cloneDeep`. * - * The `mapper` callback is responsible for creating a **shallow** - * clone of the received object. If you return the received object - * without cloning it, traversal is skipped. To allow for maximum - * flexibility, the `mapper` callback receives all object types, - * including `RegExp`, `Date`, etc. + * 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. * - * ```ts - * import { clone, cloneDeep } from 'radashi' + * 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. * - * // Just plain objects - * let obj = { a: 1, b: { c: 2 } } - * let clone = cloneDeep(obj, (value) => ({ ...value })) + * ```ts + * const obj = { a: 1, b: { c: 2 } } + * const clone = cloneDeep(obj) * - * // Complex objects like RegExp and arrays - * obj = { a: /regexp/, b: [1, 2, 3] } - * let clone = cloneDeep(obj, clone) + * assert(clone !== obj) + * assert(clone.b !== obj.b) + * assert(JSON.stringify(clone) === JSON.stringify(obj)) * ``` */ -export function cloneDeep( - root: Root, - mapper: (obj: object, context?: TraverseContext) => object, - outerContext?: TraverseContext, - ownKeys: (obj: object) => Iterable = Object.keys, -): Root { - const clone = mapper(root, outerContext) as Root - if (clone !== root) { - traverse( - clone, - (value, key, parent: any, context) => { - if (value && typeof value === 'object') { - parent[key] = cloneDeep(value, mapper, context, ownKeys) - context.skip(value) - } - }, - outerContext, - ownKeys, - ) +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 } - return clone + + 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 index f094e4bd8..f743ade5d 100644 --- a/tests/object/cloneDeep.test.ts +++ b/tests/object/cloneDeep.test.ts @@ -1,77 +1,84 @@ import * as _ from 'radashi' describe('cloneDeep', () => { - test('clone a simple object with no nested objects', () => { - const obj = { a: 1, b: 'test' } - const cloned = _.cloneDeep(obj, o => ({ ...o })) - expect(cloned).toEqual(obj) - expect(cloned).not.toBe(obj) + 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('clone an object with nested objects', () => { - const obj = { a: 1, b: { c: 2 } } - const cloned = _.cloneDeep(obj, o => ({ ...o })) - expect(cloned).toEqual(obj) - expect(cloned.b).not.toBe(obj.b) + 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('clone an object with arrays and nested arrays', () => { - const obj = { a: [1, 2], b: { c: [3, 4] } } - const cloned = _.cloneDeep(obj, o => (_.isArray(o) ? [...o] : { ...o })) - expect(cloned).toEqual(obj) - expect(cloned.a).not.toBe(obj.a) - expect(cloned.b.c).not.toBe(obj.b.c) + 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('handle null values correctly', () => { - const obj = { a: null } - const cloned = _.cloneDeep(obj, o => ({ ...o })) - expect(cloned).toEqual(obj) + 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('clone an object with multiple levels of nested objects', () => { - const obj = { a: 1, b: { c: { d: 2 } } } - const cloned = _.cloneDeep(obj, o => ({ ...o })) - expect(cloned).toEqual(obj) - expect(cloned.b).not.toBe(obj.b) - expect(cloned.b.c).not.toBe(obj.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('clone an object with complex types of nested objects', () => { - const obj = { a: new Date(), b: /test/g, c: [1, 2] } - const cloned = _.cloneDeep(obj, o => ({ ...o })) - expect(cloned).toEqual(obj) - expect(cloned.a).toEqual(obj.a) - expect(cloned.b).toEqual(obj.b) - expect(cloned.c).not.toBe(obj.c) + test('set ownKeys argument to handle objects with non-enumerable properties', () => { + const source = { a: 1 } + Object.defineProperty(source, 'b', { value: 2, enumerable: false }) + const result = _.cloneDeep(source, null, Reflect.ownKeys) + expect(result).toEqual(source) }) - test('do not clone objects that are part of the prototype chain', () => { - const proto = { a: 1 } - const obj = Object.create(proto) - obj.b = 2 - const cloned = _.cloneDeep(obj, o => ({ ...o })) - expect(cloned).toEqual(obj) - expect(Object.getPrototypeOf(cloned)).toEqual(Object.getPrototypeOf(obj)) + 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('set ownKeys argument to handle objects with non-enumerable properties', () => { - const obj = { a: 1 } - Object.defineProperty(obj, 'b', { value: 2, enumerable: false }) - const cloned = _.cloneDeep( - obj, - o => ({ ...o }), - undefined, - o => Reflect.ownKeys(o), - ) - expect(cloned).toEqual(obj) + 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('handle circular references', () => { - const obj: any = { a: 1 } - obj.b = obj - const cloned = _.cloneDeep(obj, o => ({ ...o })) - expect(cloned).toEqual(obj) - expect(cloned.b).toBe(cloned) + 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) }) })