In the Inheritance section, we separated basic behavior from specific behavior. Basic behavior corresponds to the surface-level description of the component's goal. The Stencil class is responsible for basic behavior, while specific behavior is handled by the component created from the Stencil class through inheritance.
If we rely solely on inheritance, after creating a sufficiently large set of components, it becomes clear that many components exhibit very similar specific behaviors.
This similarity in behavior suggests the idea of categorizing components. Categorization could help organize the variety of components and reduce code volume, as some behaviors could be implemented once in the ancestor class, with no need for the descendants to reimplement them.
However, regardless of the categorization we create, similar behavior is observed not only among components within the same category but also between components belonging to different categories. For example, a label component might fetch data from an external source, and a table component might do the same. In other words, achieving order and reducing code requires something more than inheritance with the use of Stencil classes.
In this case, the behavior of "fetching data from an external source" could have its own designation. For instance, such behavior might be called ExternalDataCapable. As a result, some types of behavior can be given specific names and are likely to be decoupled from the component code, which can then be constructed from a set of behaviors. This is typically referred to as composition.
Separation of Behaviors
There are many reasons for separating behaviors.
- The same behavior can be used across multiple classes. Separating behaviors helps avoid code duplication in these classes.
- It is easier to read code when it focuses on solving only one task. Behaviors can be separated according to task divisions.
- The tasks a class solves may involve subtasks. Extracting subtasks into behaviors helps simplify the understanding of the algorithm for solving the main task.
By combining the separated behaviors with the basic behavior, a complete component can be assembled, which aligns with the concept of composition in OOP. You can refer to OOP theory and find several more reasons for separating behaviors.
However, while there are many reasons for separating behaviors and they are relatively easy to articulate, it is not always easy to do because methods often call one another, and it’s not always clear where to draw the boundary between two behaviors.
For example, let’s return to the KoiLabel class. If we forget the current implementation of KoiLabel and start building the component from scratch, we can begin by listing its functions.
- Retrieve data from attributes
- React to programmatic data changes
- Transform data into text
- Display the text
At this point, the boundary between behaviors is not yet obvious. Attributes are transformed into data, the data is modified, and these changes trigger a reaction. During the reaction, the data is converted into text, and the text is displayed by the component. One entity is linked to another, and no matter where we draw the line, one entity will always cross it.
Let’s take another step. If we implement all the listed functions, the method names could look as follows:
- constructOwnDataObject()
- prepareDefaultDataValuesFromAttributes()
- setDataValuesToOwnDataObject()
- getDataValuesFromOwnDataObject()
- convertDataValuesIntoText()
- ...
- isSomethingChanged()
- handleSomethingChanged()
- ...
- displaySocket
- updateSocket
- updateTextInSocket
- ...
In this list, the method names are similar, and a pattern starts to emerge that can help group the methods. For example, one group of methods can be identified as solving tasks related to appearance changes, while another group handles data-related tasks.
Therefore, the boundary for separating behaviors can be defined by the names of the tasks they address.
Mixins
JavaScript offers several ways to implement composition. I use two of them: mixins and "has-a". Each has its own characteristics and drawbacks.
Let’s start with mixins. They can be implemented in various ways. One elegant approach is to use a function that generates a new class based on an existing one.
The resulting KoiLabel will inherit all methods from both KoiLabelStencil (the base class) and KoiLabelSocketConnectable (the behavior). All descendants of KoiLabel will inherit both sets of methods.
Let me outline some key features of this approach to separating behaviors.
A behavior can add new methods to the base class, but it can also override existing methods from the base class. If a behavior includes a method, it is considered an addition if the base class does not already have a method with the same name, and an override if the base class already has a method with the same name.
To avoid overriding, the method can be extended using the super keyword.
Here is a fictional example of how the super keyword can be used.
However, this does not fully resolve the overriding issue. In the comments in the code, I pointed out that in the onConstructed method of the KoiLabel class, the order of method calls is fairly obvious and is determined by the construction KoiLabelSocketConnectable(KoiLabelStencil). But in the onConstructed method of the KoiLabelSocketConnectable behavior, it’s not at all clear what the super keyword refers to and which onConstructed method will be invoked.
Let’s consider this from another perspective. As mentioned earlier, whether a method is considered an override is not determined by its implementation but by its presence in the base class. Unfortunately, it’s easy to forget which base classes a particular behavior can be added to and which it cannot. Recognizing by the code whether a behavior can be added to a class requires examining the code.
I see only two ways to simplify this recognition. The first is to create only additive behaviors. If behaviors are written purely to add methods, then to add a behavior to a class, it’s enough to check that there are no methods with the same name in the base class. The second way is to name behaviors in such a way that the name clearly indicates the appropriate base class.
From this point on, I will use the term "behavior knows about the base class" when a behavior relies on methods defined in the base class, and "behavior does not know about the base class" when a behavior can be applied to any base class without modifying its workflow.
With these terms, the previous statement can be rephrased as follows: behaviors are easiest to use when they don’t know about the base class.
It should also be noted that one method can call another method through the this keyword. Therefore, a method from one behavior can invoke a method from another behavior. I can’t think of a situation where this would be necessary. I would confidently say that this could lead to chaos, whereas the purpose of behaviors is the opposite — to separate functionality between behaviors. So, behaviors should neither be aware of each other nor of the unifying class.
Finally, there are a number of aspects of the mixin mechanism that I don’t like. For example, I don’t like that although behavior methods are separated from the class methods, they still appear in the same list in the browser’s debugger. Since I use the debugger quite often, dealing with a long list of methods with unclear names is rather exhausting.
Another issue I have is that when using the mixin mechanism, despite grouping methods into separate behaviors, the methods do not share a common namespace. It would be better to shorten method names; for instance, setDataValues could be shortened to setValues or simply set.
Note: This may seem obvious, but it’s worth mentioning that all the methods collected into the composition are still available at the constructor stage. This is important if we need to use methods from a behavior before the component is rendered in the DOM.
Has-a
The KoiLabelStencil class displays text obtained from data. If the data is unavailable, it should display a default state corresponding to the situation. Therefore, KoiLabelStencil should define a set of display methods:
- displayWaiting
- displayError
- displayData
- ...
All these names include the prefix "display," which indicates that these methods should be grouped and moved to a supplementary class. We’ll call this supplementary class KoiLabelSocket.
An instance of the supplementary class can be initialized as an attribute of the KoiLabel class.
This is a "has-a" composition. After initialization, the methods of the KoiLabelSocket class become available to the methods of KoiLabel, and the KoiLabel class can use the KoiLabelSocket methods as its own.
The main difference between the mixins composition and the "has-a" composition is that KoiLabelSocket is perceived as part of KoiLabel only after the this.socket attribute is initialized. Therefore, when creating the workflow for the KoiLabel component, it is important to clearly define the stage at which the attribute is created, so that no method using the methods of KoiLabelSocket is called before this stage.
Fortunately, the web components standard defines a constructor method. If all the classes we wish to attach using "has-a" are initialized in this method, and their methods are not called within the constructor, the problem of premature invocation will be resolved.
Now, let’s consider what the "has-a" composition offers.
First, the "has-a" approach is an excellent way to make methods final without fearing accidental overriding. In the mixins approach, we focused on how behavior methods are added to base class methods, sometimes creating the problem of method overriding. In the "has-a" composition, all methods of the supplementary class are automatically final and cannot be overridden by methods from the encompassing class.
Second, the supplementary class knows nothing about the encompassing class, which allows for abstraction from other functions in the encompassing class. For example, KoiLabelSocket solves only the task of display and is designed without regard for where and how the data is sourced.
Third, the methods in the supplementary class take on the name of the attribute as their namespace, shortening their names. A call like this.displaySomethingInSocket() becomes this.socket.displaySomething().
Finally, thanks to the introduction of namespaces, not only in the code but even in the browser's debugger, the list of methods in the class becomes significantly reduced and organized.
The drawbacks, of course, are the flip side of the advantages. For example, suppose you need to create a component that, like KoiLabel, will display data but will do so using a different template.
The template in the KoiLabelSocket class is defined in the getTemplate method, and I haven't created functionality that allows for loading external templates. To change the template, the getTemplate method must be overridden. Due to the use of the "has-a" composition, it is not enough to inherit the component class by overriding only the getTemplate method. You would need to inherit both classes, overriding both the getTemplate and the initialization methods.
The second issue is that, since we now have two classes instead of one, data transfer between them can only occur through a data object whose configuration is known to both classes. Specifically, KoiLabel needs to modify its existing data to make it compatible for use by the KoiLabelSocket class. This complicates the KoiLabel code somewhat, as a conversion method is required to transfer the data.
On the other hand, these drawbacks are only significant for small components. From the perspective of developing small projects, everything I’ve discussed about KoiLabel could, in theory, be done using a simple standard span tag. However, when working with large, complex components, the only thing that saves you is encapsulation, and at these moments, everything described above becomes very relevant and necessary.
Assign
The assign method is an intermediate approach between mixins and has-a. Interestingly, the assign method incorporates not so much the advantages of these two methods as their drawbacks.
To use the assign method, you need to declare a dictionary of methods.
As you can see, the methods use the this keyword, which refers to the combining class. The ILocalized dictionary here acts not as a separate class, but as an enumeration of methods. To add methods to the combining class, we use the following construction:
As with the "has-a" composition, we can only use methods from ILocalized after the Object.assign call. This means we cannot override these methods in descendants using the usual method. In other words, the methods in ILocalized automatically become final.
At the same time, the methods in ILocalized do not receive their own namespace within the combining class. Therefore, their names are not shortened and should not overlap with the combining class's method names.
Thus, the applicability of the assign method is very limited, and it is best suited when you need final methods that have access to the attributes and methods of the combining class.
The following table will help quickly determine which composition method suits you best.
Mixins | Has-a | Assign | |
---|---|---|---|
Inheritance possible | Yes | No | No |
Methods become final | No | Yes | Yes |
Methods get a namespace | No | Yes | No |
Access to combining class | Yes | No | Yes |
Composition vs Inheritance
The goal of the "has-a" composition is to encapsulate behavior. By moving part of the behavior into a subclass, we hide it and prevent external code from managing it.
When discussing components, the primary goal is to encapsulate the details of data display and data retrieval. Therefore, I use the "has-a" composition for both the component's template and its data.
The goal of the mixins composition is to separate behaviors. By placing a behavior into a mixin, we isolate that behavior from the rest of the component code. It is convenient to work with a component when you know that one mixin is responsible for one behavior, while another mixin is responsible for another, with no interdependencies between them. For this reason, I use mixins, among other things, to create adapters for subclasses. For example, I use the KoiLabelSocketConnectable mixin to define how the component works with KoiLabelSocket.
The goal of inheritance is to stabilize the component. KoiLabel stabilizes the behaviors KoiLabelSocket, KoiLabelSocketConnectable, and KoiLabelStencil, binding them together so that a new component based on KoiLabel can later be created with minimal effort, without needing to reassemble the component from multiple behaviors.
In conclusion, I would say that using composition together with inheritance allows web components to achieve much more, simplifying the code and increasing its reusability.