Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add cloneDeep function #81

Merged
merged 6 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading