#246 AJAX History State
- Download:
- source codeProject Files in Zip (123 KB)
- mp4Full Size H.264 Video (21.3 MB)
- m4vSmaller H.264 Video (12.6 MB)
- webmFull Size VP8 Video (29 MB)
- ogvFull Size Theora Video (28.1 MB)
En el episodio 240 [verlo, leerlo] vimos cómo utilizar AJAX para ordenar, paginar y buscar sobre una tabla de productos. Uno de los problemas de este uso de AJAX es que la URL de la página no cambia cuando ordenamos o filtramos la tabla, lo que significa que no podemos usar el botón de vuelta atrás del navegador para volver al estado original de la tabla, ni podemos guardarnos un marcador de un estado de filtrado de la página para poder volver a ver los mismos resultados.
Se trata de un problema muy habitual cuando se añade funcionalidad AJAX a un sitio y sería muy útil dar con una solución. En este episodio veremos cómo hacerlo.
El método history.pushState
Ya vimos en el episodio 175 [verlo, leerlo (en inglés)] que podemos hacer que una aplicación AJAX respete la historia de navegación y pueda ser guardada com un marcador mediante la manipulación de la URL. En dicho episodio cuando paginábamos por los resultados se añadía un ancla a la URL de forma que, por ejemplo, la URL de la tercera página fuese http://localhost:3000/#page=3
. Esto permite guardar la página como marcador y además hace que el botón de vuelta atrás funcione como es de esperar. Este enfoque funciona bien pero se complica rápidamente en escenarios más sofisticados como el que tenemos aquí donde tenemos que poder ordenar, buscar y paginar sobre un conjunto de resultados.
La técnica que vamos a emplear aquí es la usada por GitHub en su visor de archivos. Cuando se hace clic en una carpeta de un repositorio en GitHub la página del navegador se actualiza mediante AJAX y se modifica por JavaScript toda la URL, no sólo un ancla. Se trata de una solución mucho más completa que nos permite recargar la página o guardarla como marcador y además respeta la historia de navegación.
En GitHub escribieron una anotación al respecto y es interesante leer los detalles de implementación. Esta funcionalidad requiere de una versión reciente de Safari, pero merece la pena. La “magia” que está detrás de todo esto son las funciones pushState
y replaceState
del objeto history
así como el evento popstate
. En la anotación del blog de GitHub hay varios enlaces interesantes que dan más información sobre el funcionamiento de estas funciones y demuestra su uso.
Veamos cómo de fácil es añadir esta funcionalidad a nuestra sencilla aplicación de tienda cuando paginamos, ordenamos o hacemos una búsqueda en el listado de productos. El JavaScript que pusimos en la aplicación original tiene el siguiente aspecto:
$(function () { $('#products th a, #products .pagination a').live('click', function () { $.getScript(this.href); return false; } ); $('#products_search input').keyup(function () { $.get($('#products_search').attr('action'), $('#products_search').serialize(), null, 'script'); return false; }); })
Los que no estén familiarizados con el código anterior pueden echar un vistazo al episodio 240 para ver los detalles. Estamos usando jQuery pero esta técnica funciona también con Prototype (al menos en lo que se refiere a <ocde>pushState</ocde> y replaceState
).
La primera parte del código de arriba maneja la funcionalidad AJAX de la ordenación y paginación. Vamos a añadir una llamada a history.pushState
para que se añada la URL al histórico del navegador cuando se haga clic en alguno de los enlaces de paginación.
En el sitio para desarrolladores de Mozilla se puede encontrar la documentación de pushState
donde se indica que recibe tres parámetros. El primero es un objeto de estado, que puede ser cualquier cosa, y que será devuelto cuando se active el evento popState
. El segundo argumento es el título y el tercero una URL. Ahora que ya sabemos esto ya podemos añadir la llamada a pushState
.
$('#products th a, #products .pagination a').live('click', function () { $.getScript(this.href); history.pushState(null, "", this.href); return false; });
Aquí todavía no tenemos que almacenar ningún estado por lo que pasamos null
como primer parámetro. De la misma manera tampoco necesitamos el título por lo que pasamos una cadena vacía, y como URL pasaremos this.href
que es la URL del enlace activado.
Si ahora recargamos la página y seguimos algunos de los enlaces de paginación u ordenación AJAX la página se actualizará sin recargar, pero ahora con cada clic veremos que la URL de la barra de direccion cambia para reflejar lo que hayamos pasado en la función pushState
y además esta URL será guardada en la historia del navegador.
Así que ya podemos ordenar y paginar la tabla y guardarlo todo en un marcador y cuando lo recuperemos se preservará la ordenación y paginación, aunque la página se actualice dinámicamente mediante AJAX. El botón de volver atrás, sin embargo, no funciona como sería de espera porque no estamos atendiendo al evento popstate
. Podemos arreglarlo activando una función cuando se dispare el evento popstate
.
$(function () { // (se omiten otras funciones) $(window).bind("popstate", function () { $.getScript(location.href); }); })
Para cuando se active el evento popstate
la URL ya habrá cambiado (a la anterior en la historia de navegación) por lo que ya podemos actualizar la tabla de productos invocando $.getScript
y pasándole la URL actual.
Si recargamos la página y ordenamos o paginamos la tabla un par de veces comprobaremos que el botón de vuelta atrás funciona y que podemos navegar por todos los cambios que hemos ido haciendo.
Búsqueda
Veamos a continuación la funcionalidad de búsqueda. Si buscamos ahora un producto en nuestra aplicación se filtrarán los resultados pero la URL permanecerá igual lo que significa, como ya sabemos, que si guardamos un marcador de la página o la recargamos perderemos la búsqueda realizada.
El JavaScript para la búsqueda por AJAX es el siguiente:
$(function () { // Other functions omitted. $('#products_search input').keyup(function () { $.get($('#products_search').attr('action'), $('#products_search').serialize(), null, 'script'); return false; }); })
Aquí usaremos pushState
de la misma manera que hicimos cuando modificamos el código de ordenación.
$(function () { // Other functions omitted. $('#products_search input').keyup(function () { var action = $('#products_search').attr('action'); var formData = $('#products_search').serialize(); $.get(action, formData, null, 'script'); history.pushState(null, "", action + "?" + formData); return false; }); })
Aquí usaremos pushState
para guardar una URL que será una combinación de la acción del formulario de búsqueda y los datos serializados del formulario, unidos mediante una interrogación para formar una URL válida.
Si ahora recargamos la página veremos que la URL cambia cada vez que escribimos una letra en el campo de búsqueda. Pero si pulsamos en el botón de volver atrás veremos que en la historia del navegador se ha guardado una URL para cada una de estas letras, lo que dista de ser ideal. Esto ocurre porque estamos invocando a pushState
cada vez que se pulsa una tecla. Lo ideal sería que cada vez que se pulsase una tecla se modificase el estado actual de lugar de crearse un nuevo estado. Por suerte esto es fácil de hacer y sólo hay que llamar a replaceState
en lugar de pushState
.
$(function () { // Other functions omitted. $('#products_search input').keyup(function () { var action = $('#products_search').attr('action'); var formData = $('#products_search').serialize(); $.get(action, formData, null, 'script'); history.replaceState(null, "", action + "?" + formData); return false; }); })
Si abrimos la página de productos en una nueva ventana y buscamos algo veremos que la URL va cambiando pero que estos cambios no se guardan en el histórico del navegador.
No se trata exactamente de la funcionalidad que queremos; en teoría tendríamos que usar pushState
cuando el usuario empieza a buscar y luego iríamos haciendo replaceState
para que se añada el término de búsqueda en la historia. Esto está un poco fuera del alcance del episodio, así que no lo veremos.
Un título para cada estado
Si ordenamos los productos de varias maneras y luego mantenemos pulsado el botón de volver atrás para ver la historia de navegación veremos la URL de cada página, lo que puede ponernos difícil el decidir a qué página queremos volver. Sería mucho más amistoso que se viese un título junto a cada página, y esto lo podemos hacer estableciendo el segundo argumento de cada llamada pushState
con el título de la página.
¿Cómo hacemos que el título de la página muestre algo útil? Lo que haremos será establecer el título de cada página en el fichero index.js.erb
que devuelve la tabla actualizada por AJAX y luego usaremos este título en pushState
.
$('#products').html('<%= escape_javascript(render("products")) %>'); document.title = "<%= escape_javascript("#{params[:search].to_s.titleize} Products by #{(params[:sort] || 'name').titleize} - Page #{(params[:page] || 1)}") %>"
Haremos que el título muestre los términos de búsqueda y ordenación, así como la página actual, de forma que se muestre toda la información relevante. Luego actualizaremos las llamadas a pushState
y replaceState
para que establezcan el segundo parámetro conforme al título de la página.
$(function () { $('#products th a, #products .pagination a').live('click', function () { $.getScript(this.href); history.pushState(null, document.title, this.href); return false; }); $('#products_search input').keyup(function () { var action = $('#products_search').attr('action'); var formData = $('#products_search').serialize(); $.get(action, formData, null, 'script'); history.replaceState(null, document.title, action + "?" + formData); return false; }); $(window).bind("popstate", function () { $.getScript(location.href); }); })
Ahora el título de la página cambiará cada vez que busquemos u ordenemos el listado de productos, y si inspeccionamos la historia de navegación veremos un listado de títulos en vez de URLs.
Comportamiento con navegadores antiguos
La nueva funcionalidad de nuestra aplicación ya funciona bien pero hemos estado asumiendo que el método history.pushState
se encuentra disponible en el navegador del usuario, algo que no es del todo seguro porque sólo lo soportan las últimas versiones de Safari, Chrome y Firefox. Para poder trabajar con todos los navegadores tenemos primero que comprobar que tenemos disponible esta función y si no es así modificar el comportamiento de la aplicación.
Lo que haremos será comprobar que existen el objeto history
y su función history.pushState
, y si no es así desactivaremos la funcionalidad AJAX por completo para que la aplicación pase a usar enlaces HTML estándar.
if (history && history.pushState) { $(function () { $('#products th a, #products .pagination a').live('click', function () { $.getScript(this.href); history.pushState(null, document.title, this.href); return false; }); $('#products_search input').keyup(function () { var action = $('#products_search').attr('action'); var formData = $('#products_search').serialize(); $.get(action, formData, null, 'script'); history.replaceState(null, document.title, action + "?" + formData); return false; }); $(window).bind("popstate", function () { $.getScript(location.href); }); }) }
Con esto concluye este episodio. Aunque no ha estado específicamente dedicado a Rails, sí que hemos visto problemas con los que nos podemos topar cuando trabajemos con AJAX en nuestras aplicaciones. Con la capacidad de modificar la URL podemos tratar los enlaces AJAX al mismo nivel que los enlaces HTML tradicionales.