04/05/2021
Diario de desarrollo. Una aplicación "moderna" con vanilla JS. Custom Elements
Toda aplicación moderna tiene que estar basada en componentes y dado que uno de los objetivos es reducir las dependencias y los procesos de «build» pues es el caso ideal para usar Custom Elements. Los custom elements están dentro del paraguas de los Web Components que además incluyen el Shadow DOM, HTML Template cargados como ES Modules (RIP HTML imports).
En este caso que me ocupa sólo (de momento) voy a utilizar los Custom Elements y voy a pasar del Shadow DOM, el motivo para esto es simple: los estilos. Una de las grandes ventajas de usar Shadow DOM es la encapsulación de los estilos, es algo que está muy bien, permite definir los estilos de tu componente sin que estos se filtren al resto de la aplicación y sin que los estilos generales le afecten, pero es esa misma cualidad la que me hace decantarme por no usarlos. El problema es que para como quiero escribir mi aplicación quiero usar una hoja de estilos común y no quiero tener que definir estilos comunes en cada componente (ni tampoco cargar una hoja de estilos externa en estos), para mi caso en concreto las ventajas de esto exceden los incovenientes.
Tal y como yo lo veo, si quieres hacer un componente para compartirlo y que se pueda utilizar en otros sitios pues es una manera de tener control sobre el look & feel de tu componente, pues perfecto, a usar el Shadow DOM. Pero si quieres hacer una aplicación con tus componentes y lo realmente importante es la encapsulación de funcionalidades pues no es necesario.
Entonces, me quedo con los Custom Elements a secas, que me da esto: etiquetas HTML personalizadas (tipo: <song-item></song-item>
), lifecycle callbacks, y otro hook donde controlar el cambio de los valores de los atributos. La verdad es que es bastante y este API está genial, pero cuando se empezó a escribir sobre esto ya se decía que este API es para construir sistemas de componentes, no tanto como para crear tus elementos tal cual, que por otro lado, si sólo vas a crear uno (y más si es para compartirlo por ahí) pues igual no merece la pena hacer una abstracción por encima.
En mi caso, si que voy a crear un puñado de elementos y si que me compensa hacer una capa por encima.
La idea es crear un componente base del que puedan extender los demás, las funciones principales de este componente son ocuparse de la reactividad (aunque de esto ya escribo otro día) y simplificar el API para la creación de componentes (eliminar boilerplate). Además, porque es un patrón que ya he utilizado otras veces y me gusta, un componente puede cargar dinámicamente otros componentes, esto es útil para no tener que definir de antemano todo lo que necesito utilizar y sólo cargar lo necesario cada vez.
Es complicado explicar mi componente base sin meterme a tratar cosas como la reactividad (que en serio, otro día), así que voy a simplificar y asumo que siempre hago un renderizado completo (esto no es así siempre, pero de momento...).
Lo que queda es algo así:
class BaseComponent extends HTMLElement {
constructor() {
super();
// Call beforemount hook
this.beforeMount();
}
// Component is injected to page
connectedCallback() {
// Load components if any
this.loadComponents();
// Render component
this.render();
// Call mounted hook
this.mounted();
}
disconnnectedCallback() {
// Call destroyed hook (clean event listeners an such)
this.destroyed();
}
// Properties to watch, trigger render on attributeChangedCallback
static get observedAttributes() {
return this.properties;
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
// Dynamic loading components
loadComponents() {
if (!this.components) {
return;
}
this.components.forEach(this.importElement);
}
async importElement(path) {
return import(path).then((module) => {
const exists = customElements.get(module.default.tagName);
if (!exists) {
if (!module.default.tagName) {
throw Error(`Component ${ module.default.name } doesn't have tagName`);
}
customElements.define(module.default.tagName, module.default);
}
});
}
// Optional hooks definitions
beforeMount() {
return;
}
mounted() {
return;
}
destroyed() {
return;
}
render(html, el) {
// set defaults
const htmlString = html || this.html;
const container = el || this;
// innerHTML, TODO: research if is needed
const frag = document.createRange().createContextualFragment(htmlString);
container.replaceChildren(frag);
}
}
export default BaseComponent;
No es todo lo que tengo, pero para hacerse una idea creo que vale, este componente me permite escribir un componente así:
class ChildComponent extends BaseComponent {
static tagName = 'child-component';
// Siempre que cambia esta propiedad se renderiza otra vez el componente
static properties = ['count'];
get html() {
return `<p>Child component, counter: ${ this.getAttribute('count') }</p>`
}
}
Y usarlo así:
<child-component count="3"></child-component>
O incluso cargarlo desde otro componente para actualizar esa propiedad:
class TestComponent extends BaseComponent {
static tagName = 'test-component';
// con esto cargo automáticamente el componente
components = [ChildComponent];
// propiedad de ejemplo, un contador
count = 0;
mounted() {
// evento para incrementar el contador
this.querySelector('#counter').addEventListener('click', e => {
this.count++;
// tengo que volver a renderizar el componente para que tenga efecto
this.render();
// y tengo que volver a crear el evento porque al renderizar lo he perdido 😭
this.mounted();
});
}
get html() {
return `
<p>Basic component</p>
<button id="counter">Add one to child</button>
<child-component count="${ this.count }"></child-component>
`;
}
}
<test-component></test-component>
Evidentemente el renderizar cada vez todo y el tener que volver a acoplar los eventos no es lo óptimo y no entraría en lo que yo considero una aplicación «moderna», pero ya lo vemos cuando escriba de mis soluciones churruteras para tratar con la reactividad.
See the Pen Custom Elements - basic demo by Ander (@andersanmiguel) on CodePen.