El conocimiento es el nuevo dinero.
Aprender es la nueva manera en la que inviertes
Acceso Cursos

Más allá de Angular Signals: Señales y estrategias de renderizado personalizadas

Recientemente vimos que angular lanzo su versión 16 y uno de los cambios más esperados y comentados ha sido el tema de los signals. Aquí te contaremos la importancia de los signals y si todo es ventajas como lo cuentan o que trae por debajo.

· 9 min de lectura
Más allá de Angular Signals: Señales y estrategias de renderizado personalizadas

Angular Signals podría facilitar el seguimiento de todas las expresiones en una vista (Component o EmbeddedView) y programar estrategias de renderizado personalizadas de una manera muy quirúrgica. Por lo tanto, permitiendo algunas optimizaciones interesantes.

Recuerda que si quieres aprender más acerca de Angular te dejamos link al curso.

Aunque las librerías y los frameworks son cada vez mejores a la hora de rastrear cambios de forma detallada y propagarlos al DOM, podemos darnos cuenta de que a veces, el cuello de botella del rendimiento reside en las actualizaciones del DOM.

Exploremos cómo Angular Signals podría permitirnos superar estos cuellos de botella de rendimiento con estrategias de renderizado personalizadas.

De Tick a Sig


Hace ya un tiempo que el equipo de Angular ha estado explorando (mucho más de lo que podemos pensar) modelos alternativos de reactividad y buscando algo que se encuentre entre los extremos de Zone.js ingenuo (es decir, Zone.js sin OnPush) y Angular sin Zon combinado con pipes y directivas especiales como las que proporciona RxAngular.

😬 Las actualizaciones del DOM no son tan baratas.
Lo fantástico de Signals es cómo frameworks y librerías como Angular, SolidJS, Preact o Qwik rastrean "mágicamente" los cambios y rerenderizan lo que tenga que rerenderizar sin mucha boilerplate comparado con alternativas más manuales.

Pero, ¡espera! Si rerenderizan lo que haya que rerenderizar, ¿qué pasa si el cuello de botella del rendimiento es la propia actualización del DOM?

Intentemos actualizar 10.000 elementos cada 100ms...

@Component({
  ...
  template: `
    <div *ngFor="let _ of lines">{{ count() }}</div>
  `,
})
export class CounterComponent implements OnInit {
  count = signal(0);
  lines = Array(10_000);

  ngOnInit() {
    setInterval(() => this.count.update(value => value + 1), 100);
  }
}

¡Oups! Pasamos más del 90% de nuestro tiempo renderizando...

...y podemos notar que la velocidad de fotogramas cae a unos 20 fps.

🦧 Vamo a calmarnos...


La primera solución en la que podríamos pensar es simplemente actualizar las Señales sólo cuando queramos rerenderizar, pero eso requeriría algo de boilerplate (es decir, crear Señales intermedias, ¡que no son Señales computadas!

@Component({
  ...
  template: `{{ throttledCount() }}`
})
class MyCmp {
  count = signal(0);
  throttledCount = throttleSignal(this.count, {duration: 1000});
  ...
}

Cf. throttleSignal().

Si tu quieres entender aún más los signals te dejo el siguiente video

pero esto tiene un par de inconvenientes

🐞 utilizar una única Señal no acelerada en la misma vista echaría por tierra nuestros esfuerzos,
⏱️ si las actualizaciones intermedias programadas de las Señales no están cohesionadas, podríamos introducir algunas inconsistencias aleatorias y romper toda la implementación sin fallos de las Señales.

📺 Actualizar sólo la ventana gráfica


Y si el navegador fuera sensible? Se volvería hacia nosotros y nos diría: "¡Estoy cansado de trabajar tanto y que a nadie le importen mis esfuerzos! A partir de ahora, ¡no trabajaré si no me miráis!".

Probablemente estaríamos de acuerdo.

De hecho, ¿por qué íbamos a seguir actualizando los elementos below the fold? O más en general, ¿por qué seguir actualizando elementos fuera de la ventana gráfica?

Si intentáramos implementar esto usando una Señal intermedia, entonces la función necesitaría una referencia al elemento DOM para saber si está en la ventana gráfica:

lazyCount = applyViewportStrategy(this.count, {element});

esto requeriría más código y, como la misma señal podría utilizarse en diferentes lugares, necesitaríamos una señal intermedia para cada uso.

Aunque esto podría resolverse utilizando una directiva estructural, saturaríamos la plantilla:

template: `
  <span *lazyViewportSignal="count(); let countValue">{{ countValue }}</span>
  <span> x 2 = </span>
  <span *lazyViewportSignal="double(); let doubleValue">{{ doubleValue }}</span>
`

... Que está lejos de ser ideal.

🤔 ¿Qué pasa con la Consistencia Eventual para las actualizaciones del DOM?


Otra alternativa es actuar a nivel de detección de cambios. Si podemos personalizar la estrategia de renderizado, entonces podemos posponer fácilmente el renderizado del contenido por debajo del pliegue.

Más concretamente, podríamos dejar de actualizar el contenido fuera de la ventana gráfica hasta que esté en ella.

Aunque introducir tal inconsistencia entre el estado y la vista puede sonar aterrador. Si se aplica sabiamente, esto no es más que Consistencia Eventual, lo que significa que eventualmente terminaremos en un estado consistente.

Después de todo, podríamos enunciar el siguiente teorema (obviamente inspirado en el Teorema CAP)

El proceso de sincronizar el estado y la vista no puede garantizar tanto la consistencia como la disponibilidad.

Inspirado por el trabajo de mis amigos de RxAngular, pensé que combinando algo como las estrategias de renderizado personalizadas con el sistema de seguimiento Signals, podríamos obtener lo mejor de ambos mundos y lograr nuestro objetivo de la forma más discreta posible.

Esto podría ser algo como esto:

@Component({
  ...
  template: `
    <div *viewportStrategy>
      <span>{{ count() }}</span>
      <span> x 2 = </span>
      <span>{{ double() }} </span>
    </div>
  `,
})
export class CounterComponent implements OnInit {
  count = Signal(0);
  double = computed(() => count());
}

👨🏻‍🍳 Colándose entre Señales y Detección de Cambios


Obviamente, lo primero que hice fue preguntar al equipo de Angular (más concretamente, a mi querido amigo Alex, que ahora forma parte de Pawælex, como ya he mencionado) si había algún plan para proporcionar una API que anulara la forma en que las señales activan la detección de cambios.

Alex dijo: no.

Yo oí: todavía no.

Entonces dije: gracias.

Y simultáneamente dijimos: adiós.

Si tu quieres conocer más acerca de las nuevas funcionalidades que trae angularv16 te dejamos los siguientes recursos

Fue entonces cuando me puse el delantal de programador y empecé a probar cosas ingenuas.

Mi primer intento no fue más que algo como esto:

/**
 * This doesn't work as expected!
 */
const viewRef = vcr.createEmbeddedView(templateRef);
viewRef.detach();
effect(() => {
  console.log('Yeay! we are in!'); // if called more than once
  viewRef.detectChanges();
});

... pero no funcionó.

La idea ingenua detrás de esto era que si effect() puede rastrear llamadas a Señales y si detectChanges() tiene que llamar sincrónicamente a las Señales en la vista, entonces el efecto debería ejecutarse de nuevo cada vez que una Señal cambia.

Fue entonces cuando me di cuenta de que tenemos suerte de que esto no funcione porque, de lo contrario, esto significaría que activaríamos la detección de cambios en nuestra vista cada vez que cambiara una Señal en cualquier hijo o hijo profundamente anidado.

Algo a nivel de vista detuvo la propagación de las señales y actuó como un mecanismo de límite. Tuve que encontrar lo que era, y la mejor manera era saltar en el código fuente.

(¡Sí! Lo sé... me gusta probar cosas al azar primero 😬)

🔬 El gráfico reactivo


Para que las Señales rastreen los cambios, Angular tiene que construir un gráfico reactivo. Cada nodo de este grafo extiende la clase abstracta ReactiveNode.

Actualmente hay cuatro tipos de nodos reactivos:

  • Señales escribibles: signal()
  • Señales computadas: computed()
  • Observadores: effect()
  • el Consumidor de Vistas Lógicas Reactivas: el especial que necesitamos 😉 .
    (la introducción de componentes basados en Señales probablemente añadirá más tipos de nodos como entradas de componentes)

Cada ReactiveNode conoce a todos sus consumidores y productores (que son todos ReactiveNodes). Esto es necesario para conseguir la implementación push/pull glitch-free de Angular Signals.

Este grafo reactivo se construye utilizando la función setActiveConsumer() que establece el consumidor actualmente activo en una variable global que es leída por el productor cuando es llamada en la misma pila de llamadas.

Por último, cada vez que un nodo reactivo puede haber cambiado, notifica a sus consumidores llamando a su método onConsumerDependencyMayHaveChanged().

🎯 El Reactive Logical ViewConsumer


Mientras espeleaba, y arruinaba mi delantal, me topé con un sorprendente tipo de nodo reactivo que vive en el código fuente del renderizador IVy, el ReactiveLViewConsumer.

Mientras que las señales escribibles son los nodos hoja del grafo reactivo, los Consumidores de Vistas Lógicas Reactivas son los nodos raíz.

Como cualquier otro nodo reactivo, implementa el método onConsumerDependencyMayHaveChanged(), pero a diferencia de cualquier otro nodo reactivo, está ligado a una vista para controlar la detección de cambios... ¡y lo hace! ¡y lo hace! marcando la vista como sucia cuando es notificada por un productor:

onConsumerDependencyMayHaveChanged() {
  ...
  markViewDirty(this._lView);
}

🐘 Colarse (como un elefante) entre Señales y Detección de Cambios


Lamentablemente, no parece haber ninguna forma elegante de anular el comportamiento actual de marcar la vista para comprobar cuando las Señales activan una notificación de cambio...

...pero, por suerte, tengo mi delantal de codificación puesto, así que no tengo miedo de ensuciarme.

Crear la vista incrustada


En primer lugar, vamos a crear una directiva estructural típica para poder crear y controlar la vista incrustada.

@Directive({
  standalone: true,
  selector: '[viewportStrategy]',
})
class ViewportStrategyDirective {
  private _templateRef = inject(TemplateRef);
  private _vcr = inject(ViewContainerRef);

  ngOnInit() {
    const viewRef = this._vcr.createEmbeddedView(this._templateRef);
  }
}

2. Activar la detección de cambios una vez


Por alguna razón, el ReactiveLViewConsumer es instanciado después de la primera detección de cambios. Mi delantal ya estaba demasiado sucio como para profundizar más, pero mi suposición es que se inicializa perezosamente cuando se usan Signals por cuestiones de rendimiento.

La solución es activar la detección de cambios una vez antes de desconectar el detector de cambios:

viewRef.detectChanges();

viewRef.detach();

Ajá! Mientras escribía esto, me tropecé con este comentario aquí... ¡así que tenía razón! ¡Por fin una vez! ¡Yeah!

3. Coge el ReactiveLViewConsumer

const reactiveViewConsumer = viewRef['_lView'][REACTIVE_TEMPLATE_CONSUMER /* 23 */];

4. Sobrescribe el manejador de notificaciones Signal como un mono 🙈


Ahora que tenemos la instancia de ReactiveLViewConsumer, podemos dejar que el hacker que llevamos dentro anule el método onConsumerDependencyMayHaveChanged() y dispare/salte/programe la detección de cambios con la estrategia que queramos, como un acelerador ingenuo:

let timeout;
reactiveViewConsumer.onConsumerDependencyMayHaveChanged = () => {
  if (timeout != null) {
    return;
  }

  timeout = setTimeout(() => {
    viewRef.detectChanges();
    timeout = null;
  }, 1000);
};

... o podemos usar RxJS que sigue siendo una de las formas más convenientes de manejar estrategias relacionadas con la temporización (y de todos modos ya viene incluido en la mayoría de las aplicaciones 😉)

Cf. ThrottleStrategyDirective & ViewportStrategyDirective

🚀 ¡y funciona!
¡Vamos a probar!

Esto parece ser al menos 5 veces más rápido... (aunque, rastrear la aparición del elemento en la ventana gráfica es una tarea relativamente costosa)

y la velocidad de fotogramas es bastante decente:

... pero ten en cuenta que:

Esto podría romperse en cualquier versión futura (mayor o menor) de Angular. Tal vez, no deberías hacer esto en el trabajo.

Además, esto sólo rastrea la vista manejada por la directiva. No separará y rastreará vistas o componentes hijos.

🔮 ¿Qué es lo siguiente?


🚦 RxAngular + Señales


Las estrategias implementadas en nuestra demo son voluntariamente ingenuas y necesitan una mejor programación y coalescencia para reducir la cantidad de reflujos y repintados. En lugar de aventurarnos en eso, esto podría combinarse con RxAngular Render Strategies... ¡guiño, guiño, guiño! 😉 a nuestros amigos de RxAngular.

🅰️ Puede que necesitemos más APIs de Angular de bajo nivel.


Para lograr nuestro objetivo, tuvimos que hackear nuestro camino en las internas de Angular que podrían cambiar sin previo aviso en futuras versiones.

Si Angular pudiera proporcionar algunas APIs adicionales como:

interface ViewRef {
  /* This doesn't exist. */
  setCustomSignalChangeHandler(callback: () => void);
}

... o algo menos verboso 😅, podríamos combinar esto con ViewRef.detach() y colarnos fácilmente entre las Señales y la detección de cambios.

Componentes basados en señales


A día de hoy, los componentes basados en Signal aún no están implementados, por lo que no hay forma de saber si esto funcionaría, ya que los detalles de implementación probablemente cambiarán.

Estrategias de renderizado personalizadas en otras bibliotecas y frameworks
¿Qué pasa con otras bibliotecas y frameworks?

React


En React, sin importar si estamos usando Signals o no, podríamos implementar un Componente de Orden Superior que decida si realmente renderiza o devuelve un valor memoizado dependiendo de su estrategia.

const CounterWithViewportStrategy = withViewportStrategy(() => <div>{count}</div>);

export function App() {
  ...
  return <>
    {items.map(() => <CounterWithViewportStrategy count={count}/>}
  </>
}

Esto probablemente podría ser más eficiente con Signals si se consiguiera envolviendo React.createElement como hace la integración Preact Signals e implementando una estrategia personalizada en lugar del comportamiento por defecto. O quizás, usando un hook personalizado basado en useSyncExternalStore().

Vue.js


Usando JSX, podríamos envolver render() como hace withMemo():

defineComponent({
  setup() {
    const count = ref(0);

    return viewportStrategy(({ rootEl }) => (
      <div ref={rootEl}>{ count }</div>
    ));
  },
})

... pero todavía me estoy preguntando cómo esto podría funcionar en SFC sin tener que añadir una transformación de nodo compilador para convertir algo como v-viewport-estrategia en una envoltura. 🤔

👨🏻‍🏫 Observaciones finales


☢️ ¡Por favor, no hagas esto en el trabajo!


La solución presentada se basa en APIs internas que pueden cambiar en cualquier momento, incluyendo las próximas versiones menores o parches de Angular.

Entonces, ¿por qué escribir sobre ello? Mi objetivo aquí es mostrar algunas nuevas capacidades que podrían ser habilitadas gracias a las Señales mientras se mejora la eXPeriencia del Desarrollador al mismo tiempo.

¿Quieres probar estrategias de renderizado personalizadas antes de cambiar a Signals?
Echa un vistazo a la plantilla de RxAngular

Conclusión


Aunque las estrategias de renderizado personalizadas pueden mejorar instantáneamente el rendimiento en algunas situaciones específicas, la nota final es que deberías preferir mantener un número bajo de elementos DOM y reducir el número de actualizaciones.

En otras palabras, mantén tus aplicaciones simples (tanto como puedas), organizadas, y tu flujo de datos optimizado por diseño usando reactividad de grano fino (ya sea que estés usando soluciones basadas en RxJS, o Signals).

Fuente