Skip to content

Commit

Permalink
✨ Add constructorHandlers option (#40)
Browse files Browse the repository at this point in the history
* ✨ Add `constructorHandlers` option

The motivation of this change is to allow passing custom handlers for
particular classes. For example, [`ObjectId`][1].

These can be passed using the new `constructorHandlers` option:

```js
const clone = rfdc({
	constructorHandlers: [
	  [ObjectId, (o) => new ObjectId(o)],
	],
})
```

Similarly, `RegExp` support can be added manually:

```js
const clone = rfdc({
	constructorHandlers: [
	  [RegExp, (o) => new RegExp(o)],
	],
})
```

Internally, the special handlers for `Date`, `Map`, and `Set` are
moved to use this mechanism to keep code tidy.

Limitations
-----------

Note that - for performance - this is backed under the hood by a `Map`
with the classes as keys, which gives constant-time lookup (compared to
eg iterating over an array of handlers). A limitation that this
introduces is that subclasses would not be matched, and would need their
own handlers, since we don't look up the prototype chain.

Performance
-----------

Benchmarks before:

```
benchRfdc*100: 206.839ms
benchRfdcProto*100: 206.776ms
benchRfdcCircles*100: 231.711ms
benchRfdcCirclesProto*100: 229.874ms
```

Benchmarks after:

```
benchRfdc*100: 221.126ms
benchRfdcProto*100: 239.467ms
benchRfdcCircles*100: 241.456ms
benchRfdcCirclesProto*100: 257.926ms
```

[1]: #7

* 📝 Add `constructorHandlers` option to `readme`

* ⚡️ Add constructor handler fast path

Improves benchmarks to:

```
benchRfdc*100: 212.82ms
benchRfdcProto*100: 229.3ms
benchRfdcCircles*100: 255.588ms
benchRfdcCirclesProto*100: 238.306ms
```

* ⚡️ Assign handler in `if` statement

Improves benchmarks to:

```
benchRfdc*100: 203.999ms
benchRfdcProto*100: 215.779ms
benchRfdcCircles*100: 224.12ms
benchRfdcCirclesProto*100: 243.172ms
```

* 🚨 Fix linter warning about `RegExp`

```
standard: Use JavaScript Standard Style (https://standardjs.com)
  /home/runner/work/rfdc/rfdc/test/index.js:124:25: Use a regular expression literal instead of the 'RegExp' constructor. (prefer-regex-literals)
```

* ⚡️ Avoid extra array allocation

Updated benchmark

```
benchRfdc*100: 206.686ms
benchRfdcProto*100: 218.722ms
benchRfdcCircles*100: 228.578ms
benchRfdcCirclesProto*100: 239.244ms
```
  • Loading branch information
alecgibson authored Jun 12, 2024
1 parent 578c71e commit 8c6bfec
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 42 deletions.
89 changes: 48 additions & 41 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,20 @@ function copyBuffer (cur) {

function rfdc (opts) {
opts = opts || {}

if (opts.circles) return rfdcCircles(opts)

const constructorHandlers = new Map()
constructorHandlers.set(Date, (o) => new Date(o))
constructorHandlers.set(Map, (o, fn) => new Map(cloneArray(Array.from(o), fn)))
constructorHandlers.set(Set, (o, fn) => new Set(cloneArray(Array.from(o), fn)))
if (opts.constructorHandlers) {
for (const handler of opts.constructorHandlers) {
constructorHandlers.set(handler[0], handler[1])
}
}

let handler = null

return opts.proto ? cloneProto : clone

function cloneArray (a, fn) {
Expand All @@ -23,8 +35,8 @@ function rfdc (opts) {
const cur = a[k]
if (typeof cur !== 'object' || cur === null) {
a2[k] = cur
} else if (cur instanceof Date) {
a2[k] = new Date(cur)
} else if (cur.constructor !== Object && (handler = constructorHandlers.get(cur.constructor))) {
a2[k] = handler(cur, fn)
} else if (ArrayBuffer.isView(cur)) {
a2[k] = copyBuffer(cur)
} else {
Expand All @@ -36,22 +48,18 @@ function rfdc (opts) {

function clone (o) {
if (typeof o !== 'object' || o === null) return o
if (o instanceof Date) return new Date(o)
if (Array.isArray(o)) return cloneArray(o, clone)
if (o instanceof Map) return new Map(cloneArray(Array.from(o), clone))
if (o instanceof Set) return new Set(cloneArray(Array.from(o), clone))
if (o.constructor !== Object && (handler = constructorHandlers.get(o.constructor))) {
return handler(o, clone)
}
const o2 = {}
for (const k in o) {
if (Object.hasOwnProperty.call(o, k) === false) continue
const cur = o[k]
if (typeof cur !== 'object' || cur === null) {
o2[k] = cur
} else if (cur instanceof Date) {
o2[k] = new Date(cur)
} else if (cur instanceof Map) {
o2[k] = new Map(cloneArray(Array.from(cur), clone))
} else if (cur instanceof Set) {
o2[k] = new Set(cloneArray(Array.from(cur), clone))
} else if (cur.constructor !== Object && (handler = constructorHandlers.get(cur.constructor))) {
o2[k] = handler(cur, clone)
} else if (ArrayBuffer.isView(cur)) {
o2[k] = copyBuffer(cur)
} else {
Expand All @@ -63,21 +71,17 @@ function rfdc (opts) {

function cloneProto (o) {
if (typeof o !== 'object' || o === null) return o
if (o instanceof Date) return new Date(o)
if (Array.isArray(o)) return cloneArray(o, cloneProto)
if (o instanceof Map) return new Map(cloneArray(Array.from(o), cloneProto))
if (o instanceof Set) return new Set(cloneArray(Array.from(o), cloneProto))
if (o.constructor !== Object && (handler = constructorHandlers.get(o.constructor))) {
return handler(o, cloneProto)
}
const o2 = {}
for (const k in o) {
const cur = o[k]
if (typeof cur !== 'object' || cur === null) {
o2[k] = cur
} else if (cur instanceof Date) {
o2[k] = new Date(cur)
} else if (cur instanceof Map) {
o2[k] = new Map(cloneArray(Array.from(cur), cloneProto))
} else if (cur instanceof Set) {
o2[k] = new Set(cloneArray(Array.from(cur), cloneProto))
} else if (cur.constructor !== Object && (handler = constructorHandlers.get(cur.constructor))) {
o2[k] = handler(cur, cloneProto)
} else if (ArrayBuffer.isView(cur)) {
o2[k] = copyBuffer(cur)
} else {
Expand All @@ -92,6 +96,17 @@ function rfdcCircles (opts) {
const refs = []
const refsNew = []

const constructorHandlers = new Map()
constructorHandlers.set(Date, (o) => new Date(o))
constructorHandlers.set(Map, (o, fn) => new Map(cloneArray(Array.from(o), fn)))
constructorHandlers.set(Set, (o, fn) => new Set(cloneArray(Array.from(o), fn)))
if (opts.constructorHandlers) {
for (const handler of opts.constructorHandlers) {
constructorHandlers.set(handler[0], handler[1])
}
}

let handler = null
return opts.proto ? cloneProto : clone

function cloneArray (a, fn) {
Expand All @@ -102,8 +117,8 @@ function rfdcCircles (opts) {
const cur = a[k]
if (typeof cur !== 'object' || cur === null) {
a2[k] = cur
} else if (cur instanceof Date) {
a2[k] = new Date(cur)
} else if (cur.constructor !== Object && (handler = constructorHandlers.get(cur.constructor))) {
a2[k] = handler(cur, fn)
} else if (ArrayBuffer.isView(cur)) {
a2[k] = copyBuffer(cur)
} else {
Expand All @@ -120,10 +135,10 @@ function rfdcCircles (opts) {

function clone (o) {
if (typeof o !== 'object' || o === null) return o
if (o instanceof Date) return new Date(o)
if (Array.isArray(o)) return cloneArray(o, clone)
if (o instanceof Map) return new Map(cloneArray(Array.from(o), clone))
if (o instanceof Set) return new Set(cloneArray(Array.from(o), clone))
if (o.constructor !== Object && (handler = constructorHandlers.get(o.constructor))) {
return handler(o, clone)
}
const o2 = {}
refs.push(o)
refsNew.push(o2)
Expand All @@ -132,12 +147,8 @@ function rfdcCircles (opts) {
const cur = o[k]
if (typeof cur !== 'object' || cur === null) {
o2[k] = cur
} else if (cur instanceof Date) {
o2[k] = new Date(cur)
} else if (cur instanceof Map) {
o2[k] = new Map(cloneArray(Array.from(cur), clone))
} else if (cur instanceof Set) {
o2[k] = new Set(cloneArray(Array.from(cur), clone))
} else if (cur.constructor !== Object && (handler = constructorHandlers.get(cur.constructor))) {
o2[k] = handler(cur, clone)
} else if (ArrayBuffer.isView(cur)) {
o2[k] = copyBuffer(cur)
} else {
Expand All @@ -156,23 +167,19 @@ function rfdcCircles (opts) {

function cloneProto (o) {
if (typeof o !== 'object' || o === null) return o
if (o instanceof Date) return new Date(o)
if (Array.isArray(o)) return cloneArray(o, cloneProto)
if (o instanceof Map) return new Map(cloneArray(Array.from(o), cloneProto))
if (o instanceof Set) return new Set(cloneArray(Array.from(o), cloneProto))
if (o.constructor !== Object && (handler = constructorHandlers.get(o.constructor))) {
return handler(o, cloneProto)
}
const o2 = {}
refs.push(o)
refsNew.push(o2)
for (const k in o) {
const cur = o[k]
if (typeof cur !== 'object' || cur === null) {
o2[k] = cur
} else if (cur instanceof Date) {
o2[k] = new Date(cur)
} else if (cur instanceof Map) {
o2[k] = new Map(cloneArray(Array.from(cur), cloneProto))
} else if (cur instanceof Set) {
o2[k] = new Set(cloneArray(Array.from(cur), cloneProto))
} else if (cur.constructor !== Object && (handler = constructorHandlers.get(cur.constructor))) {
o2[k] = handler(cur, cloneProto)
} else if (ArrayBuffer.isView(cur)) {
o2[k] = copyBuffer(cur)
} else {
Expand Down
25 changes: 24 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ clone({a: 1, b: {c: 2}}) // => {a: 1, b: {c: 2}}

## API

### `require('rfdc')(opts = { proto: false, circles: false }) => clone(obj) => obj2`
### `require('rfdc')(opts = { proto: false, circles: false, constructorHandlers: [] }) => clone(obj) => obj2`

#### `proto` option

Expand Down Expand Up @@ -48,6 +48,29 @@ object. If performance is important, try removing the circular reference from
the object (set to `undefined`) and then add it back manually after cloning
instead of using this option.

#### `constructorHandlers` option

Sometimes consumers may want to add custom clone behaviour for particular classes
(for example `RegExp` or `ObjectId`, which aren't supported out-of-the-box).

This can be done by passing `constructorHandlers`, which takes an array of tuples,
where the first item is the class to match, and the second item is a function that
takes the input and returns a cloned output:

```js
const clone = require('rfdc')({
constructorHandlers: [
[RegExp, (o) => new RegExp(o)],
]
})

clone({r: /foo/}) // => {r: /foo/}
```

**NOTE**: For performance reasons, the handlers will only match an instance of the
*exact* class (not a subclass). Subclasses will need to be added separately if they
also need special clone behaviour.

### `default` import
It is also possible to directly import the clone function with all options set
to their default:
Expand Down
18 changes: 18 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,24 @@ test('circles and proto option – deep circular array', async ({
is(c.nest[2], c, 'circular references point to copied parent')
isNot(c.nest[2], o, 'circular references do not point to original parent')
})
test('custom constructor handler', async ({ same, ok, isNot }) => {
class Foo {
constructor (s) {
this.s = s
}
}
const data = { foo: new Foo('foo') }
const cloned = rfdc({ constructorHandlers: [[Foo, (o) => new Foo(o.s)]] })(data)
ok(cloned.foo instanceof Foo)
same(cloned.foo.s, data.foo.s, 'same values')
isNot(cloned.foo, data.foo, 'different objects')
})
test('custom RegExp handler', async ({ same, ok, isNot }) => {
const data = { regex: /foo/ }
const cloned = rfdc({ constructorHandlers: [[RegExp, (o) => new RegExp(o)]] })(data)
isNot(cloned.regex, data.regex, 'different objects')
ok(cloned.regex.test('foo'))
})

function types (clone, label) {
test(label + ' – number', async ({ is }) => {
Expand Down

0 comments on commit 8c6bfec

Please sign in to comment.