From 8c6bfec5610aa47eee23f624db692c751a56268f Mon Sep 17 00:00:00 2001 From: Alec Gibson <12036746+alecgibson@users.noreply.github.com> Date: Wed, 12 Jun 2024 10:44:59 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20`constructorHandlers`=20optio?= =?UTF-8?q?n=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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]: https://github.com/davidmarkclements/rfdc/issues/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 ``` --- index.js | 89 +++++++++++++++++++++++++++------------------------ readme.md | 25 ++++++++++++++- test/index.js | 18 +++++++++++ 3 files changed, 90 insertions(+), 42 deletions(-) diff --git a/index.js b/index.js index 41a49c1..a964561 100644 --- a/index.js +++ b/index.js @@ -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) { @@ -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 { @@ -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 { @@ -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 { @@ -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) { @@ -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 { @@ -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) @@ -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 { @@ -156,10 +167,10 @@ 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) @@ -167,12 +178,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), 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 { diff --git a/readme.md b/readme.md index 104bc81..892b11f 100644 --- a/readme.md +++ b/readme.md @@ -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 @@ -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: diff --git a/test/index.js b/test/index.js index a8e3cf4..5bdd9ac 100644 --- a/test/index.js +++ b/test/index.js @@ -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 }) => {