-
Notifications
You must be signed in to change notification settings - Fork 21
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
rewrite cloneDeep to not use traverse
- Loading branch information
1 parent
3365499
commit f7f0900
Showing
3 changed files
with
244 additions
and
92 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
}) |