Skip to content

Commit

Permalink
rewrite cloneDeep to not use traverse
Browse files Browse the repository at this point in the history
  • Loading branch information
aleclarson committed Jul 4, 2024
1 parent 295ebfd commit 5090793
Show file tree
Hide file tree
Showing 3 changed files with 244 additions and 92 deletions.
1 change: 1 addition & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
216 changes: 180 additions & 36 deletions src/object/cloneDeep.ts
Original file line number Diff line number Diff line change
@@ -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: <K, V>(
parent: Map<K, V>,
track: (newParent: Map<K, V>) => Map<K, V>,
clone: <T>(value: T) => T,
) => Map<K, V> | null
cloneSet: <T>(
parent: Set<T>,
track: (newParent: Set<T>) => Set<T>,
clone: <T>(value: T) => T,
) => Set<T> | null
cloneArray: <T>(
parent: readonly T[],
track: (newParent: T[]) => T[],
clone: <T>(value: T) => T,
) => T[] | null
cloneObject: <T extends object>(
parent: T,
track: (newParent: T) => T,
clone: <T>(value: T) => T,
) => T | null
cloneOther: <T>(
parent: T,
track: (newParent: T) => T,
clone: <T>(value: T) => T,
) => T | null
}

export const DefaultCloningStrategy = {
cloneMap<K, V>(
input: Map<K, V>,
track: (newParent: Map<K, V>) => Map<K, V>,
clone: <T>(value: T) => T,
): Map<K, V> {
const output = track(new Map())
for (const [key, value] of input) {
output.set(key, clone(value))
}
return output
},
cloneSet<T>(
input: Set<T>,
track: (newParent: Set<T>) => Set<T>,
clone: <T>(value: T) => T,
): Set<T> {
const output = track(new Set())
for (const value of input) {
output.add(clone(value))
}
return output
},
cloneArray<T>(
input: readonly T[],
track: (newParent: T[]) => T[],
clone: <T>(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<T extends object>(
input: T,
track: (newParent: T) => T,
clone: <T>(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<T>(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: <T extends object>(
input: T,
track: (newParent: T) => T,
clone: <T>(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 extends object>(
root: Root,
mapper: (obj: object, context?: TraverseContext) => object,
outerContext?: TraverseContext,
ownKeys: (obj: object) => Iterable<keyof any> = 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<T extends object>(
root: T,
customStrategy?: Partial<CloningStrategy>,
): T {
const strategy = { ...DefaultCloningStrategy, ...customStrategy }

const tracked = new Map<unknown, unknown>()
const track = (parent: unknown, newParent: unknown) => {
tracked.set(parent, newParent)
return newParent
}
return clone

const clone = <T>(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
}
119 changes: 63 additions & 56 deletions tests/object/cloneDeep.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})

0 comments on commit 5090793

Please sign in to comment.