Datos persistentes a través de recargas de página: cookies, IndexedDB y todo lo demás
Publicado: 2022-03-11Supongamos que estoy visitando un sitio web. Hago clic derecho en uno de los enlaces de navegación y selecciono para abrir el enlace en una nueva ventana. ¿Qué debería pasar? Si soy como la mayoría de los usuarios, espero que la nueva página tenga el mismo contenido que si hubiera hecho clic en el enlace directamente. La única diferencia debería ser que la página aparece en una nueva ventana. Pero si su sitio web es una aplicación de una sola página (SPA), es posible que vea resultados extraños a menos que haya planificado cuidadosamente este caso.
Recuerde que en un SPA, un vínculo de navegación típico suele ser un identificador de fragmento, que comienza con una almohadilla (#). Hacer clic directamente en el enlace no recarga la página, por lo que se conservan todos los datos almacenados en las variables de JavaScript. Pero si abro el enlace en una nueva pestaña o ventana, el navegador vuelve a cargar la página, reiniciando todas las variables de JavaScript. Por lo tanto, cualquier elemento HTML vinculado a esas variables se mostrará de manera diferente, a menos que haya tomado medidas para preservar esos datos de alguna manera.
Hay un problema similar si vuelvo a cargar explícitamente la página, como al presionar F5. Puede pensar que nunca debería necesitar presionar F5, porque ha configurado un mecanismo para enviar cambios desde el servidor automáticamente. Pero si soy un usuario típico, puedes apostar que todavía voy a recargar la página. Tal vez mi navegador parece haber repintado la pantalla incorrectamente, o simplemente quiero estar seguro de que tengo las últimas cotizaciones de acciones.
Las API pueden ser apátridas, la interacción humana no lo es
A diferencia de una solicitud interna a través de una API RESTful, la interacción de un usuario humano con un sitio web no es apátrida. Como usuario de la web, pienso en mi visita a su sitio como una sesión, casi como una llamada telefónica. Espero que el navegador recuerde datos sobre mi sesión, de la misma manera que cuando llamo a su línea de ventas o soporte, espero que el representante recuerde lo que se dijo anteriormente en la llamada.
Un ejemplo obvio de datos de sesión es si estoy conectado y, de ser así, con qué usuario. Una vez que paso por una pantalla de inicio de sesión, debería poder navegar libremente a través de las páginas específicas del usuario del sitio. Si abro un enlace en una nueva pestaña o ventana y aparece otra pantalla de inicio de sesión, eso no es muy fácil de usar.
Otro ejemplo es el contenido del carrito de compras en un sitio de comercio electrónico. Si presionar F5 vacía el carrito de compras, es probable que los usuarios se molesten.
En una aplicación tradicional de varias páginas escrita en PHP, los datos de la sesión se almacenarían en la matriz superglobal $_SESSION. Pero en un SPA, debe estar en algún lugar del lado del cliente. Hay cuatro opciones principales para almacenar datos de sesión en un SPA:
- Galletas
- Identificador de fragmento
- almacenamiento web
- Base de datos indexada
Cuatro kilobytes de cookies
Las cookies son una forma más antigua de almacenamiento web en el navegador. Originalmente, estaban destinados a almacenar los datos recibidos del servidor en una solicitud y enviarlos de vuelta al servidor en solicitudes posteriores. Pero desde JavaScript, puede usar cookies para almacenar casi cualquier tipo de datos, hasta un límite de tamaño de 4 KB por cookie. AngularJS ofrece el módulo ngCookies para administrar cookies. También hay un paquete js-cookies que proporciona una funcionalidad similar en cualquier marco.
Tenga en cuenta que cualquier cookie que cree se enviará al servidor en cada solicitud, ya sea una recarga de página o una solicitud de Ajax. Pero si los datos de la sesión principal que necesita almacenar son el token de acceso para el usuario que inició sesión, desea que esto se envíe al servidor en cada solicitud de todos modos. Es natural intentar usar esta transmisión automática de cookies como el medio estándar para especificar el token de acceso para las solicitudes de Ajax.
Puede argumentar que el uso de cookies de esta manera es incompatible con la arquitectura RESTful. Pero en este caso está bien ya que cada solicitud a través de la API aún no tiene estado, tiene algunas entradas y algunas salidas. Es solo que una de las entradas se envía de una manera divertida, a través de una cookie. Si puede hacer arreglos para que la solicitud API de inicio de sesión envíe el token de acceso de regreso en una cookie también, entonces su código del lado del cliente apenas necesita tratar con cookies. Nuevamente, es solo otra salida de la solicitud que se devuelve de una manera inusual.
Las cookies ofrecen una ventaja sobre el almacenamiento web. Puede proporcionar una casilla de verificación "mantenerme conectado" en el formulario de inicio de sesión. Con la semántica, espero que si lo dejo sin marcar, permaneceré conectado si vuelvo a cargar la página o abro un enlace en una nueva pestaña o ventana, pero tengo la garantía de cerrar la sesión una vez que cierre el navegador. Esta es una característica de seguridad importante si estoy usando una computadora compartida. Como veremos más adelante, el almacenamiento web no admite este comportamiento.
Entonces, ¿cómo podría funcionar este enfoque en la práctica? Suponga que está utilizando LoopBack en el lado del servidor. Ha definido un modelo de Persona, extendiendo el modelo de Usuario incorporado, agregando las propiedades que desea mantener para cada usuario. Configuró el modelo Person para que se exponga sobre REST. Ahora necesita modificar server/server.js para lograr el comportamiento de cookies deseado. A continuación se muestra server/server.js, a partir de lo que generó slc loopback, con los cambios marcados:
var loopback = require('loopback'); var boot = require('loopback-boot'); var app = module.exports = loopback(); app.start = function() { // start the web server return app.listen(function() { app.emit('started'); var baseUrl = app.get('url').replace(/\/$/, ''); console.log('Web server listening at: %s', baseUrl); if (app.get('loopback-component-explorer')) { var explorerPath = app.get('loopback-component-explorer').mountPath; console.log('Browse your REST API at %s%s', baseUrl, explorerPath); } }); }; // start of first change app.use(loopback.cookieParser('secret')); // end of first change // Bootstrap the application, configure models, datasources and middleware. // Sub-apps like REST API are mounted via boot scripts. boot(app, __dirname, function(err) { if (err) throw err; // start of second change app.remotes().after('Person.login', function (ctx, next) { if (ctx.result.id) { var opts = {signed: true}; if (ctx.req.body.rememberme !== false) { opts.maxAge = 1209600000; } ctx.res.cookie('authorization', ctx.result.id, opts); } next(); }); app.remotes().after('Person.logout', function (ctx, next) { ctx.res.cookie('authorization', ''); next(); }); // end of second change // start the server if `$ node server.js` if (require.main === module) app.start(); });El primer cambio configura el analizador de cookies para usar 'secret' como el secreto de firma de cookies, lo que habilita las cookies firmadas. Debe hacer esto porque aunque LoopBack busca un token de acceso en cualquiera de las cookies 'autorización' o 'access_token', requiere que dicha cookie esté firmada. En realidad, este requisito no tiene sentido. La firma de una cookie tiene por objeto garantizar que la cookie no se haya modificado. Pero no hay peligro de que modifiques el token de acceso. Después de todo, podría haber enviado el token de acceso sin firmar, como un parámetro ordinario. Por lo tanto, no necesita preocuparse de que el secreto de firma de la cookie sea difícil de adivinar, a menos que esté usando cookies firmadas para otra cosa.
El segundo cambio configura algún procesamiento posterior para los métodos Person.login y Person.logout. Para Person.login , desea tomar el token de acceso resultante y enviarlo al cliente como la 'autorización' de cookie firmada también. El cliente puede agregar una propiedad más al parámetro de credenciales, recordarme, indicando si desea que la cookie sea persistente durante 2 semanas. El defecto es cierto. El método de inicio de sesión ignorará esta propiedad, pero el posprocesador la comprobará.
Para Person.logout , desea borrar esta cookie.
Puede ver los resultados de estos cambios de inmediato en StrongLoop API Explorer. Normalmente, después de una solicitud Person.login, tendría que copiar el token de acceso, pegarlo en el formulario en la parte superior derecha y hacer clic en Establecer token de acceso. Pero con estos cambios, no tienes que hacer nada de eso. El token de acceso se guarda automáticamente como la "autorización" de la cookie y se devuelve en cada solicitud posterior. Cuando el Explorador muestra los encabezados de respuesta de Person.login, omite la cookie, porque JavaScript nunca puede ver los encabezados de Set-Cookie. Pero tenga la seguridad de que la galleta está ahí.
En el lado del cliente, al volver a cargar la página, vería si existe la 'autorización' de cookies. Si es así, debe actualizar su registro del ID de usuario actual. Probablemente, la forma más fácil de hacer esto es almacenar el ID de usuario en una cookie separada en el inicio de sesión exitoso, para que pueda recuperarlo en una recarga de página.
El identificador de fragmento
Como estoy visitando un sitio web que se ha implementado como un SPA, la URL en la barra de direcciones de mi navegador podría parecerse a "https://example.com/#/my-photos/37". La parte del identificador de fragmento de esto, "#/mis-fotos/37", ya es una colección de información de estado que podría verse como datos de sesión. En este caso, probablemente estoy viendo una de mis fotos, aquella cuyo DNI es 37.
Puede decidir incrustar otros datos de sesión dentro del identificador de fragmento. Recuerde que en la sección anterior, con el token de acceso almacenado en la 'autorización' de la cookie, aún necesitaba realizar un seguimiento del ID de usuario de alguna manera. Una opción es almacenarlo en una cookie separada. Pero otro enfoque es incrustarlo en el identificador del fragmento. Puede decidir que mientras esté conectado, todas las páginas que visite tendrán un identificador de fragmento que comience con "#/u/XXX", donde XXX es el ID de usuario. Entonces, en el ejemplo anterior, el identificador de fragmento podría ser "#/u/59/my-photos/37" si mi ID de usuario es 59.
Teóricamente, podría incrustar el token de acceso en el identificador de fragmento, evitando la necesidad de cookies o almacenamiento web. Pero eso sería una mala idea. Mi token de acceso sería visible en la barra de direcciones. Cualquiera que mirara por encima de mi hombro con una cámara podría tomar una instantánea de la pantalla y así acceder a mi cuenta.

Una nota final: es posible configurar un SPA para que no use identificadores de fragmentos en absoluto. En su lugar, utiliza direcciones URL ordinarias como "http://example.com/app/dashboard" y "http://example.com/app/my-photos/37", con el servidor configurado para devolver el HTML de nivel superior para su SPA en respuesta a una solicitud de cualquiera de estas URL. Luego, su SPA realiza su enrutamiento en función de la ruta (por ejemplo, "/app/dashboard" o "/app/my-photos/37") en lugar del identificador de fragmento. Intercepta los clics en los enlaces de navegación y usa History.pushState() para enviar la nueva URL, luego continúa con el enrutamiento como de costumbre. También escucha los eventos popstate para detectar al usuario haciendo clic en el botón Atrás y nuevamente continúa con el enrutamiento en la URL restaurada. Los detalles completos de cómo implementar esto están más allá del alcance de este artículo. Pero si usa esta técnica, obviamente puede almacenar datos de sesión en la ruta en lugar del identificador de fragmento.
Almacenamiento web
El almacenamiento web es un mecanismo para que JavaScript almacene datos dentro del navegador. Al igual que las cookies, el almacenamiento web es independiente para cada origen. Cada elemento almacenado tiene un nombre y un valor, los cuales son cadenas. Pero el almacenamiento web es completamente invisible para el servidor y ofrece una capacidad de almacenamiento mucho mayor que las cookies. Hay dos tipos de almacenamiento web: almacenamiento local y almacenamiento de sesión.
Un elemento de almacenamiento local es visible en todas las pestañas de todas las ventanas y persiste incluso después de cerrar el navegador. En este sentido, se comporta como una cookie con una fecha de caducidad muy lejana en el futuro. Por lo tanto, es adecuado para almacenar un token de acceso en el caso de que el usuario haya marcado "mantenerme conectado" en el formulario de inicio de sesión.
Un elemento de almacenamiento de sesión solo es visible dentro de la pestaña donde se creó y desaparece cuando se cierra esa pestaña. Esto hace que su vida útil sea muy diferente a la de cualquier cookie. Recuerde que una cookie de sesión aún es visible en todas las pestañas de todas las ventanas.
Si usa AngularJS SDK para LoopBack, el lado del cliente usará automáticamente el almacenamiento web para guardar tanto el token de acceso como el ID de usuario. Esto sucede en el servicio LoopBackAuth en js/services/lb-services.js. Utilizará el almacenamiento local, a menos que el parámetro RememberMe sea falso (lo que normalmente significa que la casilla de verificación "mantenerme conectado" no esté marcada), en cuyo caso utilizará el almacenamiento de la sesión.
El resultado es que si inicio sesión con "mantener la sesión iniciada" sin marcar y luego abro un enlace en una nueva pestaña o ventana, no iniciaré sesión allí. Lo más probable es que vea la pantalla de inicio de sesión. Puede decidir por sí mismo si este es un comportamiento aceptable. Algunos podrían considerarlo una buena característica, donde puede tener varias pestañas, cada una iniciada como un usuario diferente. O puede decidir que ya casi nadie usa computadoras compartidas, por lo que puede omitir la casilla de verificación "mantenerme conectado" por completo.
Entonces, ¿cómo se vería el manejo de datos de la sesión si decide usar el SDK de AngularJS para LoopBack? Suponga que tiene la misma situación que antes en el lado del servidor: ha definido un modelo de Persona, extendiendo el modelo de Usuario y ha expuesto el modelo de Persona sobre REST. No utilizará cookies, por lo que no necesitará ninguno de los cambios descritos anteriormente.
En el lado del cliente, en algún lugar de su controlador más externo, probablemente tenga una variable como $scope.currentUserId que contiene el ID de usuario del usuario que ha iniciado sesión actualmente, o nulo si el usuario no ha iniciado sesión. Luego, para manejar las recargas de página correctamente, debe simplemente incluya esta declaración en la función constructora para ese controlador:
$scope.currentUserId = Person.getCurrentId();Es fácil. Agregue 'Persona' como una dependencia de su controlador, si aún no lo está.
Base de datos indexada
IndexedDB es una instalación más nueva para almacenar grandes cantidades de datos en el navegador. Puede usarlo para almacenar datos de cualquier tipo de JavaScript, como un objeto o una matriz, sin tener que serializarlo. Todas las solicitudes contra la base de datos son asíncronas, por lo que recibe una devolución de llamada cuando se completa la solicitud.
Puede usar IndexedDB para almacenar datos estructurados que no están relacionados con ningún dato en el servidor. Un ejemplo podría ser un calendario, una lista de tareas pendientes o juegos guardados que se juegan localmente. En este caso, la aplicación es realmente local y su sitio web es solo el vehículo para entregarla.
Actualmente, Internet Explorer y Safari solo tienen soporte parcial para IndexedDB. Otros navegadores principales lo admiten completamente. Sin embargo, una limitación importante en este momento es que Firefox desactiva IndexedDB por completo en el modo de navegación privada.
Como ejemplo concreto del uso de IndexedDB, tomemos la aplicación de rompecabezas deslizante de Pavol Daniš y ajústela para guardar el estado del primer rompecabezas, el rompecabezas deslizante básico 3x3 basado en el logotipo de AngularJS, después de cada movimiento. Volver a cargar la página restaurará el estado de este primer rompecabezas.
Configuré una bifurcación del repositorio con estos cambios, todos los cuales están en app/js/puzzle/slidingPuzzle.js. Como puede ver, incluso un uso rudimentario de IndexedDB es bastante complicado. Voy a mostrar los aspectos más destacados a continuación. Primero, se llama a la función de restauración durante la carga de la página, para abrir la base de datos IndexedDB:
/* * Tries to restore game */ this.restore = function(scope, storekey) { this.storekey = storekey; if (this.db) { this.restore2(scope); } else if (!window.indexedDB) { console.log('SlidingPuzzle: browser does not support indexedDB'); this.shuffle(); } else { var self = this; var request = window.indexedDB.open('SlidingPuzzleDatabase'); request.onerror = function(event) { console.log('SlidingPuzzle: error opening database, ' + request.error.name); scope.$apply(function() { self.shuffle(); }); }; request.onupgradeneeded = function(event) { event.target.result.createObjectStore('SlidingPuzzleStore'); }; request.onsuccess = function(event) { self.db = event.target.result; self.restore2(scope); }; } };El evento request.onupgradeneeded maneja el caso en el que la base de datos aún no existe. En este caso, creamos el almacén de objetos.
Una vez que la base de datos está abierta, se llama a la función restaurar2 , que busca un registro con una clave dada (que en realidad será la constante 'Básica' en este caso):
/* * Tries to restore game, once database has been opened */ this.restore2 = function(scope) { var transaction = this.db.transaction('SlidingPuzzleStore'); var objectStore = transaction.objectStore('SlidingPuzzleStore'); var self = this; var request = objectStore.get(this.storekey); request.onerror = function(event) { console.log('SlidingPuzzle: error reading from database, ' + request.error.name); scope.$apply(function() { self.shuffle(); }); }; request.onsuccess = function(event) { if (!request.result) { console.log('SlidingPuzzle: no saved game for ' + self.storekey); scope.$apply(function() { self.shuffle(); }); } else { scope.$apply(function() { self.grid = request.result; }); } }; }Si existe tal registro, su valor reemplaza la matriz de cuadrícula del rompecabezas. Si hay algún error al restaurar el juego, simplemente barajamos las fichas como antes. Tenga en cuenta que grid es una matriz de 3x3 de objetos de mosaico, cada uno de los cuales es bastante complejo. La gran ventaja de IndexedDB es que puede almacenar y recuperar dichos valores sin tener que serializarlos.
Usamos $apply para informar a AngularJS que el modelo ha cambiado, por lo que la vista se actualizará adecuadamente. Esto se debe a que la actualización está ocurriendo dentro de un controlador de eventos DOM, por lo que, de lo contrario, AngularJS no podría detectar el cambio. Cualquier aplicación AngularJS que use IndexedDB probablemente necesitará usar $apply por este motivo.
Después de cualquier acción que cambiaría la matriz de cuadrícula, como un movimiento por parte del usuario, se llama a la función guardar, que agrega o actualiza el registro con la clave adecuada, según el valor de cuadrícula actualizado:
/* * Tries to save game */ this.save = function() { if (!this.db) { return; } var transaction = this.db.transaction('SlidingPuzzleStore', 'readwrite'); var objectStore = transaction.objectStore('SlidingPuzzleStore'); var request = objectStore.put(this.grid, this.storekey); request.onerror = function(event) { console.log('SlidingPuzzle: error writing to database, ' + request.error.name); }; request.onsuccess = function(event) { // successful, no further action needed }; }Los cambios restantes son para llamar a las funciones anteriores en los momentos apropiados. Puede revisar la confirmación que muestra todos los cambios. Tenga en cuenta que estamos llamando a restaurar solo para el rompecabezas básico, no para los tres rompecabezas avanzados. Aprovechamos el hecho de que los tres acertijos avanzados tienen un atributo api, por lo que para esos solo hacemos el barajado normal.
¿Qué pasaría si también quisiéramos guardar y restaurar los rompecabezas avanzados? Eso requeriría cierta reestructuración. En cada uno de los rompecabezas avanzados, el usuario puede ajustar el archivo de origen de la imagen y las dimensiones del rompecabezas. Entonces tendríamos que mejorar el valor almacenado en IndexedDB para incluir esta información. Más importante aún, necesitaríamos una forma de actualizarlos desde una restauración. Eso es un poco demasiado para este ejemplo ya largo.
Conclusión
En la mayoría de los casos, el almacenamiento web es su mejor opción para almacenar datos de sesión. Es totalmente compatible con todos los principales navegadores y ofrece una capacidad de almacenamiento mucho mayor que las cookies.
Usaría cookies si su servidor ya está configurado para usarlas, o si necesita que se pueda acceder a los datos en todas las pestañas de todas las ventanas, pero también desea asegurarse de que se eliminen cuando se cierra el navegador.
Ya usa el identificador de fragmento para almacenar datos de sesión que son específicos de esa página, como la identificación de la foto que está viendo el usuario. Si bien podría incrustar otros datos de sesión en el identificador de fragmento, esto realmente no ofrece ninguna ventaja sobre el almacenamiento web o las cookies.
Es probable que el uso de IndexedDB requiera mucha más codificación que cualquiera de las otras técnicas. Pero si los valores que está almacenando son objetos JavaScript complejos que serían difíciles de serializar, o si necesita un modelo transaccional, entonces puede valer la pena.
