Dependency Injection in Vue.js with good-injector-vue
Published on Tuesday, February 13, 2018 6:00:00 AM UTC in Announcements & Programming & Tools
Creating a TypeScript IoC container was only the beginning. Until it can be integrated with other frameworks, it's only of limited use. Of course, using something like this in different contexts is always possible, but ideally it feels natural to the environment your application resides in, and not forced. As you probably already guessed, this article describes how to integrate "good-injector" with Vue.js. But it goes further: today I announce good-injector-vue, an adapter of my IoC container for Vue.js that does all the required work for you and seamlessly integrates with it.
You can find the full source code on GitHub and the respective package on NPM.
In Vue.js, everything is about components, and each component passes through a well-defined life cycle. This is nicely described in their documentation. As a developer, you use the provided hooks like "created" or "mounted" to add your own logic. Integrating with Vue.js for a dependency injection framework hence means to be aware of this life cycle and its hooks, and ideally extend them with injection features naturally.
Concept
As it turned out, creating the Vue.js integration was much harder than writing the actual IoC container. It's not like a lot of code was necessary to implement it, quite the contrary. All in all the whole transpiled implementation is like 60 lines of code, including the plugin logic for Vue.js and all. But understanding the moving parts not only of Vue.js but additionally also the hoops the TypeScript integration jumps through-that wasn't so straight-forward. Here's the basic concept of what the implementation and its features look like:
- The adapter to good-injector is provided as plugin to Vue.js and adds a
$container
property to Vue components that points to the IoC container. This can be used for service locator pattern-like access (which I don't encourage by the way) or to access the container from within your components for other means, for example to mimic the provide and inject feature of Vue.js but with more powerful options. - The plugin either creates an own container or uses one passed from the outside, so you can prepare all your registrations before configuring the plugin itself.
- The adapter provides a new decorator named
InjectArguments
that is used to mark the life cycle hooks of your components for injection. This is a technical requirement so the necessary meta data for "good-injector" is emitted by the compiler. Please refer to my explanations in the article on "good-injector" for the details on emitting meta data if you're interested. - The decorator replaces the original life cycle hook of Vue.js with a wrapper that makes use of the container to resolve the arguments of the hook first, and then calls the original implementation with these arguments.
The implementation has been carefully crafted to preserve the original context for the hook, and to be minimally invasive: it does not touch anything if it's not necessary, and it correctly preserves chained calls when multiple entries for a life cycle hook exist.
Following the philosophy of "good-injector", the adapter also is quite strict, explicit and does not try to do too much black magic:
- It requires hooks to be decorated properly.
- It throws if you try to decorate a method that is not a life cycle hook of Vue.js
- It throws if dependencies cannot not be resolved.
Ok, let's dive in to the implementation!
The following describes the implementation of the plugin itself. If you're only interested in how to use it in your application, this is not for you. Please refer to the documentation on GitHub and NPM for details that get you started quickly
Requirements
Since once again we're dealing with meta data and decorators, you need to turn on the respective TypeScript compiler features:
"experimentalDecorators": true,
"emitDecoratorMetadata": true
This shouldn't be an issue; if you're using Vue.js with TypeScript, these have to be active anyway, for the required packages "vue-class-component" and "vue-property-decorator".
The first package ("vue-class-component") is also a dependency of "good-injector-vue" because it makes use of one of its helpers. This brings "good-injector-vue" to three dependencies in total, being:
- good-injector (obviously)
- reflect-metadata (obviously)
- vue-class-component
With that out of the way, let's see what the actual implementation looks like.
Implementation of the decorator
All the involved code for the new decorator is actually quite straight forward, with one tiny detail that needs a bit of explanation:
export function InjectArguments() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
if (!__supportedLifeCycleHooks.find((item) => item === propertyKey)) {
const lifeCycleHooks = __supportedLifeCycleHooks.join(", ");
throw new Error(`The decorated method '${propertyKey}' is not supported. Supported life cycle hooks are: '${lifeCycleHooks}'`);
}
let paramTypes = <Function[]>Reflect.getMetadata("design:paramtypes", target, propertyKey);
if (paramTypes == undefined || paramTypes.length === 0) {
return;
}
// defer actual decoration with vue-class-component, so we can get access to the component options
let decorator = createDecorator((componentOptions, handler) => {
wrapLifeCycleHook(componentOptions, propertyKey, paramTypes);
});
decorator(target, propertyKey);
}
}
First the code checks to see if the decorator was applied to a valid life cycle hook. Then it makes sure we have meta data and early-exits if there's nothing to do. Then, and this is the interesting part, it prepares replacing the corresponding life cycle hook, but it doesn't attempt this directly. It rather defers the operation using a helper function of "vue-class-component" named createDecorator
. What this helper does is:
- Take the provided function and attach it to a
__decorators__
property of the constructor of the target. - Later on, when all the preparation of the Vue component is complete, it invokes the decorators temporarily stored in that property.
The reason this is necessary is that particularly the component options we need to manipulate are not available at the time the component is instantiated and the decorator is invoked. The "vue-class-component" helper makes sure we can invoke our logic when everything is set up correctly, but before the actual life cycle hooks are invoked-perfect!
Now on to the actual replacement code:
function wrapLifeCycleHook(componentOptions: ComponentOptions<_Vue>, hookName: string, paramTypes: Function[]) {
const genericOptions = <any>componentOptions; // because indexer access is not possible otherwise
const currentComponentHooks = <Function | Function[]>genericOptions[hookName];
if (currentComponentHooks == undefined || currentComponentHooks.length === 0) {
throw new Error(`Got metadata for life cycle hook '${hookName}' but that hook is not part of the component '${componentOptions.name}'(??)`);
}
const originalHook = typeof currentComponentHooks === "function" ?
currentComponentHooks :
currentComponentHooks[currentComponentHooks.length - 1];
const replacementHook = function(this: _Vue) {
if (__o == undefined || __o.container == undefined) { // __o are the previously configured options of the plugin
throw new Error("No container defined. Did you forget to configure the good-injector plugin?");
}
// resolve arguments
let args: any[] = paramTypes.map((item: any) => __o.container!.resolve(item));
// call original hook with arguments
originalHook.call(this, ...args);
};
if (typeof currentComponentHooks === "function") {
genericOptions[hookName] = replacementHook;
}
else {
currentComponentHooks[currentComponentHooks.length - 1] = replacementHook;
}
}
A lot of this simply is plumbing code. For example, the hooks for a given life cycle name can either be a single function or a list of functions (where the last entry is the one that is the actual component method). The code has to compensate for that, hence the multiple conditionals.
The crucial part is the definition of the replacement hook, and its involved steps are really straight-forward:
- Let the container resolve the arguments of the original life cycle hook.
- Call the original life cycle hook with the resolved arguments.
And that's it!
Usage
First of all, set up your container and configure the plugin accordingly. The container is re-exported by the plug-in so you don't have to juggle with multiple imports. All this typically is done in your Main.ts
:
import { Container, GoodInjectorPlugin } from "good-injector-vue";
// setup dependency injection
var container = new Container();
container.registerSingleton(Repository);
// configure plugin
Vue.use(GoodInjectorPlugin, { container })
// ... rest of the bootstrapping as usual
Then, in your components, you can inject all registered types into every life cycle hook. Like:
// in Home.vue
import { InjectArguments } from "good-injector-vue";
export class Home extends Vue {
@InjectArguments()
public mounted(repo: Repository): void {
// use "repo" and enjoy!
}
}
Of course you can also use the $container
property in your components to access the container directly if you need to.
Limitations and hints
- Please note that the
InjectArguments
decorator is a decorator factory. Don't forget the parenthesis, i.e.InjectArguments()
(in newer TypeScript versions, the compiler will complain if you forget this). - Please note that the
SupportsInjection
decorator of good-injector does not work directly on Vue.js components, because they are not instantiated by the container. The decorator fully works on all your dependencies though, for example theRepository
type in the sample above. - Make sure that you don't inject costly to construct transient dependencies into potentially frequently called life cycle hooks like updated.
Have fun!
Tags: Dependency Injection · Good-injector · Good-injector-vue · TypeScript · Vue.js