Este experimento es de junio de 2018. Construimos herramientas mínimas para entender sus conceptos, no una implementación completa de React o Redux.

Es sábado, 3 de la mañana y me cruzo con esta cita de Pete Hunt:

The DOM is stateful. We can’t destroy the DOM and recreate it all the time. This leaves poor performance and a bad user experience.

En español:

El DOM tiene estado. No podemos destruir el DOM y recrearlo todo el tiempo. Esto nos deja una performance pobre y una mala experiencia de usuario.

“No podemos destruir el DOM y recrearlo todo el tiempo” … O si? 😛. Vamos a analizar lo que dijo.

Acá hay dos estados que conviene distinguir: el de nuestra aplicación, como el valor de un contador, y el que conserva el DOM, como el foco, la selección o el contenido de un input. Reemplazar un nodo puede perder ese segundo estado, incluso en una aplicación pequeña.

Destruir y recrear DOM - Aplicaciones que mantienen estado

Si pensamos en destruir y recrear una aplicación significa que nuestras aplicaciones cambian con el tiempo. Es decir, mantienen estado. Un estado que es actualizado y modificado.

Cuál es el problema? Bueno…

La web fue diseñada para construir documentos de hipertexto y no para crear aplicaciones. Partiendo de esta premisa hay muchas cosas que son entendibles. Una de ellas es que asignar un nuevo valor a una variable de JavaScript no actualiza automáticamente el nodo del DOM que la muestra.

Esto no es menor: necesitamos alguna forma de coordinar el cambio de estado con su representación en el DOM. En el siguiente ejemplo lo vamos a hacer manualmente.

Prestemos atención al siguiente ejemplo:

JavaScript
var count = 1;

var $countContainer = document.createElement('div');
$countContainer.innerText = count;

var $incrementBtn = document.createElement('button');
$incrementBtn.innerText = '+';
$incrementBtn.addEventListener(
  'click',
  function () {
    count = count + 1;
    console.log(count);
  },
  false
);

var $root = document.getElementById('root');
$root.appendChild($countContainer);
$root.appendChild($incrementBtn);
Ejemplo de actualización manual del DOM
Contador y consola actualizándose al presionar el botón de incremento

Ejemplo de actualización manual del DOM

El código de arriba incrementa la variable, pero no actualiza el contador visible: tenemos que actualizar el nodo manualmente. Si el DOM y JS fuese reactivo y nuestra variable quedase bindeada al nodo, este se actualizaría automáticamente.

Para “solucionar “ esto lo que podemos hacer es cambiarle el contenido al nodo manualmente con innerText.

JavaScript
var count = 1;

var $countContainer = document.createElement('div');
$countContainer.innerText = count;

var $incrementBtn = document.createElement('button');
$incrementBtn.innerText = '+';
$incrementBtn.addEventListener(
  'click',
  function () {
    // Actualizando el estado
    count++;
    // Reemplazando el contenido de $countContainer
    $countContainer.innerText = count;
  },
  false
);

var $root = document.getElementById('root');
$root.appendChild($countContainer);
$root.appendChild($incrementBtn);
Ejemplo de recreación del DOM
Contador actualizándose mientras el inspector muestra los elementos del DOM

Ejemplo de recreación del DOM

Con count++ mantenemos actualizado nuestro estado y con $countContainer.innerText = count; plasmamos la representación de nuestro estado en nuestra aplicación.

Para este contador alcanza. El problema aparece cuando tenemos que coordinar muchas actualizaciones como esta.

¿Qué pasa cuando la aplicación crece?

Bueno, la web fue así por mucho tiempo. De hecho hay muchas webs que pueden mantener este tipo de código. Pero esto tiene muchas desventajas:

  • La lógica y las actualizaciones del DOM están mezcladas, lo que dificulta probarlas por separado
  • El estado de la aplicación no está bien delimitado
  • Las actualizaciones pueden quedar repartidas entre muchos eventos
  • Sin una estructura clara, el código se vuelve difícil de seguir
  • A medida que crece, aumenta el trabajo de coordinar todos esos cambios

El código imperativo sí puede testearse. Veamos cómo separar responsabilidades nos ayuda a hacerlo.

Esto va más allá de que realices tests o no. Acá estamos hablando de que si podés romper este código en componentes según su funcionalidad podés tener menos problemas a futuro porque estos van a estar encapsulados. El ejemplo de arriba es un código simple, pero imaginen varias funcionalidades y a gran escala. Tratar de debuggear un error con este estilo puede ser un gran dolor de cabeza.

Tratemos de pensar esto en componentes:

Tenemos dos componentes que podemos identificar: el contador y el botón para incrementar el contador.

JavaScript
function createCounterContainer(props) {
  var $ = document.createElement('div');
  $.innerText = props.count;
  return $;
}

function createIncrementButton(props) {
  var $ = document.createElement('button');
  $.innerText = props.label;
  $.addEventListener('click', props.onClick, false);
  return $;
}

Algo interesante de estos dos componentes es que podemos identificar que son testeables ya que mediante cierta entrada producen cierta salida. En este caso, las dos funciones devuelven un elemento.

Sigamos con el resto del código. Usemos esos generadores de componentes y pasemos las props que piden.

JavaScript
function createCounterContainer(props) {
  var $ = document.createElement('div');
  $.innerText = props.count;
  return $;
}

function createIncrementButton(props) {
  var $ = document.createElement('button');
  $.innerText = props.label;
  $.addEventListener('click', props.onClick, false);
  return $;
}

function render(nodes) {
  var $root = document.getElementById('root');

  for (var i = 0; i < nodes.length; i++) {
    $root.appendChild(nodes[i]);
  }
}

// App

var state = {
  count: 0
};

var $counterContainer = createCounterContainer({
  count: state.count
});

var $incrementButton = createIncrementButton({
  label: '+',
  onClick: function () {
    state.count++;
    $counterContainer.innerText = state.count;
  }
});

render([$counterContainer, $incrementButton]);

Agregué una simple función render que va a introducir los nodos que le pasemos en donde le indiquemos. En este caso al #root.

En resumen:

  • Ahora mantenemos el estado en un objeto
  • Tenemos elementos delineados por componentes, donde estos reciben props y devuelven un elemento (testeable)
  • Ya no luce tanto como Spaghetti Code. Nuestro código es más declarativo.
  • Nuestro estado es mutable y no podemos aún seguir sus cambios pero buscando state podemos ver qué sucede.

Sigamos: createIncrementButton y createCounterContainer lucen geniales. Pero como dijimos antes comparten en común que ambos devuelven elementos. No estaría mejor si pudiéramos hacer algo más genérico como un createElement ?

Vamos a intentarlo.

Para esto vamos a basarnos en el createElement de React:

JavaScript
React.createElement(type, [props], [...children]);

Por si no lo conocías, con la transformación clásica de JSX usada en este ejemplo, <div></div> se traduce a React.createElement('div', null). Ya te rompí la cabeza?

Básicamente, Javascript no soporta HTML y JSX es una amigable forma de usar algo similar a HTML en Javascript.

Para entender mejor como funciona JSX dejo un par de links:

Bien, sigamos. Construyamos nuestra versión de createElement:

JavaScript
function createElement(type, props, children) {
  var e = document.createElement(type);
  implementProps(props, e);

  if ('string' === typeof children) {
    e.appendChild(document.createTextNode(children));
  } else if ('number' === typeof children) {
    e.appendChild(document.createTextNode(children.toString()));
  } else if (Array.isArray(children)) {
    children.forEach(child => e.appendChild(child));
  } else if (children instanceof HTMLElement) {
    e.appendChild(children);
  }

  return e;
}

Apa, se puso heavy. Y si… acá estamos creando un método que vamos a utilizar bastante. Es por esta razón que tenemos que contemplar sus edge cases. En resumen, ese if contempla si recibe en su tercer parámetro: una string, un number, un Array o un nodo.

Su uso sería así:

JavaScript
var div = createElement('div', null, 'Hola!');

Esto nos devolvería un div con un Hola! dentro.

No los quiero volver locos pero. TENEMOS LA PARTE MÁS IMPORTANTE DE TODAS y el motor de las apps frontend. Un creador de elementos. Es decir, podemos recrear la representación exacta de nuestra UI en Javascript.

Si queremos hacer esto en HTML:

HTML
<div>
	<div>1</div>
	<button>+</button>
</div>

Con nuestra API luce así:

JavaScript
  createElement('div', null, [
    createElement('div', null, 1),
    createElement('button', null, '+')
  ]);

Hablamos un poco del poder que nos confiere esto:

Con nuestra API podemos regenerar la interfaz a partir del estado. Pero ojo: nuestro createElement devuelve nodos del DOM real. Todavía no construimos un Virtual DOM ni comparamos representaciones para actualizar solo lo necesario.

Una posible siguiente etapa sería describir la UI con objetos de JavaScript separados del DOM y comparar esas descripciones para decidir qué nodos conservar o modificar. Ahí entran los conceptos de Virtual DOM y reconciliación. En este experimento vamos a mantener la versión simple que reemplaza el árbol completo.

Cambiar el DOM puede hacer que el browser tenga que recalcular estilos y posiciones; a ese cálculo de layout también se lo llama reflow. El costo depende de qué cambiamos y de cuándo el browser necesita calcularlo. Menos operaciones sobre el DOM no garantizan, por sí solas, menos trabajo de layout.

Sigamos:

Ahora podemos crear todos los nodos que queramos con nuestro createElement. Vamos a darle soporte para las props así usamos eventos y atributos.

JavaScript
var eventTypes = {
  onClick: {
    registrationName: 'click'
  }
};

var attrs = ['className'];

function implementProps(props, e) {
  if (props && 'object' === typeof props) {
    Object.keys(props).forEach(p => {
      // Adding events
      if (Object.keys(eventTypes).includes(p)) {
        e.addEventListener(eventTypes[p].registrationName, props[p], false);
      }
      // Adding attributes
      if (attrs.includes(p)) {
        e.setAttribute(p === 'className' ? 'class' : p, props[p]);
      }
    });
  }
}

En este ejemplo delimitamos los eventos que soportamos y los atributos, para solo añadir lo que permitimos.

Ya que estamos, agregamos una render function:

JavaScript
function render(nodeTree, el) {
  el.appendChild(nodeTree);
}

Esta función difiere de la anterior función render que creamos porque ésta sólo toma un único nodo. render la vamos a utilizar para renderizar toda nuestra app en el nodo que indiquemos.

Llegó el momento.

JavaScript
var state = {
  count: 0
};

function increment() {
  state.count++;
}

function reRender() {
  var rootNode = document.getElementById('root');
  rootNode.replaceChild(App(), rootNode.firstChild);
}

function App() {
  return createElement('div', null, [
    createElement('div', null, state.count),
    createElement(
      'button',
      {
        onClick: function () {
          increment();
          reRender();
        }
      },
      '+'
    )
  ]);
}

render(App(), document.getElementById('root'));

Wow. Ahora nuestra app luce muuuuy bien. Por supuesto, obviando la parte en la que tiramos todo el nodo y lo reemplazamos por uno nuevo. No hay ninguna policía de la web y esto en un ejemplo tan mínimo no le genera peso al browser. Pero pongamos 600 contadores y tiremos los 600 cada vez que uno quiera sumar un + 1. Ah, ahí los quiero ver. (Y encima mantener el estado de los 600 😩)

Ahí las librerías de frontend pueden ayudarnos a manejar los componentes y coordinar los updates al DOM. No todas las aplicaciones necesitan una, pero vale la pena entender qué trabajo nos ahorran.

Hasta ahora construimos una pequeña API para crear y reemplazar la interfaz, pero todavía no hablamos mucho de estado y como trackear sus cambios. 😏 Me imagino que estarás pensando en agregarle Redux. Así que de bonus track vamos a hacer una mínima representación de Redux.

Rompiendo los conceptos de Redux creándola desde cero

Redux es una simple librería que te ayuda a manejar el estado de tu aplicación. Quizás ni la necesites en tu aplicación pero es importante que sepas qué hace y qué mejor que hacerla de cero?

Empecemos declarando nuestro estado inicial:

JavaScript
var initialState = {
  count: 0
};

Ahora pensemos una serie de utilidades/funcionalidades para interactuar con nuestro estado:

  • Obtener el estado actual
  • Escuchar cambios de estado
  • Modificar el estado
  • Un lugar donde podamos ver como se modifica nuestro estado en base a las acciones que se gatillan
  • Una lista de acciones/cambios que puede sufrir nuestro estado

Bien, vayamos punto por punto. Para esto vamos a crear un store que es donde vamos a encapsular toda esta lógica.

JavaScript
function createStore() {
  //...
}
  • Obtener el estado actual

Bien, luce bastante simple. Para esto tenemos que entender un par de cosas. Debemos recibir el estado inicial y poder devolver el estado actual.

JavaScript
function createStore(initialState) {
  var store = {};
  store.state = initialState;

  function getState() {
    return store.state;
  }

  return {
    getState
  };
}
  • Escuchar los cambios de estado

Vamos a crear un array que pueda recibir funciones que vamos a ejecutar luego de un cambio de estado. Por ahora no hace mucho más que registrar estas funciones.

JavaScript
function createStore(initialState) {
  var store = {};
  store.state = initialState;
  store.listeners = [];

  function subscribe(listener) {
    store.listeners.push(listener);
  }

  function getState() {
    return store.state;
  }

  return {
    getState,
    subscribe
  };
}
  • Modificar el estado

La parte más importante. Vamos a crear nuestra dispatch function. Para esto vamos a necesitar un reducer. Lo que tenés que saber es que un reducer es una función que recibe el estado actual y la acción que va a modificar el estado y devuelve el nuevo estado. (Hablaremos más del reducer en el próximo punto)

La función dispatch funciona recibiendo una acción y ejecutando el reducer, junto a todas las funciones que escuchan los cambios de estado. (Entiéndase a acción como todo cambio que afectará al estado)

JavaScript
function createStore(reducer, initialState) {
  var store = {};
  store.state = initialState;
  store.listeners = [];

  function subscribe(listener) {
    store.listeners.push(listener);
  }

  function dispatch(action) {
    store.state = reducer(store.state, action);
    store.listeners.forEach(listener => {
      listener(action);
    });
  }

  function getState() {
    return store.state;
  }

  return {
    getState,
    subscribe,
    dispatch
  };
}
  • Un lugar donde podamos ver como se modifica nuestro estado en base a las acciones que se gatillan

Con esto describimos nuestro reducer. Como dijimos previamente: Un reducer es una función que recibe el estado actual y una acción y devuelve el nuevo estado.

Un reducer luce así:

JavaScript
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return {
        count: state.count + 1
      };
    case 'DECREMENT':
      return {
        count: state.count - 1
      };
    default:
      return state;
  }
};

Es un simple switch que recibe acciones y devuelven el nuevo estado. Si, así de simple.

Sigamos:

  • Una lista de acciones/cambios que puede sufrir nuestro estado

Las acciones de nuestra aplicación lucen así:

JavaScript
function increment() {
  return {
    type: 'INCREMENT'
  };
}

function decrement() {
  return {
    type: 'DECREMENT'
  };
}

Es una función que devuelve un objeto con una propiedad type que especifica el nombre único de la acción y su payload. La acción es el objeto que devuelve. En este caso no tenemos payload porque sabemos con qué se va a modificar nuestro estado. +1, -1.

Con payload se vería así:

JavaScript
function handleSomething(payload) {
  return {
    type: 'HANDLE_SOMETHING',
    ...payload
  };
}

Forma de uso:

JavaScript
store.dispatch(increment());

Ya tenemos nuestro pequeño store inspirado en Redux. Para mantener el experimento corto, subscribe no devuelve una función para desuscribirse, no hay middleware y pasamos el estado inicial explícitamente. Tampoco reproducimos todos los detalles de la API de Redux. Se ve así:

JavaScript
function createStore(reducer, initialState) {
  var store = {};
  store.state = initialState;
  store.listeners = [];

  function subscribe(listener) {
    store.listeners.push(listener);
  }

  function dispatch(action) {
    store.state = reducer(store.state, action);
    store.listeners.forEach(listener => {
      listener(action);
    });
  }

  function getState() {
    return store.state;
  }

  return {
    getState,
    subscribe,
    dispatch
  };
}

Usando todas las utilidades que creamos nuestra app se vería así

JavaScript
// Declaramos el estado inicial
const getInitialState = () => {
  return {
    count: 0
  };
};

const reducer = (state = getInitialState(), action) => {
  switch (action.type) {
    case 'INCREMENT':
      return {
        count: state.count + 1
      };
    case 'DECREMENT':
      return {
        count: state.count - 1
      };
    default:
      return state;
  }
};

var store = createStore(reducer, getInitialState());

// Acciones

function increment() {
  return {
    type: 'INCREMENT'
  };
}

function decrement() {
  return {
    type: 'DECREMENT'
  };
}

// Aplicación

const slomo = { createElement, render };

function renderApp() {
  slomo.render(
    slomo.createElement('div', null, [
      slomo.createElement('h1', null, 'Counter'),
      slomo.createElement('div', null, [
        slomo.createElement('span', null, store.getState().count),
        slomo.createElement(
          'button',
          {
            className: 'btn',
            onClick: function () {
              store.dispatch(increment());
            }
          },
          'Increment'
        ),
        slomo.createElement(
          'button',
          {
            className: 'btn',
            onClick: function () {
              store.dispatch(decrement());
            }
          },
          'Decrement'
        )
      ])
    ]),
    document.getElementById('root')
  );
}

store.subscribe(function (action) {
  // Destruimos el root node y lo generamos devuelta :D
  var rootNode = document.getElementById('root');
  rootNode.removeChild(rootNode.firstChild);
  renderApp();
});

renderApp();

Si, el state manager que hicimos se llama stato y la “librería“ de frontend se llama slomo por slow motion, al ser la menos performante del mercado.

Conclusión

Me parece que fue un lindo experimento entender por qué usamos las librerías que usamos y qué rol cumplen. Ir desde lo mínimo y desmenuzar contenidos.

Construimos una API para crear nodos reales y un store que conecta acciones, reducers y listeners. Nos queda por resolver cómo conservar los nodos que no cambiaron, cuidar el estado del DOM y manejar el ciclo de vida de las suscripciones. Ahí empieza la siguiente parte del problema.

Más adelante me gustaría hablar del poder de tener la representación exacta de tu UI, profundizar en Virtual DOM y Reflow, y sobre todo ver cómo Angular, Backbone, Knockout, Ember y más realizan sus modificaciones del DOM.

Entender la historia de los que estuvieron antes que nosotros te ayuda a entender tu presente. Les dejo esta quote de Alan Perlis para que no den nunca nada por sentado.

Programmers know the value of everything and the cost of nothing.

Si hay algún tema que te interese / feedback -> @okbel 👋