![Search icon](https://hackernoon.imgix.net/search-new.png?w=19&h=19)
En 2013, me propuse crear un conjunto minimalista de herramientas para desarrollar aplicaciones web. Tal vez lo mejor que surgió de ese proceso fue gotoB , un marco de trabajo de interfaz de usuario basado en JavaScript puro y del lado del cliente escrito en 2000 líneas de código.
Me motivó a escribir este artículo después de adentrarme en un mundo de lectura de artículos interesantes escritos por autores de frameworks frontend muy exitosos:
Lo que me entusiasmó de estos artículos es que hablan de la evolución de las ideas detrás de lo que construyen; la implementación es solo una forma de hacerlas realidad, y las únicas características que se discuten son aquellas que son tan esenciales como para representar las ideas en sí mismas.
Sin duda, el aspecto más interesante de lo que surgió de gotoB son las ideas que surgieron como resultado de enfrentar los desafíos que implicaba su creación. Eso es lo que quiero abordar aquí.
Como construí el marco desde cero y estaba tratando de lograr tanto minimalismo como consistencia interna, resolví cuatro problemas de una manera que creo que es diferente a la forma en que la mayoría de los marcos resuelven los mismos problemas.
Estas cuatro ideas son las que quiero compartir con ustedes ahora. No lo hago para convencerlos de que utilicen mis herramientas (¡aunque pueden hacerlo!), sino con la esperanza de que les interesen las ideas en sí.
Cualquier aplicación web necesita crear marcado (HTML) sobre la marcha, según el estado de la aplicación.
Esto se explica mejor con un ejemplo: en una aplicación de lista de tareas pendientes ultra simple, el estado podría ser una lista de tareas pendientes: ['Item 1', 'Item 2']
. Debido a que estás escribiendo una aplicación (en lugar de una página estática), la lista de tareas pendientes debe poder cambiar.
Debido a que el estado cambia, el código HTML que crea la interfaz de usuario de su aplicación debe cambiar con el estado. Por ejemplo, para mostrar sus tareas pendientes, puede usar el siguiente código HTML:
<ul> <li>Item 1</li> <li>Item 2</li> </ul>
Si el estado cambia y se agrega un tercer elemento, su estado ahora se verá así: ['Item 1', 'Item 2', 'Item 3']
; luego, su HTML debería verse así:
<ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>
El problema de generar HTML basado en el estado de la aplicación generalmente se resuelve con un lenguaje de plantillas , que inserta construcciones del lenguaje de programación (variables, condicionales y bucles) en pseudo-HTML que se expande en HTML real.
Por ejemplo, aquí hay dos formas en que esto se puede hacer en diferentes herramientas de creación de plantillas:
// Assume that `todos` is defined and equal to ['Item 1', 'Item 2', 'Item 3'] // Moustache <ul> {{#todos}} <li>{{.}}</li> {{/todos}} </ul> // JSX <ul> {todos.map((item, index) => ( <li key={index}>{item}</li> ))} </ul>
Nunca me gustaron estas sintaxis que aportaban lógica al HTML. Al darme cuenta de que las plantillas requerían programación y querer evitar tener una sintaxis independiente para ello, decidí en cambio incorporar HTML a js, utilizando literales de objeto . De este modo, pude modelar mi HTML simplemente como literales de objeto:
['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]]
Si quisiera utilizar la iteración para generar la lista, podría simplemente escribir:
['ul', items.map ((item) => ['li', item])]
Y luego usar una función que convierta este objeto literal en HTML. De esta manera, todas las plantillas se pueden hacer en JS, sin ningún lenguaje de plantillas ni transpilación. Utilizo el nombre liths para describir estas matrices que representan HTML.
Hasta donde yo sé, ningún otro framework de JS aborda la creación de plantillas de esta manera. Busqué un poco y encontré JSONML , que utiliza casi la misma estructura para representar HTML en objetos JSON (que son casi lo mismo que los literales de objetos de JS), pero no encontré ningún framework creado a partir de él.
Mithril y Hyperapp se acercan bastante al enfoque que utilicé, pero aún utilizan llamadas de función para cada elemento.
// Mithril m("ul", [ m("li", "Item 1"), m("li", "Item 2") ]) // hyperapp h("ul", [ h("li", "Item 1"), h("li", "Item 2") ])
El enfoque de usar literales de objetos funcionó bien para HTML, así que lo extendí a CSS y ahora genero todo mi CSS también a través de literales de objetos.
Si por alguna razón estás en un entorno donde no puedes transpilar JSX o usar un lenguaje de plantillas, y no quieres concatenar cadenas, puedes usar este enfoque en su lugar.
No estoy seguro de si el enfoque de Mithril/Hyperapp es mejor que el mío; encuentro que cuando escribo literales de objetos largos que representan liths, a veces olvido una coma en algún lugar y a veces puede ser difícil encontrarla. Aparte de eso, realmente no tengo quejas. Y me encanta el hecho de que la representación para HTML sea tanto 1) datos como 2) en JS. Esta representación puede funcionar realmente como un DOM virtual, como veremos cuando lleguemos a la Idea n.° 4.
Detalle extra: si quieres generar HTML a partir de literales de objetos, solo tienes que resolver los dos problemas siguientes:
Nunca me gustaron los componentes. Para estructurar una aplicación en torno a ellos es necesario colocar los datos que pertenecen al componente dentro del propio componente. Esto hace que sea difícil o incluso imposible compartir esos datos con otras partes de la aplicación.
En todos los proyectos en los que trabajé, descubrí que siempre necesitaba que algunas partes del estado de la aplicación se compartieran entre componentes que estaban bastante alejados entre sí. Un ejemplo típico es el nombre de usuario: es posible que lo necesites en la sección de cuenta y también en el encabezado. Entonces, ¿dónde va el nombre de usuario?
Por lo tanto, decidí desde el principio crear un objeto de datos simple ( {}
) y guardar allí todo mi estado. Lo llamé tienda . La tienda contiene el estado de todas las partes de la aplicación y, por lo tanto, puede ser utilizada por cualquier componente.
Este enfoque fue algo herético en 2013-2015, pero desde entonces ha ganado prevalencia e incluso dominio.
Lo que creo que sigue siendo bastante novedoso es que utilizo rutas para acceder a cualquier valor dentro de la tienda. Por ejemplo, si la tienda es:
{ user: { firstName: 'foo' lastName: 'bar' } }
Puedo usar una ruta para acceder (por ejemplo) al lastName
, escribiendo B.get ('user', 'lastName')
. Como puedes ver, ['user', 'lastName']
es la ruta a 'bar'
. B.get
es una función que accede al almacén y devuelve una parte específica del mismo, indicada por la ruta que pasas a la función.
A diferencia de lo anterior, la forma estándar de acceder a las propiedades reactivas es hacer referencia a ellas a través de una variable JS. Por ejemplo:
// Svelte let { firstName, lastName } = $props(); firstName = 'foo'; lastName = 'bar'; // Knockout const firstName = ko.observable('foo'); const lastName = ko.observable('bar'); // mobx class UserStore { firstName = 'foo'; lastName = 'bar'; constructor() { makeAutoObservable(this); } } const userStore = new UserStore(); // SolidJS const [firstName, setFirstName] = createSignal('foo'); const [lastName, setLastName] = createSignal('bar');
Sin embargo, esto requiere que mantengas una referencia a firstName
y lastName
(o userStore
) en cualquier lugar donde necesites ese valor. El enfoque que utilizo solo requiere que tengas acceso a la tienda (que es global y está disponible en todas partes) y te permite tener acceso detallado a ella sin definir variables JS para ellas.
Immutable.js y Firebase Realtime Database hacen algo mucho más parecido a lo que yo hice, aunque trabajan en objetos separados. Pero es posible que puedas usarlos para almacenar todo en un solo lugar al que se pueda acceder de forma granular.
// Immutable.js let store = Map({ user: Map({ firstName: 'foo', lastName: 'bar' }) }); const firstName = store.getIn(['user', 'firstName']); // 'foo' // Firebase const db = firebase.database(); db.ref('user').set({ firstName: 'foo', lastName: 'bar' }); db.ref('user/firstName').once('value').then(snapshot => { const firstName = snapshot.val(); // 'foo' });
Tener mis datos en un almacén de acceso global al que se pueda acceder de forma granular a través de rutas es un patrón que me ha resultado extremadamente útil. Siempre que escribo const [count, setCount] = ...
o algo así, parece redundante. Sé que podría simplemente hacer B.get ('count')
cada vez que necesite acceder a eso, sin tener que declarar y pasar count
o setCount
.
Si la idea n.° 2 (un almacén global accesible a través de rutas) libera datos de los componentes, la idea n.° 3 es la manera en que liberé el código de los componentes. Para mí, esta es la idea más interesante de este artículo. ¡Aquí va!
Nuestro estado son datos que, por definición, son mutables (para quienes utilizan la inmutabilidad, el argumento sigue siendo válido: aún se desea que la última versión del estado cambie, incluso si se conservan instantáneas de versiones anteriores del estado). ¿Cómo cambiamos el estado?
Decidí utilizar eventos. Ya tenía rutas a la tienda, por lo que un evento podría ser simplemente la combinación de un verbo (como set
, add
o rem
) y una ruta. Por lo tanto, si quisiera actualizar user.firstName
, podría escribir algo como esto:
B.call ('set', ['user', 'firstName'], 'Foo')
Esto es definitivamente más detallado que escribir:
user.firstName = 'Foo';
Pero me permitió escribir código que respondiera a un cambio en user.firstName
. Y esta es la idea fundamental: en una interfaz de usuario, hay diferentes partes que dependen de diferentes partes del estado. Por ejemplo, podría tener estas dependencias:
user
y currentView
user
items
La gran pregunta a la que me enfrenté fue: ¿cómo actualizo el encabezado y la sección de cuenta cuando cambia user
, pero no cuando cambian items
? ¿Y cómo administro estas dependencias sin tener que hacer llamadas específicas como updateHeader
o updateAccountSection
? Este tipo de llamadas específicas representan la "programación jQuery" en su forma más difícil de mantener.
Lo que me pareció una mejor idea fue hacer algo como esto:
B.respond ('set', [['user'], ['currentView']], function (user, currentView) { // Update the header }); B.respond ('set', ['user'], function (user) { // Update the account section }); B.respond ('set', ['items'], function (items) { // Update the todo list });
Por lo tanto, si se llama a un evento set
para user
, el sistema de eventos notificará a todas las vistas que estén interesadas en ese cambio (sección de encabezado y cuenta), mientras que no afectará a las demás vistas (lista de tareas pendientes). B.respond
es la función que utilizo para registrar los respondedores (que normalmente se denominan "escuchadores de eventos" o "reacciones"). Tenga en cuenta que los respondedores son globales y no están vinculados a ningún componente; sin embargo, solo escuchan eventos de set
en determinadas rutas.
Ahora bien, ¿cómo se llama a un evento change
en primer lugar? Así es como lo hice:
B.respond ('set', '*', function () { // Assume that `path` is the path on which set was called B.call ('change', path); });
Estoy simplificando un poco, pero así es esencialmente cómo funciona en gotoB.
Lo que hace que un sistema de eventos sea más poderoso que las simples llamadas a funciones es que una llamada a un evento puede ejecutar 0, 1 o múltiples fragmentos de código, mientras que una llamada a una función siempre llama exactamente a una función . En el ejemplo anterior, si llama a B.call ('set', ['user', 'firstName'], 'Foo');
, se ejecutan dos fragmentos de código: el que cambia el encabezado y el que cambia la vista de la cuenta. Tenga en cuenta que a la llamada para actualizar firstName
no le "importa" quién está escuchando esto. Simplemente hace lo suyo y permite que el respondedor recoja los cambios.
Los eventos son tan poderosos que, según mi experiencia, pueden reemplazar valores calculados y también reacciones. En otras palabras, se pueden usar para expresar cualquier cambio que deba ocurrir en una aplicación.
Un valor calculado se puede expresar con un respondedor de eventos. Por ejemplo, si desea calcular un fullName
y no desea usarlo en la tienda, puede hacer lo siguiente:
B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; // Do something with `fullName` here. });
De manera similar, las reacciones se pueden expresar con un respondedor. Considere lo siguiente:
B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; document.getElementById ('header').innerHTML = '<h1>Hello, ' + fullName + '</h1>'; });
Si ignoras por un minuto la vergonzosa concatenación de cadenas para generar HTML, lo que ves arriba es un respondedor que ejecuta un "efecto secundario" (en este caso, actualizar el DOM).
(Nota al margen: ¿cuál sería una buena definición de un efecto secundario, en el contexto de una aplicación web? Para mí, se reduce a tres cosas: 1) una actualización del estado de la aplicación; 2) un cambio en el DOM; 3) enviar una llamada AJAX).
Descubrí que realmente no hay necesidad de un ciclo de vida independiente que actualice el DOM. En gotoB, hay algunas funciones de respuesta que actualizan el DOM con la ayuda de algunas funciones auxiliares. Por lo tanto, cuando user
cambia, cualquier función de respuesta (o más precisamente, función de vista , ya que ese es el nombre que le doy a las funciones de respuesta que tienen la tarea de actualizar una parte del DOM) que depende de ella se ejecutará, lo que generará un efecto secundario que termina actualizando el DOM.
Hice que el sistema de eventos fuera predecible al hacer que ejecutara las funciones de respuesta en el mismo orden y una a la vez. Las respuestas asincrónicas pueden seguir ejecutándose como sincrónicas y las respuestas que vienen "después" de ellas las esperarán.
Se pueden agregar patrones más sofisticados, donde se necesita actualizar el estado sin actualizar el DOM (generalmente por razones de rendimiento), agregando verbos mudos , como mset , que modifican el almacenamiento pero no activan ningún respondedor. Además, si necesita hacer algo en el DOM después de que se realiza un rediseño, simplemente puede asegurarse de que ese respondedor tenga una prioridad baja y se ejecute después de todos los demás respondedores:
B.respond ('set', 'date', {priority: -1000}, function () { var datePicker = document.getElementById ('datepicker'); // Do something with the date picker });
El enfoque anterior, de tener un sistema de eventos que utiliza verbos y rutas y un conjunto de respuestas globales que se ejecutan con determinadas llamadas de eventos, tiene otra ventaja: cada llamada de evento se puede colocar en una lista. Luego, puede analizar esta lista cuando esté depurando su aplicación y realizar un seguimiento de los cambios en el estado.
En el contexto de un frontend, esto es lo que permiten los eventos y los respondedores:
Esto es lo que se les permite (en mi experiencia) prescindir:
En realidad, todo se reduce a llamadas de eventos y respondedores. Algunos respondedores solo se ocupan de las vistas y otros de otras operaciones. Todos los elementos internos del marco solo utilizan el espacio del usuario .
Si tienes curiosidad sobre cómo funciona esto en gotoB, puedes consultar esta explicación detallada .
El enlace de datos bidireccional ahora suena bastante anticuado, pero si tomamos una máquina del tiempo y nos remontamos al año 2013 y abordamos desde los principios básicos el problema de volver a dibujar el DOM cuando cambia el estado, ¿qué sería más razonable?
De hecho, la opción 2, que es el flujo de datos unidireccional del estado al DOM, suena más complicada e ineficiente.
Ahora, vamos a concretar esto: en el caso de un <input>
o <textarea>
interactivo que esté enfocado, ¡debe recrear partes del DOM con cada pulsación de tecla del usuario! Si está utilizando flujos de datos unidireccionales, cada cambio en la entrada desencadena un cambio en el estado, que luego vuelve a dibujar el <input>
para que coincida exactamente con lo que debería ser.
Esto establece unos estándares muy altos para las actualizaciones del DOM: deben ser rápidas y no obstaculizar la interacción del usuario con los elementos interactivos. No es un problema fácil de abordar.
Ahora bien, ¿por qué ganaron los datos unidireccionales del estado al DOM (JS a HTML)? Porque es más fácil razonar al respecto. Si el estado cambia, no importa de dónde provenga este cambio (podría ser una devolución de llamada AJAX que trae datos del servidor, podría ser una interacción del usuario, podría ser un temporizador). El estado cambia (o más bien, se muta ) de la misma manera siempre. Y los cambios del estado siempre fluyen hacia el DOM.
Entonces, ¿cómo se pueden realizar actualizaciones del DOM de una manera eficiente que no obstaculice la interacción del usuario? Esto generalmente se reduce a realizar la cantidad mínima de actualizaciones del DOM que permitan realizar el trabajo. Esto generalmente se denomina "diferenciación", porque se hace una lista de diferencias que se necesitan para tomar una estructura antigua (el DOM existente) y convertirla en una nueva (el nuevo DOM después de que se actualice el estado).
Cuando empecé a trabajar en este problema en 2016, hice trampa y eché un vistazo a lo que hacía React. Me dieron la idea crucial de que no había un algoritmo generalizado de rendimiento lineal para comparar dos árboles (el DOM es un árbol). Pero, por terquedad que fuera, quería un algoritmo de propósito general para realizar la comparación. Lo que particularmente no me gustaba de React (o de casi cualquier framework, en realidad) era la insistencia en que se deben usar claves para elementos contiguos:
function MyList() { const items = ['Item 1', 'Item 2', 'Item 3']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }
Para mí, la directiva key
era superflua, porque no tenía nada que ver con el DOM; era solo una pista sobre el marco.
Luego pensé en probar un algoritmo de diferencia textual en versiones aplanadas de un árbol. ¿Qué sucedería si aplanara ambos árboles (la parte antigua del DOM que tenía y la parte nueva del DOM con la que quería reemplazarla) y calculara una diff
en ella (un conjunto mínimo de ediciones), de modo que pudiera pasar del antiguo al nuevo en la menor cantidad de pasos?
Así que tomé el algoritmo de Myers , el que se usa cada vez que se ejecuta git diff
, y lo puse a trabajar en mis árboles aplanados. Ilustrémoslo con un ejemplo:
var oldList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ]]; var newList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]];
Como puedes ver, no estoy trabajando con el DOM, sino con la representación literal del objeto que vimos en la Idea 1. Ahora, notarás que necesitamos agregar un nuevo <li>
al final de la lista.
Los árboles aplanados se ven así:
var oldFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'C ul']; var newFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'O li', 'L Item 3', 'C li', 'C ul'];
La O
significa "etiqueta abierta", la L
significa "literal" (en este caso, un texto) y la C
significa "etiqueta cerrada". Observe que cada árbol ahora es una lista de cadenas y ya no hay matrices anidadas. Esto es lo que quiero decir con aplanamiento.
Cuando ejecuto una comparación en cada uno de estos elementos (tratando cada elemento de la matriz como si fuera una unidad), obtengo:
var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['add', 'O li'] ['add', 'L Item 3'] ['add', 'C li'] ['keep', 'C ul'] ];
Como probablemente habrás deducido, conservamos la mayor parte de la lista y añadimos un <li>
hacia el final. Esas son las entradas add
que ves.
Si ahora cambiamos el texto del tercer <li>
del Item 3
al Item 4
y ejecutamos una comparación, obtendríamos:
var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['keep', 'O li'] ['rem', 'L Item 3'] ['add', 'L Item 4'] ['keep', 'C li'] ['keep', 'C ul'] ];
No sé cuán matemáticamente ineficiente es este enfoque, pero en la práctica ha funcionado bastante bien. Solo funciona mal cuando se comparan árboles grandes que tienen muchas diferencias entre sí; cuando eso sucede ocasionalmente, recurro a un tiempo de espera de 200 ms para interrumpir la comparación y simplemente reemplazar por completo la parte del DOM que causa el problema. Si no usara un tiempo de espera, toda la aplicación se detendría durante un tiempo hasta que se completara la comparación.
Una ventaja afortunada de usar la comparación Myers es que prioriza las eliminaciones sobre las inserciones: esto significa que si hay una elección igualmente eficiente entre eliminar un elemento y agregar un elemento, el algoritmo eliminará un elemento primero. En la práctica, esto me permite tomar todos los elementos DOM eliminados y poder reciclarlos si los necesito más adelante en la comparación. En el último ejemplo, el último <li>
se recicla cambiando su contenido de Item 3
a Item 4
. Al reciclar elementos (en lugar de crear nuevos elementos DOM) mejoramos el rendimiento hasta un grado en el que el usuario no se da cuenta de que el DOM se está rediseñando constantemente.
Si te preguntas qué tan complejo es implementar este mecanismo de aplanamiento y comparación que aplica cambios al DOM, logré hacerlo en 500 líneas de JavaScript ES5, e incluso funciona en Internet Explorer 6. Pero, admito que fue quizás el fragmento de código más difícil que escribí. Ser terco tiene un costo.
¡Ésas son las cuatro ideas que quería presentar! No son del todo originales, pero espero que resulten novedosas e interesantes para algunos. ¡Gracias por leer!