Date persistente în reîncărcările paginilor: cookie-uri, IndexedDB și tot ce se află între ele
Publicat: 2022-03-11Să presupunem că vizitez un site web. Dau clic dreapta pe unul dintre linkurile de navigare și selectez pentru a deschide linkul într-o fereastră nouă. Ce ar trebui să se întâmple? Dacă sunt ca majoritatea utilizatorilor, mă aștept ca noua pagină să aibă același conținut ca și cum aș fi făcut clic direct pe link. Singura diferență ar trebui să fie că pagina apare într-o fereastră nouă. Dar dacă site-ul dvs. web este o aplicație cu o singură pagină (SPA), este posibil să vedeți rezultate ciudate, dacă nu ați planificat cu atenție acest caz.
Amintiți-vă că într-un SPA, o legătură de navigare tipică este adesea un identificator de fragment, începând cu un semn hash (#). Făcând clic direct pe link nu se reîncarcă pagina, astfel încât toate datele stocate în variabilele JavaScript sunt păstrate. Dar dacă deschid linkul într-o filă sau fereastră nouă, browserul reîncarcă pagina, reinițialând toate variabilele JavaScript. Deci, orice elemente HTML legate de acele variabile se vor afișa diferit, cu excepția cazului în care ați luat măsuri pentru a păstra acele date într-un fel.
Există o problemă similară dacă reîncarc în mod explicit pagina, cum ar fi apăsând F5. S-ar putea să credeți că nu ar trebui să apes vreodată pe F5, deoarece ați configurat un mecanism pentru a împinge automat modificările de pe server. Dar dacă sunt un utilizator obișnuit, puteți paria că încă voi reîncărca pagina. Poate că browserul meu pare să fi vopsit incorect ecranul sau vreau doar să fiu sigur că am cele mai recente cotații bursiere.
API-urile pot fi apatride, interacțiunea umană nu
Spre deosebire de o solicitare internă prin intermediul unui API RESTful, interacțiunea unui utilizator uman cu un site web nu este apatridă. În calitate de utilizator web, mă gândesc la vizita mea pe site-ul dvs. ca la o sesiune, aproape ca la un apel telefonic. Mă aștept ca browserul să-și amintească datele despre sesiunea mea, în același mod în care atunci când vă sun la linia de vânzări sau de asistență, mă aștept ca reprezentantul să-și amintească ceea ce s-a spus mai devreme în apel.
Un exemplu evident de date de sesiune este dacă sunt conectat și, dacă da, ca utilizator. Odată ce parcurg un ecran de autentificare, ar trebui să pot naviga liber prin paginile site-ului specifice utilizatorului. Dacă deschid un link într-o filă sau fereastră nouă și mi se prezintă un alt ecran de conectare, nu este foarte ușor de utilizat.
Un alt exemplu este conținutul coșului de cumpărături dintr-un site de comerț electronic. Dacă apăsarea F5 golește coșul de cumpărături, este posibil ca utilizatorii să se supere.
Într-o aplicație tradițională cu mai multe pagini scrisă în PHP, datele sesiunii ar fi stocate în matricea superglobală $_SESSION. Dar într-un SPA, trebuie să fie undeva pe partea clientului. Există patru opțiuni principale pentru stocarea datelor de sesiune într-un SPA:
- Cookie-uri
- Identificator de fragment
- Stocare web
- IndexedDB
Patru Kilobytes de cookie-uri
Cookie-urile sunt o formă mai veche de stocare web în browser. Ele au fost inițial destinate să stocheze datele primite de la server într-o singură solicitare și să le trimită înapoi la server în cererile ulterioare. Dar din JavaScript, puteți folosi cookie-uri pentru a stoca aproape orice tip de date, până la o limită de dimensiune de 4 KB per cookie. AngularJS oferă modulul ngCookies pentru gestionarea cookie-urilor. Există, de asemenea, un pachet js-cookies care oferă funcționalități similare în orice cadru.
Rețineți că orice cookie pe care îl creați va fi trimis către server la fiecare solicitare, fie că este vorba de o reîncărcare a paginii sau de o solicitare Ajax. Dar dacă datele principale ale sesiunii pe care trebuie să le stocați sunt jetonul de acces pentru utilizatorul conectat, doriți ca acesta să fie trimis la server la fiecare solicitare oricum. Este firesc să încercăm să folosiți această transmisie automată a cookie-urilor ca mijloc standard de specificare a jetonului de acces pentru cererile Ajax.
Puteți argumenta că utilizarea cookie-urilor în acest mod este incompatibilă cu arhitectura RESTful. Dar, în acest caz, este bine, deoarece fiecare solicitare prin intermediul API-ului este încă apatridă, având unele intrări și unele ieșiri. Doar că una dintre intrări este trimisă într-un mod amuzant, prin intermediul unui cookie. Dacă puteți aranja ca cererea API de conectare să trimită și jetonul de acces înapoi într-un cookie, atunci codul dvs. din partea clientului nu trebuie să se ocupe deloc de cookie-uri. Din nou, este doar o altă ieșire a cererii care este returnată într-un mod neobișnuit.
Cookie-urile oferă un avantaj față de stocarea web. Puteți introduce o casetă de selectare „Păstrați-mă conectat” în formularul de conectare. Cu semantica, mă aștept, dacă o las nebifată, atunci voi rămâne conectat dacă reîncarc pagina sau deschid un link într-o filă sau fereastră nouă, dar sunt garantat că voi fi deconectat odată ce închid browserul. Aceasta este o caracteristică de siguranță importantă dacă folosesc un computer partajat. După cum vom vedea mai târziu, stocarea web nu acceptă acest comportament.
Deci, cum ar putea funcționa această abordare în practică? Să presupunem că utilizați LoopBack pe partea serverului. Ați definit un model de persoană, extinzând modelul de utilizator încorporat, adăugând proprietățile pe care doriți să le păstrați pentru fiecare utilizator. Ați configurat modelul Persoană pentru a fi expus prin REST. Acum trebuie să modificați server/server.js pentru a obține comportamentul cookie dorit. Mai jos este server/server.js, pornind de la ceea ce a fost generat de slc loopback, cu modificările marcate:
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(); });
Prima modificare configurează analizatorul cookie să folosească „secret” ca secret de semnare a cookie-urilor, permițând astfel cookie-urile semnate. Trebuie să faceți acest lucru deoarece, deși LoopBack caută un token de acces în oricare dintre cookie-urile „autorizare” sau „access_token”, necesită semnarea unui astfel de cookie. De fapt, această cerință este inutilă. Semnarea unui cookie are scopul de a se asigura că cookie-ul nu a fost modificat. Dar nu există niciun pericol ca tu să modifici jetonul de acces. La urma urmei, ai fi putut trimite jetonul de acces în formă nesemnată, ca un parametru obișnuit. Astfel, nu trebuie să vă faceți griji că secretul de semnare a cookie-urilor este greu de ghicit, cu excepția cazului în care utilizați cookie-uri semnate pentru altceva.
A doua modificare stabilește o postprocesare pentru metodele Person.login și Person.logout. Pentru Person.login , doriți să luați jetonul de acces rezultat și să îl trimiteți clientului, de asemenea, ca „autorizare” cookie semnat. Clientul poate adăuga încă o proprietate la parametrul de acreditări, rememberme, indicând dacă să facă cookie-ul persistent timp de 2 săptămâni. Valoarea implicită este adevărată. Metoda de conectare în sine va ignora această proprietate, dar postprocesorul o va verifica.
Pentru Person.logout , doriți să ștergeți acest cookie.
Puteți vedea rezultatele acestor modificări imediat în StrongLoop API Explorer. În mod normal, după o solicitare Person.login, va trebui să copiați jetonul de acces, să îl lipiți în formularul din dreapta sus și să faceți clic pe Set Access Token. Dar cu aceste modificări, nu trebuie să faci nimic din toate astea. Indicatorul de acces este salvat automat ca „autorizare” cookie și trimis înapoi la fiecare solicitare ulterioară. Când Explorer afișează anteturile de răspuns din Person.login, omite cookie-ul, deoarece JavaScript nu are niciodată voie să vadă anteturile Set-Cookie. Dar fiți siguri, prăjitura este acolo.
Pe partea clientului, la o reîncărcare a paginii, veți vedea dacă „autorizarea” cookie-ul există. Dacă da, trebuie să vă actualizați înregistrarea ID-ului de utilizator actual. Probabil că cel mai simplu mod de a face acest lucru este să stocați ID-ul utilizatorului într-un cookie separat la autentificarea cu succes, astfel încât să îl puteți recupera la o reîncărcare a paginii.
Identificatorul de fragment
Pe măsură ce vizitez un site web care a fost implementat ca SPA, adresa URL din bara de adrese a browserului meu ar putea arăta ceva de genul „https://example.com/#/my-photos/37”. Partea de identificare a fragmentului din aceasta, „#/my-photos/37”, este deja o colecție de informații despre stare care ar putea fi vizualizată ca date de sesiune. În acest caz, probabil că văd una dintre fotografiile mele, cea al cărei ID este 37.
Puteți decide să încorporați alte date de sesiune în identificatorul de fragment. Amintiți-vă că în secțiunea anterioară, cu token-ul de acces stocat în cookie-ul „autorizare”, mai trebuia să țineți cumva evidența userId-ului. O opțiune este să-l stocați într-un cookie separat. Dar o altă abordare este să o încorporați în identificatorul de fragment. Puteți decide că, în timp ce sunt conectat, toate paginile pe care le vizitez vor avea un identificator de fragment care începe cu „#/u/XXX”, unde XXX este userId. Deci, în exemplul anterior, identificatorul de fragment ar putea fi „#/u/59/my-photos/37” dacă ID-ul meu de utilizator este 59.
Teoretic, ai putea încorpora jetonul de acces în sine în identificatorul de fragment, evitând orice nevoie de cookie-uri sau stocare web. Dar asta ar fi o idee proastă. Tokenul meu de acces ar fi apoi vizibil în bara de adrese. Oricine se uită peste umărul meu cu o cameră poate face un instantaneu al ecranului, obținând astfel acces la contul meu.

O notă finală: este posibil să configurați un SPA astfel încât să nu folosească identificatori de fragmente deloc. În schimb, utilizează adrese URL obișnuite precum „http://example.com/app/dashboard” și „http://example.com/app/my-photos/37”, cu serverul configurat pentru a returna HTML de nivel superior pentru dvs. SPA ca răspuns la o solicitare pentru oricare dintre aceste adrese URL. SPA-ul dvs. își face apoi rutarea în funcție de cale (de exemplu, „/app/dashboard” sau „/app/my-photos/37”) în loc de identificatorul de fragment. Interceptează clicurile pe link-urile de navigare și folosește History.pushState() pentru a împinge noua adresă URL, apoi continuă cu rutarea ca de obicei. De asemenea, ascultă evenimentele popstate pentru a detecta utilizatorul care dă clic pe butonul Înapoi și continuă din nou cu rutarea pe adresa URL restaurată. Detaliile complete despre cum să implementați acest lucru depășesc scopul acestui articol. Dar dacă utilizați această tehnică, atunci evident că puteți stoca datele de sesiune în cale în loc de identificatorul de fragment.
Stocare web
Stocarea web este un mecanism pentru ca JavaScript să stocheze date în browser. La fel ca cookie-urile, stocarea web este separată pentru fiecare origine. Fiecare element stocat are un nume și o valoare, ambele fiind șiruri. Dar stocarea web este complet invizibilă pentru server și oferă o capacitate de stocare mult mai mare decât cookie-urile. Există două tipuri de stocare web: stocare locală și stocare sesiune.
Un element de stocare locală este vizibil în toate filele din toate ferestrele și persistă chiar și după ce browserul este închis. În acest sens, se comportă oarecum ca un cookie cu o dată de expirare foarte îndepărtată în viitor. Astfel, este potrivit pentru stocarea unui token de acces în cazul în care utilizatorul a bifat „ține-mă conectat” în formularul de autentificare.
Un element de stocare a sesiunii este vizibil numai în fila în care a fost creat și dispare când acea filă este închisă. Acest lucru face ca durata sa de viață să fie foarte diferită de cea a oricărui cookie. Amintiți-vă că un cookie de sesiune este încă vizibil în toate filele din toate ferestrele.
Dacă utilizați SDK-ul AngularJS pentru LoopBack, partea clientului va folosi automat stocarea web pentru a salva atât jetonul de acces, cât și ID-ul de utilizator. Acest lucru se întâmplă în serviciul LoopBackAuth din js/services/lb-services.js. Va folosi stocarea locală, cu excepția cazului în care parametrul rememberMe este fals (în mod normal, ceea ce înseamnă că caseta de selectare „Păstrează-mă conectat” a fost debifată), caz în care va folosi stocarea sesiunii.
Rezultatul este că, dacă mă conectez cu „ține-mă conectat” nebifat și apoi deschid un link într-o filă sau fereastră nouă, nu voi fi conectat acolo. Cel mai probabil voi vedea ecranul de conectare. Puteți decide singur dacă acesta este un comportament acceptabil. Unii ar putea considera că este o caracteristică plăcută, în care puteți avea mai multe file, fiecare conectată ca utilizator diferit. Sau ați putea decide că aproape nimeni nu mai folosește computere partajate, așa că puteți omite cu totul caseta de selectare „Păstrați-mă conectat”.
Deci, cum ar arăta gestionarea datelor de sesiune dacă decideți să utilizați SDK-ul AngularJS pentru LoopBack? Să presupunem că aveți aceeași situație ca înainte pe partea serverului: ați definit un model de persoană, extinzând modelul de utilizator și ați expus modelul de persoană peste REST. Nu veți folosi cookie-uri, așa că nu veți avea nevoie de niciuna dintre modificările descrise mai devreme.
Pe partea clientului, undeva în controlerul tău cel mai exterior, probabil că ai o variabilă precum $scope.currentUserId care deține userId-ul utilizatorului conectat în prezent, sau null dacă utilizatorul nu este conectat. Apoi, pentru a gestiona corect reîncărcările paginii, trebuie să includeți doar această declarație în funcția de constructor pentru acel controler:
$scope.currentUserId = Person.getCurrentId();
Este atât de ușor. Adăugați „Persoană” ca dependență a controlerului dvs., dacă nu este deja.
IndexedDB
IndexedDB este o facilitate mai nouă pentru stocarea unor cantități mari de date în browser. Îl puteți folosi pentru a stoca date de orice tip JavaScript, cum ar fi un obiect sau o matrice, fără a fi nevoie să le serializați. Toate cererile din baza de date sunt asincrone, astfel încât veți primi un apel înapoi când solicitarea este finalizată.
Puteți utiliza IndexedDB pentru a stoca date structurate care nu au legătură cu nicio dată de pe server. Un exemplu ar putea fi un calendar, o listă de activități sau jocuri salvate care sunt jucate local. În acest caz, aplicația este într-adevăr una locală, iar site-ul dvs. web este doar vehiculul pentru livrarea acesteia.
În prezent, Internet Explorer și Safari au suport doar parțial pentru IndexedDB. Alte browsere majore îl acceptă pe deplin. O limitare serioasă în acest moment este însă că Firefox dezactivează IndexedDB complet în modul de navigare privată.
Ca exemplu concret de utilizare a IndexedDB, să luăm aplicația de puzzle glisante de la Pavol Daniš și să o modificăm pentru a salva starea primului puzzle, puzzle-ul de alunecare Basic 3x3 bazat pe sigla AngularJS, după fiecare mișcare. Reîncărcarea paginii va restabili starea acestui prim puzzle.
Am configurat o furcă a depozitului cu aceste modificări, toate fiind în app/js/puzzle/slidingPuzzle.js. După cum puteți vedea, chiar și o utilizare rudimentară a IndexedDB este destul de implicată. Voi arăta doar cele mai importante momente mai jos. În primul rând, funcția de restaurare este apelată în timpul încărcării paginii, pentru a deschide baza de date 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); }; } };
Evenimentul request.onupgradeneeded se ocupă de cazul în care baza de date nu există încă. În acest caz, creăm depozitul de obiecte.
Odată ce baza de date este deschisă, este apelată funcția restaurare2, care caută o înregistrare cu o cheie dată (care va fi de fapt constanta „De bază” în acest caz):
/* * 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; }); } }; }
Dacă o astfel de înregistrare există, valoarea ei înlocuiește matricea grilă a puzzle-ului. Dacă există vreo eroare la restaurarea jocului, amestecăm plăcile ca înainte. Rețineți că grila este o matrice 3x3 de obiecte țiglă, fiecare dintre acestea fiind destul de complex. Marele avantaj al IndexedDB este că puteți stoca și prelua astfel de valori fără a fi nevoie să le serializați.
Folosim $apply pentru a informa AngularJS că modelul a fost modificat, astfel încât vizualizarea va fi actualizată corespunzător. Acest lucru se datorează faptului că actualizarea are loc în interiorul unui handler de evenimente DOM, astfel încât AngularJS nu ar putea, altfel, să detecteze modificarea. Orice aplicație AngularJS care utilizează IndexedDB va trebui probabil să folosească $apply din acest motiv.
După orice acțiune care ar schimba matricea grilei, cum ar fi o mutare a utilizatorului, este apelată funcția de salvare care adaugă sau actualizează înregistrarea cu cheia corespunzătoare, pe baza valorii grilei actualizate:
/* * 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 }; }
Modificările rămase sunt pentru a apela funcțiile de mai sus la momentele adecvate. Puteți examina comiterea afișând toate modificările. Rețineți că apelăm la restaurare doar pentru puzzle-ul de bază, nu pentru cele trei puzzle-uri avansate. Exploatăm faptul că cele trei puzzle-uri avansate au un atribut API, așa că pentru cei doi facem doar amestecarea normală.
Dacă am vrea să salvăm și să restabilim și puzzle-urile avansate? Asta ar necesita ceva restructurare. În fiecare dintre puzzle-urile avansate, utilizatorul poate ajusta fișierul sursă a imaginii și dimensiunile puzzle-ului. Deci, ar trebui să îmbunătățim valoarea stocată în IndexedDB pentru a include aceste informații. Mai important, am avea nevoie de o modalitate de a le actualiza dintr-o restaurare. Este un pic cam mult pentru acest exemplu deja lung.
Concluzie
În cele mai multe cazuri, stocarea web este cel mai bun pariu pentru stocarea datelor de sesiune. Este pe deplin acceptat de toate browserele majore și oferă o capacitate de stocare mult mai mare decât cookie-urile.
Veți folosi cookie-uri dacă serverul dumneavoastră este deja configurat să le utilizeze sau dacă aveți nevoie ca datele să fie accesibile în toate filele din toate ferestrele, dar doriți să vă asigurați că vor fi șterse atunci când browserul este închis.
Utilizați deja identificatorul de fragment pentru a stoca date de sesiune specifice paginii respective, cum ar fi ID-ul fotografiei la care se uită utilizatorul. Deși ați putea încorpora alte date de sesiune în identificatorul de fragment, acest lucru nu oferă cu adevărat niciun avantaj față de stocarea web sau cookie-uri.
Utilizarea IndexedDB poate necesita mult mai multă codare decât oricare dintre celelalte tehnici. Dar dacă valorile pe care le stocați sunt obiecte JavaScript complexe, care ar fi dificil de serializat, sau dacă aveți nevoie de un model tranzacțional, atunci ar putea fi util.