Revisitando Señales y DOM Reactivo

En este capítulo me gustaría refactorizar un poco el código de las señales vanilla y el DOM Reactivo para hacer más sencilla la interacción con las librerías y la posterior modificación

Señales #

La idea sería hacer que el valor de una señal no se recoja cuando intentamos mostrar o utilizar su representación, si no que seamos nosotros mismos quienes forcemos a mostrar el valor.

Aunque ya tenemos disponible la propiedad `valor` dentro de ellas, se hace incómodo utilizar la propiedad tanto para recibir el valor, como para actualizarlo. Habrá que pensar otra cosa. Y aquí entran en juego las funciones.

Suponiendo que el valor de la señal es una función:

const x = señal(123); // valor interno: 123

  • Si la llamada a la función viene sin parámetros: devolvemos el valor de la señal.

    x() // devuelve 123

  • Si la llamada a la función viene con un valor: se actualiza el valor interno a dicho argumento.

    x(321) // valor interno: 321

  • Si la llamada a la función viene con una función: se actualiza el valor interno a la ejecución de la función. El parámetro indica el valor anterior.

    x((prev) => prev + 100) // prev: 321; valor interno: 421

Arreglando señales #

Veamos cómo era el código anterior y cómo podemos conseguir la funcionalidad descrita.

let suscriptor = null;

const señal = (_interno) => {
  const suscriptores = new Set();

  return {
    get valor() {
      if (suscriptor) suscriptores.add(suscriptor);
      return _interno;
    },
    set valor(nuevo) {
      if (nuevo === _interno) return;

      _interno = nuevo;
      suscriptores.forEach((fn) => fn());
    },
  };
};

const efecto = (fn) => {
  suscriptor = fn;
  fn();
  suscriptor = null;
};

const derivado = (fn) => {
  const _señal = señal();

  efecto(() => (_señal.valor = fn()));

  return _señal;
};
          

Ok. No tiene porqué ser difícil, ya tenemos funciones que acceden y cambian el valor interno. Hagámoslas más explícitas, independiente de los getters y setters de una propiedad:

const getValor = () => {
  if (suscriptor) suscriptores.add(suscriptor);

  return _interno;
};

const setValor = (_nuevo) => {
  if (_nuevo === _interno) return;

  _interno = _nuevo;
  suscriptores.forEach((fn) => fn());
};
          

Hecho. Ahora faltaría devolver una función con la señal:

const señal = (_interno) => {
  // ...

  return (_nuevo) => {
    if (_nuevo === undefined) return getValor();
    if (typeof _nuevo === "function") return setValor(_nuevo(_interno));
    return setValor(_nuevo);
  }
};
          

Bien. Solo quedaría actualizar el efecto dentro del derivado para que llame a la función de actualización del valor y ya está hecho. Quedaría así:

let suscriptor = null;

export const señal = (_interno) => {
  const suscriptores = new Set();

  const getValor = () => {
    if (suscriptor) suscriptores.add(suscriptor);

    return _interno;
  };

  const setValor = (_nuevo) => {
    if (_nuevo === _interno) return;

    _interno = _nuevo;
    suscriptores.forEach((fn) => fn());
  };

  return (_nuevo) => {
    if (_nuevo === undefined) return getValor();
    if (typeof _nuevo === "function") return setValor(_nuevo(_interno));
    return setValor(_nuevo);
  };
};

export const efecto = (fn) => {
  suscriptor = fn;
  fn();
  suscriptor = null;
};

export const derivado = (fn) => {
  const _señal = señal();

  efecto(() => _señal(fn()));

  return _señal;
};
          

DOM Reactivo #

Para el DOM Reactivo lo que queremos es abstraer la parte común de las directivas para poder generar nuevas sin tanto roce. Además hay que soportar las nuevas señales. Veamos cómo está ahora:

import { efecto } from "señales.js";

const directivaTexto = (contexto, raiz) => {
  const textos = [...raiz.querySelectorAll("[data-texto]")];

  textos.forEach((el) => {
    const clave = el.dataset.texto;
    efecto(() => (el.textContent = contexto[clave].valor));
    el.removeAttribute("data-texto");
  });
};

const directivaMostrar = (contexto, raiz) => {
  const mostrados = [...raiz.querySelectorAll("[data-mostrar]")];

  mostrados.forEach((el) => {
    const clave = el.dataset.mostrar;
    efecto(() => (el.style.display = contexto[clave].valor ? "block" : "none"));
    el.removeAttribute("data-mostrar");
  });
};

export const activarDOM = (contexto, raiz = document) => {
  directivaTexto(contexto, raiz);
  directivaMostrar(contexto, raiz);
};
          

Arreglando DOM Reactivo #

Como podemos ver en la versión inicial, la parte común entre ambas directivas es que: recogen los elementos que tengan un atributo concreto, iteran los elementos y por cada uno de ellos recogen el valor del atributo, realizan un `efecto` asociado al DOM del elemento y eliminan el atributo. Veamos como hacer esta directiva genérica:

const directivaGenerica = (atributo, fn) => (contexto, raiz) => {
  const elementos = [...raiz.querySelectorAll(`[data-${atributo}]`)];

  elementos.forEach((el) => {
    const clave = el.dataset[atributo];

    efecto(() => fn(el, contexto, clave));

    delete el.dataset[atributo];
  })
};
          

Genial. Esto simplificará mucho las cosas. ¿Cómo quedarían entonces las directivas anteriores? Veamos:

const directivaTexto = directivaGenerica("texto", (el, ctx, clave) => {
  el.textContent = ctx[clave]()
});

const directivaMostrar = directivaGenerica("mostrar", (el, ctx, clave) => {
  el.style.display = ctx[clave]() ? "block" : "none"
});
          

Por último, vamos a soportar claves anidadas, es decir, que si tenemos una señal:

const juan = señal({ nombre: "Juan", apellido: "Martínez" });
          

¿Cómo podríamos acceder desde la directiva de texto al apellido? Es por eso que necesitamos la función valorContexto:

const valorContexto = (contexto, clave) => {
  const [actual, ...resto] = clave.split(".");

  const valor = contexto[actual]();
  if (!resto.length) return valor;

  return resto.reduce((acc, i) => acc[i], valor);
};
          

Y actualizando las directivas:

const directivaTexto = directivaGenerica("texto", (el, ctx, clave) => {
  el.textContent = valorContexto(ctx, clave)
});

const directivaMostrar = directivaGenerica("mostrar", (el, ctx, clave) => {
  el.style.display = valorContexto(ctx, clave) ? "block" : "none"
});
          

Ahora podremos hacer lo siguiente:

<span data-texto="juan.apellido"></span>
          

Lo que ejecutará la señal juan y después devolverá su propiedad apellido. Y visto esto, veamos el resultado final:

import { efecto } from "señales.js";
    
const valorContexto = (contexto, clave) => {
  const [actual, ...resto] = clave.split(".");

  const valor = contexto[actual]();
  if (!resto.length) return valor;

  return resto.reduce((acc, i) => acc[i], valor);
};

const directivaGenerica = (atributo, fn) => (contexto, raiz) => {
  const elementos = [...raiz.querySelectorAll(`[data-${atributo}]`)];

  elementos.forEach((el) => {
    const clave = el.dataset[atributo];

    efecto(() => fn(el, contexto, clave));

    delete el.dataset[atributo];
  })
};

const directivaTexto = directivaGenerica("texto", (el, ctx, clave) => {
  el.textContent = valorContexto(ctx, clave)
});
const directivaMostrar = directivaGenerica("mostrar", (el, ctx, clave) => {
  el.style.display = valorContexto(ctx, clave) ? "block" : "none"
});

export const activarDOM = (contexto, raiz = document) => {
  directivaTexto(contexto, raiz);
  directivaMostrar(contexto, raiz);
};
          

Conclusión #

Con las señales mejoradas y las directivas genéricas y actualizadas, ya tenemos un camino más cómodo hacia la creación de nuevas directivas más complejas. En el futuro vendrán la directiva atributos y lista. ¿Quieres verlas?

El código completo estará disponible en los retales