18.

Stimulus: Escribir JavaScript profesional

|

Share this awesome video!

|

Sabemos cómo escribir HTML en nuestras plantillas. Y manejamos CSS con Tailwind. ¿Qué pasa con JavaScript? Bueno, como con CSS, hay un archivo app.js, y ya está incluido en la página. Así que puedes poner aquí el JavaScript que quieras.

Pero te recomiendo encarecidamente que utilices una pequeña, pero malvada, biblioteca JavaScript llamada Stimulus. Es una de mis cosas favoritas de Internet. Tomas una parte de tu HTML existente y lo conectas a un pequeño archivo JavaScript, llamado controlador. Esto te permite añadir un comportamiento: por ejemplo, cuando pulses este botón, se llamará al método greet del controlador.

¡Y eso es todo! Seguro que Stimulus tiene más funciones, pero ya entiendes el núcleo de su funcionamiento. A pesar de su simplicidad, nos permitirá construir cualquier funcionalidad JavaScript y de interfaz de usuario que necesitemos, de forma fiable y predecible. Así que vamos a instalarlo.

Instalar Stimulus

Stimulus es una librería JavaScript, pero Symfony tiene un bundle que ayuda a integrarla. En tu terminal, si quieres ver lo que hace la receta, confirma tus cambios. Yo ya lo he hecho. Luego ejecuta:

composer require symfony/stimulus-bundle

Cuando esto termine... la receta ha hecho algunos cambios. Veamos los más importantes. El primero está en app.js: nuestro archivo JavaScript principal. Ábrelo y ya está.

11 lines | assets/app.js
import './bootstrap.js';
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import './styles/app.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

Añadió un import en la parte superior - ./bootstrap.js - a un nuevo archivo que vive justo al lado de éste.

import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);

El propósito de este archivo es iniciar el motor Stimulus. Además, enimportmap.php, la receta añadió el paquete JavaScript @hotwired/stimulus junto con otro archivo que ayuda a arrancar Stimulus dentro de Symfony.

28 lines | importmap.php
// ... lines 1 - 15
return [
// ... lines 17 - 20
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
'@symfony/stimulus-bundle' => [
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
],
];

Por último, la receta creó un directorio assets/controllers/. Aquí es donde vivirán nuestros controladores personalizados. ¡E incluía un controlador de demostración para que pudiéramos empezar! ¡Gracias!

import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}

Estos archivos de controlador tienen una importante convención de nombres. Como se llama hello_controller.js, para conectarlo con un elemento de la página, utilizaremos data-controller="hello".

Cómo funciona Stimulus

Así es como funciona. En cuanto Stimulus vea un elemento en la página condata-controller="hello", instanciará una nueva instancia de este controlador y llamará al método connect(). Así, este controlador hello cambiará automática e instantáneamente el contenido del elemento al que está unido.

Y ya podemos verlo. Actualiza la página. Stimulus está ahora activo en nuestro sitio. Esto significa que está buscando elementos con data-controller. Hagamos algo salvaje: inspecciona los elementos de la página, busca cualquier elemento -como esta etiqueta de anclaje- y añade data-controller="hello". Observa lo que ocurre cuando hago clic en desactivar para activar este cambio. ¡Pum! Stimulus ha visto ese elemento, ha instanciado nuestro controlador y ha llamado al método connect(). Y puedes hacer esto tantas veces como quieras en la página.

La cuestión es: no importa cómo llegue un elemento data-controller a tu página, Stimulus lo ve. Así que si hacemos una llamada Ajax que devuelva HTML y ponemos eso en la página... sí, Stimulus va a verlo y nuestro JavaScript va a funcionar. Ésa es la clave: cuando escribes JavaScript con Stimulus, tu JavaScript siempre funcionará, independientemente de cómo y cuándo se añada ese HTML a la página.

Crear un controlador Stimulus que se pueda cerrar

Utilicemos Stimulus para activar nuestro botón de cierre. En el directorio assets/controller/, duplica hello_controller.js y crea uno nuevo llamadocloseable_controller.js.

Borraré casi todo y me limitaré a lo más básico: importaController de Stimulus... y luego crea una clase que lo extienda.

import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
// ... lines 4 - 6
}

Esto no hace nada, pero ya podemos adjuntarlo a un elemento de la página. Éste es el plan: vamos a adjuntar el controlador a todo el elemento aside. Luego, cuando pulsemos este botón, eliminaremos el elemento aside.

Ese elemento vive en templates/main/_shipStatusAside.html.twig. Para adjuntar el controlador, añade data-controller="closeable". ¿Ves ese autocompletado? Proviene de un plugin de Stimulus para PhpStorm.

37 lines | templates/main/_shipStatusAside.html.twig
<aside
// ... line 2
data-controller="closeable"
>
// ... lines 5 - 35
</aside>

Si nos desplazamos y actualizamos, aún no ocurrirá nada: el botón de cerrar no funciona. Pero abre la consola de tu navegador. ¡Qué bien! Stimulus añade útiles mensajes de depuración: que se está iniciando y luego - lo que es importante - closeable initialize,closeable connect.

Esto significa que sí vio el elemento data-controller e inicializó ese controlador.

Así que volvamos a nuestro objetivo: cuando pulsemos este botón, queremos llamar a código dentro del controlador cerrable que elimine el aside. En closeable_controller.js, añade un nuevo método llamado, qué tal, close(). Dentro, digamos this.element.remove().

// ... lines 1 - 2
export default class extends Controller {
close() {
this.element.remove();
}
}

En Stimulus, this.element será siempre el elemento al que esté unido tu controlador. Por tanto, este elemento aside. Pero por lo demás, este código es JavaScript estándar: cada Elemento tiene un método remove().

Para llamar al método close(), en el botón, añade data-action="" luego el nombre de nuestro controlador - closeable - un signo #, y el nombre del método: close.

37 lines | templates/main/_shipStatusAside.html.twig
<aside
// ... line 2
data-controller="closeable"
>
<div class="flex justify-between mt-11 mb-7">
// ... line 6
<button data-action="closeable#close">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 448 512"><!--!Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc.--><path fill="#fff" d="M384 96c0-17.7 14.3-32 32-32s32 14.3 32 32V416c0 17.7-14.3 32-32 32s-32-14.3-32-32V96zM9.4 278.6c-12.5-12.5-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L109.3 224 288 224c17.7 0 32 14.3 32 32s-14.3 32-32 32l-178.7 0 73.4 73.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0l-128-128z"/></svg>
</button>
</div>
// ... lines 11 - 35
</aside>

Animar el cierre

Ya está Hora de probar. ¡Clic! ¡Ya está! ¡Pero quiero que sea más elegante! Quiero que se anime al cerrarse en lugar de ser instantáneo. ¿Podemos hacerlo? ¡Claro que sí! Y no necesitamos mucho JavaScript... porque el CSS moderno es increíble.

Sobre el elemento aside, añade una nueva clase CSS -puede ir en cualquier sitio- llamadatransition-all.

Es una clase Tailwind que activa las transiciones CSS. Esto significa que si cambian ciertas propiedades de estilo -como que la anchura se ponga de repente a 0- hará una transición de ese cambio, en lugar de cambiarlo instantáneamente.

También añade overflow-hidden para que, al reducirse la anchura, no cree una extraña barra de desplazamiento.

Si probamos esto ahora, se sigue cerrando instantáneamente. Eso es porque no hay nada que transicionar: no estamos cambiando la anchura... sólo eliminando el elemento.

Pero fíjate en esto. Inspecciona el elemento y busca el aside: aquí está. Cambia manualmente la anchura a 0. ¡Genial! ¡Vas pequeñito, grande, pequeñito, grande, pequeñito! El lado CSS de las cosas está funcionando.

De vuelta en nuestro controlador, en lugar de eliminar el elemento, tenemos que cambiar la anchura a cero, esperar a que termine la transición CSS y luego eliminar el elemento. Podemos hacer lo primero con this.element.style.width = 0.

37 lines | templates/main/_shipStatusAside.html.twig
<aside
// ... line 2
data-controller="closeable"
>
<div class="flex justify-between mt-11 mb-7">
// ... line 6
<button data-action="closeable#close">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 448 512"><!--!Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc.--><path fill="#fff" d="M384 96c0-17.7 14.3-32 32-32s32 14.3 32 32V416c0 17.7-14.3 32-32 32s-32-14.3-32-32V96zM9.4 278.6c-12.5-12.5-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L109.3 224 288 224c17.7 0 32 14.3 32 32s-14.3 32-32 32l-178.7 0 73.4 73.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0l-128-128z"/></svg>
</button>
</div>
// ... lines 11 - 35
</aside>

La parte complicada es esperar a que termine la transición CSS antes de eliminar el elemento. Para ayudarte con eso, voy a pegar un método en la parte inferior de nuestro controlador.

17 lines | assets/controllers/closeable_controller.js
// ... lines 1 - 2
export default class extends Controller {
async close() {
this.element.style.width = '0';
// ... lines 6 - 8
}
#waitForAnimation() {
return Promise.all(
this.element.getAnimations().map((animation) => animation.finished),
);
}
}

Si no estás familiarizado, el signo # hace que éste sea un método privado en JavaScript: un pequeño detalle. Este código parece lujoso, pero tiene una función sencilla: pedir al elemento que nos diga cuándo han terminado todas sus animaciones CSS.

Gracias a eso, aquí arriba, podemos decir await this.#waitForAnimation(). Y siempre que utilices await, tienes que poner async en la función alrededor de esto. No entraré en detalles sobre async, pero eso no cambiará el funcionamiento de nuestro código.

17 lines | assets/controllers/closeable_controller.js
// ... lines 1 - 2
export default class extends Controller {
async close() {
this.element.style.width = '0';
await this.#waitForAnimation();
this.element.remove();
}
#waitForAnimation() {
return Promise.all(
this.element.getAnimations().map((animation) => animation.finished),
);
}
}

¡Comprobemos el resultado! Actualiza. Y... Me encanta.

A continuación, todo el mundo quiere una aplicación de página única, ¿verdad? Un sitio en el que no haya refrescos de página completa. Pero para construir una, ¿no necesitamos utilizar un framework JavaScript como React? ¡No! Vamos a transformar nuestra aplicación en una aplicación de una sola página en... unos 3 minutos con Turbo.