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.
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.pageElement = this.container.querySelector('.sub-component-example');
new AnotherExampleComponent(this.pageElement);
}
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.pageElement = this.container.querySelector('.sub-component-example');
this.clickMeButton = this.container.querySelector('.click-me');
new AnotherExampleComponent(this.pageElement);
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 better 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. 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 been in production for over a year, lit-html is currently on the fact track for a 1.0 release.
I have this same post on my blog where I talk more about the latest and greatest web development patterns over frameworks.
Latest comments (29)
Would love your thoughts on how I am doing it youtube.com/watch?v=jJDiyeeMWpc
It's always fun to see a new VanillaJs component approach. You could also use Inheritance to reduce the amount of line in the CTOR, introduce lifecycles. But this could also become quickly unmaintainable.
Great article !
Small mistake in your second example. It should be :
get title() {
return this.titleValue;
}
@megazar7 you inspired me to introduce a other usefull pattern what do you think about that? dev.to/frankdspeed/the-html-compon...
Interesting approach. Using MVC to make this setup would perhaps be more scalable. Any thoughts on this @megazear7 ?
In the same spirit, to create DOM components in pure JavaScript, I implemented a small library just to wrap all my code into DOM components, per se:
github.com/vitaly-t/excellent
You still write everything directly for DOM, but it helps with reusability + lifespan + isolation.
I like this! Thank you.
Using innerHTML like this looks like a massive XSS vulnerability waiting to happen. Please don't do components like this. Your users will suffer the consequences when your page contents get hijacked.
I thought innerHTML was only an issue if you were applying it with user input? I thought if it's my code and my data then I can use innerHTML. Crap! Please give me a link or a little more on this as I'm using it wrong and I thought I was being careful.
Of course, it is possible to use innerHTML without an XSS vulnerability, but it's much easier to reduce your XSS attack surface when you don't use it at all. You have to make that call for your project.
In my professional JS projects, we aren't building an end-user application, and we won't ultimately be in control, so we ban the use of innerHTML project-wide.
As an alternative, you can/should construct individual elements with createElement, and use innerText or createTextNode for the textual parts. For example:
const div1 = document.createElement('div');
div1.appendChild(document.createTextNode('It may be a pain, but this is '));
const em1 = document.createElement('em');
em1.innerText = 'worth';
div1.appendChild(em1);
div1.appendChild(document.createTextNode(' it!'));
document.body.appendChild(div1);
But if you find yourself doing this often... consider doing something else, like using a templating library or something. When we use this pattern at work, we're building a middle-ware library that constructs a DOM hierarchy with UI controls for something the app developer is building. It could have been done as a web component, but we just fill in an app-supplied div instead. There's not much text involved, except for labels and aria attributes, so it's not terribly burdensome. YMMV.
I have updated the contents of this blog on the source blog post to more directly address and discuss some of these concerns: alexlockhart.me/2018/07/the-vanill...
I agree, in most cases a trusted template library should be used. The point I wanted to stress in the blog post is that I think that in my opinion template literals should be the preferred method of rendering html from JavaScript, and that libraries can be layered on top of that basic scheme.
Correct you would need to do both JS and HTML encoding for security reasons. Ideally using a template library would also alleviate many conerns as well.
This post is great if you don't know what Reactive programming or how it can hugely improve your app architecture
I think updating DOM in a reactive way would be a great addition instead of rerendering the whole template each time. How might you suggest integrating reactive programming into the pattern I've described in the post?
I am not suggesting, ReactJS has done this very well (updating Dom in a reactive way) and it is 1 of the lib that I have to keep in my toolbox these day
Amen.