Skip to content

Commit

Permalink
feat: add cloneDeep function (#81)
Browse files Browse the repository at this point in the history
  • Loading branch information
aleclarson authored Jul 12, 2024
1 parent 2231c0e commit 46ee7c7
Show file tree
Hide file tree
Showing 5 changed files with 397 additions and 0 deletions.
14 changes: 14 additions & 0 deletions benchmarks/object/cloneDeep.bench.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
65 changes: 65 additions & 0 deletions docs/object/cloneDeep.mdx
Original file line number Diff line number Diff line change
@@ -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
},
})
```
1 change: 1 addition & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
190 changes: 190 additions & 0 deletions src/object/cloneDeep.ts
Original file line number Diff line number Diff line change
@@ -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: <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.
*
* ```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<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
}

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
}
Loading

0 comments on commit 46ee7c7

Please sign in to comment.