Commit 947a2acc authored by Robert Knight's avatar Robert Knight

Make construction method for objects in `Injector` explicit

Previously a heuristic based on function naming was used to determine
whether `Injector` should construct objects with `new` or not. This is
brittle because compiler transforms may alter function names (eg. terser
removes them by default).

This commit re-works the API so that the caller explicitly indicates how
the object should be created (the "provider" to use). As a convenience
if a function is passed it is assumed to be a class.

```
// Register a class.
register("anObject", SomeClass);
register("anObject", { class: SomeClass });

// Register a factory.
register("anObject", { factory: makeMeAnObject });

// Register a plain value.
register("anObject", { value: "foobar" });
```
parent 52ca1eae
/* global Map */ /* global Map */
function isClass(functionOrClass) { /**
return functionOrClass.name.match(/^[A-Z]/); * @typedef Provider
* @prop {any} [value] - The value for the object
* @prop {Function} [class] - A class that should be instantiated to create the object
* @prop {Function} [factory] - Function that should be called to create the object
*/
/**
* @param {Provider} provider
*/
function isValidProvider(provider) {
if (typeof provider !== 'object' || provider === null) {
return false;
}
return (
'value' in provider ||
typeof provider.class === 'function' ||
typeof provider.factory === 'function'
);
} }
/** /**
* `Injector` is a dependency injection container. * `Injector` is a dependency injection container.
* *
* It provides a convenient way to instantiate a set of named objects with * It provides a convenient way to instantiate a set of named objects with
* dependencies. Objects are constructed using a factory function or * dependencies. Objects are constructed using a _provider_, which can be a
* class constructor. The factory function or constructor may have dependencies * factory function, class constructor or value.
*
* If the provider is a factory function or constructor it may have dependencies
* which are indicated by a `$inject` property on the function/class which * which are indicated by a `$inject` property on the function/class which
* is a list of the names of the dependencies. The `$inject` property can be * is a list of the names of the dependencies. The `$inject` property can be
* added manually or by a compiler plugin (eg. `babel-plugin-angularjs-annotate`). * added manually or by a compiler plugin (eg. `babel-plugin-angularjs-annotate`).
* *
* To construct an object, call the `register` method with the name and factory * To construct an object, call the `register` method with the name and provider
* function/class for the object and each of its dependencies, and then call * for the object and each of its dependencies, and then call
* the `get` method to construct or return the existing object with a given name, * the `get` method to construct the object and its dependencies return it.
* along with all of its dependencies.
*/ */
export class Injector { export class Injector {
constructor() { constructor() {
// Map of name to factory function that creates an instance or class used // Map of name to object specifying how to create/provide that object.
// as prototype for instance. this._providers = new Map();
this._factories = new Map();
// Map of name to existing instance. // Map of name to existing instance.
this._instances = new Map(); this._instances = new Map();
...@@ -44,12 +62,17 @@ export class Injector { ...@@ -44,12 +62,17 @@ export class Injector {
return this._instances.get(name); return this._instances.get(name);
} }
const factory = this._factories.get(name); const provider = this._providers.get(name);
if (!factory) { if (!provider) {
throw new Error(`"${name}" is not registered`); throw new Error(`"${name}" is not registered`);
} }
if ('value' in provider) {
this._instances.set(name, provider.value);
return provider.value;
}
if (this._constructing.has(name)) { if (this._constructing.has(name)) {
throw new Error( throw new Error(
`Encountered a circular dependency when constructing "${name}"` `Encountered a circular dependency when constructing "${name}"`
...@@ -59,7 +82,10 @@ export class Injector { ...@@ -59,7 +82,10 @@ export class Injector {
this._constructing.add(name); this._constructing.add(name);
try { try {
const resolvedDependencies = []; const resolvedDependencies = [];
const dependencies = factory.$inject || []; const dependencies =
('class' in provider && provider.class.$inject) ||
('factory' in provider && provider.factory.$inject) ||
[];
for (const dependency of dependencies) { for (const dependency of dependencies) {
try { try {
...@@ -74,10 +100,11 @@ export class Injector { ...@@ -74,10 +100,11 @@ export class Injector {
} }
let instance; let instance;
if (isClass(factory)) { if (provider.class) {
// eslint-disable-next-line new-cap // eslint-disable-next-line new-cap
instance = new factory(...resolvedDependencies); instance = new provider.class(...resolvedDependencies);
} else { } else {
const factory = provider.factory;
instance = factory(...resolvedDependencies); instance = factory(...resolvedDependencies);
} }
this._instances.set(name, instance); this._instances.set(name, instance);
...@@ -89,20 +116,24 @@ export class Injector { ...@@ -89,20 +116,24 @@ export class Injector {
} }
/** /**
* Register a factory with a given name. * Register a provider for an object in the container.
* *
* If `factory`'s name starts with an upper-case letter it is treated as a * If `provider` is a function, it is treated like a class. In other words
* class. If it starts with a lower-case letter it is treated as a factory * `register(name, SomeClass)` is the same as `register(name, { class: SomeClass })`.
* function, which may return any type of value.
* *
* @param {string} name - Name of object * @param {string} name - Name of object
* @param {() => any} factory - * @param {Function|Provider} provider -
* A function that constructs the service, or a class that will be instantiated * The class or other provider to use to create the object.
* when the object is requested.
* @return {this} * @return {this}
*/ */
register(name, factory) { register(name, provider) {
this._factories.set(name, factory); if (typeof provider === 'function') {
provider = { class: provider };
} else if (!isValidProvider(provider)) {
throw new Error(`Invalid provider for "${name}"`);
}
this._providers.set(name, provider);
return this; return this;
} }
} }
...@@ -2,25 +2,41 @@ import { Injector } from '../injector'; ...@@ -2,25 +2,41 @@ import { Injector } from '../injector';
describe('Injector', () => { describe('Injector', () => {
describe('#get', () => { describe('#get', () => {
it('calls a non-class factory as a function to create instance', () => { it('calls a "factory" provider as a function to create the object', () => {
const instance = {}; const instance = 'aValue';
const factory = sinon.stub().returns(instance); const factory = sinon.stub().callsFake(function() {
assert.isUndefined(this);
return instance;
});
const container = new Injector(); const container = new Injector();
container.register('service', factory); container.register('service', { factory });
const constructed = container.get('service'); const constructed = container.get('service');
assert.equal(constructed, instance); assert.equal(constructed, instance);
}); });
it('calls a class factory with `new` to create instance', () => { it('calls a "class" provider with `new` to create instance', () => {
class Foo {} class Foo {}
const container = new Injector(); const container = new Injector();
container.register('foo', Foo); container.register('foo', Foo);
container.register('foo2', { class: Foo });
const constructed = container.get('foo'); const constructed = container.get('foo');
assert.instanceOf(constructed, Foo); assert.instanceOf(constructed, Foo);
const constructed2 = container.get('foo2');
assert.instanceOf(constructed2, Foo);
});
it('uses the value of a "value" provider as the instance', () => {
const instance = {};
const container = new Injector();
container.register('anObject', { value: instance });
assert.equal(container.get('anObject'), instance);
}); });
it('returns the existing instance if already constructed', () => { it('returns the existing instance if already constructed', () => {
...@@ -64,20 +80,16 @@ describe('Injector', () => { ...@@ -64,20 +80,16 @@ describe('Injector', () => {
return factory; return factory;
}; };
container.register('a', () => 'a'); container.register('a', { value: 'a' });
container.register( container.register('b', { factory: addDeps(a => a + 'b', ['a']) });
'b', container.register('c', {
addDeps(a => a + 'b', ['a']) factory: addDeps((b, a) => b + 'c' + a, ['b', 'a']),
); });
container.register(
'c',
addDeps((b, a) => b + 'c' + a, ['b', 'a'])
);
assert.equal(container.get('c'), 'abca'); assert.equal(container.get('c'), 'abca');
}); });
it('throws an error if factory is not registered', () => { it('throws an error if provider is not registered for name', () => {
const container = new Injector(); const container = new Injector();
assert.throws(() => { assert.throws(() => {
container.get('invalid'); container.get('invalid');
...@@ -131,5 +143,14 @@ describe('Injector', () => { ...@@ -131,5 +143,14 @@ describe('Injector', () => {
container container
); );
}); });
[{}, 'invalid', true, null, undefined].forEach(invalidProvider => {
it('throws an error if the provider is not valid', () => {
const container = new Injector();
assert.throws(() => {
container.register('foo', invalidProvider);
}, `Invalid provider for "foo"`);
});
});
}); });
}); });
...@@ -246,16 +246,16 @@ function startAngularApp(config) { ...@@ -246,16 +246,16 @@ function startAngularApp(config) {
// nb. In many cases these can be replaced by direct imports in the services // nb. In many cases these can be replaced by direct imports in the services
// that use them, since they don't depend on instances of other services. // that use them, since they don't depend on instances of other services.
container container
.register('$window', () => window) .register('$window', { value: window })
.register('Discovery', () => Discovery) .register('Discovery', { value: Discovery })
.register('OAuthClient', () => OAuthClient) .register('OAuthClient', { value: OAuthClient })
.register('VirtualThreadList', () => VirtualThreadList) .register('VirtualThreadList', { value: VirtualThreadList })
.register('isSidebar', () => isSidebar) .register('isSidebar', { value: isSidebar })
.register('random', () => random) .register('random', { value: random })
.register('serviceConfig', () => serviceConfig) .register('serviceConfig', { value: serviceConfig })
.register('settings', () => config) .register('settings', { value: config })
.register('time', () => time) .register('time', { value: time })
.register('urlEncodeFilter', () => urlEncodeFilter); .register('urlEncodeFilter', { value: urlEncodeFilter });
// Register services which only Angular can construct, once Angular has // Register services which only Angular can construct, once Angular has
// constructed them. // constructed them.
...@@ -263,8 +263,8 @@ function startAngularApp(config) { ...@@ -263,8 +263,8 @@ function startAngularApp(config) {
// @ngInject // @ngInject
function registerAngularServices($rootScope, toastr) { function registerAngularServices($rootScope, toastr) {
container container
.register('toastr', () => toastr) .register('toastr', { value: toastr })
.register('$rootScope', () => $rootScope); .register('$rootScope', { value: $rootScope });
} }
angular angular
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment