Debounce y throttle: no trabajar en cada pulsación
Un autocmd sobre TextChangedI se dispara en cada tecla, y un plugin ingenuo hace en cada tecla el mismo trabajo caro. Debounce y throttle son las dos respuestas clásicas, y ambas se construyen con un timer de vim.uv y un closure. Implementación correcta de las dos, la diferencia entre borde de entrada y de salida, el debounce por buffer que necesita un linter real, y la limpieza que evita fugar handles.
Escribes seis caracteres por segundo. Si tu plugin recalcula un diagnóstico, lanza un grep o consulta un servidor en cada TextChangedI, has pedido seis trabajos caros por segundo para tirar cinco de ellos a la basura. Las dos técnicas que corrigen esto son viejas, se llaman debounce y throttle, y en Neovim se construyen exactamente con lo aprendido en las lecciones anteriores: un timer de vim.uv, un closure de la 7.3 guardando el estado, y un salto de vuelta con vim.schedule. No son la misma técnica con distinto nombre; responden a preguntas distintas, y elegir mal produce un plugin que se siente peor que sin optimizar.
- Implementar un
debouncecorrecto sobre un timer devim.uv, y su variante por buffer. - Implementar un
throttley distinguir su borde de entrada de su borde de salida. - Elegir entre ambas según si el trabajo es un resultado final o un caudal continuo.
- Reconocer que el estado de ambas técnicas vive en upvalues, y qué implica para su ciclo de vida.
- Cancelar y limpiar los handles asociados sin dejar timers colgando.
Dos respuestas a la misma avalancha
Ante una ráfaga de eventos, hay exactamente dos cosas razonables que hacer: esperar a que la ráfaga termine, o dejar pasar uno cada tanto. Todo lo demás son variantes.
Debounce
Reinicia la cuenta atrás con cada evento y solo ejecuta cuando ha habido silencio durante N milisegundos. Durante una ráfaga larga no ejecuta nada. Ideal cuando solo importa el estado final.
Throttle
Ejecuta de inmediato y después ignora todo durante N milisegundos. Garantiza un ritmo máximo pero no deja de trabajar durante la ráfaga. Ideal cuando el progreso intermedio sí es útil.
Un linter que analiza mientras escribes quiere debounce: los diagnósticos de «neo» no le sirven a nadie si vas a escribir «neovim», y lo verás montado de verdad en la 13.4. Una barra de progreso que refleja una compilación quiere throttle: quedarse muda hasta el final sería inútil, pero redibujar mil veces por segundo también. Y un resaltado de la palabra bajo el cursor sobre CursorMoved quiere debounce corto: mientras el cursor vuela con j mantenida, no hay nada que resaltar.
Debounce con vim.uv
La implementación es directa si recuerdas de la 12.2 que t:start sobre un timer ya activo reinicia su cuenta. Esa es toda la magia: cada llamada cancela la anterior. El t:stop() explícito antes del start no es imprescindible, pero deja la intención escrita.
local uv = vim.uvlocal function debounce(ms, fn) local timer = uv.new_timer() local envuelto = function(...) local args = { ... } timer:stop() timer:start(ms, 0, function() vim.schedule(function() fn(unpack(args)) end) end) end return envuelto, timerendTres detalles importan más de lo que parece. Primero, los argumentos se capturan en una tabla porque el callback del timer no los recibe; cada llamada crea una tabla nueva, así que el disparo usa siempre los argumentos de la última llamada, que es lo que quieres. Segundo, vim.schedule es obligatorio: el callback del timer corre en fast event context, y el cuerpo de fn casi siempre tocará la API del editor —la 12.3 entera cabe en esa línea—. Tercero, la función devuelve también el timer, para que quien la use pueda cerrarlo cuando el plugin se descargue: si solo devolvieras envuelto, el handle quedaría vivo e inaccesible para siempre.
Y fíjate en dónde vive el estado: timer y args son upvalues del closure, el mecanismo exacto de la 7.3. No hay objeto, no hay tabla de módulo, no hay registro global: el estado de un debounce es un upvalue, invisible desde fuera y compartido solo entre las funciones que nacieron dentro de la fábrica.
local recalcular = debounce(250, function(bufnr) if not vim.api.nvim_buf_is_valid(bufnr) then return end vim.diagnostic.reset(nil, bufnr) -- trabajo caro sobre el buffer, ya en contexto seguroend)vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { callback = function(ev) recalcular(ev.buf) end,})La revalidación con nvim_buf_is_valid no es decorativa: entre el último tecleo y el disparo pasan 250 milisegundos, y en ese hueco el usuario puede haber cerrado el buffer. Un debounce es la versión agravada del «aplazar es viajar al futuro» de la 12.3.
Hay una trampa en ese ejemplo que solo aparece con uso real: el debounce de arriba tiene un solo timer para todos los buffers. Si tecleas en el buffer A, saltas al B y tecleas allí antes de que venzan los 250 ms, la llamada del B reinicia la cuenta y cancela el análisis pendiente del A, que se queda sin diagnósticos hasta que vuelvas a tocarlo. Para un linter de verdad quieres una instancia por buffer, y el patrón es una tabla de funciones envueltas creada bajo demanda:
local por_buffer = {}local function debounce_buf(bufnr, ms, fn) if not por_buffer[bufnr] then por_buffer[bufnr] = debounce(ms, fn) end return por_buffer[bufnr]end-- y en BufDelete, cerrar el timer de ese buffer y quitarlo de la tablaDecidir el ámbito del debounce —¿global, por buffer, por ventana?— es parte del diseño, no un detalle: es preguntarse qué ráfagas deben cancelarse entre sí y cuáles no.
Elige el retardo con la mano, no con la cabeza
Entre 100 y 200 milisegundos el usuario no percibe la espera; por encima de 400 la interfaz empieza a sentirse perezosa. Para trabajo local —resaltado, plegado— quédate en 50 o 100. Para consultas a un proceso externo, 250 o 300 es lo habitual. Y mide con vim.uv.hrtime() antes de discutir: el retardo correcto depende del coste real de fn, no de la intuición.
Throttle y la limpieza de handles
El throttle invierte la lógica: ejecuta primero y luego calla. La variante más simple es la de borde de entrada, que dispara inmediatamente y descarta lo que llegue durante el periodo de enfriamiento.
local function throttle(ms, fn) local en_espera = false local timer = uv.new_timer() local envuelto = function(...) if en_espera then return end en_espera = true fn(...) timer:start(ms, 0, function() en_espera = false end) end return envuelto, timerendAquí el timer no dispara el trabajo: solo apaga la bandera en_espera cuando la ventana vence. Por eso su callback puede permitirse no usar vim.schedule —asignar a un upvalue es Lua puro, legal en fast event—, mientras que fn se ejecuta en el contexto del llamante, que en un autocmd es el hilo principal.
Su defecto conocido es que pierde el último evento: si la ráfaga termina justo dentro del periodo de enfriamiento, ese estado final nunca se procesa. La cura habitual es guardar los últimos argumentos y, al vencer el timer, ejecutar una vez más si hubo llamadas descartadas. Eso es un throttle con borde de salida, y en la práctica es lo que quieres casi siempre.
local function throttle_final(ms, fn) local en_espera, pendiente = false, nil local timer = uv.new_timer() local function disparar(...) fn(...) en_espera = true timer:start(ms, 0, function() en_espera = false if pendiente then local args = pendiente pendiente = nil vim.schedule(function() disparar(unpack(args)) end) end end) end return function(...) if en_espera then pendiente = { ... } else disparar(...) end end, timerendSea cual sea la variante, el timer que has creado sigue vivo. Un plugin serio lo cierra al desmontarse, con la disciplina exacta de la 12.2, y lo hace de forma defensiva porque cerrar dos veces es un error.
local function cerrar(timer) if timer and not timer:is_closing() then timer:stop() timer:close() endendNo amontones defer_fn creyendo que es un debounce
Un error frecuente es «debounciar» llamando a vim.defer_fn(fn, 300) en cada evento. Eso no cancela nada: cada evento crea su propio timer y los treinta disparos acaban llegando todos, en fila, 300 milisegundos tarde. El debounce funciona precisamente porque todas las llamadas comparten un único timer que se reinicia; treinta timers independientes son treinta promesas que nadie revoca.
Debounce y throttle no optimizan: cambian la semántica de tu plugin
El error de encuadre más común es tratar estas dos técnicas como un ajuste de rendimiento que se añade al final, cuando algo va lento. No lo son. Al aplicar un debounce estás afirmando algo fuerte sobre tu problema: que los estados intermedios carecen de valor y solo importa el último. Al aplicar un throttle afirmas lo contrario: que los intermedios sí valen, pero que existe una frecuencia por encima de la cual dejan de aportar información al usuario. Son decisiones de diseño sobre el significado de tu funcionalidad, y por eso elegir mal se nota aunque el código sea impecable. Un autocompletado con throttle parpadea con resultados obsoletos que nadie pidió; un indicador de progreso con debounce se queda mudo justo cuando el usuario necesitaba verlo moverse. La prueba para decidir es una sola pregunta: si el evento número siete cancela al seis, ¿he perdido algo? Si la respuesta es no, debounce. Si es sí, throttle. Hay además una consecuencia estructural que rara vez se enuncia: ambas técnicas introducen la posibilidad de que fn se ejecute después de que su contexto haya dejado de tener sentido —el buffer se cerró, la ventana desapareció, el usuario cambió de proyecto—. Un debounce de 300 milisegundos es una promesa de ejecutar en el futuro, y el futuro no está obligado a parecerse al presente. Por eso toda función diferida debe revalidar sus supuestos antes de actuar: comprobar nvim_buf_is_valid, verificar que el buffer sigue siendo el actual, confirmar que la petición no quedó obsoleta. El timer no es solo una optimización; es una máquina del tiempo pequeña, y como toda máquina del tiempo, exige comprobar dónde has aterrizado.
Drills
Una sola notificación, ~300 ms después de la tercera llamada: las dos primeras cuentas atrás murieron reiniciadas. El estado entero del debounce es el upvalue t capturado por la global D — la 7.3 trabajando para la 12.4.
Solo «ejecuto 1»: las llamadas 2 a 5 encontraron la bandera bajada y murieron en el return. Espera un segundo, ejecuta el bucle otra vez y verás «ejecuto 1» de nuevo. El 5 —el estado final— jamás se procesó: acabas de ver el defecto que throttle_final corrige.
Mantén j pulsada unos segundos, para, espera medio segundo y consulta: E habrá subido a cientos y X valdrá un puñado — una ejecución por cada pausa del cursor. Ese cociente es el ahorro exacto que el debounce le regala a tu plugin.
Medio segundo después llegan los cinco mensajes en ráfaga: cinco timers independientes, cinco promesas que nadie canceló. Compáralo con el primer drill, donde tres llamadas produjeron un disparo. La diferencia es compartir el timer y reiniciarlo.
Construye las dos y compáralas
- Implementa
debouncey engánchalo aTextChangedImostrando unvim.notifycon un contador; escribe rápido y cuenta cuántas veces se ejecuta frente a cuántas teclas pulsaste. - Cambia el retardo entre 50, 250 y 800 milisegundos y describe cómo cambia la sensación de uso en cada caso.
- Implementa el
throttlede borde de entrada y demuestra con una ráfaga corta que pierde el último evento; amplíalo athrottle_finaly verifica que ahora el estado final siempre se procesa exactamente una vez. - Provoca el fallo del debounce compartido: engancha tu debounce global a dos buffers, teclea en uno, salta al otro antes del vencimiento y comprueba que el primero se quedó sin su ejecución. Arréglalo con el patrón por buffer.
- Añade a tu
debounceuna revalidación connvim_buf_is_validy provoca el caso de borde cerrando el buffer antes de que venza el timer. - Añade la limpieza: una función
cerrardefensiva y su enganche aVimLeavePre, verificando con el censo deuv.walkde la 12.1 que no dejas timers huérfanos.