Dados persistentes entre recarregamentos de página: cookies, IndexedDB e tudo entre eles

Publicados: 2022-03-11

Suponha que eu esteja visitando um site. Clico com o botão direito do mouse em um dos links de navegação e seleciono para abrir o link em uma nova janela. O que deve acontecer? Se eu for como a maioria dos usuários, espero que a nova página tenha o mesmo conteúdo como se eu tivesse clicado diretamente no link. A única diferença deve ser que a página aparece em uma nova janela. Mas se o seu site for um aplicativo de página única (SPA), você poderá ver resultados estranhos, a menos que tenha planejado cuidadosamente para este caso.

Lembre-se de que em um SPA, um link de navegação típico geralmente é um identificador de fragmento, começando com uma marca de hash (#). Clicar no link diretamente não recarrega a página, portanto, todos os dados armazenados nas variáveis ​​JavaScript são retidos. Mas se eu abrir o link em uma nova aba ou janela, o navegador recarrega a página, reinicializando todas as variáveis ​​JavaScript. Portanto, quaisquer elementos HTML vinculados a essas variáveis ​​serão exibidos de maneira diferente, a menos que você tenha tomado medidas para preservar esses dados de alguma forma.

Dados persistentes entre recarregamentos de página: cookies, IndexedDB e tudo entre eles

Dados persistentes entre recarregamentos de página: cookies, IndexedDB e tudo entre eles
Tweet

Há um problema semelhante se eu recarregar explicitamente a página, como pressionando F5. Você pode pensar que eu nunca deveria precisar pressionar F5, porque você configurou um mecanismo para enviar as alterações do servidor automaticamente. Mas se eu for um usuário típico, pode apostar que ainda vou recarregar a página. Talvez meu navegador pareça ter repintado a tela incorretamente ou eu só quero ter certeza de que tenho as cotações de ações mais recentes.

APIs podem ser apátridas, a interação humana não é

Ao contrário de uma solicitação interna por meio de uma API RESTful, a interação de um usuário humano com um site não é sem estado. Como usuário da web, considero minha visita ao seu site como uma sessão, quase como um telefonema. Espero que o navegador se lembre dos dados da minha sessão, da mesma forma que, quando ligo para sua linha de vendas ou suporte, espero que o representante se lembre do que foi dito anteriormente na chamada.

Um exemplo óbvio de dados de sessão é se estou logado e, em caso afirmativo, como qual usuário. Depois de passar por uma tela de login, devo poder navegar livremente pelas páginas específicas do usuário do site. Se eu abrir um link em uma nova guia ou janela e for apresentada outra tela de login, isso não é muito amigável.

Outro exemplo é o conteúdo do carrinho de compras em um site de comércio eletrônico. Se pressionar F5 esvazia o carrinho de compras, os usuários provavelmente ficarão chateados.

Em um aplicativo tradicional de várias páginas escrito em PHP, os dados da sessão seriam armazenados no array superglobal $_SESSION. Mas em um SPA, ele precisa estar em algum lugar do lado do cliente. Há quatro opções principais para armazenar dados de sessão em um SPA:

  • Biscoitos
  • Identificador de fragmento
  • armazenamento web
  • BD indexado

Quatro Kilobytes de Cookies

Os cookies são uma forma mais antiga de armazenamento da web no navegador. Eles foram originalmente destinados a armazenar dados recebidos do servidor em uma solicitação e enviá-los de volta ao servidor em solicitações subsequentes. Mas a partir do JavaScript, você pode usar cookies para armazenar praticamente qualquer tipo de dados, até um limite de tamanho de 4 KB por cookie. AngularJS oferece o módulo ngCookies para gerenciamento de cookies. Há também um pacote js-cookies que fornece funcionalidade semelhante em qualquer estrutura.

Lembre-se de que qualquer cookie que você criar será enviado ao servidor em cada solicitação, seja um recarregamento de página ou uma solicitação Ajax. Mas se os dados da sessão principal que você precisa armazenar são o token de acesso para o usuário conectado, você deseja que isso seja enviado ao servidor em cada solicitação de qualquer maneira. É natural tentar usar essa transmissão automática de cookies como o meio padrão de especificar o token de acesso para solicitações Ajax.

Você pode argumentar que o uso de cookies dessa maneira é incompatível com a arquitetura RESTful. Mas, neste caso, tudo bem, pois cada solicitação via API ainda é sem estado, tendo algumas entradas e algumas saídas. É que uma das entradas está sendo enviada de forma engraçada, via cookie. Se você puder fazer com que a solicitação da API de login também envie o token de acesso de volta em um cookie, seu código do lado do cliente dificilmente precisará lidar com cookies. Novamente, é apenas outra saída da solicitação sendo retornada de maneira incomum.

Os cookies oferecem uma vantagem sobre o armazenamento na web. Você pode fornecer uma caixa de seleção "mantenha-me conectado" no formulário de login. Com a semântica, espero que, se deixar desmarcada, permanecerei conectado se recarregar a página ou abrir um link em uma nova guia ou janela, mas garanto que sairei assim que fechar o navegador. Esse é um recurso de segurança importante se eu estiver usando um computador compartilhado. Como veremos mais adiante, o armazenamento na Web não oferece suporte a esse comportamento.

Então, como essa abordagem pode funcionar na prática? Suponha que você esteja usando o LoopBack no lado do servidor. Você definiu um modelo Person, estendendo o modelo interno User, adicionando as propriedades que deseja manter para cada usuário. Você configurou o modelo Person para ser exposto em REST. Agora você precisa ajustar server/server.js para obter o comportamento de cookie desejado. Abaixo está server/server.js, a partir do que foi gerado pelo slc loopback, com as alterações marcadas:

 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(); });

A primeira alteração configura o analisador de cookies para usar 'secret' como o segredo de assinatura do cookie, habilitando assim os cookies assinados. Você precisa fazer isso porque, embora o LoopBack procure um token de acesso em qualquer um dos cookies 'autorização' ou 'access_token', ele exige que esse cookie seja assinado. Na verdade, essa exigência é inútil. Assinar um cookie destina-se a garantir que o cookie não foi modificado. Mas não há perigo de você modificar o token de acesso. Afinal, você poderia ter enviado o token de acesso de forma não assinada, como um parâmetro comum. Assim, você não precisa se preocupar com o segredo de assinatura do cookie ser difícil de adivinhar, a menos que você esteja usando cookies assinados para outra coisa.

A segunda alteração configura algum pós-processamento para os métodos Person.login e Person.logout. Para Person.login , você deseja pegar o token de acesso resultante e enviá-lo ao cliente como a 'autorização' do cookie assinado também. O cliente pode adicionar mais uma propriedade ao parâmetro de credenciais, rememberme, indicando se deseja tornar o cookie persistente por 2 semanas. O padrão é verdadeiro. O próprio método de login ignorará essa propriedade, mas o pós-processador a verificará.

Para Person.logout , você deseja limpar este cookie.

Você pode ver os resultados dessas alterações imediatamente no StrongLoop API Explorer. Normalmente, após uma solicitação Person.login, você teria que copiar o token de acesso, colá-lo no formulário no canto superior direito e clicar em Definir token de acesso. Mas com essas mudanças, você não precisa fazer nada disso. O token de acesso é salvo automaticamente como a 'autorização' do cookie e enviado de volta em cada solicitação subsequente. Quando o Explorer está exibindo os cabeçalhos de resposta de Person.login, ele omite o cookie, porque o JavaScript nunca tem permissão para ver os cabeçalhos Set-Cookie. Mas fique tranquilo, o biscoito está lá.

No lado do cliente, em um recarregamento de página, você veria se a 'autorização' do cookie existe. Nesse caso, você precisa atualizar seu registro do userId atual. Provavelmente, a maneira mais fácil de fazer isso é armazenar o userId em um cookie separado no login bem-sucedido, para que você possa recuperá-lo ao recarregar a página.

O identificador do fragmento

Como estou visitando um site que foi implementado como um SPA, a URL na barra de endereços do meu navegador pode ser algo como “https://example.com/#/my-photos/37”. A parte do identificador de fragmento disso, “#/my-photos/37”, já é uma coleção de informações de estado que podem ser vistas como dados de sessão. Nesse caso, provavelmente estou vendo uma das minhas fotos, aquela cujo ID é 37.

Você pode decidir incorporar outros dados de sessão no identificador de fragmento. Lembre-se que na seção anterior, com o token de acesso armazenado na 'autorização' do cookie, você ainda precisava acompanhar o userId de alguma forma. Uma opção é armazená-lo em um cookie separado. Mas outra abordagem é incorporá-lo no identificador do fragmento. Você pode decidir que enquanto eu estiver logado, todas as páginas que eu visitar terão um identificador de fragmento começando com “#/u/XXX”, onde XXX é o ID do usuário. Portanto, no exemplo anterior, o identificador do fragmento pode ser “#/u/59/my-photos/37” se meu userId for 59.

Teoricamente, você poderia incorporar o próprio token de acesso no identificador do fragmento, evitando a necessidade de cookies ou armazenamento na web. Mas isso seria uma má ideia. Meu token de acesso ficaria visível na barra de endereços. Qualquer um que olhasse por cima do meu ombro com uma câmera poderia tirar uma foto da tela, obtendo assim acesso à minha conta.

Uma observação final: é possível configurar um SPA para que ele não use identificadores de fragmentos. Em vez disso, ele usa URLs comuns como “http://example.com/app/dashboard” e “http://example.com/app/my-photos/37”, com o servidor configurado para retornar o HTML de nível superior para seu SPA em resposta a uma solicitação para qualquer uma dessas URLs. Seu SPA então faz seu roteamento com base no caminho (por exemplo, “/app/dashboard” ou “/app/my-photos/37”) em vez do identificador do fragmento. Ele intercepta cliques em links de navegação e usa History.pushState() para enviar o novo URL e, em seguida, prossegue com o roteamento normalmente. Ele também escuta eventos popstate para detectar o usuário clicando no botão Voltar e, novamente, continua com o roteamento na URL restaurada. Os detalhes completos de como implementar isso estão além do escopo deste artigo. Mas se você usar essa técnica, obviamente poderá armazenar os dados da sessão no caminho em vez do identificador do fragmento.

Armazenamento web

O armazenamento na Web é um mecanismo para JavaScript armazenar dados no navegador. Assim como os cookies, o armazenamento na web é separado para cada origem. Cada item armazenado tem um nome e um valor, ambos são strings. Mas o armazenamento na web é completamente invisível para o servidor e oferece uma capacidade de armazenamento muito maior do que os cookies. Existem dois tipos de armazenamento na Web: armazenamento local e armazenamento de sessão.

Um item de armazenamento local é visível em todas as guias de todas as janelas e persiste mesmo depois que o navegador é fechado. A este respeito, ele se comporta um pouco como um cookie com uma data de validade muito distante no futuro. Assim, é adequado para armazenar um token de acesso caso o usuário tenha marcado “mantenha-me conectado” no formulário de login.

Um item de armazenamento de sessão só é visível na guia em que foi criado e desaparece quando essa guia é fechada. Isso torna sua vida útil muito diferente da de qualquer cookie. Lembre-se de que um cookie de sessão ainda está visível em todas as guias de todas as janelas.

Se você usar o SDK do AngularJS para LoopBack, o lado do cliente usará automaticamente o armazenamento da Web para salvar o token de acesso e o userId. Isso acontece no serviço LoopBackAuth em js/services/lb-services.js. Ele usará o armazenamento local, a menos que o parâmetro RememberMe seja falso (normalmente significando que a caixa de seleção “mantenha-me conectado” foi desmarcada), caso em que ele usará o armazenamento de sessão.

O resultado é que, se eu fizer login com a opção “manter-me conectado” desmarcada e abrir um link em uma nova guia ou janela, não estarei conectado lá. Muito provavelmente eu vou ver a tela de login. Você pode decidir por si mesmo se este é um comportamento aceitável. Alguns podem considerá-lo um bom recurso, onde você pode ter várias guias, cada uma conectada como um usuário diferente. Ou você pode decidir que quase ninguém usa mais computadores compartilhados, então você pode simplesmente omitir a caixa de seleção “manter-me conectado”.

Então, como seria a manipulação de dados da sessão se você decidir usar o SDK do AngularJS para LoopBack? Suponha que você tenha a mesma situação de antes no lado do servidor: você definiu um modelo Person, estendendo o modelo User e expôs o modelo Person sobre REST. Você não usará cookies, portanto, não precisará de nenhuma das alterações descritas anteriormente.

No lado do cliente, em algum lugar em seu controlador externo, você provavelmente tem uma variável como $scope.currentUserId que contém o userId do usuário conectado no momento, ou null se o usuário não estiver conectado. apenas inclua esta declaração na função construtora para esse controlador:

 $scope.currentUserId = Person.getCurrentId();

É tão fácil. Adicione 'Person' como uma dependência do seu controlador, se ainda não estiver.

BD indexado

IndexedDB é um recurso mais recente para armazenar grandes quantidades de dados no navegador. Você pode usá-lo para armazenar dados de qualquer tipo de JavaScript, como um objeto ou array, sem precisar serializá-lo. Todas as solicitações no banco de dados são assíncronas, portanto, você recebe um retorno de chamada quando a solicitação é concluída.

Você pode usar IndexedDB para armazenar dados estruturados que não estejam relacionados a nenhum dado no servidor. Um exemplo pode ser um calendário, uma lista de tarefas ou jogos salvos que são jogados localmente. Nesse caso, o aplicativo é realmente local e seu site é apenas o veículo para entregá-lo.

Atualmente, o Internet Explorer e o Safari só têm suporte parcial para IndexedDB. Outros navegadores importantes o suportam totalmente. Uma limitação séria no momento, porém, é que o Firefox desativa o IndexedDB inteiramente no modo de navegação privada.

Como um exemplo concreto do uso do IndexedDB, vamos pegar o aplicativo de quebra-cabeça deslizante de Pavol Daniš e ajustá-lo para salvar o estado do primeiro quebra-cabeça, o quebra-cabeça deslizante básico 3x3 baseado no logotipo AngularJS, após cada movimento. Recarregar a página restaurará o estado desse primeiro quebra-cabeça.

Configurei uma bifurcação do repositório com essas alterações, todas em app/js/puzzle/slidingPuzzle.js. Como você pode ver, até mesmo um uso rudimentar do IndexedDB está bastante envolvido. Vou mostrar apenas os destaques abaixo. Primeiro, a função restore é chamada durante o carregamento da página, para abrir o banco de dados 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); }; } };

O evento request.onupgradeneeded trata do caso em que o banco de dados ainda não existe. Nesse caso, criamos o armazenamento de objetos.

Uma vez que o banco de dados é aberto, a função restore2 é chamada, que procura um registro com uma determinada chave (que na verdade será a constante 'Basic' neste 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; }); } }; }

Se tal registro existir, seu valor substituirá a matriz de grade do quebra-cabeça. Se houver algum erro na restauração do jogo, apenas embaralhamos as peças como antes. Observe que a grade é uma matriz 3x3 de objetos lado a lado, cada um dos quais é bastante complexo. A grande vantagem do IndexedDB é que você pode armazenar e recuperar esses valores sem precisar serializá-los.

Usamos $apply para informar ao AngularJS que o modelo foi alterado, para que a visualização seja atualizada adequadamente. Isso ocorre porque a atualização está acontecendo dentro de um manipulador de eventos DOM, portanto, o AngularJS não seria capaz de detectar a alteração. Qualquer aplicativo AngularJS usando IndexedDB provavelmente precisará usar $apply por esse motivo.

Após qualquer ação que altere a matriz da grade, como uma movimentação do usuário, é chamada a função save que adiciona ou atualiza o registro com a chave apropriada, com base no valor da grade atualizada:

 /* * 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 }; }

As alterações restantes são para chamar as funções acima em momentos apropriados. Você pode revisar o commit mostrando todas as alterações. Observe que estamos chamando a restauração apenas para o quebra-cabeça básico, não para os três quebra-cabeças avançados. Nós exploramos o fato de que os três quebra-cabeças avançados têm um atributo api, então para aqueles nós apenas fazemos o embaralhamento normal.

E se quiséssemos salvar e restaurar os quebra-cabeças avançados também? Isso exigiria alguma reestruturação. Em cada um dos quebra-cabeças avançados, o usuário pode ajustar o arquivo de origem da imagem e as dimensões do quebra-cabeça. Portanto, teríamos que aprimorar o valor armazenado no IndexedDB para incluir essas informações. Mais importante, precisaríamos de uma maneira de atualizá-los a partir de uma restauração. Isso é um pouco demais para este exemplo já longo.

Conclusão

Na maioria dos casos, o armazenamento na Web é sua melhor aposta para armazenar dados de sessão. É totalmente suportado por todos os principais navegadores e oferece uma capacidade de armazenamento muito maior do que os cookies.

Você usaria cookies se seu servidor já estivesse configurado para usá-los, ou se você precisar que os dados estejam acessíveis em todas as guias de todas as janelas, mas você também deseja garantir que eles sejam excluídos quando o navegador for fechado.

Você já usa o identificador de fragmento para armazenar dados de sessão específicos dessa página, como o ID da foto que o usuário está visualizando. Embora você possa incorporar outros dados de sessão no identificador de fragmento, isso não oferece nenhuma vantagem sobre o armazenamento na Web ou os cookies.

O uso do IndexedDB provavelmente exigirá muito mais codificação do que qualquer uma das outras técnicas. Mas se os valores que você está armazenando forem objetos JavaScript complexos que seriam difíceis de serializar, ou se você precisar de um modelo transacional, pode valer a pena.