NOTE: The examples used here are coded in Angular and PrimeNG. The syntax might be different with another stack. The file structure is simplified. I've removed everything automatically generated by the framework that was unnecessary for the demonstration, to make it easier to read. The data loading cycle is not implemented, again for the sake of clarity.
In a project where it is not the view that drives but the model how to make the different parts of the model communicate.
About standard event binding
State changes and user interactions are events that induce changes in the UI and underlying data. A notification received on a messaging thread listened by the application, a user click on an interface button or a keystroke on the keyboard are events that produce changes, both visible and invisible.
Any DOM lib seamlessly integrates the management of these events by allowing an interface element (a DOM tag) to be linked to an underlying element (a model value or function) using a dedicated syntax. Intercepting the click on a button, for example, means declaring in the view that you wish to link the mouse click (click)
to a function you have to write elsewhere, called onSave()
. This is achieved by the following declaration: <button (click)="onSave()">Save</button>
.
In short, all this is well known to any developer, but that's not the point. If, like me, you're looking to decouple the view from the model as much as possible (MVVM pattern), you'll need an additional mechanism to link events between the different classes in your view model.
Simple MVVM event feature
A simple example to get you started with menu item click management. Each menu item declares a command property command: () => this.menuItemClick(#menuId)
which refers to a method that centralizes clicks and executes the listener method if one has been declared. The menu item id is passed directly as an argument, but to reinforce type control we could use an abstract EventArg class with different variations for different types of events.
This is a kind of dependency injection. The listener injects its listening method into the class that produces the event. In this example, we're talking about AppVM, which is the root of the view model, and ToolbarVM, which is the model underlying the component.
Here's how it looks in the application:
This is a very convenient way of connecting two classes that have a direct relationship, in this case from parent to child. It is, however, limited by the fact that :
- classes must be aware of each other in the model tree, i.e. if we can declare
this.toolbarVM.menuItemClicked = this.menuItemClicked.bind(this);
in the AppVM ctor it's because, in this context, we have both an instance of AppVM and an instance of ToolbarVM. - there can only be one listener for an event.
Extended MVVM event feature
We'll now look at how we can resolve these two limitations, i.e. :
- allow multiple listeners for the same event
- allow a listener to be connected to a remote emitter. Distant both in the view model tree and in the application lifecycle. The listener and sender can be arbitrarily distant in the view model tree and instantiated at different times in the application lifecycle.
Although more sophisticated than the above simple version, the complete solution nevertheless boils down to a few classes and little code:
Event classes | |
---|---|
EventCallbackStore | events callbacks store |
EventEmitter | event emitter model must inherit from this abstract base class |
EventListener | event listener model must inherit from this abstract base class |
EventProcess | events callback wrapper as stored in EventCallbackStore |
IEventListener | contract structure for an event model |
ModelEventLifeCycle | abstract base class for view components whose model is an IEventListener |
AppEventsEnum | just an enum for events names |
Utilities classes | |
---|---|
readableUUID | small function to return a human readable unique id for better console log experience |
LoggerService | console log utilities with messages grouping and prod context checking |
Note that this solution requires a dependency injection system. I use the Microsoft lib tsyringe.
A few pointers about MVVM
You'll note in passing the particular architecture used for MVVM. A view component is linked to its view model via an injected property that I always call 'model'.
export class RightPanelComponent extends ModelEventLifeCycle {
@Input() override model: RightPanelVM | undefined;
}
The view is only loaded into the DOM when 'model' is instantiated:
@if (model) {
<div class="flex flex-column gap-5">
<h3>Right panel</h3>
...
</div>
}
And of course, with the exception of the root of the view model tree, which is initialized by the root page (AppComponent instancie AppVM), it's never the view component that instantiates its model, but it's because an instance of the model is created by its parent model that the corresponding view is injected into the DOM.
It's the model tree that drives the view tree.
There is nothing more than the declaration of the view model in the component's ts file. Coupling between view and model is thus reduced to an absolute minimum.
ModelEventLifeCycle
Listeners are callback methods that are called when the event is emitted. Since there may be several listeners for the same event, several callbacks will be executed when the event is emitted.
But when a listener is destroyed, for example when a view changed because the view model is no longer in use, we want to ensure that the callbacks associated with this listener are immediately destroyed.
The method consists in using the ngOnDestroy()
of the view component life cycle to destroy callbacks. Each view component whose view model is an event listener must extend a ModelEventLifeCycle
base component.
@Component({ template: '' })
export abstract class ModelEventLifeCycle implements OnChanges, OnDestroy {
@Input() model: IEventListener | undefined;
ngOnChanges(changes: SimpleChanges): void {
const modelChanges = changes["model"];
if (modelChanges && !modelChanges.firstChange && modelChanges.previousValue) {
// remove the listeners if a new instance of the model is created
(modelChanges.previousValue as IEventListener).removeListeners();
}
}
ngOnDestroy(): void {
// remove the listeners
// in some cases, model and listeners could have been already destroyed par the parent model
if (this.model) {
this.model.removeListeners();
}
}
}
This component deletes callbacks when the view model is reinstantiated or the component is removed from the DOM.
A view component whose model is an event listener must extend ModelEventLifeCycle
and override the base model with its own view model. @Input() override model:...
.
IEventListener
A view model that needs to listen to events must implement this interface, whose contract includes a unique identifier and two methods for managing listening.
export interface IEventListener {
invariantId: string;
addEventListener(methodName: string, method: any): void;
removeListeners(): void;
}
readableUUID
Just a small tool to return a human readable unique id for better console log better experience.
export function readableUUID(prefix: string): string {
return `${prefix}.${crypto.randomUUID()}`;
}
Which in the console results, for example, in :
EventProcess
When an IEventListener view model add a new event listener, an EventProcess is registered into the event callback store.
// EventCallbackStore excerpt
addEventListener(listenerId: string, eventName: string, method: any): void {
const process = this.getProcess(listenerId, eventName);
if (process) {
// if the process already exists, replace the callback method
process.method = method;
} else {
// else create a new event process
this.processes.push(new EventProcess(listenerId, eventName, method));
}
}
EventEmitter
What does it mean to emit an event ? When a EventEmitter view model call its async emitEventAsync(eventName: string, arg1?: any): Promise<any> {}
method, it execute each EventProcess stored in the EventCallbackStore whose that have the same eventName which is equivalent for the event to being received by the listener, because the EventProcess callback method belongs to the listener.
EventListener
A view model can be just an EventEmitter if it doesn't need to listen to any event. But an EventListener is also an EventEmitter. this architecture is due to the limitation of type script, which does not allow mutliple inheritance. So EventListener extends EventEmitter.
export abstract class EventListener extends EventEmitter implements IEventListener {...}
An EventListener must be instanciated with a unique identifier that is generated using the readableUUID tool.
export class RightPanelVM extends EventListener {
...
constructor() {
super(readableUUID(RightPanelVM.name));
...
}
...
}
And call its addEventListener base method to register in the EventCallbackStore in the name of the listener unique id.
this.addEventListener(#event name#, (#event arg#) => {
#callback instructions#
});
Note that the callback method can be async, very handy to wait for an api call result.
this.addEventListener(#event name#, async (#event arg#) => {
await something();
});
The sample app
I've made an sample playground application to demonstrate how it works, which you can find on github.
A menu bar and a content divided in two panels, left and right. The left panel listens to the right panel and vice versa. The menu bar listens to the two content panels. When a panel send a message event, the over one displays the incoming message and the menu bar counts sent messages.
For each event emitted by a panel, there are two listeners, the other panel and the menu bar. The two panels are two leaves of the model tree. They have no common reference, and therefore no 'knowledge' of each other.
Do you want to play a bit ?
Thanks for checking by.