El patrón de trocear trabajo largo con cesión
El modelo completo del troceado por presupuesto de tiempo, por qué el corte por número de elementos es peor, cómo se combina con la construcción fuera del documento, y qué hacer cuando el trabajo no se puede partir.
Trocear una tarea larga es la técnica que convierte un bloqueo de 800 milisegundos en veinte pausas de 40 que el usuario no percibe. El trabajo total no baja —de hecho sube ligeramente— y sin embargo la página pasa de inutilizable a fluida. La parte que separa una implementación buena de una mala no es la cesión en sí, que son dos líneas, sino el criterio de corte y qué se hace con el DOM entre trozo y trozo.
- Implementar el troceado por presupuesto de tiempo en lugar de por número de elementos.
- Explicar por qué el corte por cantidad fija falla en dispositivos heterogéneos.
- Combinar el troceado con la construcción fuera del árbol vivo.
- Reconocer los tres tipos de trabajo que no se pueden trocear y qué hacer con ellos.
El modelo
La estructura es siempre la misma: acumular trabajo hasta agotar un presupuesto de tiempo, ceder, y repetir.
flowchart TB
I[Empieza el trabajo] --> M[Marca el instante de inicio del trozo]
M --> U[Procesa un elemento]
U --> C{Se agoto el presupuesto de tiempo}
C -->|No| Q{Quedan elementos}
Q -->|Si| U
Q -->|No| F[Terminado]
C -->|Si| A{Hay senal de cancelacion}
A -->|Si| X[Abandona sin terminar]
A -->|No| Y[Cede el hilo]
Y --> N[El navegador atiende entrada y pinta]
N --> M
style M fill:#89b4fa,color:#11111b
style U fill:#89b4fa,color:#11111b
style C fill:#f9e2af,color:#11111b
style Y fill:#a6e3a1,color:#11111b
style N fill:#a6e3a1,color:#11111b
style X fill:#f38ba8,color:#11111b
style F fill:#94e2d5,color:#11111bLa implementación completa, que es la que conviene tener como utilidad del proyecto:
import { ceder } from './planificacion.js';
/**
* Recorre 'items' llamando a 'trabajo' en cada uno, cediendo el hilo
* cada vez que se agota el presupuesto de tiempo del trozo.
*/
export async function recorrerCediendo(items, trabajo, {
presupuestoMs = 40,
senal,
} = {}) {
let inicioTrozo = performance.now();
let i = 0;
for (const item of items) {
if (senal?.aborted) return { completado: false, procesados: i };
trabajo(item, i);
i++;
if (performance.now() - inicioTrozo >= presupuestoMs) {
await ceder();
inicioTrozo = performance.now();
}
}
return { completado: true, procesados: i };
}
Y el uso:
const control = new AbortController();
const { completado, procesados } = await recorrerCediendo(
registros,
(r) => indexar(r),
{ presupuestoMs: 40, senal: control.signal }
);
El presupuesto de 40 milisegundos deja margen por debajo del umbral de 50: entre la última comprobación y la cesión efectiva cabe todavía el procesamiento de un elemento, y si ese elemento es lento el trozo puede pasarse. Con 40 hay diez milisegundos de colchón, que suele bastar.
Por qué presupuesto de tiempo y no cantidad fija
El corte por número de elementos —ceder cada cien— es lo primero que se le ocurre a todo el mundo y es sistemáticamente peor. Dos razones.
Los dispositivos son heterogéneos. Cien elementos que en tu portátil cuestan 12 milisegundos pueden costar 70 en un móvil de gama baja. Un corte por cantidad afinado para tu máquina produce tareas largas en el dispositivo de tus usuarios, que es exactamente donde no las querías.
Los elementos son heterogéneos. En una lista real, algunos elementos son triviales y otros arrastran una imagen, un cálculo o una consulta. Un corte por cantidad puede meter cinco elementos caros en el mismo trozo.
El corte por tiempo se autoajusta a las dos cosas sin ninguna calibración. En un dispositivo lento hace trozos con menos elementos; en uno rápido, con más. Es el mismo criterio que usan internamente los planificadores de los marcos modernos, y por la misma razón.
Una variante útil cuando el trabajo por elemento es muy variable: comprobar el presupuesto antes de procesar y estimar si el siguiente cabe. Requiere conocer el coste aproximado y solo compensa cuando hay elementos muy caros mezclados con muy baratos.
El DOM entre trozo y trozo
Aquí está el detalle que decide si el troceado mejora o empeora el tiempo total.
Cada vez que cedes, el navegador puede hacer un ciclo de renderizado. Si tu trabajo ha modificado el DOM, ese ciclo incluye recálculo de estilo, layout, pintura y composición sobre el árbol entero. Trocear una inserción de dos mil nodos en cuarenta trozos significa cuarenta layouts completos en lugar de uno.
Los números son contundentes: insertar dos mil filas de golpe puede costar 600 milisegundos en una sola tarea; insertarlas en cuarenta trozos con cesión puede costar 1.800 milisegundos en total, repartidos. Has triplicado el trabajo para eliminar el bloqueo. A veces merece la pena y a veces no.
La técnica que da lo mejor de las dos: construir fuera del árbol vivo y confirmar por lotes grandes.
export async function insertarFilasCediendo(contenedor, datos, { senal } = {}) {
const TAM_LOTE = 250;
for (let i = 0; i < datos.length; i += TAM_LOTE) {
if (senal?.aborted) return;
// Construir en un fragmento: no toca el arbol vivo, no dispara layout
const frag = document.createDocumentFragment();
const lote = datos.slice(i, i + TAM_LOTE);
for (const d of lote) frag.append(construirFila(d));
// Una sola confirmacion por lote: un layout, no 250
contenedor.append(frag);
await ceder();
}
}
Con lotes de 250 y dos mil elementos son ocho confirmaciones y ocho ciclos de renderizado, en lugar de cuarenta o de dos mil. El tiempo total se mantiene cerca del de la versión monolítica y ninguna tarea supera el umbral.
El tamaño del lote se calibra midiendo: empieza en 200 o 300, mide la tarea más larga resultante en el dispositivo de referencia, y ajusta.
Hay además un aliado de CSS que reduce mucho el coste de cada confirmación cuando el contenido es largo: aislar el subárbol para que el layout de cada fila no propague al resto. Lo verás en el nivel de contención de renderizado.
Lo que no se puede trocear
Tres tipos de trabajo se resisten, y cada uno tiene su salida.
Una operación atómica del motor. JSON.parse de un documento de 8 MB es una sola llamada que no devuelve el control hasta terminar. No hay forma de partirla desde JavaScript. Las salidas: pedir menos datos, pedirlos paginados, o parsear en un trabajador —donde bloquea un hilo que a nadie le importa— y transferir el resultado.
Una expresión regular sobre una entrada grande. Igual de atómica. La salida es partir la entrada o reescribir la expresión para que no tenga retroceso.
Un algoritmo que necesita todo el estado a la vez. Una ordenación, una transformación de matriz, un cálculo de disposición de grafo. Se pueden trocear con esfuerzo si el algoritmo se reescribe de forma incremental, y casi siempre es mejor moverlo a un trabajador.
La regla que ordena estos tres casos: si el trabajo no toca el DOM, un trabajador es mejor solución que el troceado. El troceado reparte el bloqueo; el trabajador lo elimina del hilo principal.
La cesión introduce puntos de reentrada en una función que antes era atómica, y ahí aparece una clase de fallo que en JavaScript de un solo hilo la gente no espera encontrarse nunca: condiciones de carrera.
La secuencia. El usuario escribe en el buscador. Se lanza recorrerCediendo sobre los resultados. En el primer trozo, el bucle cede. Durante esa cesión, el navegador procesa otra pulsación de tecla, que lanza otra ejecución de recorrerCediendo sobre resultados distintos. Ahora hay dos bucles vivos, alternándose, escribiendo en el mismo contenedor. El resultado es una lista con filas de dos búsquedas mezcladas, en orden impredecible, y el fallo no se reproduce de forma consistente porque depende del ritmo de escritura del usuario.
En una función síncrona esto era imposible: no había punto donde otra cosa pudiera colarse. Al ceder, lo has creado.
Tres protecciones, y conviene aplicar las tres.
Uno: un testigo de ejecución que cancele la anterior. Es la corrección mínima:
let controlActual = null;
async function renderizarResultados(items) {
controlActual?.abort(); // cancela la ejecucion previa
const control = new AbortController();
controlActual = control;
contenedor.replaceChildren();
await insertarFilasCediendo(contenedor, items, { senal: control.signal });
if (controlActual === control) controlActual = null;
}Dos: comprobar la vigencia después de cada cesión, no solo antes. El estado puede cambiar durante la espera, y una comprobación al principio del trozo no cubre el caso de que cambie a mitad. La comprobación va inmediatamente después del await ceder().
Tres, y es la más robusta arquitectónicamente: identificar la generación del trabajo. En lugar de cancelar por señal, cada ejecución lleva un número de generación y compara antes de escribir:
let generacion = 0;
async function renderizar(items) {
const mia = ++generacion;
for (const lote of enLotes(items, 250)) {
if (mia !== generacion) return; // alguien mas empezo: abandona
confirmarLote(lote);
await ceder();
}
}Es más simple que la señal, no requiere objetos adicionales y cubre el caso general. La señal sigue siendo preferible cuando además necesitas cancelar peticiones de red asociadas, porque la misma señal sirve para las dos cosas.
Y una recomendación de método: cuando trocees algo, escribe la prueba que dispara dos ejecuciones solapadas. Es fácil de escribir, falla siempre en la implementación ingenua, y previene un fallo que en producción se manifiesta como datos incorrectos y no como lentitud, con lo que nadie lo relaciona nunca con el troceado.
Coge la operación más larga de tu aplicación que construya DOM. Impleméntala de tres formas: monolítica, troceada por elemento con cesión en cada uno, y troceada por lotes de 250 con construcción en fragmento. Mide en las tres el tiempo total de la operación y la duración de la tarea más larga, en el dispositivo de referencia. Después dispara dos ejecuciones solapadas en la tercera versión y comprueba si el resultado es correcto; si lo es, es que ya habías puesto la protección de generación.