<CarlosDev/>

Actual

Pretext: esquivar la operación más cara del navegador

Prepara las métricas del texto una vez y compón las líneas con aritmética pura, sin lecturas del DOM ni reflow. Serie de cuatro partes con dos demos en vivo.

Publicado
Actualizado
Lectura
18 min
Redacción
En desarrollo
Banner de la serie Pretext — reflow de layout frente a layout aritmético cacheado.
En este registro8 secciones

Cada vez que llamas a getBoundingClientRect() para medir un elemento de texto, el navegador hace algo brutal en silencio: descarta todo su árbol de layout, recalcula cada posición desde cero y te devuelve un número. Todo ocurre de forma síncrona, en el hilo principal, bloqueando todo lo demás.

Para un blog estático esto no importa. Para una interfaz de chat de IA que hace streaming a 60 fotogramas por segundo — o una lista virtualizada con cientos de elementos de altura variable — es un muro.

Pretext es una librería de 15 kb de Cheng Lou (creador de React Motion, ingeniero senior en Midjourney) que elimina este costo por completo. Mide y compone texto multilínea usando aritmética pura, sin tocar el DOM después de la preparación.


La idea central en un párrafo

Pretext divide el trabajo en dos fases. prepare() se ejecuta una vez por combinación texto+fuente: usa la API measureText() del Canvas para medir cada segmento de texto y cachea los resultados. layout() se ejecuta tantas veces como necesites: calcula saltos de línea y alturas usando solo aritmética sobre esos anchos cacheados — cero lecturas del DOM, cero reflow. Los cambios de ancho son gratuitos. Las actualizaciones en streaming son gratuitas. El costo de medición se paga una sola vez.

import { prepare, layout } from '@chenglou/pretext'

const prepared = prepare(
  'El reflow de layout es el asesino silencioso del rendimiento.',
  '16px Inter',
)

// Primer ancho — barato
const { height, lineCount } = layout(prepared, 320, 24)

// Ancho diferente — sigue siendo solo aritmética
const { height: h2 } = layout(prepared, 480, 24)

Por qué importa ahora

El momento no es accidental. Las aplicaciones de IA que transmiten texto token por token necesitan redimensionar burbujas en cada fotograma. Las listas virtualizadas necesitan predecir alturas antes de que los elementos existan en el DOM. Los layouts de tipo masonry necesitan conocer las alturas antes de colocar las tarjetas. Todos estos patrones eran o poco fluidos o requerían rodeos con contenedores ocultos fuera de pantalla.

Pretext resuelve el problema subyacente en lugar de esquivarlo: tratar el motor de fuentes del navegador como un oráculo durante la preparación, y nunca volver a preguntarle.


Los números

OperaciónCosto
prepare() para 500 bloques~19 ms (igual que un pase DOM)
layout() por llamada~0.09 ms
getBoundingClientRect() por llamada~0.04 ms — pero fuerza reflow cuando el layout está sucio
Reflow completo en árbol sucio10–100+ ms según la complejidad de la página

La ganancia real no está en la velocidad por llamada — está en que layout() nunca ensucia el árbol de layout, así que nunca desencadena la cascada.


Lo que viene

El resto del texto va del problema a un demo funcionando: qué es realmente el layout reflow y por qué getBoundingClientRect() es caro, y después el modelo de dos fases que lo evita — el oráculo Canvas, el layout aritmético y la API completa. De ahí en adelante deja de ser teoría: un chat de IA en streaming con las alturas de burbuja medidas con pretext, y un layout editorial donde el texto fluye alrededor de un bailarín y se recomputa en cada fotograma.

¿Quieres verlo antes de leer nada? Texto fluyendo alrededor de una figura bailando, recomputado en cada fotograma, corriendo en esta misma página — sin instalación. Saltar al demo de Matteflow

Parte 1 de 4

4 min

El impuesto del reflow: por qué medir texto es caro

Qué es el layout reflow, exactamente qué lecturas lo desencadenan y por qué medir texto siempre es una lectura sucia.

Leer por separado: El impuesto del reflow: por qué medir texto es caro

¿Qué es el layout reflow?

Cuando un navegador renderiza una página, construye dos árboles: el árbol DOM (qué elementos existen) y el árbol de layout (dónde está cada elemento, en píxeles). Construir el árbol de layout requiere conocer fuentes, contenido, dimensiones del contenedor y reglas CSS de cada elemento. Es costoso — las páginas modernas pueden tardar decenas de milisegundos.

El navegador es inteligente al respecto. Agrupa las mutaciones del DOM y solo recomputa el árbol de layout cuando es absolutamente necesario — típicamente antes de pintar. Esta agrupación es lo que hace que las IUs complejas en JavaScript se sientan rápidas.

El problema es que algunas operaciones de JavaScript fuerzan al navegador a abandonar esa agrupación y computar el layout de inmediato, síncronamente, en medio de tu código. Esta computación síncrona forzada es el layout reflow.


¿Qué desencadena el reflow?

Leer cualquier propiedad de geometría cuando el layout está “sucio” (es decir, JavaScript ha cambiado algo desde el último pase de layout) desencadena el reflow:

// Esta escritura ensucia el layout
element.style.width = '300px'

// Esta lectura fuerza el reflow inmediato — el navegador debe
// terminar el layout antes de poder darte una respuesta válida
const height = element.getBoundingClientRect().height

// Otra escritura, otra marca de suciedad
element.style.fontSize = '18px'

// Otro reflow forzado
const newHeight = element.offsetHeight

El patrón de arriba — escribir, leer, escribir, leer — se llama layout thrashing. Cada par lectura-después-escritura fuerza un recálculo de layout completo.

Las propiedades que desencadenan reflow forzado incluyen:

  • getBoundingClientRect()
  • offsetHeight, offsetWidth, offsetTop, offsetLeft
  • scrollHeight, scrollWidth
  • clientHeight, clientWidth
  • getComputedStyle()

Medir texto siempre es una lectura sucia

Aquí está el punto de dolor específico para la medición de texto. Cuando quieres saber qué tan alto será un bloque de texto, el enfoque estándar es:

function getTextHeight(text, font, maxWidth) {
  const el = document.createElement('div')
  el.style.cssText = `
    position: fixed;
    top: -9999px;
    width: ${maxWidth}px;
    font: ${font};
    word-break: break-word;
  `
  el.textContent = text
  document.body.appendChild(el)

  const height = el.getBoundingClientRect().height // ← reflow forzado

  document.body.removeChild(el)
  return height
}

Cada llamada a getTextHeight() agrega un elemento DOM (ensucia el layout), lee geometría (fuerza reflow) y elimina el elemento (ensucia de nuevo). Si llamas esto para 100 elementos de una lista, activas 100 pases de layout síncronos.

En una página compleja, cada reflow forzado puede tomar 10–50 ms. Para 100 elementos, eso es potencialmente 5 segundos de hilo principal bloqueado — solo para mediciones.


Cuándo realmente duele

La mayoría de los sitios web nunca se topan con esto. Un blog estático con 10 posts no tiene problema. Los patrones donde se vuelve crítico son:

Chat de IA en streaming

Una respuesta en streaming actualiza una burbuja de chat cada pocos milisegundos. Si calculas la altura usando medición DOM, cada token desencadena un reflow. A tan solo 10 tokens por segundo, estás forzando 10 pases de layout por segundo solo para la altura, además de todo el renderizado normal. Las burbujas se entrecortan, las animaciones se atascan, el ancla de scroll lucha con cada actualización.

Listas virtualizadas

Librerías como react-virtual o tanstack/virtual necesitan conocer la altura de cada elemento antes de renderizarlo para posicionar correctamente los elementos debajo de él. La solución típica es renderizar elementos en un contenedor oculto, medirlos y luego eliminarlos. Para una lista de 1.000 elementos, esto significa 1.000 reflows forzados antes de poder siquiera desplazarse.

Layouts masonry

Masonry requiere conocer las alturas de las tarjetas antes de colocarlas en columnas. El mismo patrón de renderizar-oculto-y-medir causa la misma tormenta de reflows.

Colaboración en tiempo real

Cualquier interfaz donde el contenido de texto cambia rápidamente por actualizaciones externas (documentos colaborativos, feeds en vivo, dashboards) choca con esta pared. El contenido llega, el layout se ajusta, las mediciones se actualizan — todo peleando entre sí en el hilo principal.


Por qué getBoundingClientRect no puede ser rápido

Podrías preguntarte: ¿pueden los navegadores optimizar esto? La respuesta es mayoritariamente no. La razón por la que getBoundingClientRect() debe desencadenar un recálculo de layout síncrono es que JavaScript es monohilo y los navegadores deben darte una respuesta válida y actual.

Si el navegador calculara el layout de forma diferida en segundo plano (lo que hace para la pintura), una llamada de JavaScript que lee geometría obtendría datos desactualizados. Como JavaScript puede tomar decisiones basadas en esas mediciones (if (height > 300) do X), los navegadores no pueden mentir sobre el estado actual del layout.

La única manera de hacer la medición de texto verdaderamente rápida es moverla completamente fuera del DOM.

Eso es exactamente lo que pretext hace. En lugar de preguntarle al motor de layout del navegador por las alturas, le pregunta al motor de fuentes — a través del Canvas — que no tiene árbol de layout y nunca causa reflow.

Parte 2 de 4

5 min

Cómo funciona pretext: dos fases, un canvas, cero reflow

El modelo mental de dos fases — un oráculo de fuentes en canvas en prepare(), aritmética pura en layout() — y la API completa.

Leer por separado: Cómo funciona pretext: dos fases, un canvas, cero reflow

El modelo mental

Pretext está construido sobre una sola idea: medir texto y componer texto son dos problemas diferentes.

Medir es costoso porque requiere el pipeline completo de conformación de fuentes del navegador — kerning, ligaduras, reglas de scripts complejos, clusters de emoji. Pero solo necesitas hacerlo una vez por combinación texto+fuente.

Componer es rápido si ya tienes las medidas: es solo aritmética — acumula anchos hasta que desbordes el contenedor, inserta un salto de línea, repite.

Pretext separa estas dos cosas claramente:

prepare(text, font) → PreparedText    // Oráculo de fuentes: mide una vez
layout(prepared, width, lineHeight)   // Aritmética pura: ejecuta muchas veces

Fase 1: prepare() — el oráculo canvas

import { prepare } from '@chenglou/pretext'

const prepared = prepare(
  'El reflow desaparece cuando sacas la medición del DOM.',
  '16px Inter',
)

Durante prepare(), pretext hace tres cosas:

1. Segmentación — Usando Intl.Segmenter, divide el texto en segmentos con conciencia del idioma. Esto maneja correctamente caracteres CJK, texto árabe de derecha a izquierda, clusters de emoji y límites de palabras a través de todos los scripts.

2. Medición en Canvas — Cada segmento se mide usando CanvasRenderingContext2D.measureText(). Esta es la operación clave. measureText() del Canvas le pregunta al motor de fuentes del navegador por anchos con precisión de píxeles — el mismo motor que el DOM usa para el layout — pero sin pasar por el pipeline de layout. Sin reflow, sin árbol de layout, solo métricas de glifos.

3. Caché — Los resultados se almacenan en un identificador opaco PreparedText. Este identificador es independiente del ancho: puedes llamar a layout() con cualquier ancho de contenedor y las medidas siguen siendo válidas.

El costo de preparación es aproximadamente equivalente a un pase de medición DOM. Lo pagas una vez.


Fase 2: layout() — aritmética pura

import { layout } from '@chenglou/pretext'

const { height, lineCount } = layout(prepared, 320, 24)
// 320px de ancho del contenedor, 24px de altura de línea

layout() itera a través de los anchos de segmento cacheados, acumulando el ancho de línea actual. Cuando el ancho acumulado superaría maxWidth, rompe la línea. Cada salto de línea agrega lineHeight a la altura total.

Esto es aritmética de enteros/flotantes sobre un array de números. Sin acceso al DOM, sin llamadas a Canvas, sin interacción con el motor de fuentes. Se ejecuta en microsegundos independientemente de la longitud del texto.

El caso de redimensionamiento — la optimización más importante — se vuelve trivial:

const prepared = prepare(text, font) // una vez

// Responder al redimensionamiento del contenedor — solo aritmética
window.addEventListener('resize', () => {
  const { height } = layout(prepared, container.offsetWidth, 24)
  element.style.height = `${height}px`
})

La API rica: prepareWithSegments + layoutWithLines

El camino rápido te da altura y número de líneas. Cuando necesitas el contenido real de las líneas — para renderizado personalizado, posicionamiento del cursor o layout en streaming — usa la API rica:

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments(
  'Cada token en streaming suma al costo de layout — a menos que uses pretext.',
  '18px "Helvetica Neue"',
)
const { lines } = layoutWithLines(prepared, 320, 26)

for (const line of lines) {
  console.log(`"${line.text}" — ${line.width}px`)
}

Cada LayoutLine lleva text, width, cursores start y end. Los cursores son posiciones de segmento/grafema — no offsets de string crudos — así que se mantienen correctos a través de caracteres multibyte y emoji.


Layout en streaming: layoutNextLine

Para el streaming de IA, a menudo necesitas fluir texto línea por línea mientras llegan los tokens. La función layoutNextLine maneja esto:

import {
  prepareWithSegments,
  layoutNextLine,
  type LayoutCursor,
} from '@chenglou/pretext'

const prepared = prepareWithSegments(streamingText, '16px Inter')
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }

// Llamado cada vez que el texto crece
function renderNextLine(containerWidth: number) {
  const line = layoutNextLine(prepared, cursor, containerWidth)
  if (!line) return

  drawLine(line.text, currentY)
  cursor = line.end
  currentY += lineHeight
}

Esta es la API que hace que el chat de IA en streaming sea fluido: cada nuevo token puede extender la línea actual o iniciar una nueva, y el costo de esa determinación son nanosegundos.


Qué maneja pretext (y qué no)

Maneja correctamente:

  • Latin, CJK (chino, japonés, coreano), árabe, hebreo, tailandés, jemer
  • Emoji y secuencias de emoji (🏳️‍🌈 cuenta como un cluster)
  • Texto bidireccional (algoritmo Unicode BiDi)
  • overflow-wrap: break-word para palabras muy largas
  • white-space: pre-wrap (tabulaciones, saltos de línea, espacios explícitos)
  • word-break: keep-all para titulares CJK

No maneja:

  • Fuente system-ui en macOS — Canvas y DOM pueden resolver variantes ópticas diferentes para el mismo tamaño. Siempre usa nombres de fuente explícitos: '16px Inter' no '16px system-ui'
  • Renderizado del lado del servidor — Canvas requiere un entorno de navegador. La llamada a prepare() debe ejecutarse en el cliente
  • Formato en línea CSS completo (spans anidados con fuentes mixtas) — usa el sidecar inline-flow para eso

Cuándo no usarlo

Pretext agrega sobrecarga de configuración (prepare() es tan costoso como un pase DOM) y complejidad mental. Es la herramienta incorrecta para:

  • Contenido estático que no se redimensiona ni hace streaming
  • Mediciones únicas de pocos elementos
  • Cualquier contexto donde no estés en el camino caliente de “medir muchas veces”

Si getBoundingClientRect() no te está causando problemas de rendimiento, pretext no te ayudará. Agrega complejidad cuando los números lo justifican.

La teoría es limpia. Los demos que siguen la hacen tangible.

Parte 3 de 4

2 min

Demo React: chat en streaming con cero reflow

Un chat de IA en streaming en vivo donde cada altura de burbuja se calcula con pretext — sin lecturas del DOM, sin reflow, expansión fluida.

Leer por separado: Demo React: chat en streaming con cero reflow

El demo

Pulsa Simular streaming para ver llegar mensajes de IA token a token. Cada burbuja se expande fluidamente mientras se llena de texto — sin jank, sin reflow, sin layout thrashing. Luego cambia al modo DOM y ejecútalo de nuevo para ver la diferencia en el contador de fps.

Esta demo se ejecuta en tu navegador y necesita JavaScript. El texto que la rodea explica lo que muestra.


Qué está pasando

En modo pretext, cada fotograma hace esto:

  1. Obtiene el fragmento de texto actual en streaming
  2. Llama a prepare(text, font) — medición en canvas, se ejecuta una vez cuando el texto cambia
  3. Llama a layout(prepared, bubbleWidth, lineHeight) — aritmética pura, devuelve la altura
  4. Establece style.height en la burbuja

Sin lecturas del DOM después del paso 2. El árbol de layout del navegador nunca se ensucia por la medición en sí.

En modo DOM, cada fotograma hace esto en su lugar:

  1. Obtiene el fragmento de texto actual en streaming
  2. Crea un <div> oculto, establece su texto y estilos, lo agrega a document.body
  3. Llama a getBoundingClientRect() — fuerza reflow de layout síncrono
  4. Elimina el elemento oculto
  5. Establece style.height en la burbuja

El reflow forzado en el paso 3 bloquea el hilo principal. Con múltiples mensajes en streaming simultáneamente, los reflows se acumulan.


El patrón central

import { prepare, layout } from '@chenglou/pretext'

function measureBubbleHeight(text: string, maxWidth: number): number {
  const prepared = prepare(text, '14px ui-sans-serif, system-ui, sans-serif')
  const { height } = layout(prepared, maxWidth, 22)
  return height + 24 // agregar padding
}

// Dentro de tu manejador de actualización en streaming:
const height = measureBubbleHeight(currentText, bubbleWidth)
element.style.height = `${height}px`

En producción cachearías el identificador PreparedText entre ticks de streaming — solo llamas a prepare() cuando el texto completo cambia significativamente, no en cada token. La llamada a layout() es la barata; es la que llamas por fotograma.


Manejando múltiples streams simultáneos

Donde las ganancias se multiplican es cuando varios mensajes están en streaming al mismo tiempo. Pretext hace que cada uno sea independiente:

// Todo esto corre en el mismo animation frame sin reflow
for (const message of streamingMessages) {
  const { height } = layout(message.prepared, containerWidth, LINE_HEIGHT)
  message.element.style.height = `${height}px`
}

Con medición DOM, cada acceso a un elemento forzaría un reflow que potencialmente invalida las mediciones que acabas de calcular para los elementos anteriores. Con pretext, todas las mediciones son operaciones aritméticas independientes sobre datos cacheados.


El caso pre-wrap

Las entradas tipo textarea necesitan white-space: pre-wrap para respetar los saltos de línea duros y tabulaciones:

import { prepare, layout } from '@chenglou/pretext'

const prepared = prepare(textareaValue, '14px ui-monospace', {
  whiteSpace: 'pre-wrap',
})
const { height } = layout(prepared, textareaWidth, 20)

Esto es útil para textareas que crecen automáticamente — uno de los patrones más comunes que tradicionalmente requería loops de medición DOM.

Parte 4 de 4

4 min

Matteflow: texto que fluye alrededor de un bailarín

Un demo de canvas — extrae una silueta con un matte de distancia de color y luego fluye texto del artículo a su alrededor en cada fotograma con pretext.

Leer por separado: Matteflow: texto que fluye alrededor de un bailarín

El demo

Carga cualquier video de una persona contra un fondo liso. El demo extrae el sujeto usando un matte de distancia de color y fluye texto alrededor de la silueta en movimiento — en cada fotograma, usando pretext para la colocación de líneas.

Esta demo se ejecuta en tu navegador y necesita JavaScript. El texto que la rodea explica lo que muestra.

Mejores resultados: videos con una persona contra un fondo sólido o con poca textura (pantalla verde/blanca de estudio, pared lisa). Los sliders te permiten ajustar la fuerza del wrap, la escala del sujeto y la posición.


Cómo funciona

Este demo es un port comprimido de Matteflow por summerKK — un prototipo de layout editorial que demuestra exactamente por qué existe pretext. El original tiene cuatro módulos; aquí están portados a un componente React y a los pequeños módulos puros con los que dibuja.

Paso 1: matte de distancia de color

Cada fotograma del video se dibuja en un canvas offscreen y los datos de píxeles se leen una vez:

offCtx.drawImage(video, 0, 0, canvasWidth, canvasHeight)
const { data } = offCtx.getImageData(0, 0, canvasWidth, canvasHeight)

El color de fondo se estima muestreando los bordes del fotograma (esquinas + bordes). Luego cada píxel recibe un valor alpha basado en su distancia de color respecto a ese fondo:

const distance = Math.sqrt((r - bg.r) ** 2 + (g - bg.g) ** 2 + (b - bg.b) ** 2)
const alpha = clamp((distance - threshold) / feather, 0, 1)

Un píxel cercano al color de fondo obtiene alpha ≈ 0 (transparente). Un píxel alejado de él obtiene alpha ≈ 1 (primer plano). La demo fija ambos números (threshold y feather son constantes del renderer), así que sus sliders no tocan el matte: Wrap define cuánto se curva el texto alrededor de la silueta, Escala ajusta el tamaño de la figura y Desp. V / Desp. H la desplazan dentro del cuadro.

Paso 2: perfil de wrap

La máscara alpha se divide en bandas horizontales. Para cada banda, los píxeles más a la izquierda y más a la derecha del primer plano definen el ancho del sujeto y el centro horizontal a esa altura:

// Para cada banda (rebanada vertical de la figura)
const bandWidth = (rightPixel - leftPixel) / frameWidth
const bandCenter = (leftPixel + rightPixel) / 2 / frameWidth - 0.5
profile.push({ width: bandWidth, offset: bandCenter })

Este perfil — 10 números que describen la forma de la silueta — se calcula en cada fotograma. Dirige dónde están las zonas de exclusión del texto.

Paso 3: layout de texto con pretext

Para cada línea de texto, se calculan las regiones de wrap desde el perfil:

const regions = computeWrapRegions({
  y,
  lineHeight,
  stageCenterX,
  stageWidth,
  profile,
  wrapStrength,
  gutter,
  articleLeft,
  articleWidth,
  stageTop,
  stageHeight,
})

computeWrapRegions devuelve una o dos regiones de columna — las áreas a la izquierda y derecha del sujeto que son lo suficientemente anchas para el texto. Luego layoutNextLine de pretext coloca texto en cada región:

for (const region of regions) {
  const line = prepared.nextLine(cursor, region.width)
  if (!line) break
  lines.push({ text: line.text, x: region.x, y })
  cursor = line.end
}

Esto se ejecuta por fotograma. La parte costosa — la medición de fuentes — ocurrió una vez cuando el componente se montó. El trabajo por fotograma es aritmética: cálculo del perfil + lógica de salto de línea. El resultado es texto que genuinamente sigue la forma en movimiento.


Por qué esto importa más allá del demo

El demo de Matteflow no es una característica del mundo real. Pero el patrón que demuestra sí lo es:

El layout de texto como una función pura de contenido + geometría, evaluada a voluntad.

Cuando layout() cuesta 0.09 ms, puedes llamarlo en tu loop de renderizado sin pensarlo dos veces. Puedes reconstruir todo el layout de texto en cada fotograma en respuesta a cualquier cambio — redimensionamiento del contenedor, actualización de contenido, movimiento de obstáculos — y el usuario nunca nota el costo.

Sin pretext, este demo requeriría:

  • Precalcular layouts para cada posible fotograma de video (inviable)
  • Ejecutar la medición DOM fuera del hilo (no es posible; el layout solo puede estar en el hilo principal)
  • Aceptar el costo del reflow y ver caer la tasa de fotogramas (la respuesta incorrecta)

El valor de la librería no es solo la velocidad. Es que la medición de texto se convierte en un valor que calculas — no en un efecto secundario que esperas.


Este texto está basado en el README de pretext y la librería original de Cheng Lou. El concepto de Matteflow es de summerKK/Matteflow.

Clasificado en

Citas

1

Referencias externas