Como crear búsqueda instantánea con REST fetch debounce en WordPress

Contents

Introducción

Este artículo explica paso a paso cómo implementar una búsqueda instantánea en WordPress usando la REST API, fetch y un debounce para evitar llamadas excesivas. Cubriremos la creación del endpoint REST personalizado, el encolado del script, la implementación en JavaScript con cancelación de peticiones (AbortController) y debounce, un pequeño CSS para presentar resultados, y recomendaciones de rendimiento y seguridad. Todo pensado para integrarlo en tu theme o plugin sin dependencias externas.

Requisitos previos

  • WordPress 4.7 (REST API integrada).
  • Acceso para editar functions.php del theme o crear un plugin.
  • Conocimientos básicos de PHP, JS y CSS.

Visión general del flujo

  1. Registrar un endpoint REST personalizado que reciba la consulta y devuelva resultados en JSON.
  2. Encolar e inicializar un script que use fetch para consultar ese endpoint.
  3. Aplicar un debounce al evento input para espaciar peticiones.
  4. Usar AbortController para cancelar peticiones anteriores si llega una nueva.
  5. Renderizar los resultados en un contenedor accesible y estilizado.

1) Endpoint REST en PHP (functions.php o plugin)

Registraremos una ruta REST en namespace instant-search/v1 y un endpoint /search. El callback realizará una consulta segura y devolverá un array de resultados con id, título, extracto, enlace y URL de imagen destacada si existe. También añadiremos un caché con transients por consultas para mejorar rendimiento en picos de tráfico.

// functions.php (o archivo principal del plugin)
add_action(rest_api_init, function() {
    register_rest_route(instant-search/v1, /search, array(
        methods  => GET,
        callback => is_search_endpoint_callback,
        args     => array(
            s => array(
                required => true,
                sanitize_callback => sanitize_text_field,
            ),
            per_page => array(
                required => false,
                default  => 6,
                sanitize_callback => absint,
            ),
            post_type => array(
                required => false,
                default  => post,
                sanitize_callback => sanitize_text_field,
            ),
        ),
        permission_callback => __return_true,
    ))
})

function is_search_endpoint_callback(WP_REST_Request request) {
    search = request->get_param(s)
    per_page = request->get_param(per_page)
    post_type = request->get_param(post_type)

    // Clave de caché por query
    cache_key = is_search_ . md5(search .  . per_page .  . post_type)
    cached = get_transient(cache_key)
    if (cached !== false) {
        return rest_ensure_response(cached)
    }

    args = array(
        s => search,
        post_type => post_type,
        post_status => publish,
        posts_per_page => per_page,
        no_found_rows => true,
    )

    q = new WP_Query(args)
    results = array()

    if (q->have_posts()) {
        foreach (q->posts as post) {
            thumbnail = 
            if (has_post_thumbnail(post->ID)) {
                src = wp_get_attachment_image_src(get_post_thumbnail_id(post->ID), thumbnail)
                if (!empty(src[0])) {
                    thumbnail = esc_url_raw(src[0])
                }
            }

            results[] = array(
                id      => post->ID,
                title   => wp_strip_all_tags(get_the_title(post)),
                excerpt => wp_strip_all_tags(get_the_excerpt(post)),
                link    => get_permalink(post),
                thumbnail => thumbnail,
            )
        }
    }

    // Guardar en caché por 60 segundos (ajustable)
    set_transient(cache_key, results, 60)

    return rest_ensure_response(results)
}

Notas sobre seguridad y rendimiento

  • El endpoint está públicamente accesible (permission_callback => __return_true). Si tu búsqueda requiere datos privados, cambia la comprobación y usa nonces/roles.
  • Se usa set_transient para cachear resultados por consulta ajusta la duración según tu tráfico y frecuencia de cambios de contenido.
  • La consulta está limitada con no_found_rows para reducir carga si no necesitas paginación.

2) Encolar el script y pasar configuración desde PHP

Encolaremos un JS que usará fetch y pasaremos la URL del endpoint y el nonce (si lo deseas) mediante una variable localizada.

// functions.php - encolar script
add_action(wp_enqueue_scripts, function() {
    wp_enqueue_script(
        instant-search,
        get_template_directory_uri() . /js/instant-search.js,
        array(),
        1.0,
        true
    )

    wp_localize_script(instant-search, instantSearchSettings, array(
        rest_url => esc_url_raw(rest_url(instant-search/v1/search)),
        // nonce => wp_create_nonce(wp_rest), // opcional si ocupas cabeceras para autenticación
    ))
})

3) HTML mínimo necesario en la plantilla

Coloca en la plantilla el campo de búsqueda y el contenedor donde se mostrarán los resultados. Importante usar atributos ARIA para accesibilidad.


4) JavaScript: fetch debounce AbortController

Este script implementa:

  • Debounce para retrasar la petición hasta que el usuario deje de escribir (300ms por defecto).
  • AbortController para cancelar peticiones previas y evitar race conditions.
  • Renderizado sencillo de resultados con enlaces y miniaturas.
// /js/instant-search.js
(function() {
  const input = document.getElementById(instant-search-input)
  const resultsContainer = document.getElementById(instant-search-results)
  const REST_URL = (window.instantSearchSettings  window.instantSearchSettings.rest_url) ? window.instantSearchSettings.rest_url : /wp-json/instant-search/v1/search
  const DEBOUNCE_MS = 300
  let debounceTimer = null
  let currentController = null

  function clearResults() {
    resultsContainer.innerHTML = 
    resultsContainer.style.display = none
  }

  function renderResults(items) {
    if(!items  items.length === 0) {
      resultsContainer.innerHTML = 

No se han encontrado resultados.

resultsContainer.style.display = block return } const list = document.createElement(ul) list.className = is-results-list items.forEach(item => { const li = document.createElement(li) li.className = is-result-item li.setAttribute(role, option) const a = document.createElement(a) a.href = item.link a.innerHTML = (item.thumbnail ? class=is-thumb : )
escapeHtml(item.title)

escapeHtml(item.excerpt)

li.appendChild(a) list.appendChild(li) }) resultsContainer.innerHTML = resultsContainer.appendChild(list) resultsContainer.style.display = block } function escapeHtml(str) { if (!str) return return str.replace(/[<>]/g, function(m) { return ({:amp,<:lt,>:gt,:quot,:#039})[m] }) } function fetchResults(query) { if (currentController) { currentController.abort() // cancelar petición anterior } currentController = new AbortController() const signal = currentController.signal const url = new URL(REST_URL, window.location.origin) url.searchParams.set(s, query) url.searchParams.set(per_page, 8) // Opcional: si usas nonce const headers = {} if (window.instantSearchSettings window.instantSearchSettings.nonce) { headers[X-WP-Nonce] = window.instantSearchSettings.nonce } resultsContainer.innerHTML =

Buscando...

resultsContainer.style.display = block fetch(url.toString(), { method: GET, headers: headers, signal: signal, credentials: same-origin }) .then(response => { if (!response.ok) throw new Error(Network response was not ok) return response.json() }) .then(data => { renderResults(data) }) .catch(err => { if (err.name === AbortError) return // petición cancelada, silencioso resultsContainer.innerHTML =

Error al buscar. Inténtalo más tarde.

}) } function onInput(e) { const value = e.target.value.trim() if (debounceTimer) clearTimeout(debounceTimer) if (!value) { clearResults() return } debounceTimer = setTimeout(function() { fetchResults(value) }, DEBOUNCE_MS) } // Listener if (input) { input.addEventListener(input, onInput) input.addEventListener(blur, function() { // Opcional: cerrar resultados al perder foco después de un pequeño retraso setTimeout(clearResults, 200) }) } })()

Explicación de puntos clave en el JS

  • Debounce: evita lanzar una petición por cada pulsación. Ajusta DEBOUNCE_MS según la latencia tolerable y la velocidad de tecleo deseada.
  • AbortController: previene condiciones de carrera y evita renderizar resultados de peticiones antiguas cuando ya hay nuevas.
  • escapeHtml: evita XSS al inyectar títulos/extractos desde la API.

5) CSS básico para los resultados

Estilos simples para mostrar resultados en forma de lista tipo dropdown. Ajusta según tu theme.

/ instant-search.css /
.instant-search-form { position: relative max-width: 600px }
#instant-search-results { position: absolute top: 100% left: 0 right: 0 background: #fff border: 1px solid #ddd z-index: 50 display: none box-shadow: 0 4px 8px rgba(0,0,0,0.05) }
.is-results-list { list-style: none margin: 0 padding: 0 }
.is-result-item { border-bottom: 1px solid #f0f0f0 }
.is-result-item a { display: flex align-items: center padding: 8px 10px text-decoration: none color: inherit }
.is-thumb { width: 56px height: 56px object-fit: cover margin-right: 10px border-radius: 4px }
.is-text { flex: 1 }
.is-excerpt { margin: 4px 0 0 color: #666 font-size: 13px }
.is-loading, .is-no-results, .is-error { padding: 10px margin: 0 color: #666 }

6) Accesibilidad y experiencia de teclado

  • Usa role=listbox y role=option en el contenedor y elementos para que lectores de pantalla identifiquen la lista dinámica.
  • Considera implementar navegación con flechas (arriba/abajo) y selección con Enter para una experiencia completa con teclado. La implementación básica presentada debe extenderse para manejar focus y aria-activedescendant si lo necesitas.

Ejemplo breve de manejo de teclado (opcional)

// Fragmento para extender la navegación por teclado (añadir a instant-search.js)
// Este fragmento asume que renderResults crea una lista UL con .is-result-item
let focusedIndex = -1
input.addEventListener(keydown, function(e) {
  const items = document.querySelectorAll(.is-result-item)
  if (!items.length) return

  if (e.key === ArrowDown) {
    e.preventDefault()
    focusedIndex = Math.min(focusedIndex   1, items.length - 1)
    items[focusedIndex].querySelector(a).focus()
  } else if (e.key === ArrowUp) {
    e.preventDefault()
    focusedIndex = Math.max(focusedIndex - 1, 0)
    items[focusedIndex].querySelector(a).focus()
  } else if (e.key === Escape) {
    clearResults()
    input.focus()
  }
})

7) Recomendaciones y mejoras avanzadas

  • Integración con ElasticPress o Algolia para sitios con mucho contenido o necesidades de relevancia personalizada.
  • Indexado en caché y pre-generación de sugerencias (search indexing) para respuestas más rápidas.
  • Agregar ranking por relevancia (meta, taxonomías, búsqueda por título ponderado) en el callback PHP si se desea mejorar precisión.
  • Agregar lazy-loading o placeholders para miniaturas si la carga de imágenes afecta el rendimiento.
  • Limitar el número de resultados y añadir paginación/infinite scroll para búsquedas más exhaustivas.

Conclusión

Esta solución proporciona una búsqueda instantánea ligera y controlada usando la REST API de WordPress, fetch, debounce y AbortController. Es fácil de integrar y ampliar: desde mejoras de accesibilidad y navegación por teclado hasta optimizaciones con caché y motores de búsqueda externos. Implementa las partes que necesites y ajusta parámetros (debounce, per_page, caché) según el comportamiento real de tus usuarios y la carga de tu servidor.

Recursos útiles



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *