Angular elements: Communication between components 🤝
A brief overview đź“‹
Web components are a standard of modern web development. They allow us to write modular, scalable, reusable, and encapsulated HTML elements with complex business logic inside, which is supported by all modern browsers.
The @angular/elements
library provides the ability to wrap Angular components into a standalone Web component relatively easily.
Current state of @angular/elements 🎩
Although @angular/elements
provides great opportunity for generating standalone and encapsulated components which could be used almost anywhere, it has some flaws:
- Complex
NgModule
structure; - Components are bound to it’s module
Injector
; - No support for
ContentChild
andContentChildren;
Fixing the Injector đź›
The reason for the complex NgModules is simple: components need to be aware of each other. This task is handled by the module Injector.
But what if we want to keep our NgModules simple and straightforward?
In this case, child elements from different NgModule will not be aware of the parent components from the other NgModule. Sometimes this issue can break whole applications. In Fundamental-ngx, for complex components, e.g. Platform Table, we rely on it’s awareness of the parent container for styling, passing data and retrieving one.
If we take a look into the implementation of the createCustomElement
, we will see that for options it supports passing custom strategyFactory
property which is actually responsible for generating the web component.
So let’s take advantage over this factory class and make web component aware of it’s parents!
What we need to do, is to grab the parent element injector and inherit it for our current injector. Here’s simple schema how it works:
The code snippet below is an extended code from ComponentNgElementStrategyFactory
class provided by @angular/elements
itself.
In the code above we do the following:
- When element is placed inside the DOM tree, we try to get the parent component for it’s injector
- If the injector is found, we extend our current injector for the current component with the found parent injector, thus, giving the ability to inject things from parent elements. Similarly how default Angular DI works.
- After we initiated our injector, we set it as an element property for further usages by child or parent components.
This actually solves one of the annoying things of @angular/elements
such as complex NgModules
.
Before, you had to have some root NgModule
which imports all secondary NgModules
for sharing the Injector and declaring the components. Now you can have much simpler NgModules
which are responsible for individual set of components. For example, TabModule
which declares and generates only Tab-related components:
In the code snippet above we export two types of module:
- One for native Angular application;
- Second is for web component usage.
The second module is extended from BaseWebComponentModule
which holds common business logic for generating the Web components:
Finding lost children 🔎
Handling child-parent communication was pretty simple and straightforward, right?
Now, let’s try fixing another communication issue: parent with children.
Due to Angular’s rendering engine specifics, @ContentChild
and @ContentChildren
wont work in Web component. So, we’ll have to write our own solution for it, yet, keeping the backwards compatibility.
Let’s try making our @WebComponentContentChildren
!
So, the plan is:
- It should be a decorator which can be placed on top of
@ContentChildren;
- It should return
QueryList
same way@ContentChildren
does; - It should react on Subtree changes;
- It should support the same syntax as
@ContentChildren
with passing Class, Injection token, string, etc. - It should be performant;
- ???
- PROFIT!!!
Let’s start with the decorator:
In the code above we do simple task: storing information about current component @ContentChildren
condition.
Now, backwards compatibility and Subtree changes reaction.
In the code above, we initialise MutationObserver for our Web component, which will notify us on any subtree changes. In this example it reacts on almost any change, which is not needed in real case scenarios.
Now, let’s think how we can support the same Selector syntax @ContentChildren
has.
Remember, in the first part we exposed Component’s injector as a public property?
We are going to use it to check if current element fits the condition!
The code above is pretty self-explanatory, but let’s describe it a bit more.
- If passed selector is a string, let’s threat it as CSS selector, and use QuerySelectorAll. If your application uses string providers, you can remove this part of the code.
- Second check is for injection tokens. Since we are using Shadow DOM encapsulation, getting all child elements is not a performance-breaker, since it will not get elements from children Shadow DOM, and we like to keep our components simple and split complex logic with smaller components or directives. After we get all child elements, we check whether element exposes the injector. If so, we try to receive token value from it’s injector, but keeping the scope to the element itself (without going to the parent injectors for the value), and flagging it as optional so if there’s no value, we don’t get an injector error.
Now, all we need to do is to notify our component of the changes, when they actually happening:
And now the usage part:
That’s all, folks. We made @angular/elements
even more powerful with the support of such beloved features as DI and child elements communication.
If you like to know more, you can play around with our library’s implementation of web components wrapper.
And also check the demo app.