One of the greatest advantages of web components is their support for inheritance. With an existing component in your library, you can effortlessly create a new component that builds on its functionality.
Inheritance enables the incremental expansion of the base library, allowing you to add more components and extend the library's range of use cases.
For instance, you might develop a set of components tailored to a specific project and later reuse these components — either entirely or selectively — in another project. Over time, if you find that certain components are consistently useful across multiple projects, you can consolidate them into your own custom component library. Even better, this process can be as simple as copying the necessary directories to your chosen location, eliminating the need to deal with dependency managers.
Modification in Inheritance
When discussing inheritance, three key terms often arise: narrowing, extending, and modifying. While these terms appear simple and self-explanatory, it’s worth delving deeper into their implications.
Let’s start with modification. Modification refers to altering the behavior of existing component functionality. This typically involves overriding methods in the base class to adjust their behavior or adapt their parameters.
Overriding methods introduces a potential issue: substituting one component with another may result in different outcomes for the same method call. This can be seen as a direct violation of the Liskov Substitution Principle, which states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
But it’s not that simple. Not every method modification necessarily leads to a different outcome, as the interpretation of results is often context-dependent. For instance, changing text color from black to red is unlikely to break the program logic, though it might affect the user interface’s visual perception. Similarly, replacing a method that retrieves data from a database with one that pulls data from a file is unlikely to disrupt code that iterates through a set of components and instructs each to load data. For such code, the source of the data is irrelevant.
Therefore, whether a modification violates the Liskov Substitution Principle depends on how the components are utilized. This leaves two strategies: assess each modification individually to determine if it violates the substitution principle, or avoid modifications entirely.
One way to avoid modifications is by creating a base class and deriving subclasses, each implementing a different version of the method. For instance, one subclass could work with a database and the other with files. The code iterating over components and invoking the data-loading functionality would treat it as a call to an abstract method of the base class.
While this is the ideal approach, there are situations where it might not be applicable. Imagine you need a class that is very similar to one in the library but with a slightly different method. You cannot modify the library code because you rely on automatic updates. Consequently, you can’t create a new base class to derive both the library class and your custom class. In such cases, this solution won’t work.
I have preemptively designed base classes wherever feasible to help you avoid modifications. However, remember that not all method overrides change outcomes. Most of the time, you can safely override many methods from the library without concerns.
Extension
Let’s discuss extension. This process involves adding new functionality or expanding the scope of existing components. Subclasses can introduce new methods or enhance the functionality of inherited ones. While extension offers clear benefits, such as increased flexibility and additional features, it also has potential drawbacks.
Here’s my perspective: I’m not a proponent of universal components. Using such components often requires navigating a long list of configuration options. These settings may change with library updates, leading to potential confusion. They might get buried in the project’s code, and even if they don’t, the configuration file could become unwieldy and difficult to manage.
The solution? Strive to create components that focus on solving one specific task. If you need to address two distinct tasks, create two separate components. This approach ensures clarity, maintainability, and a cleaner project structure.
Narrowing
I advocate for specialization. This approach involves refining existing components to make them more specific. It typically occurs when a subclass overrides the methods of a base class, enabling them to handle more specialized parameters or carry out more targeted tasks.
Embrace specialization. Consider which base components can be refined to create the component you need. For instance, a table filter and a data entry form serve a similar purpose: they provide input fields for the user, collect the user's input, and pass the resulting data to an object. The main difference lies in how the data object behaves in each case.
Given the significant overlap between a table filter and a data entry form, they could both inherit from a more generalized base component. This approach fosters code reuse and streamlines component development.
Project Structure
Inheritance enables you to develop new functionality without modifying the base library, which is a crucial benefit. I strongly recommend using inheritance even when it seems unnecessary. Even if the base component fully meets your requirements, inherit it in your specific project rather than referencing it directly from the base library. This means creating a file in the js directory containing an empty subclass and referencing that subclass on your pages. This practice prepares you for future modifications by giving you a ready-made framework for extending the component.
Additionally, I suggest mirroring the hierarchy of the library’s directory in your js and css directories. For instance, if you are inheriting a component from web-components-lib/controls/labels, replicate this structure in your js directory, such as your_project_name/controls/labels, and place your customized component there.
For an even more robust setup, consider starting by creating a dedicated library for your project. Within the libs directory, create a folder for your library and place your components there. This approach encourages you to divide tasks into two categories: unique and universal. Components specific to the project belong in the js directory, while reusable components should reside in your newly created library.
By the way, regarding naming components: to avoid confusion, I recommend fully or partially repeating the name of the parent file in the name of the child file, and repeating the name of the parent class in the name of the child class. This helps prevent mix-ups. Believe me, even a long name like label_that_shows_accuracy_percentage.js is better than simply naming it percents.js. The second thing I recommend is using namespaces. For example, I use the koi prefix wherever possible. Here's an example of an inheritance chain:
In principle, it's possible to extend base functionality without inheritance. JavaScript is flexible enough for this, and we’ll definitely discuss this further. However, I recommend using inheritance. The reason is simple – by inheriting a component, you lock its functionality into the name. In other words, you closely associate the component’s name with its functionality, making the name a clearly defined term in the language. Such a name can be used to communicate meaning in the code, making the code clearer and less confusing.
Example
Suppose I need to use a label in my project whose content is determined by some data. The base component KoiLabel is responsible for this label. To have such a component in my project, I create a subdirectory docs/controls/labels within the js directory. This way, I replicate the directory structure of the KoiCom library to avoid confusion.
Inside the labels directory, I create a file named docs_sample_label.js. The file name indicates that I have inherited from control_label.js. Additionally, since my new component will be used as an illustration, I use the sample prefix. To show that the component will be used in the documentation project, I add the docs prefix.
Comment for the Component
At the beginning of the file, I write a comment outlining the purpose for which I am creating the component. Notice that I don't describe what the component does, but rather the purpose of its existence. This helps me in the future to revisit the component with a fresh perspective and consider if I can achieve this goal in a more elegant and efficient way.
Yes, sometimes in the comment, I include how the component achieves its goal and how this approach differs from others. This adds context. When I come across the component after weeks or months, I’ll be able to understand how to use it if I know its purpose and method. If I only describe what the component does, I’ll be left questioning why it does it. This makes it harder to understand the component’s role in the project. To avoid this, I write down the goal and, if necessary, the method.
Next, I define the component's getTagName and getTag methods. You can find details on how to do this in the Usage section.
Basic Functionality
Now, notice that we’ve created a subclass, but it does exactly what the base component does. It takes the string from the value attribute and displays it inside itself without any changes.
A little later, we’ll come up with additional functionality for our new component. In principle, we can add anything. But for now, let’s first understand what functionality is and what kinds it can take.
To do this, let's look at the functionality that is central to KoiLabelStencil and that our component inherits in the form of a set of methods.
This code takes the standard workflow of the KoiBaseControl component and defines what the _updateSocket method, which is part of the workflow, should do. The _updateSocket method is supposed to update the component’s appearance. In the case of the KoiLabelStencil component, the _updateSocket method should update the displayed text. To update the text, the component needs to obtain that text from somewhere. To get the text, it needs to fetch some data and transform it into text.
The KoiLabelStencil class outlines what actions need to be taken and the order in which they should be executed, but it does not provide the details of how those actions are carried out.
At this point, the concept of an abstract class typically comes to mind, and I would call KoiLabelStencil an abstract class, but KoiLabelStencil does in fact specify certain actions. If you look at the behavior code for KoiSocketConnectable and the code for the KoiBaseControl class, you will find concrete methods. Therefore, KoiLabelStencil is not an abstract class.
One could argue with me here, pointing out that an abstract class can have concrete methods, and that an abstract class has a clear definition: it is a class that does not allow the creation of instances. However, instances of KoiLabelStencil can certainly be created, so that definition does not apply either.
So, if we try to use the concept of an abstract class here, it would turn into a pointless argument. I suggest we distance ourselves as much as possible from the ideas of abstract and concrete classes. I refer to the KoiLabelStencil class as a base class or a template, as indicated by the Stencil prefix.
I separate Stencil classes not to list numerous abstract methods. I separate them to define the component's purpose.
To define the component's purpose, it is not necessary to describe the path to achieving that purpose. It is sufficient to give a name to that purpose.
Here is a subtle point. A brief name for the purpose might be misinterpreted. A more detailed name defines the purpose more accurately but starts to resemble a description of the path to achieving it. A superficial description of the path to achieving the purpose can be referred to as a description of basic behavior. Thus, we can say that the Stencil class defines the goal through the description of basic behavior.
For any component inherited from KoiLabelStencil, the goal is to display text in the socket. This goal can be briefly stated as "invoke the method _updateTextInSocket during the execution of the _updateSocket method." A more detailed formulation of the goal would be: the component should display some text, which needs to be retrieved from certain data, which also has to be obtained in some way. This leads us to a chain of methods, some of which we will reference but not detail within the KoiLabelStencil class.
If you want to follow the route of component creation that I am trying to outline, almost the entire KoiCom library is built on this principle. There is a goal, there is basic behavior that provides a superficial description of the path to the goal, and there is specific behavior that allows you to follow that path.
Classes defining the goal and basic behavior are prefixed with Stencil. Classes defining specific behavior are considered components and inherit from Stencil classes.
I reiterate that this is very similar to abstract and concrete classes, but it’s better to use the terms Stencil and Component.
Also, note that KoiLabelStencil does not have a getTagName method. This further emphasizes that KoiLabelStencil should not be used on its own, and that to accomplish a specific task, a subclass of KoiLabelStencil needs to be created.
Encapsulation in Inheritance
Let’s now examine the KoiLabel component.
Since the KoiLabel component is inherited from KoiLabelStencil, all methods from KoiLabelStencil and its ancestors are inherited by the KoiLabel component.
Each component has many ancestors and, consequently, many methods. The set of inherited methods is extensive, and it’s easy to get overwhelmed. It’s important to highlight the methods that the component should develop and use, while hiding those that were necessary for specific actions but will no longer be required in any descendants and should be disregarded.
The issue is that over two or three levels of inheritance, we may evolve in a certain direction and use a set of methods, only to later halt that evolution, concluding that the code sufficiently solves the task. At this point, some methods should stop being visible to descendants and should be classified as methods that cannot be modified or extended. The question arises: how can this be done? I don’t have an answer to this yet.
Furthermore, I can assert that there will always be a method that was developed earlier but should no longer be extended, and there will always be a method that seemingly shouldn’t be extended, but which may unexpectedly become necessary at a later stage of evolution.
It is unlikely that I will be able to solve the problem of hiding methods in a clean and simple way. Therefore, I can only offer some guidelines regarding the principles of component development.
Protected methods
Note the underscore at the beginning of some method names. I use the underscore prefix to highlight methods that are protected (can only be called within the class itself and its subclasses).
This partially follows JavaScript conventions. Methods with an underscore are considered protected and are visible only to the class itself and its subclasses. All other methods are not considered protected and are visible when manipulating components in the project.
Here’s an example: If you open the console and run the following code, the text in the DocsSampleLabel component will change.
In other words, methods without an underscore can be used in the project for manipulating the component.
Technically, there is nothing stopping you from calling methods that start with an underscore in the same way. However, by convention, the underscore indicates that these methods should not be used directly in the project. They are woven into the component’s workflow and are called at specific points. Direct invocation is not intended, so these methods should only be called in the code of the component itself and its descendants.
Interfaces
Open the code from the control_label.js file and review the methods. You will see that the _getDataToDisplayInSocket method from the KoiLabelStencil class has been overridden in the KoiLabel component.
Earlier, we decided that, in many cases, method overriding should be avoided. So, why was this method overridden here? Let’s take a closer look at the KoiLabelStencil class code.
The _getDataToDisplayInSocket method defined in KoiLabelStencil is empty and only exists because it is called in the _getTextToDisplayInSocket method of the same class. If _getDataToDisplayInSocket were not present in KoiLabelStencil, upon reading the code, I would have been inclined to search for where this method is defined, spending a considerable amount of time studying the ancestor classes of KoiLabelStencil. To make it clear that this method is not yet implemented, I define it in KoiLabelStencil but do not implement it.
The _getTextToDisplayInSocket method is what I would call part of the interface. JavaScript does not have a standard way to declare interfaces, but we can always come up with some convention. I could have chosen not to define this method in KoiLabelStencil, and instead written about it in the documentation or as a comment, hoping that you would read the documentation and define the method yourself. However, knowing how people read documentation, I decided that explicitly having the method in the code would be more reliable.
Conclusion
In conclusion, the library is built on top of web components primarily because web components provide a convenient way to inherit components from one another. Inheritance allows for the creation of custom components. When inheriting, one should avoid overriding methods and instead use narrowing to specialize components. When creating methods, it's advisable to make all methods protected. The best approach to creating a component is to begin with a Stencil class, which outlines the component's goal through a description of its basic behavior.