Entendiendo Frontend Hoy
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:
- JSX Live Compiler - Una herramienta interactiva para entender como se compila JSX.
- Act and React. Fast! - Belén Curcio - DevDayAR 2016 - YouTube - Hablo de React y sus conceptos básicos
- GitHub - okbel/act-and-react-fast: Act and React, fast! - El repositorio de la charla
- https://act-and-react-fast.now.sh/ - Sus ejemplos online interactivos
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: