Aprovechar la programación declarativa para crear aplicaciones web mantenibles
Publicado: 2022-03-11En este artículo, muestro cómo la adopción juiciosa de técnicas de programación de estilo declarativo puede permitir que los equipos creen aplicaciones web que son más fáciles de extender y mantener.
"... la programación declarativa es un paradigma de programación que expresa la lógica de un cálculo sin describir su flujo de control". —Remo H. Jansen, Programación funcional práctica con TypeScript
Como la mayoría de los problemas de software, la decisión de utilizar técnicas de programación declarativa en sus aplicaciones requiere una evaluación cuidadosa de las ventajas y desventajas. Echa un vistazo a uno de nuestros artículos anteriores para una discusión en profundidad de estos.
Aquí, la atención se centra en cómo se pueden adoptar gradualmente los patrones de programación declarativa para aplicaciones nuevas y existentes escritas en JavaScript, un lenguaje que admite múltiples paradigmas.
Primero, discutimos cómo usar TypeScript tanto en la parte trasera como en la delantera para hacer que su código sea más expresivo y resistente al cambio. Luego exploramos las máquinas de estado finito (FSM) para agilizar el desarrollo front-end y aumentar la participación de las partes interesadas en el proceso de desarrollo.
Los FSM no son una tecnología nueva. Fueron descubiertos hace casi 50 años y son populares en industrias como el procesamiento de señales, la aeronáutica y las finanzas, donde la corrección del software puede ser fundamental. También se adaptan muy bien a los problemas de modelado que surgen con frecuencia en el desarrollo web moderno, como la coordinación de animaciones y actualizaciones de estado asincrónicas complejas.
Este beneficio surge debido a las limitaciones en la forma en que se gestiona el estado. Una máquina de estado puede estar en un solo estado simultáneamente y tiene estados vecinos limitados a los que puede pasar en respuesta a eventos externos (como clics del mouse o buscar respuestas). El resultado suele ser una tasa de defectos significativamente reducida. Sin embargo, los enfoques de FSM pueden ser difíciles de escalar para que funcionen bien en aplicaciones grandes. Las extensiones recientes de FSM llamadas statecharts permiten visualizar FSM complejos y escalarlos a aplicaciones mucho más grandes, que es el tipo de máquinas de estado finito en las que se centra este artículo. Para nuestra demostración, usaremos la biblioteca XState, que es una de las mejores soluciones para FSM y diagramas de estado en JavaScript.
Declarativo en el Back End con Node.js
La programación de un back-end de servidor web utilizando enfoques declarativos es un tema amplio y, por lo general, podría comenzar evaluando un lenguaje de programación funcional del lado del servidor adecuado. En cambio, supongamos que está leyendo esto en un momento en que ya eligió (o está considerando) Node.js para su back-end.
Esta sección detalla un enfoque para modelar entidades en el back-end que tiene los siguientes beneficios:
- Legibilidad de código mejorada
- Refactorización más segura
- Potencial para mejorar el rendimiento debido a las garantías que proporciona el modelado de tipos
Garantías de comportamiento a través del modelado de tipos
JavaScript
Considere la tarea de buscar un usuario determinado a través de su dirección de correo electrónico en JavaScript:
function validateEmail(email) { if (typeof email !== "string") return false; return isWellFormedEmailAddress(email); } function lookupUser(validatedEmail) { // Assume a valid email is passed in. // Safe to pass this down to the database for a user lookup.. }
Esta función acepta una dirección de correo electrónico como cadena y devuelve el usuario correspondiente de la base de datos cuando hay una coincidencia.
La suposición es que lookupUser()
solo se llamará una vez que se haya realizado la validación básica. Esta es una suposición clave. ¿Qué pasa si varias semanas después, se realiza alguna refactorización y esta suposición ya no se cumple? Crucemos los dedos para que las pruebas unitarias detecten el error, ¡o podríamos estar enviando texto sin filtrar a la base de datos!
TypeScript (primer intento)
Consideremos un equivalente de TypeScript de la función de validación:
function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }
Esta es una ligera mejora, ya que el compilador de TypeScript nos ha evitado agregar un paso de validación de tiempo de ejecución adicional.
Las garantías de seguridad que puede brindar una tipificación fuerte aún no se han aprovechado. Echemos un vistazo a eso.
TypeScript (segundo intento)
Mejoremos la seguridad de tipos y no permitamos pasar cadenas sin procesar como entrada a looukupUser
:
type ValidEmail = { value: string }; function validateEmail(input: string): Email | null { if (!isWellFormedEmailAddress(input)) return null; return { value: email }; } function lookupUser(email: ValidEmail): User { // No need to perform validation. Compiler has already ensured only valid emails have been passed in. return lookupUserInDatabase(email.value); }
Esto es mejor, pero es engorroso. Todos los usos de ValidEmail
acceden a la dirección real a través email.value
. TypeScript emplea escritura estructural en lugar de la escritura nominal empleada por lenguajes como Java y C#.
Si bien es poderoso, esto significa que cualquier otro tipo que se adhiera a esta firma se considera equivalente. Por ejemplo, el siguiente tipo de contraseña podría pasarse a lookupUser()
sin quejas del compilador:
type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.
TypeScript (tercer intento)
Podemos lograr tipeo nominal en TypeScript usando la intersección:
type ValidEmail = string & { _: "ValidEmail" }; function validateEmail(input: string): ValidEmail { // Perform email validation checks.. return input as ValidEmail; } type ValidPassword = string & { _: "ValidPassword" }; function validatePassword(input: string): ValidPassword { ... } lookupUser("[email protected]"); // Error: expected type ValidEmail. lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail. lookupUser(validateEmail("[email protected]")); // Ok.
Ahora hemos logrado el objetivo de que solo se puedan pasar cadenas de correo electrónico validadas a lookupUser()
.
Consejo profesional: aplica este patrón fácilmente usando el siguiente tipo de ayuda:
type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;
ventajas
Al escribir fuertemente entidades en su dominio, podemos:
- Reduzca la cantidad de comprobaciones que deben realizarse en el tiempo de ejecución, que consumen valiosos ciclos de CPU del servidor (aunque son una cantidad muy pequeña, se suman cuando se atienden miles de solicitudes por minuto).
- Mantenga menos pruebas básicas debido a las garantías que ofrece el compilador de TypeScript.
- Aproveche la refactorización asistida por editor y compilador.
- Mejore la legibilidad del código a través de una mejor relación señal-ruido.
Contras
El modelado de tipos viene con algunas compensaciones a considerar:
- La introducción de TypeScript suele complicar la cadena de herramientas, lo que lleva a tiempos de ejecución del conjunto de pruebas y compilación más prolongados.
- Si su objetivo es crear un prototipo de una función y ponerla en manos de los usuarios lo antes posible, es posible que no valga la pena el esfuerzo adicional necesario para modelar explícitamente los tipos y propagarlos a través del código base.
Hemos mostrado cómo el código JavaScript existente en el servidor o la capa de validación back-end/front-end compartida se puede ampliar con tipos para mejorar la legibilidad del código y permitir una refactorización más segura, requisitos importantes para los equipos.
Interfaces de usuario declarativas
Las interfaces de usuario desarrolladas utilizando técnicas de programación declarativa centran el esfuerzo en describir el "qué" sobre el "cómo". Dos de los tres ingredientes básicos principales de la web, CSS y HTML, son lenguajes de programación declarativos que han resistido la prueba del tiempo y más de mil millones de sitios web.
React fue de código abierto de Facebook en 2013 y alteró significativamente el curso del desarrollo front-end. Cuando lo usé por primera vez, me encantó cómo podía declarar la GUI como una función del estado de la aplicación. Ahora podía componer interfaces de usuario grandes y complejas a partir de bloques de construcción más pequeños sin tener que lidiar con los complicados detalles de la manipulación del DOM y el seguimiento de qué partes de la aplicación necesitan actualizarse en respuesta a las acciones del usuario. Podría ignorar en gran medida el aspecto del tiempo al definir la interfaz de usuario y centrarme en garantizar que mi aplicación pase correctamente de un estado al siguiente.
Para lograr una forma más sencilla de desarrollar interfaces de usuario, React insertó una capa de abstracción entre el desarrollador y la máquina/navegador: el DOM virtual .
Otros marcos de interfaz de usuario web modernos también han cerrado esta brecha, aunque de diferentes maneras. Por ejemplo, Vue emplea reactividad funcional a través de getters/setters de JavaScript (Vue 2) o proxies (Vue 3). Svelte aporta reactividad a través de un paso adicional de compilación del código fuente (Svelte).
Estos ejemplos parecen demostrar un gran deseo en nuestra industria de proporcionar herramientas mejores y más simples para que los desarrolladores expresen el comportamiento de las aplicaciones a través de enfoques declarativos.
Estado y lógica de la aplicación declarativa
Si bien la capa de presentación continúa girando en torno a alguna forma de HTML (por ejemplo, JSX en React, plantillas basadas en HTML que se encuentran en Vue, Angular y Svelte), postulo que el problema de cómo modelar el estado de una aplicación de una manera que es fácilmente comprensible para otros desarrolladores y mantenible a medida que crece la aplicación aún no se ha resuelto. Vemos evidencia de esto a través de una proliferación de bibliotecas y enfoques de administración estatal que continúa hasta el día de hoy.
La situación se complica por las crecientes expectativas de las aplicaciones web modernas. Algunos desafíos emergentes que los enfoques modernos de gestión estatal deben soportar:
- Primeras aplicaciones sin conexión que utilizan técnicas avanzadas de suscripción y almacenamiento en caché
- Código conciso y reutilización de código para requisitos de tamaño de paquete cada vez más reducidos
- Demanda de experiencias de usuario cada vez más sofisticadas a través de animaciones de alta fidelidad y actualizaciones en tiempo real
(Re)aparición de máquinas de estado finito y diagramas de estado
Las máquinas de estado finito se han utilizado ampliamente para el desarrollo de software en ciertas industrias donde la solidez de la aplicación es fundamental, como la aviación y las finanzas. También está ganando popularidad constantemente para el desarrollo frontal de aplicaciones web a través, por ejemplo, de la excelente biblioteca XState.
Wikipedia define una máquina de estados finitos como:
Una máquina abstracta que puede estar exactamente en uno de un número finito de estados en un momento dado. El FSM puede cambiar de un estado a otro en respuesta a algunas entradas externas; el cambio de un estado a otro se llama transición. Una FSM se define por una lista de sus estados, su estado inicial y las condiciones para cada transición.
Y además:
Un estado es una descripción del estado de un sistema que está esperando para ejecutar una transición.
Los FSM en su forma básica no se adaptan bien a sistemas grandes debido al problema de la explosión de estados. Recientemente, se crearon diagramas de estado UML para ampliar las FSM con jerarquía y concurrencia, que facilitan el uso generalizado de las FSM en aplicaciones comerciales.
Declare su lógica de aplicación
Primero, ¿cómo se ve un FSM como código? Hay varias formas de implementar una máquina de estado finito en JavaScript.

- Máquina de estados finitos como declaración de cambio
Aquí hay una máquina que describe los posibles estados en los que puede estar un JavaScript, implementada mediante una declaración de cambio:
const initialState = { type: 'idle', error: undefined, result: undefined }; function transition(state = initialState, action) { switch (action) { case 'invoke': return { type: 'pending' }; case 'resolve': return { type: 'completed', result: action.value }; case 'error': return { type: 'completed', error: action.error ; default: return state; } }
Este estilo de código resultará familiar para los desarrolladores que han usado la popular biblioteca de administración de estado de Redux.
- Máquina de estados finitos como un objeto de JavaScript
Aquí está la misma máquina implementada como un objeto JavaScript usando la biblioteca XState de JavaScript:
const promiseMachine = Machine({ id: "promise", initial: "idle", context: { result: undefined, error: undefined, }, states: { idle: { on: { INVOKE: "pending", }, }, pending: { on: { RESOLVE: "success", REJECT: "failure", }, }, success: { type: "final", actions: assign({ result: (context, event) => event.data, }), }, failure: { type: "final", actions: assign({ error: (context, event) => event.data, }), }, }, });
Si bien la versión XState es menos compacta, la representación de objetos tiene varias ventajas:
- La máquina de estado en sí es JSON simple, que se puede conservar fácilmente.
- Debido a que es declarativo, la máquina se puede visualizar.
- Si usa TypeScript, el compilador verifica que solo se realicen transiciones de estado válidas.
XState admite gráficos de estado e implementa la especificación SCXML, lo que lo hace adecuado para su uso en aplicaciones muy grandes.
Visualización de Statecharts de una promesa:
Prácticas recomendadas de XState
Las siguientes son algunas de las mejores prácticas que se pueden aplicar al usar XState para ayudar a que los proyectos se puedan mantener.
Separe los efectos secundarios de la lógica
XState permite que los efectos secundarios (que incluyen actividades como registro o solicitudes de API) se especifiquen independientemente de la lógica de la máquina de estado.
Esto tiene los siguientes beneficios:
- Ayude a la detección de errores lógicos manteniendo el código de la máquina de estado lo más limpio y simple posible.
- Visualice fácilmente la máquina de estado sin necesidad de eliminar primero el texto estándar adicional.
- Pruebas más fáciles de la máquina de estado mediante la inyección de servicios simulados.
const fetchUsersMachine = Machine({ id: "fetchUsers", initial: "idle", context: { users: undefined, error: undefined, nextPage: 0, }, states: { idle: { on: { FETCH: "fetching", }, }, fetching: { invoke: { src: (context) => fetch(`url/to/users?page=${context.nextPage}`).then((response) => response.json() ), onDone: { target: "success", actions: assign({ users: (context, event) => [...context.users, ...event.data], // Data holds the newly fetched users nextPage: (context) => context.nextPage + 1, }), }, onError: { target: "failure", error: (_, event) => event.data, // Data holds the error }, }, }, // success state.. // failure state.. }, });
Si bien es tentador escribir máquinas de estado de esta manera mientras todavía está haciendo que las cosas funcionen, se logra una mejor separación de preocupaciones al pasar los efectos secundarios como opciones:
const services = { getUsers: (context) => fetch( `url/to/users?page=${context.nextPage}` ).then((response) => response.json()) } const fetchUsersMachine = Machine({ ... states: { ... fetching: { invoke: { // Invoke the side effect at key: 'getUsers' in the supplied services object. src: 'getUsers', } on: { RESOLVE: "success", REJECT: "failure", }, }, ... }, // Supply the side effects to be executed on state transitions. { services } });
Esto también permite realizar pruebas unitarias sencillas de la máquina de estado, lo que permite una burla explícita de las recuperaciones del usuario:
async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });
División de máquinas grandes
No siempre es inmediatamente obvio cuál es la mejor manera de estructurar un dominio de problema en una buena jerarquía de máquinas de estado finito cuando se comienza.
Sugerencia: use la jerarquía de los componentes de la interfaz de usuario para ayudar a guiar este proceso. Consulte la siguiente sección sobre cómo asignar máquinas de estado a componentes de interfaz de usuario.
Un beneficio importante de usar máquinas de estado es modelar explícitamente todos los estados y transiciones entre estados en sus aplicaciones para que el comportamiento resultante se entienda claramente, haciendo que los errores lógicos o las brechas sean fáciles de detectar.
Para que esto funcione bien, las máquinas deben mantenerse pequeñas y concisas. Afortunadamente, componer máquinas de estado jerárquicamente es fácil. En el ejemplo de los gráficos de estado canónicos de un sistema de semáforo, el propio estado "rojo" se convierte en una máquina de estado secundaria. La máquina "ligera" principal no es consciente de los estados internos de "rojo", pero decide cuándo ingresar "rojo" y cuál es el comportamiento previsto al salir:
1-1 Asignación de máquinas de estado a componentes de interfaz de usuario con estado
Tomemos, por ejemplo, un sitio de comercio electrónico ficticio muy simplificado que tiene las siguientes vistas de React:
<App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>
El proceso para generar máquinas de estado correspondientes a las vistas anteriores puede resultar familiar para aquellos que han utilizado la biblioteca de gestión de estado de Redux:
- ¿El componente tiene un estado que necesita ser modelado? Por ejemplo, Administrador/Productos no pueden; las recuperaciones paginadas al servidor más una solución de almacenamiento en caché (como SWR) pueden ser suficientes. Por otro lado, los componentes como SignInForm o Cart suelen contener estados que deben gestionarse, como los datos introducidos en los campos o el contenido actual del carrito.
- ¿Las técnicas de estado local (p. ej.,
setState() / useState()
de React) son suficientes para capturar el problema? Rastrear si el modal emergente del carrito está actualmente abierto apenas requiere el uso de una máquina de estado finito. - ¿Es probable que la máquina de estados resultante sea demasiado compleja? Si es así, divida la máquina en varias más pequeñas, identificando oportunidades para crear máquinas secundarias que puedan reutilizarse en otro lugar. Por ejemplo, las máquinas SignInForm y RegistrationForm pueden invocar instancias de una textFieldMachine secundaria para modelar la validación y el estado de los campos de correo electrónico, nombre y contraseña del usuario.
Cuándo usar un modelo de máquina de estados finitos
Si bien los gráficos de estado y los FSM pueden resolver con elegancia algunos problemas desafiantes, decidir cuáles son las mejores herramientas y enfoques para usar en una aplicación en particular generalmente depende de varios factores.
Algunas situaciones en las que brilla el uso de máquinas de estado finito:
- Su aplicación incluye un componente considerable de entrada de datos en el que la accesibilidad o la visibilidad de los campos se rigen por reglas complejas: por ejemplo, la entrada de formularios en una aplicación de reclamaciones de seguros. Aquí, los FSM ayudan a garantizar que las reglas comerciales se implementen de manera sólida. Además, las características de visualización de los gráficos de estado se pueden utilizar para ayudar a aumentar la colaboración con las partes interesadas no técnicas e identificar los requisitos comerciales detallados en una etapa temprana del desarrollo.
- Para funcionar mejor en conexiones más lentas y brindar experiencias de mayor fidelidad a los usuarios , las aplicaciones web deben administrar flujos de datos asincrónicos cada vez más complejos. Los FSM modelan explícitamente todos los estados en los que se puede encontrar una aplicación, y los gráficos de estado se pueden visualizar para ayudar a diagnosticar y resolver problemas de datos asincrónicos.
- Aplicaciones que requieren mucha animación sofisticada basada en estado. Para animaciones complejas, las técnicas para modelar animaciones como flujos de eventos a través del tiempo con RxJS son populares. Para muchos escenarios, esto funciona bien, sin embargo, cuando la animación enriquecida se combina con una serie compleja de estados conocidos, los FSM proporcionan "puntos de descanso" bien definidos entre los que fluyen las animaciones. Los FSM combinados con RxJS parecen la combinación perfecta para ayudar a ofrecer la próxima ola de experiencias de usuario expresivas y de alta fidelidad.
- Aplicaciones de cliente enriquecidas , como edición de fotos o videos, herramientas de creación de diagramas o juegos, donde gran parte de la lógica comercial reside en el lado del cliente. Los FSM están intrínsecamente desacoplados del marco o las bibliotecas de la interfaz de usuario y son pruebas fáciles de escribir para permitir que las aplicaciones de alta calidad se iteren rápidamente y se envíen con confianza.
Advertencias sobre máquinas de estado finito
- El enfoque general, las mejores prácticas y la API para las bibliotecas de gráficos de estado como XState son novedosos para la mayoría de los desarrolladores front-end, quienes requerirán una inversión de tiempo y recursos para volverse productivos, particularmente para los equipos menos experimentados.
- Similar a la advertencia anterior, mientras que la popularidad de XState continúa creciendo y está bien documentada, las bibliotecas de administración de estado existentes como Redux, MobX o React Context tienen muchos seguidores que brindan una gran cantidad de información en línea que XState aún no coincide.
- Para las aplicaciones que siguen un modelo CRUD más simple, las técnicas de administración de estado existentes combinadas con una buena biblioteca de almacenamiento en caché de recursos como SWR o React Query serán suficientes. Aquí, las restricciones adicionales que brindan los FSM, si bien son increíblemente útiles en aplicaciones complejas, pueden ralentizar el desarrollo.
- La herramienta es menos madura que otras bibliotecas de administración de estado, y aún se está trabajando en mejorar la compatibilidad con TypeScript y las extensiones de herramientas de desarrollo del navegador.
Terminando
La popularidad y la adopción de la programación declarativa en la comunidad de desarrollo web continúan aumentando.
Si bien el desarrollo web moderno continúa volviéndose más complejo, las bibliotecas y los marcos que adoptan enfoques de programación declarativa surgen cada vez con mayor frecuencia. La razón parece clara: es necesario crear enfoques más simples y descriptivos para escribir software.
El uso de lenguajes fuertemente tipados como TypeScript permite que las entidades en el dominio de la aplicación se modelen de manera sucinta y explícita, lo que reduce la posibilidad de errores y la cantidad de código de verificación propenso a errores que debe manipularse. La adopción de máquinas de estado finito y diagramas de estado en el front-end permite a los desarrolladores declarar la lógica comercial de una aplicación a través de transiciones de estado, lo que permite el desarrollo de herramientas de visualización ricas y aumenta la oportunidad de una estrecha colaboración con personas que no son desarrolladores.
Cuando hacemos esto, cambiamos nuestro enfoque de los aspectos prácticos de cómo funciona la aplicación a una vista de nivel superior que nos permite centrarnos aún más en las necesidades del cliente y crear un valor duradero.