Arquitectura de interfaces web: Parte 2

Vistas, componentes y renderizado

Este post es parte de la serie Arquitectura de interfaces web. Si todavía no has leído la primera parte (Arquitectura, TDD, entorno de desarrollo y herramientas), te recomiendo empezar por ahí 😉


Vistas / Componentes

Una casa con buenas “vistas”, pero estará construida sobre cimientos sólidos? Photo by Cindy Tang on Unsplash

Continuando con el ejemplo de la primera parte, ya teníamos un primer storyboard y un entorno de trabajo listo. Nuestro storyboard se veía algo así:

Primer storyboard de nuestra aplicación de ejemplo

Qué es una vista (view)?

Si nos fijamos bien en el storyboard, podríamos decir que los dos primeros rectángulos representan dos estados de una misma vista o página. Ésta va a ser nuestra primera abstracción a nivel de arquitectura: las vistas. En líneas generales, podemos decir que las vistas tienen tres características principales.

  1. Se pueden pintar (entregar al usuario)
    En toda aplicación con interfaz gráfica (GUI), vamos a hablar de una forma u otra de vistas, que representan objetos que podemos entregar al usuario (pintar en la pantalla).
  2. Se pueden combinar/anidar en una estructura de árbol
    En el caso de las aplicaciones web, solemos imaginar cada página como una vista independiente. Pero las vistas no solo representan estas páginas o pantallas, sino que normalmente nos permiten combinarlas para componer vistas que contienen vistas que a su vez pueden contener a otras. Algo parecido a lo que ocurre en HTML cuando anidamos una etiqueta dentro de otra y así sucesivamente, construyendo una estructura de árbol.
  3. Ofrecen posibilidad de interacción con el usuario
    Además de mostrar data al usuario, las vistas son el punto de interacción con el usuario, a través de botones, links, inputs, … Las vistas deben poder capturar esa interacción y comunicar la intención del usuario al resto de la aplicación.

Implementando un sistema de vistas

Para nuestro ejemplo vamos a inspirarnos en los componentes stateless de React. De hecho, a partir de ahora me voy a permitir el lujo de hablar de vistas y componentes indistintamente.

Vamos a implementar nuestras vistas como funciones puras que reciben un objeto (props) con la información necesaria para retornar un pedacito de DOM que podamos pintar en la pantalla. Nuestros componentes van a tener una firma (signature) así:

HTMLElement MyComponent(props)

Esto significa que van a ser funciones que esperan recibir un argumento (el objeto props) y retornan un objeto de tipo HTMLElement. Una interfaz simple, elegante y súper poderosa! 💪

Ahora que ya tenemos una idea de qué es una vista en nuestra aplicación, vamos a describir con unos tests la primera: la pantalla de inicio de sesión. Como convención en nuestra aplicación de ejemplo, vamos a poner nuestras vistas principales (páginas o pantallas) en una carpeta con el nombre pages dentro de la carpeta src. Como comentamos en la primera parte, en la carpeta test vamos a ir replicando la estructura de src, así que creamos la carpeta pages dentro de test y agregamos un archivo con nuestro primer test (test/pages/SignIn.spec.js).

import SignIn from '../../src/pages/SignIn';
describe('SignIn', () => {
it('should return div with nested button', () => {
const el = SignIn();
expect(el.tagName).toBe('DIV');
expect(el.className).toBe('sign-in');
expect(el.children.length).toBe(1);
expect(el.children[0].tagName).toBe('BUTTON');
expect(el.children[0].innerText).toBe('Sign in');
});
});

Este test asume que podemos importar un módulo de ../../src/pages/SignIn, que sería lo mismo que decir src/pages/SignIn.js desde la carpeta raíz del proyecto. Todavía no hemos creado ese archivo 😱 … corramos el test para confirmar el error que esperamos con el siguiente comando:

npx jest test/pages/SignIn.spec.js

En este comando usamos npx, que es una herramienta de línea de comando que viene con node/npm (a partir de npm@5.2.0) que nos permite invocar ejecutables de dependencias instaladas localmente (entre otras cosas). En la primera parte de esta serie habíamos instalado Jest, que viene con un ejecutable (jest), así que el comando npx jest va a invocar ese ejecutable, al cual también podemos acceder a través de ./node_modules/.bin/jest.

Esto nos debería mostrar un error así:

Como esperábamos, el test revienta porque no puede encontrar el módulo que todavía no hemos creado 😉. Ahora crea un nuevo archivo para la implementación de nuestra vista (src/pages/SignIn.js):

export default () => {
const page = document.createElement('div');
page.className = 'sign-in';
  const signInButton = document.createElement('button');
signInButton.innerText = 'Sign in';
  page.appendChild(signInButton);
return page;
};

Como habíamos acordado, nuestro módulo exporta una función, el componente, que al ser invocado retorna un objeto de tipo HTMLElement (HTMLDIvElement en este caso en particular, que hereda de HTMLElement).

Corramos nuestro test otra vez para ver si ya satisfacemos la descripción.

npx jest test/pages/SignIn.spec.js

Premio, nuestro primer test ya pasa! 💝

Recuerdas que dijimos que una de las propiedades de los componentes (vistas) es que unos pueden contener a otros? Siguiendo este patrón, vamos a separar nuestro botón de la vista principal. Nuestro botón no va a ser un componente página, así que vamos a crear una carpeta con el nombre components dentro de src para poner ahí nuestros componentes más genéricos (y reusables). Dentro de esta carpeta components/, crea un archivo para la implementación de nuestro componente SignInButton (src/components/SignInButton.js).

export default () => {
const el = document.createElement('button');
el.innerText = 'Sign in';
return el;
};

Ahora importemos el nuevo componente SignInButton en la página de SignIn (src/pages/SignIn.js) y refactorizamos para hacer uso del nuevo componente.

import SignInButton from '../components/SignInButton';
export default () => {
const el = document.createElement('div');
el.className = 'sign-in';
el.appendChild(SignInButton());
return el;
};

Corramos los tests otra vez para ver si rompimos algo refactorizando, pero esta vez hagámoslo con el comando yarn test. En la primera parte de esta serie habíamos configurado unas tareas (npm-scripts), entre ellas pretest y test, que podemos ejecutar a través del comando yarn test.

Ouch!! Qué pasó? Nada grave 😉. Si nos fijamos en lo que dice el output, el problema no viene de los tests en sí, sino del comando eslint src test, que se ha ejecutado automáticamente como parte de la tarea pretest. Hasta ahora no habíamos ejecutado nuestro linter… Es hora de darle un poquito de cariño.

Los errores dicen que describe, it y expect no están definidas. Estas funciones las provee Jest como parte del entorno de nuestros tests, así que necesitamos decirle a Eslint que tenga en cuenta el hecho de que los scripts de la carpeta test son pruebas diseñadas para correr con Jest. Para solucionar esto creamos un archivo de configuración de Eslint dentro de la carpeta test (test/.eslintrc):

{
"env": {
"jest": true
}
}

Volvamos a ejecutar el comando yarn test:

Nuestro test sigue pasando, y ahora el linter ha pasado antes de correr los tests y se ha incluido cobertura (coverage)! 🎉 🎈 🍰 🚀 🎸

De hecho, este es una de las ventajas más grandes de los test, a la hora de refactorizar tenemos la garantía de que, desde afuera, el código se sigue comportando igual.

Vayamos un paso más allá… nuestra interfaz probablemente vaya a tener más de un botón, y quizás queremos que todos los botones compartan una serie de características. Agreguemos otro componente reusable! Crea un archivo para nuestro componente Button (src/components/Button.js):

export default (props) => {
const el = document.createElement('button');
el.innerText = props.text;
return el;
};

Y ahora importemos el componente Button en SignUpButton y reemplazamos la implementación para hacer uso del nuevo componente.

import Button from './Button';
export default () => Button({ text: 'Sign in' });

Volvamos a correr los tests una vez más para comprobar que todo siga en orden.


Llegado a este punto, nos empezamos a dar cuenta de que nuestros componentes siempre usan document.createElement() para crear elementos del DOM y después asignarles propiedades y agregarles hijos. Para evitar un poco de verbosidad y hacer nuestro código un poquito más declarativo, me tomo la libertad de implementar una utilidad con la que vamos a crear nodos del DOM en el resto del ejemplo. Esta utilidad va a ser una función con la siguiente firma:

HTMLElement createElement(tagName, options)

La idea es que en una sola invocación podamos construir el elemento en base a una configuración. Veamos la implementación (src/lib/createElement.js):

export default (tagName, opts = {}) => {
const { children, ...rest } = opts;
const element = Object.assign(
document.createElement(tagName),
rest,
);
  if (children && typeof children.forEach === 'function') {
children
.filter(item => item)
.forEach(element.appendChild.bind(element));
}
  return element;
};

Esta función espera dos argumentos, tagName y opts. El primero tagName es un string con el tipo de etiqueta queremos crear (igual que el primer argumentos de document.createElement). El segundo argumento (opts) es un objeto con los atributos que queremos asignar en el elemento. Dentro de opts hay una propiedad especial children, donde esperamos recibir opcionalmente un arreglo de hijos que agregar dentro del elemento.

Ahora podemos refactorizar nuestros componentes para que usen esta nueva utilidad (createElement) en vez de document.createElement.

src/components/Button.js

import createElement from '../lib/createElement';
export default props => createElement('button', {
innerText: props.text,
});

src/pages/SignIn.js

import createElement from '../lib/createElement';
import SignInButton from '../components/SignInButton';
export default () => createElement('div', {
className: 'sign-in',
children: [SignInButton()],
});

Volvemos a correr los tests…

Todo sigue pasando, y nuestros componentes van tomando forma 🚀


Render

Es hora de llevar nuestras vistas a la pantalla! Hasta ahora hemos usado nuestros componentes desde tests. Ahora nos toca pintar estas vistas como prometimos. Para eso vamos a introducir el concepto de renderizado. El verbo render (en inglés) significa algo como entregar o materializar. En el contexto de interfaces, hablamos de render para referirnos al acto de materializar una vista (normalmente aplicándole una data) y mostrarla en la pantalla.

Para nuestra aplicación de ejemplo vamos a implementar una función render con la siguiente firma:

void render(Component, target)

Esta firma nos dice que render es una función que recibe dos argumentos (Component y target) y no devuelve nada (void). La función no devuelve nada porque lo único que hace es modificar un objeto declarado en otra parte (el elemento target), cambiando su estado, pero no necesitamos comunicar un resultado a través del valor de retorno. En este caso el resultado es un efecto colateral (que aparezca la vista en la pantalla).

Comencemos por describir una serie de características que vamos a querer probar en test/lib/render.spec.js:

describe('render', () => {
it('should render component in given target', () => {
const Component = () => document.createElement('div');
const target = document.createElement('div');
expect(render(Component, target)).toBeUndefined();
expect(target.children.length).toBe(1);
expect(target.children[0] instanceof HTMLDivElement)
.toBe(true);
});
  it('should empty target before rendering', () => {
const Component = () => document.createElement('div');
const target = document.createElement('div');
expect(render(Component, target)).toBeUndefined();
expect(target.children.length).toBe(1);
expect(target.children[0] instanceof HTMLDivElement)
.toBe(true);
    expect(render(Component, target)).toBeUndefined();
expect(target.children.length).toBe(1);
expect(target.children[0] instanceof HTMLDivElement)
.toBe(true);
});
  it('should not append when component returns falsy', () => {
const target = document.createElement('div');
expect(render(() => null, target)).toBeUndefined();
expect(target.children.length).toBe(0);
});
});

El primer test se asegura de que dado un componente que retorna un <div>, al renderizarlo dentro de otro nodo ( target), el <div> del componente debería aparecer como hijo del nodo target. El segundo test hace lo mismo, pero dos veces, para asegurarse de que al renderizar una segunda vez se limpia primero el contenedor (target). Finalmente el tercer test comprueba que si el componente retorna un valor falsy no lo pintamos dentro del contenedor.

Ya sabiendo todo esto, podemos proceder a la implementación que satisfaga las pruebas en el archivo src/lib/render.js:

export default (Component, target) => {
const child = Component({});
if (child) {
Object.assign(target, { innerHTML: '' });
target.appendChild(child);
}
};

Nuestra implementación de render invoca al componente, comprueba si retornó un valor truthy, y si es así vacía el contenedor y después le pone el resultado a target como hijo.

Finalmente, ya estamos listos para mostrarle algo al usuario! Juntemos todas las piezas en un nuevo archivo: src/index.js.

import render from './lib/render';
import SignIn from './pages/SignIn';
render(SignIn, document.getElementById('root'));

En src/index.js hacemos uso de render para renderizar el componente SignIn dentro del <div> con id root que teníamos en nuestro HTML.

Ahora para poder ver el resultado en el navegador, ejecutemos la tarea start que definimos en los npm-scripts. Esta tarea va a usar webpack-dev-server para servir la aplicación localmente en modo de desarrollo. Esto significa que va a servir el directorio dist y va a generar dinámicamente el archivo main.js con el resultado de procesar nuestro código fuente (src).

yarn start

Deberíamos ver un output parecido donde diga que el proyecto está corriendo en http://localhost:8080/ (el puerto puede variar) y al final debería decir que ha compilado con éxito (Compiled successfully). Si ahora nos dirigimos a la URL en cuestión en el navegador deberíamos de ver nuestra hermosa interfaz:


Todo esto para mostrar un botón en la pantalla… y todavía ni siquiera hemos hablado de data (estado) ni de interacción… 😱 … pero llegaremos 😉. Al principio parece un montón de indirección y trabajo extra, pero en el mundo real los proyectos suelen ser más grandes y complejos, y seguir este tipo de convenciones, abstracciones y enfoque de diseño nos van a permitir mantener la salud mental y ayudarnos a diseñar, construir y mantener nuestro código.

En los siguientes posts de esta serie veremos como agregar data a nuestros componentes, renderizado condicional, manejo de estado, interacción y más… pero prometimos que sería pasito a pasito…


Este post es parte de la serie “Arquitectura de interfaces web”. Si quieres continuar leyendo…

<< Parte 1: Arquitectura, TDD, entorno de desarrollo y herramientas | Parte 3: Estado / HOCs / Store >>