The Vanilla Javascript Component Pattern
I started delving into web components about a year ago. I really liked the idea of getting a reference to a custom element and then calling methods and setting values right on the custom element. After that I looked into Polymer 3.0, which layered on a number of conveniences and best practices. These specifically came in the area of templating, life cycle management, and property / attribute reflection. I proceeded away from Polymer 3.0 to using lit-element, and then finally just lit-html. I continued this process of stripping away the technologies while leaving the patterns, schemes, and best practices that I had learned. What I arrived at is something of a Vanilla Javascript Component Pattern (I might need a more specific name).
This pattern doesn't even use web components, as I wanted something that could be deployed across browsers without polyfills or any additional code that would need delivered to the browser. Not that this is difficult, or should be a barrier to usage of web components on a greenfield project, however I wanted something that could be used anywhere and everywhere.
Below is a very simply example of such a component. It uses ES6 classes and a plain template literal for producing the markup. It does some fancy stuff inside the constructor, and this code is essentially boilerplate that makes sure that each DOM element only has a single JavaScript object representing it. It does this by setting a data-ref attribute with a randomly generated ID. Then, when the ExampleComponent class is used and an instance of this class already exists for the provided DOM element, the reference to the already existing object is returned from the constructor. This allows a DOM element to be passes to this classes constructor multiple times, and only one instance of the class will ever exist.
You will notice that this renders the static "Hello, World!" value in an <h1> tag. However, what if we want some dynamic values? First, we'll update the class as shown below:
We now initialize the value with the data-title attribute on the container DOM element that is provided to the constructor. In addition, we provide setter and getter methods for retrieving and updating the value, and whenever the value is updated, we re-render the component.
However, what if we want sub components rendered as a part of this component?
Notice that this time around, we add a div with a unique class name to the markup method. Then in the render method we get a reference to this element, and initialize an AnotherExampleComponent with that DOM element. Note: I have not provided an implementation here for AnotherExampleComponent. Lastly, what if we want our component to propagate events out of the component into parent components, or whatever code initialized or has a reference to our component?
Notice that we have now added an addEventListeners method which listens for events within the component. When the button is clicked, it dispatches an event with a custom name on the container, so that client code can listen to the specialized set of custom named events on the container, and does not need to be aware of the implementation details of the component itself. This is to say, that the container is the border between the client code and the implementation. The class itself should never reach outside of it's own container, and client code should never reach inside of the container for data or events. All data and events should be provided to the client through an interface of getter methods and events dispatched from the container.
All of this separation of concerns, encapsulation, and componetized development is possible in vanilla JS with no libraries, frameworks, or polyfills. Schemes and patterns are always preferable than frameworks and libraries, as I say all of the time. We also did not need web components to do this. However, where do the the benefits of web components and libraries come in?
First, web components are a platform enhancement, that turn the schemes and patterns presented here into rules for the platform. This means that with web components, the encapsulation and separation of concerns shown here cannot be broken down by client code, because the platform will enforce it. So if web components can be used, these best practices should be updated for web components (a blog post on that coming soon!).
Secondly, libraries can be helpful. One thing we haven't covered here is safely rendering the markup. Preferably a library can be used for this especially if the data you are rendering is user provided and potentially unsafe. So, if you have the room in your data budget for how much code to deliver to the client there are a few libraries that can assist us. Currently with this scheme its nothing other than the actual project code itself, as no libraries were needed. The main issue with this scheme is rendering the markup. Currently to re-render is expensive, and complex views can be complex to represent in a plain template literal. However we can use a tagged template literal library such as hyperHTML or lit-html in order to simplify the rendering process and speed up the re-rendering process. Keep in mind that while hyperHTML has had a 1.0 release that is useable in production code for over a year, lit-html is currently on the fast track for a 1.0 release.
This pattern doesn't even use web components, as I wanted something that could be deployed across browsers without polyfills or any additional code that would need delivered to the browser. Not that this is difficult, or should be a barrier to usage of web components on a greenfield project, however I wanted something that could be used anywhere and everywhere.
Below is a very simply example of such a component. It uses ES6 classes and a plain template literal for producing the markup. It does some fancy stuff inside the constructor, and this code is essentially boilerplate that makes sure that each DOM element only has a single JavaScript object representing it. It does this by setting a data-ref attribute with a randomly generated ID. Then, when the ExampleComponent class is used and an instance of this class already exists for the provided DOM element, the reference to the already existing object is returned from the constructor. This allows a DOM element to be passes to this classes constructor multiple times, and only one instance of the class will ever exist.
export default class ExampleComponent { init(container) { this.container = container; this.render(); } render() { this.container.innerHTML = ExampleComponent.markup(this); } static markup({}) { return ` <h1>Hello, World!</h1> `; } constructor(container) { // The constructor should only contain the boiler plate code for finding or creating the reference. if (typeof container.dataset.ref === 'undefined') { this.ref = Math.random(); ExampleComponent.refs[this.ref] = this; container.dataset.ref = this.ref; this.init(container); } else { // If this element has already been instantiated, use the existing reference. return ExampleComponent.refs[container.dataset.ref]; } } } ExampleComponent.refs = {}; document.addEventListener('DOMContentLoaded', () => { new ExampleComponent(document.getElementById('example-component')) });
You will notice that this renders the static "Hello, World!" value in an <h1> tag. However, what if we want some dynamic values? First, we'll update the class as shown below:
export default class ExampleComponent { set title(title) { this.titleValue = title; this.render(); } get title() { return titleValue; } init(container) { this.container = container; this.titleValue = this.container.dataset.title; this.render(); } render() { this.container.innerHTML = ExampleComponent.markup(this); } static markup({title}) { return ` <h1>${title}</h1> `; } constructor(container) { // The constructor should only contain the boiler plate code for finding or creating the reference. if (typeof container.dataset.ref === 'undefined') { this.ref = Math.random(); ExampleComponent.refs[this.ref] = this; container.dataset.ref = this.ref; this.init(container); } else { // If this element has already been instantiated, use the existing reference. return ExampleComponent.refs[container.dataset.ref]; } } } ExampleComponent.refs = {}; document.addEventListener('DOMContentLoaded', () => { new ExampleComponent(document.getElementById('example-component')) });
We now initialize the value with the data-title attribute on the container DOM element that is provided to the constructor. In addition, we provide setter and getter methods for retrieving and updating the value, and whenever the value is updated, we re-render the component.
However, what if we want sub components rendered as a part of this component?
export default class ExampleComponent { set title(title) { this.titleValue = title; this.render(); } get title() { return titleValue; } init(container) { this.container = container; this.titleValue = this.container.dataset.title; this.render(); } render() { this.container.innerHTML = ExampleComponent.markup(this); this.subComponentElement = this.container.querySelector('.sub-component-example'); new AnotherExampleComponent(this.subComponentElement); } static markup({title}) { return ` <h1>${title}</h1> <div class="sub-component-example"></div> `; } constructor(container) { // The constructor should only contain the boiler plate code for finding or creating the reference. if (typeof container.dataset.ref === 'undefined') { this.ref = Math.random(); ExampleComponent.refs[this.ref] = this; container.dataset.ref = this.ref; this.init(container); } else { // If this element has already been instantiated, use the existing reference. return ExampleComponent.refs[container.dataset.ref]; } } } ExampleComponent.refs = {}; document.addEventListener('DOMContentLoaded', () => { new ExampleComponent(document.getElementById('example-component')) });
Notice that this time around, we add a div with a unique class name to the markup method. Then in the render method we get a reference to this element, and initialize an AnotherExampleComponent with that DOM element. Note: I have not provided an implementation here for AnotherExampleComponent. Lastly, what if we want our component to propagate events out of the component into parent components, or whatever code initialized or has a reference to our component?
export default class ExampleComponent { set title(title) { this.titleValue = title; this.render(); } get title() { return titleValue; } init(container) { this.container = container; this.titleValue = this.container.dataset.title; this.render(); } render() { this.container.innerHTML = ExampleComponent.markup(this); this.subComponentElement = this.container.querySelector('.sub-component-example'); this.clickMeButton = this.container.querySelector('.click-me'); new AnotherExampleComponent(this.subComponentElement); this.addEventListeners(); } static markup({title}) { return ` <h1>${title}</h1> <button class="click-me">Click Me</div> <div class="sub-component-example"></div> `; } addEventListeners() { this.clickMeButton().addEventListener('click', () => this.container.dispatchEvent(new CustomEvent('click-me-was-clicked'))); } constructor(container) { // The constructor should only contain the boiler plate code for finding or creating the reference. if (typeof container.dataset.ref === 'undefined') { this.ref = Math.random(); ExampleComponent.refs[this.ref] = this; container.dataset.ref = this.ref; this.init(container); } else { // If this element has already been instantiated, use the existing reference. return ExampleComponent.refs[container.dataset.ref]; } } } ExampleComponent.refs = {}; document.addEventListener('DOMContentLoaded', () => { new ExampleComponent(document.getElementById('example-component')) });
Notice that we have now added an addEventListeners method which listens for events within the component. When the button is clicked, it dispatches an event with a custom name on the container, so that client code can listen to the specialized set of custom named events on the container, and does not need to be aware of the implementation details of the component itself. This is to say, that the container is the border between the client code and the implementation. The class itself should never reach outside of it's own container, and client code should never reach inside of the container for data or events. All data and events should be provided to the client through an interface of getter methods and events dispatched from the container.
All of this separation of concerns, encapsulation, and componetized development is possible in vanilla JS with no libraries, frameworks, or polyfills. Schemes and patterns are always preferable than frameworks and libraries, as I say all of the time. We also did not need web components to do this. However, where do the the benefits of web components and libraries come in?
First, web components are a platform enhancement, that turn the schemes and patterns presented here into rules for the platform. This means that with web components, the encapsulation and separation of concerns shown here cannot be broken down by client code, because the platform will enforce it. So if web components can be used, these best practices should be updated for web components (a blog post on that coming soon!).
Secondly, libraries can be helpful. One thing we haven't covered here is safely rendering the markup. Preferably a library can be used for this especially if the data you are rendering is user provided and potentially unsafe. So, if you have the room in your data budget for how much code to deliver to the client there are a few libraries that can assist us. Currently with this scheme its nothing other than the actual project code itself, as no libraries were needed. The main issue with this scheme is rendering the markup. Currently to re-render is expensive, and complex views can be complex to represent in a plain template literal. However we can use a tagged template literal library such as hyperHTML or lit-html in order to simplify the rendering process and speed up the re-rendering process. Keep in mind that while hyperHTML has had a 1.0 release that is useable in production code for over a year, lit-html is currently on the fast track for a 1.0 release.