En este post busco exponer por qué necesitamos destruir el DOM si nuestras aplicaciones no son estáticas, entender por qué existen las librerías de frontend partiendo de simples ejemplos en vanilla JS hasta crear una minima representación de las herramientas actuales de frontend.

Este experimento/ensayo es la primer parte de una serie.


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.

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 Javascript en el browser no es reactivo.

Esto no es menor, ya que Javascript es lo que utilizamos para interactuar con el DOM y si este no es reactivo hay muchas acciones que tendremos que realizar manualmente e imperativamente cuando el estado de nuestra aplicación cambie.

Prestemos atención al siguiente ejemplo:

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);

El código de arriba no funciona porque nosotros 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.

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);

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

Este código es un poco problemático y la web ya no es así.

Por qué la web ya no es así?

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:

  • No es testeable
  • El estado de la aplicación no está bien delimitado
  • No tenemos control sobre las actualizaciones al estado
  • Genera Spaghetti Code
  • No escala (por todas las anteriores)

Vamos a romper un poco el concepto de “No es testeable”.

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.

function createCounter(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.

function createCounter(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,
};

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

$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:

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

Por si no lo conocías, React.createElement es lo que usa React por debajo cuando usamos JSX. Es decir: <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:

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í:

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:

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

Con nuestra API luce así:

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

Hablamos un poco del poder que nos confiere esto:

Teniendo una representación exacta de nuestra UI en JS significa que podemos regenerar nuestra app cuantas veces queramos. No solo esto, si hay un cambio de estado podemos entender como estas dos representaciones difieren y actualizar el DOM únicamente con lo que fue actualizado. Este concepto que acabamos de describir se llama Virtual DOM.

Si reducimos las veces que actualizamos el DOM, minimizamos algo que se llama Reflow. En posteos futuros hablaré más de esto. Por ahora, es importante que sepas que existen.

Sigamos:

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

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, 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:

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.

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 😩)

Por esa razón necesitamos librerías de frontend. O algo que se encargue inteligentemente de manejar nuestros componentes y sobre todo los updates al DOM.

Hasta ahora recreamos lo que hace una librería cómo React, 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:

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.

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.

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.

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)

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,
		reducer,
	}
}
  • 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í:

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í:

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 ser vería así:

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

Forma de uso:

store.dispatch(increment());

Ya tenemos nuestra mínima expresión de Redux y se ve así:

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,
    getState,
  };
}

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

// 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

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',
          {
            class: 'btn',
            onClick: function() {
              store.dispatch(increment());
            },
          },
          'Increment'
        ),
        slomo.createElement(
          'button',
          {
            class: '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.

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 Perils 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 :wave: