Optimización AJAX 3: caché volátil

Poder guardar respuestas en caché nos puede evitar tener que realizar nuevas peticiones a consultas para las cuales ya hemos obtenido los resultados anteriormente.

Por supuesto, no siempre vamos a querer guardar estos resultados: en casos donde es muy probable que la información sea muy dinámica y lo más importante es contar con la información actualizada al segundo lo correcto sería justamente lo contrario, invalidar la caché de modo de siempre contar con datos reales.

Hay básicamente dos puntos donde podemos controlar el almacenamiento en caché de las respuestas AJAX: al momento de realizar la petición o bien al generar y enviar la respuesta. En el primer caso, el control estará de parte del cliente; mientras que en el segundo por la respuesta generada por la aplicación.

Caché volátil

Por caché volátil me voy a referir fundamentalmente al que podemos controlar desde el cliente, y que por lo general está acotado a la duración de la sesión, es decir, dura mientras el documento permanezca abierto — con dos excepciones que mencionaré más adelante.

Control de caché en peticiones AJAX con jQuery

jQuery tiene una opción de configuración en su método AJAX de bajo nivel que puede resultar un poco confusa por lo que es, así que mejor entender qué hace cuando está desactivada:

$.ajax({
    url: 'http://mismodominio.com/ajax.php',
    data: {
       type: 'highway',
       id: 61
    },
    cache: false, // es 'true' por defecto a menos que type sea 'jsonp' o 'script'
    type: 'get',
    success: function(data){
        alert('data');
    }
});
// jQuery envía una petición a:
// /ajax.php?type=highway&id=61&_1430353966067

Es decir, cuando el parámetro cache es falso, jQuery agrega un parámetro '_' a la petición con una marca de tiempo, lo que se conoce como un cache buster. Como este parámetro varía con una precisión de milisegundos, va a forzar al navegador a enviar una nueva petición ya que no coincide con una URL previamente consultada que pudiera estar en el caché del navegador.

Si dejamos como verdadero este parámetro, va a depender tanto del navegador como de las cabeceras de respuesta si la petición se guarda en caché —ojo que el artículo enlazado tiene algunos años, aunque es probable que la situación que describe se mantenga tal cual.

Habiendo despejado cómo evitar el caché de una respuesta, veamos cómo hacer que sí se guarde en caché.

Cachear respuestas en el DOM

Si la respuesta AJAX devuelve contenido HTML, es posible inyectarlo dentro de la página que el usuario está visualizando y detectar si ya existe para evitar volver a realizar la petición.

Un ejemplo común es cargar ciertas secciones de una página con AJAX, como en una interfaz con pestañas. Podríamos tener una estructura básica como:

<!-- pestañas -->
<ul id="tabs">
    <li><a class="active loaded" data-target="#uno" href="ajax.php?tab=uno">Uno</a></li>
    <li><a href="ajax.php?tab=dos" data-target="#dos">Dos</a></li>
    <li><a href="ajax.php?tab=tres" data-target="#tres">Tres</a></li>
</ul>
<!-- área donde se cargan los contenidos -->
<section id="uno" class="show">
    <!-- en la carga inicial este contenido viene precargado -->
</section>
<section id="dos"></section>
<section id="tres"></section>

Podemos utilizar varias técnicas tanto para hacer la relación entre los enlaces y los contenedores que recibirán el contenido como para determinar cuál es la pestaña y el contenido activo, pero para mantener el ejemplo sencillo estoy usando la clase active para indicar la pestaña actual y loaded para indicar si se ha hecho la petición.

Además estoy presuponiendo que el contenido de la primera pestaña viene pre-cargado al ingresar a la URL de la página.

Ahora vamos a la parte interesante:

$('#tabs').on('click', 'a', function( event ){
    // No ir al enlace
    event.preventDefault();        
    
    // Referencia al elemento donde se hizo el click
    var tab = $(this);

    // URL que tiene el contenido que vamos a cargar
    // en la pestaña
    var url = tab.attr('href');

    // El ID del área donde vamos a cargar el contenido
    var target = tab.data('target');

    // Es la misma pestaña activa, no hacemos nada
    if ( tab.hasClass('active') ) {
        return false;
    }

    // La pestaña que antes estaba activa ya no lo está
    $('#tabs').find('a.active').removeClass('active');

    // Si no se ha cargado el contenido de esta pestaña...
    if ( ! tab.hasClass('loaded') ) {

        // ... hacer la petición AJAX
        $.get( url, function( content ){
            // Al obtener la respuesta, inyectar en el
            // contenedor que corresponde
            $( target ).html( content );

            // Indicar que ya cargamos el contenido de esta
            // pestaña para no volver a hacer la petición
            tab.addClass('loaded');
        } );

    }

    // Indicar que ésta es la nueva pestaña activa
    tab.addClass('active');

    // Esconder el contenido que estaba activo...
    $('section.show').removeClass('show');

    // ... Y mostrar el nuevo contenido
    $( target ).addClass('show');
});

Como se puede ver, la lógica es bastante sencilla; la principal limitación es que esto sirve cuando la respuesta que obtenemos es contenido HTML o texto, pero si estamos trabajando con una API que devuelve JSON probablemente no lo sería tanto… o bien, necesitaríamos de un paso intermedio en el que se genera HTML a partir de los objetos recibidos.

Caché en variables

Una alternativa que puede ser más flexible sería almacenar las respuestas en un objeto. En este caso, necesitaríamos contar con un identificador que utilizaríamos como clave de búsqueda para verificar rápidamente si ya hemos obtenidos los datos de la consulta.

Podemos modificar el ejemplo anterior de la siguiente forma:

// Debe estar fuera de la función que se ejecuta al click
// ... de otro modo siempre estaría vacío
var contents = {};

// En lugar de detectar si el tab tiene la clase...

// Si no hemos cargado los datos de "target"
if ( typeof contents[ target ] === undefined ) {
    $.getJSON( url, function( data ){
        // Añadir la información al objeto
        contents[ target ] = data;

        // Hacer lo que tenga que hacer
        // ...
    } );
}

Hay poca variación ya que conceptualmente el proceso es muy similar:

  • Tener un repositorio donde se guardan los datos y un identificador único que permita diferenciarlos.
  • Debe ser posible consultar si los datos para una consulta ya fueron solicitados.
  • Si ya se hizo una petición para ese identificador, cargar la información desde el repositorio.
  • Si no está la información en el repositorio, hacer la consulta y anexar los datos al repositorio.

En ambos ejemplos nos estamos ahorrando la invalidación del caché, pero con algunas modificaciones más sería posible también considerarlo.

Siempre es posible utilizar métodos más elegantes, lo que es especialmente útil si tu aplicación es más compleja o necesitas desencadenar acciones a partir de varias consultas, etc. Para estos casos es especialmente indicada la utilización de promesas de Javascript.

… utilizando promesas y LocalStorage

La utilización de promesas lleva todo esto a otro nivel de abstracción, que quizás puede ser más complejo de comprender inicialmente pero una vez que puedes apreciar su utilidad se transforma en una herramienta muy poderosa.

Los métodos indicados hasta ahora permiten evitar nuevas consultas iguales durante una carga del documento, y al principio indiqué que hay dos excepciones; pues bien, éstas son:

  • SessionStorage, que se extiende por la duración de la sesión de página; es decir, al cargar una URL en una pestaña y sobrevive a recargas o restauración de la sesión de navegación. A diferencia de una cookie, abrir la URL en otra pestaña o ventana abre una nueva sesión.
  • LocalStorage, que tiene la diferencia de que no tiene expiración; es decir, se mantiene aun tras cerrar el navegador (por lo que probablemente necesitarás alguna forma de invalidar y refrescar los datos cacheados).

Al integrar Promesas (u objetos jQuery.Deferred(), que se comportan de modo similar) podemos obtener datos de forma asíncrona desde una API o desde localStorage de forma transparente y evitar ahogarnos en la “pirámide de la perdición” o el “infierno de callbacks” — o sea, podemos obtener un código más ordenado y simple.

En este caso el ejemplo es un poco más complejo porque me interesa mostrar algo funcional y más completo por lo que sólo comentaré lo esencial, pero puedes consultarlo completo en el gist de Caché de respuestas AJAX utilizando Promesas y localStorage (ojo que para que funcione correctamente debes descargar todos los archivos y ejecutarlo en tu servidor web local).

Vamos al ejemplo, partiendo por lo más familiar:

$(document).ready(function(){
    $('#ajax-nav').on('click', 'a', function(event){
        $('#ajax-nav').find('li.active').removeClass('active');
        $(this).parent().addClass('active');
        var indicador = $(this).data('key');
        var target = $( '#' + $(this).data('target') );
        var data = getData( indicador );

        // Siempre voy a tener como resultado una Promesa
        // y puedo hacer algo con esos datos luego de obtenerlos
        data.then(function(value){
            $('#ajax-container').html( buildDataTable( value ) );
        });
    });
});

Los cambios más notables son:

  • Modularizamos la obtención de datos a una función, para evitar repetir código y tener algo más ordenado
  • La variable data recibe un objeto Deferred en todos los casos, de modo que le es transparente obtener información desde una consulta AJAX o localStorage
  • … y como es una promesa, podemos realizar acciones de modo seguro luego de obtener los datos sin recurrir a hacks o pasar callbacks de un lado a otro

Ahora examinemos la obtención de datos:

var getData = function( key ){
    var localData = JSON.parse( window.localStorage.getItem( key ) );
    if ( ! localData || isExpired( localData ) ) {
        // jQuery envuelve la llamada AJAX como un objeto
        // Deferred que es similar a una promesa
        return $.ajax({
            url: 'data-'+ key +'.json',
            dataType: 'json',
            type: 'GET',
            cache: false
        }).done(function(value){
            // ya se resolvió la promesa, retorno datos
            return saveData( key, value );
        });
    } else {
        // Creo un objeto de Deferred que se resuelve
        // con los datos obtenidos desde localStorage
        var promise = new $.Deferred();
        promise.resolve( localData.content );
        return promise;
    }
};
  • Como jQuery ya agrega los métodos de una promesa a la petición AJAX, podemos utilizar done() para guardar los datos de la petición en localStorage. La función saveData() debe devolver el valor de modo que podemos ejecutar otras acciones tras resolver la promesa
  • En caso de tener los datos en caché, creamos un objeto Deferred y le indicamos que la promesa se debe resolver inmediatamente utilizando los datos almacenados

Para controlar la expiración de los datos, la función saveData() guarda una marca de tiempo junto con el contenido de la respuesta; y la función isExpired() comprueba la diferencia en base a un plazo fijo.