diff --git a/index.d.ts b/index.d.ts index cdecfad..95eaee2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,21 @@ declare class Emittery { + /** + * In TypeScript, returns a decorator which mixins `Emittery` as property `emitteryPropertyName` and `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the target class. + * + * @example + * ``` + * import Emittery = require('emittery'); + * + * @Emittery.mixin('emittery') + * class MyClass {} + * + * let instance = new MyClass(); + * + * instance.emit('event'); + * ``` + */ + static mixin(emitteryPropertyName: string, methodNames?: readonly string[]): Function; + /** * Subscribe to an event. * @@ -74,6 +91,22 @@ declare class Emittery { * specified. */ listenerCount(eventName?: string): number; + + /** + * Bind the given `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the `target` object. + * + * @example + * ``` + * import Emittery = require('emittery'); + * + * let object = {}; + * + * new Emittery().bindMethods(object); + * + * object.emit('event'); + * ``` + */ + bindMethods(target: object, methodNames?: readonly string[]): void; } declare namespace Emittery { diff --git a/index.js b/index.js index 1f09455..e666671 100644 --- a/index.js +++ b/index.js @@ -25,7 +25,70 @@ function getListeners(instance, eventName) { return events.get(eventName); } +function defaultMethodNamesOrAssert(methodNames) { + if (methodNames === undefined) { + return allEmitteryMethods; + } + + if (!Array.isArray(methodNames)) { + throw new TypeError('`methodNames` must be an array of strings'); + } + + for (const methodName of methodNames) { + if (!allEmitteryMethods.includes(methodName)) { + if (typeof methodName !== 'string') { + throw new TypeError('`methodNames` element must be a string'); + } + + throw new Error(`${methodName} is not Emittery method`); + } + } + + return methodNames; +} + class Emittery { + static mixin(emitteryPropertyName, methodNames) { + methodNames = defaultMethodNamesOrAssert(methodNames); + return target => { + if (typeof target !== 'function') { + throw new TypeError('`target` must be function'); + } + + for (const methodName of methodNames) { + if (target.prototype[methodName] !== undefined) { + throw new Error(`The property \`${methodName}\` already exists on \`target\``); + } + } + + function getEmitteryProperty() { + Object.defineProperty(this, emitteryPropertyName, { + enumerable: false, + value: new Emittery() + }); + return this[emitteryPropertyName]; + } + + Object.defineProperty(target.prototype, emitteryPropertyName, { + enumerable: false, + get: getEmitteryProperty + }); + + const emitteryMethodCaller = methodName => function (...args) { + return this[emitteryPropertyName][methodName](...args); + }; + + for (const methodName of methodNames) { + Object.defineProperty(target.prototype, methodName, { + enumerable: false, + value: emitteryMethodCaller(methodName) + }); + } + + return target; + }; + } + constructor() { anyMap.set(this, new Set()); eventsMap.set(this, new Map()); @@ -140,8 +203,29 @@ class Emittery { return count; } + + bindMethods(target, methodNames) { + if (typeof target !== 'object' || target === null) { + throw new TypeError('`target` must be an object'); + } + + methodNames = defaultMethodNamesOrAssert(methodNames); + + for (const methodName of methodNames) { + if (target[methodName] !== undefined) { + throw new Error(`The property \`${methodName}\` already exists on \`target\``); + } + + Object.defineProperty(target, methodName, { + enumerable: false, + value: this[methodName].bind(this) + }); + } + } } +const allEmitteryMethods = Object.getOwnPropertyNames(Emittery.prototype).filter(v => v !== 'constructor'); + // Subclass used to encourage TS users to type their events. Emittery.Typed = class extends Emittery {}; Object.defineProperty(Emittery.Typed, 'Typed', { diff --git a/readme.md b/readme.md index a4fdffa..88a3792 100644 --- a/readme.md +++ b/readme.md @@ -103,6 +103,20 @@ If `eventName` is given, only the listeners for that event are cleared. The number of listeners for the `eventName` or all events if not specified. +#### bindMethods(target, methodNames?) + +Bind the given `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the `target` object. + +```js +import Emittery = require('emittery'); + +let object = {}; + +new Emittery().bindMethods(object); + +object.emit('event'); +``` + ## TypeScript @@ -119,6 +133,21 @@ ee.emit('value', 1); // TS compilation error ee.emit('end'); // TS compilation error ``` +### Emittery.mixin(emitteryPropertyName, methodNames?) + +A decorator which mixins `Emittery` as property `emitteryPropertyName` and `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the target class. + +```ts +import Emittery = require('emittery'); + +@Emittery.mixin('emittery') +class MyClass {} + +let instance = new MyClass(); + +instance.emit('event'); +``` + ## Scheduling details diff --git a/test/index.js b/test/index.js index e8e336a..1a62cb7 100644 --- a/test/index.js +++ b/test/index.js @@ -411,3 +411,143 @@ test('listenerCount() - eventName must be undefined if not a string', t => { emitter.listenerCount(42); }, TypeError); }); + +test('bindMethods()', t => { + const methodsToBind = ['on', 'off', 'emit', 'listenerCount']; + + const emitter = new Emittery(); + const target = {}; + + const oldPropertyNames = Object.getOwnPropertyNames(target); + emitter.bindMethods(target, methodsToBind); + + t.deepEqual(Object.getOwnPropertyNames(target).sort(), oldPropertyNames.concat(methodsToBind).sort()); + + for (const method of methodsToBind) { + t.is(typeof target[method], 'function'); + } + + t.is(target.listenerCount(), 0); +}); + +test('bindMethods() - methodNames must be array of strings or undefined', t => { + t.throws(() => { + new Emittery().bindMethods({}, null); + }); + + t.throws(() => { + new Emittery().bindMethods({}, 'string'); + }); + + t.throws(() => { + new Emittery().bindMethods({}, {}); + }); + + t.throws(() => { + new Emittery().bindMethods({}, [null]); + }); + + t.throws(() => { + new Emittery().bindMethods({}, [1]); + }); + + t.throws(() => { + new Emittery().bindMethods({}, [{}]); + }); +}); + +test('bindMethods() - must bind all methods if no array supplied', t => { + const methodsExpected = ['on', 'off', 'once', 'emit', 'emitSerial', 'onAny', 'offAny', 'clearListeners', 'listenerCount', 'bindMethods']; + + const emitter = new Emittery(); + const target = {}; + + const oldPropertyNames = Object.getOwnPropertyNames(target); + emitter.bindMethods(target); + + t.deepEqual(Object.getOwnPropertyNames(target).sort(), oldPropertyNames.concat(methodsExpected).sort()); + + for (const method of methodsExpected) { + t.is(typeof target[method], 'function'); + } + + t.is(target.listenerCount(), 0); +}); + +test('bindMethods() - methodNames must only include Emittery methods', t => { + const emitter = new Emittery(); + const target = {}; + t.throws(() => emitter.bindMethods(target, ['noexistent'])); +}); + +test('bindMethods() - must not set already existing fields', t => { + const emitter = new Emittery(); + const target = { + on: true + }; + t.throws(() => emitter.bindMethods(target, ['on'])); +}); + +test('bindMethods() - target must be an object', t => { + const emitter = new Emittery(); + t.throws(() => emitter.bindMethods('string', [])); + t.throws(() => emitter.bindMethods(null, [])); + t.throws(() => emitter.bindMethods(undefined, [])); +}); + +test('mixin()', t => { + class TestClass { + constructor(v) { + this.v = v; + } + } + const TestClassWithMixin = Emittery.mixin('emitter', ['on', 'off', 'once', 'emit', 'emitSerial', 'onAny', 'offAny', 'clearListeners', 'listenerCount', 'bindMethods'])(TestClass); + const symbol = Symbol('test symbol'); + const instance = new TestClassWithMixin(symbol); + t.true(instance.emitter instanceof Emittery); + t.true(instance instanceof TestClass); + t.is(instance.emitter, instance.emitter); + t.is(instance.v, symbol); + t.is(instance.listenerCount(), 0); +}); + +test('mixin() - methodNames must be array of strings or undefined', t => { + class TestClass { + } + t.throws(() => Emittery.mixin('emitter', null)(TestClass)); + t.throws(() => Emittery.mixin('emitter', 'string')(TestClass)); + t.throws(() => Emittery.mixin('emitter', {})(TestClass)); + t.throws(() => Emittery.mixin('emitter', [null])(TestClass)); + t.throws(() => Emittery.mixin('emitter', [1])(TestClass)); + t.throws(() => Emittery.mixin('emitter', [{}])(TestClass)); +}); + +test('mixin() - must mixin all methods if no array supplied', t => { + const methodsExpected = ['on', 'off', 'once', 'emit', 'emitSerial', 'onAny', 'offAny', 'clearListeners', 'listenerCount', 'bindMethods']; + + class TestClass {} + const TestClassWithMixin = Emittery.mixin('emitter')(TestClass); + + t.deepEqual(Object.getOwnPropertyNames(TestClassWithMixin.prototype).sort(), methodsExpected.concat(['constructor', 'emitter']).sort()); +}); + +test('mixin() - methodNames must only include Emittery methods', t => { + class TestClass {} + t.throws(() => Emittery.mixin('emitter', ['nonexistent'])(TestClass)); +}); + +test('mixin() - must not set already existing methods', t => { + class TestClass { + on() { + return true; + } + } + t.throws(() => Emittery.mixin('emitter', ['on'])(TestClass)); +}); + +test('mixin() - target must be function', t => { + t.throws(() => Emittery.mixin('emitter')('string')); + t.throws(() => Emittery.mixin('emitter')(null)); + t.throws(() => Emittery.mixin('emitter')(undefined)); + t.throws(() => Emittery.mixin('emitter')({})); +});