Como “navegar” en una aplicación web por la que no se navega

En este artículo explico como añadir navegabilidad a una aplicación de una sola página que no se recarga por completo, sino solo parcialmente.

En una aplicación Web tradicional cuando se cambia la URL en la barra de dirección, estamos hablando de otra página. Se solicita la nueva página, y si se pasan parámetros por la URL (método GET) el servidor las recoge y devuelve la página dinámicamente creada.

http://www.dominio.com/ruta/a/fichero.jsp?param1=valor1&m2=valor2#anchor-de-la-pagina

Con una aplicación moderna en la que evitamos recargar la página a cada acción, se van refrescando partes de la página sin que se tenga que ver afectada la URL que aparece en la barra de dirección del navegador. Esto puede causar un problema de usabilidad, y es que estamos anulando la capacidad de navegar hacia adelante o hacia atrás con el historial del navegador. De hecho si una aplicación de este tipo el usuario navega hacia atrás se sale de la aplicación en lugar de navegar a la última acción. Por otro lado si cambiamos la URL mediante enlaces el navegador refrescará la página entera.

GMail ya resolvió el problema desde el principio, y podemos observar que a medida que navegamos la URL va cambiando (la parte tras el anchor) pero no se refresca la página entera.

https://mail.google.com/mail/u/0/#label/TSIXDOME%2FAnja/134c2cc0769f3650
https://mail.google.com/mail/u/0/#label/TSIXDOME%2FAnja/134998ffaeddf18c
https://mail.google.com/mail/u/0/#label/Putos+Modernos

Esto nos permite movernos adelante y hacia detrás. También podemos copiar la URL y acceder a ella cargando la página completamente, con lo que podemos enviar enlaces por correo y al abrir la aplicación puede sutuarnos en el mismo punto en el que estábamos. Desde fuera parece muy sencillo, y de hecho lo es si trabajamos con Frameworks que se encargan ellos mismos de ‘falsear’, procesar y modificar las URLs, y jugar con las peticiones HTTP (GET/POST/DELETE) como Ruby-on-Rails o Express para node.js.

Screen Shot 2017-01-17 at 18.56.51
Ejemplo de acciones en ruby sobre una entidad ‘photos’

Si no utilizamos un framework que nos lo haga, podemos hacerlo “a pelo” con estos pasos:

  1. Centralizar la navegación de nuestra aplicación en un solo lugar
  2. Cada navegación actualizara la URL del navegador, y ejecutará la acción
  3. Detectaremos cambios en la URL para cuando el usuario ha navegado.

Mostrare un ejemplo sencillo donde las acciones que puede hacer el usuario es listar elementos de un tipo y editar elementos de un tipo con un id.

Detectaremos cambios en la URL

Esta ha sido habitualmente la parte mas ‘tricky’. Hay algunas soluciones basadas en el event onhashevent del navegador, pero esto sirve solo para cuando codificamos los parámetros de forma “falsa”, esto es tras el hash de la URL, de esta manera:

http://www.dominio.com/ruta/a/fichero.jsp#aqui-van-mis-params/param1=valor1&m2=valor2

Pero ademas de mezclar conceptos como el hash y parámetros, es popsible que encontremos muchas incompatibilidades con navegadores. Por lo que optamos por algo que nunca falla: comprobar cada cierto tiempo si la URL ha cambiado. ¿No habeis notado que en muchas aplicaciones de este tipo siempre hay un delay si jugamos con adelante y atras comparado con pulsar un boton que hace la acción?

Para ello hemos de declarar un timer accesible y dos funciones que lo paren y enciendan:

class MainController{
    constructor(){
        this._timer_watchdog = null;
        this._current_url = null;
    }

    _stopURLTimer(){
        clearTimeout(controller._timer_watchdog);
    }

    _startURLTimer(){
        mainController._current_url = window.location.search;
        mainController._timer_watchdog = setTimeout(controller.checkUrlChange, 50);
    }

    checkUrlChange(){
        if(mainController._current_url !== window.location.search){
			
            // Retreive GET parameters from URL
            var selectedObjectId =  $.urlParam('object');
            var selectedId = $.urlParam('id');
            var action = $.urlParam('action');
			
            if(!selectedObjectId || 
                selectedObjectId==='null' || 
                !action || 
                action==='null'){
                console.log('Navigation halted due to no valid parameters');
                return;
            }
            // Guardar la nueva URL
            mainController._current_url = window.location.search;

            // Ejecutar la acción como si se hubiese activado desde otra parte
            mainController.navigate(action, selectedObjectId, selectedId, true);
            return;
        }

        setTimeout(mainController.checkUrlChange, 50);
    }
}

Centralizar la navegación y dejar “rastro” en el navegador

Primero añadiremos una función utilidad en nuestro controlador principal MainController que nos ayude a sustituir los parámetros GET de una URL, lo que seria pasar de:
http://www.dominio.com/ruta/a/fichero.jsp?param1=valor1&m2=valor2

a
http://www.dominio.com/ruta/a/fichero.jsp?param1=valor1&m2=valor3

En nuestro caso eliminaremos los anchors durante la conversión ya que nos molesten

class MainController{

    updateURLParameter(url, param, paramVal) {
        var TheAnchor = null;
        var newAdditionalURL = "";
        var tempArray = url.split("?");
        var baseURL = tempArray[0];
        var additionalURL = tempArray[1];
        var temp = "";

        var tmpAnchor;
        var TheParams;

        if (additionalURL)  {
            tmpAnchor = additionalURL.split("#");
            TheParams = tmpAnchor[0];
            TheAnchor = tmpAnchor[1];
            // Remove the anchors. Not needed
            //if(TheAnchor){
            //	additionalURL = TheParams;
            //}
            
            tempArray = additionalURL.split("&");

            for (var i=0; i<tempArray.length; i++){
                if(tempArray[i].split('=')[0] !== param){
                    newAdditionalURL += temp + tempArray[i];
                    temp = "&";
                }
            }        
        } else {
            tmpAnchor = baseURL.split("#");
            TheParams = tmpAnchor[0];
            TheAnchor  = tmpAnchor[1];

            if(TheParams){
                baseURL = TheParams;
            }
        }
        // Remove the anchors. Not needed
        //if(TheAnchor){
        //	paramVal += "#" + TheAnchor;
        //}

        var rows_txt = temp + "" + param + "=" + paramVal;
        return baseURL + "?" + newAdditionalURL + rows_txt;
    }
}

Lo primero es sustituir todas las llamada estilo controller.editElement(objectType, objectId) o controller.list(objectType) por una llamada global llamada a un función navigate(…) de este estilo:

class MainController{

    navigate(action, objectType, objectId, executedFromUrlChange){

        // Para el timer para no detectar nuestro propio cambio de URL
        mainController._stopURLTimer();

        // Modificar la URL actual y modificar los parámetros con los nuevos valores
        var newUrl = updateURLParameter(window.location.href, 'action', action);
        newUrl = updateURLParameter(newUrl, 'objectType', objectType);
        newUrl = updateURLParameter(newUrl, 'objectId', objectId);
        newUrl = newUrl + '#';

        // Añadir la nueva URL al historia (y URL actual) del navegador
        // Solo hacerlo si no ha sido invocada ya por un cambio de URL ya que guardariamos la direccicón dos veces
        if(!executedFromUrlChange) {
            window.history.pushState('', '', newUrl);
        }

        // Guardar la nueva URL para detectar cambios
        _current_url = window.location.search;

        // Ejecutar la acción que toqeu segun los parametros
        switch(action){
            case 'edit':
                controller.editElement(objectType, objectId);
                break;

            case 'list':
                controller.list(objectType);
                break; 

           default:
                throw new Error('Invalid requested action');
        }

        // Reiniciar el timer para detectar cualqueir cambio a partir de ahora
        mainController._startURLTimer();
    }
}

De esta manera, hemos convertido nuestra aplicación de una sola página en una que permite al usuario moverse adelante y atras. Para que por ejemplo se pueda copiar la URL y enviarla por email o guardarla en favoritos, habia que añadir lógica durante la carga inicial (y completa) de la página para detectar si se ha invocado con parámetros e invocar a navigate() como si de un cambio de URL se tratara.

Seguridad

Como en cualquier aplicación en la que los parámetros son visibles al usuario, debemos suponer que un usuario siempre será malicioso y jugara con la URL, cambiando IDs, para intentar acceder a elementos a los que no tiene permisos (lo que se conoce como “URL Play” o “URL Hack” o el tipo de hacking más antiguo de la Web).

Pero como todo el mundo hace (sic) se validaran siempre los permisos y entradas de usuario tanto en cliente como en servidor.

Deja un comentario

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

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.