Сохранение данных при перезагрузке страницы: файлы cookie, IndexedDB и все, что между ними

Опубликовано: 2022-03-11

Предположим, я посещаю веб-сайт. Я щелкаю правой кнопкой мыши одну из навигационных ссылок и выбираю, чтобы открыть ссылку в новом окне. Что должно произойти? Если я похож на большинство пользователей, я ожидаю, что новая страница будет иметь такое же содержание, как если бы я щелкнул ссылку напрямую. Единственная разница должна заключаться в том, что страница появляется в новом окне. Но если ваш веб-сайт представляет собой одностраничное приложение (SPA), вы можете увидеть странные результаты, если вы тщательно не спланировали этот случай.

Напомним, что в SPA типичная навигационная ссылка часто представляет собой идентификатор фрагмента, начинающийся с решётки (#). Щелчок по ссылке напрямую не перезагружает страницу, поэтому все данные, хранящиеся в переменных JavaScript, сохраняются. Но если я открываю ссылку в новой вкладке или окне, браузер перезагружает страницу, повторно инициализируя все переменные JavaScript. Таким образом, любые элементы HTML, привязанные к этим переменным, будут отображаться по-разному, если только вы не предприняли шаги для сохранения этих данных каким-либо образом.

Сохранение данных при перезагрузке страницы: файлы cookie, IndexedDB и все, что между ними

Сохранение данных при перезагрузке страницы: файлы cookie, IndexedDB и все, что между ними
Твитнуть

Аналогичная проблема возникает, если я явно перезагружаю страницу, например, нажимая F5. Вы можете подумать, что мне не нужно нажимать F5, потому что вы настроили механизм для автоматической отправки изменений с сервера. Но если я обычный пользователь, могу поспорить, я все равно перезагружу страницу. Может быть, мой браузер неправильно перерисовал экран, или я просто хочу быть уверенным, что у меня самые последние котировки акций.

API могут быть апатридами, человеческое взаимодействие — нет

В отличие от внутреннего запроса через RESTful API, взаимодействие человека с веб-сайтом не является апатридом. Как веб-пользователь, я думаю о своем посещении вашего сайта как о сеансе, почти как о телефонном звонке. Я ожидаю, что браузер запомнит данные о моем сеансе точно так же, как когда я звоню в вашу службу продаж или поддержки, я ожидаю, что представитель запомнит то, что было сказано ранее во время звонка.

Очевидным примером данных сеанса является то, вошел ли я в систему, и если да, то под каким пользователем. После того, как я пройду через экран входа в систему, я смогу свободно перемещаться по пользовательским страницам сайта. Если я открываю ссылку в новой вкладке или окне и вижу другой экран входа в систему, это не очень удобно для пользователя.

Другой пример — содержимое корзины покупок на сайте электронной коммерции. Если нажатие F5 опустошает корзину, пользователи, скорее всего, расстроятся.

В традиционном многостраничном приложении, написанном на PHP, данные сеанса будут храниться в суперглобальном массиве $_SESSION. Но в SPA это должно быть где-то на стороне клиента. Существует четыре основных варианта хранения данных сеанса в SPA:

  • Печенье
  • Идентификатор фрагмента
  • веб-хранилище
  • ИндекседБД

Четыре килобайта файлов cookie

Файлы cookie — это старая форма веб-хранилища в браузере. Изначально они предназначались для хранения данных, полученных от сервера, в одном запросе и отправки их обратно на сервер в последующих запросах. Но из JavaScript вы можете использовать файлы cookie для хранения практически любых данных, до ограничения размера 4 КБ на файл cookie. AngularJS предлагает модуль ngCookies для управления файлами cookie. Существует также пакет js-cookies, который обеспечивает аналогичную функциональность в любом фреймворке.

Имейте в виду, что любой созданный вами файл cookie будет отправляться на сервер при каждом запросе, будь то перезагрузка страницы или запрос Ajax. Но если основные данные сеанса, которые вам нужно сохранить, — это токен доступа для вошедшего в систему пользователя, вы все равно хотите, чтобы он отправлялся на сервер при каждом запросе. Естественно попытаться использовать эту автоматическую передачу файлов cookie в качестве стандартного средства указания маркера доступа для запросов Ajax.

Вы можете возразить, что использование файлов cookie таким образом несовместимо с архитектурой RESTful. Но в этом случае все в порядке, поскольку каждый запрос через API по-прежнему не имеет состояния, имеет некоторые входы и некоторые выходы. Просто один из входов отправляется забавным образом через куки. Если вы можете настроить так, чтобы запрос API входа в систему также отправлял токен доступа обратно в файл cookie, тогда вашему коду на стороне клиента вряд ли вообще нужно иметь дело с файлами cookie. Опять же, это просто еще один вывод из запроса, возвращенного необычным образом.

Файлы cookie предлагают одно преимущество перед веб-хранилищем. Вы можете установить флажок «Оставаться в системе» в форме входа. Что касается семантики, я ожидаю, что если я оставлю его неотмеченным, я останусь в системе, если перезагружу страницу или открою ссылку в новой вкладке или окне, но я гарантированно выйду из системы после закрытия браузера. Это важная функция безопасности, если я использую общий компьютер. Как мы увидим позже, веб-хранилище не поддерживает такое поведение.

Так как же этот подход может работать на практике? Предположим, вы используете LoopBack на стороне сервера. Вы определили модель Person, расширив встроенную модель User, добавив свойства, которые вы хотите сохранить для каждого пользователя. Вы настроили модель Person для предоставления через REST. Теперь вам нужно настроить server/server.js, чтобы добиться желаемого поведения файлов cookie. Ниже приведен server/server.js, начиная с того, что было сгенерировано петлей slc, с отмеченными изменениями:

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

Первое изменение настраивает синтаксический анализатор файлов cookie для использования «секрета» в качестве секрета подписи файлов cookie, что позволяет использовать подписанные файлы cookie. Вам нужно сделать это, потому что, хотя LoopBack ищет токен доступа в любом из файлов cookie «авторизация» или «access_token», он требует, чтобы такой файл cookie был подписан. На самом деле это требование бессмысленно. Подписание файла cookie предназначено для гарантии того, что файл cookie не был изменен. Но нет никакой опасности, что вы измените токен доступа. Ведь можно было отправить токен доступа в неподписанном виде, как обычный параметр. Таким образом, вам не нужно беспокоиться о секрете подписи файла cookie, который трудно угадать, если только вы не используете подписанные файлы cookie для чего-то другого.

Второе изменение устанавливает некоторую постобработку для методов Person.login и Person.logout. Для Person.login вы хотите взять полученный токен доступа и отправить его клиенту в качестве подписанного файла cookie «авторизация». Клиент может добавить еще одно свойство к параметру учетных данных, Rememberme, указывающее, следует ли сделать cookie постоянным на 2 недели. Значение по умолчанию верно. Сам метод входа проигнорирует это свойство, но постпроцессор его проверит.

Для Person.logout вы хотите удалить этот файл cookie.

Вы можете сразу увидеть результаты этих изменений в StrongLoop API Explorer. Обычно после запроса Person.login вам нужно будет скопировать токен доступа, вставить его в форму в правом верхнем углу и нажать «Установить токен доступа». Но с этими изменениями вам не нужно ничего этого делать. Токен доступа автоматически сохраняется как «авторизация» файла cookie и отправляется обратно при каждом последующем запросе. Когда Проводник отображает заголовки ответа из Person.login, он пропускает файл cookie, поскольку JavaScript никогда не может видеть заголовки Set-Cookie. Но будьте уверены, печенье есть.

На стороне клиента при перезагрузке страницы вы увидите, существует ли «авторизация» файла cookie. Если это так, вам необходимо обновить свою запись текущего идентификатора пользователя. Вероятно, самый простой способ сделать это — сохранить идентификатор пользователя в отдельном файле cookie при успешном входе в систему, чтобы вы могли получить его при перезагрузке страницы.

Идентификатор фрагмента

Поскольку я посещаю веб-сайт, реализованный как SPA, URL-адрес в адресной строке моего браузера может выглядеть примерно так: «https://example.com/#/my-photos/37». Часть этого идентификатора фрагмента, «#/my-photos/37», уже представляет собой набор информации о состоянии, которую можно рассматривать как данные сеанса. В данном случае я, вероятно, просматриваю одну из своих фотографий, ту, чей ID равен 37.

Вы можете решить встроить другие данные сеанса в идентификатор фрагмента. Вспомните, что в предыдущем разделе с токеном доступа, хранящимся в «авторизации» файла cookie, вам все еще нужно было каким-то образом отслеживать идентификатор пользователя. Один из вариантов — сохранить его в отдельном файле cookie. Но другой подход — встроить его в идентификатор фрагмента. Вы можете решить, что пока я вхожу в систему, все страницы, которые я посещаю, будут иметь идентификатор фрагмента, начинающийся с «#/u/XXX», где XXX — это идентификатор пользователя. Таким образом, в предыдущем примере идентификатор фрагмента может быть «#/u/59/my-photos/37», если мой идентификатор пользователя равен 59.

Теоретически вы можете встроить сам токен доступа в идентификатор фрагмента, избегая необходимости в файлах cookie или веб-хранилище. Но это было бы плохой идеей. Тогда мой токен доступа будет виден в адресной строке. Любой, кто заглянет мне через плечо с камерой, мог сделать снимок экрана, тем самым получив доступ к моей учетной записи.

И последнее замечание: можно настроить SPA так, чтобы он вообще не использовал идентификаторы фрагментов. Вместо этого он использует обычные URL-адреса, такие как «http://example.com/app/dashboard» и «http://example.com/app/my-photos/37», при этом сервер настроен на возврат HTML-кода верхнего уровня для вашего SPA в ответ на запрос любого из этих URL-адресов. Затем ваш SPA выполняет маршрутизацию на основе пути (например, «/app/dashboard» или «/app/my-photos/37») вместо идентификатора фрагмента. Он перехватывает клики по навигационным ссылкам и использует History.pushState() для отправки нового URL-адреса, а затем продолжает маршрутизацию, как обычно. Он также прослушивает события popstate, чтобы обнаружить, что пользователь нажал кнопку «Назад», и снова продолжает маршрутизацию по восстановленному URL-адресу. Подробное описание того, как это реализовать, выходит за рамки этой статьи. Но если вы используете этот метод, то, очевидно, вы можете хранить данные сеанса в пути вместо идентификатора фрагмента.

Веб-хранилище

Веб-хранилище — это механизм JavaScript для хранения данных в браузере. Как и файлы cookie, веб-хранилище является отдельным для каждого источника. Каждый сохраненный элемент имеет имя и значение, оба из которых являются строками. Но веб-хранилище совершенно невидимо для сервера и предлагает гораздо большую емкость, чем файлы cookie. Существует два типа веб-хранилищ: локальное хранилище и хранилище сеансов.

Элемент локального хранилища отображается на всех вкладках всех окон и сохраняется даже после закрытия браузера. В этом отношении он ведет себя как файл cookie с датой истечения срока действия в далеком будущем. Таким образом, он подходит для хранения токена доступа в случае, когда пользователь отметил «оставаться в системе» в форме входа.

Элемент хранилища сеанса отображается только на вкладке, на которой он был создан, и исчезает при закрытии этой вкладки. Это сильно отличает его время жизни от срока действия любого файла cookie. Напомним, что файл cookie сеанса по-прежнему виден на всех вкладках всех окон.

Если вы используете SDK AngularJS для LoopBack, клиентская сторона будет автоматически использовать веб-хранилище для сохранения токена доступа и идентификатора пользователя. Это происходит в сервисе LoopBackAuth в js/services/lb-services.js. Он будет использовать локальное хранилище, если только параметр RememberMe не установлен в false (обычно это означает, что флажок «оставлять меня в системе» не установлен), и в этом случае он будет использовать хранилище сеанса.

В результате, если я войду в систему со снятым флажком «оставаться в системе», а затем открою ссылку в новой вкладке или окне, я не войду туда. Скорее всего я увижу экран входа. Вы можете решить для себя, является ли это приемлемым поведением. Некоторые могут посчитать это хорошей функцией, когда у вас может быть несколько вкладок, каждая из которых зарегистрирована как другой пользователь. Или вы можете решить, что почти никто больше не использует общие компьютеры, поэтому вы можете просто убрать флажок «оставлять меня в системе».

Итак, как будет выглядеть обработка данных сеанса, если вы решите использовать AngularJS SDK для LoopBack? Предположим, у вас та же ситуация, что и раньше, на стороне сервера: вы определили модель Person, расширили модель User и предоставили модель Person через REST. Вы не будете использовать файлы cookie, поэтому вам не потребуются какие-либо изменения, описанные ранее.

На стороне клиента, где-то в вашем внешнем контроллере, у вас, вероятно, есть переменная, такая как $scope.currentUserId, которая содержит userId текущего пользователя, вошедшего в систему, или null, если пользователь не вошел в систему. Затем, чтобы правильно обрабатывать перезагрузку страницы, вы просто включите этот оператор в функцию конструктора для этого контроллера:

 $scope.currentUserId = Person.getCurrentId();

Это так просто. Добавьте «Человек» в качестве зависимости вашего контроллера, если это еще не сделано.

ИндекседБД

IndexedDB — это новое средство для хранения больших объемов данных в браузере. Вы можете использовать его для хранения данных любого типа JavaScript, таких как объект или массив, без необходимости их сериализации. Все запросы к базе данных являются асинхронными, поэтому вы получаете обратный вызов после завершения запроса.

Вы можете использовать IndexedDB для хранения структурированных данных, не связанных с какими-либо данными на сервере. Примером может быть календарь, список дел или сохраненные игры, в которые можно играть локально. В этом случае приложение действительно является локальным, а ваш веб-сайт — всего лишь средством его доставки.

В настоящее время Internet Explorer и Safari имеют лишь частичную поддержку IndexedDB. Другие основные браузеры поддерживают его полностью. Однако на данный момент существует одно серьезное ограничение: Firefox полностью отключает IndexedDB в режиме приватного просмотра.

В качестве конкретного примера использования IndexedDB давайте возьмем приложение скользящей головоломки от Павола Даниша и настроим его так, чтобы после каждого перемещения сохранялось состояние первой головоломки, базовой скользящей головоломки 3x3, основанной на логотипе AngularJS. Перезагрузка страницы восстановит состояние этой первой головоломки.

Я создал форк репозитория с этими изменениями, все они находятся в app/js/puzzle/slidingPuzzle.js. Как видите, даже элементарное использование IndexedDB требует значительных усилий. Я просто покажу основные моменты ниже. Во-первых, функция восстановления вызывается во время загрузки страницы, чтобы открыть базу данных 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); }; } };

Событие request.onupgradeneeded обрабатывает случай, когда база данных еще не существует. В этом случае мы создаем хранилище объектов.

Когда база данных открыта, вызывается функция restore2 , которая ищет запись с заданным ключом (в данном случае это будет константа Basic):

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

Если такая запись существует, ее значение заменяет массив сетки головоломки. Если при восстановлении игры возникает какая-либо ошибка, мы просто перемешиваем плитки, как и раньше. Обратите внимание, что сетка представляет собой массив тайлов 3x3, каждый из которых довольно сложен. Большим преимуществом IndexedDB является то, что вы можете хранить и извлекать такие значения без необходимости их сериализации.

Мы используем $apply , чтобы сообщить AngularJS, что модель была изменена, поэтому представление будет соответствующим образом обновлено. Это связано с тем, что обновление происходит внутри обработчика событий DOM, поэтому в противном случае AngularJS не смог бы обнаружить изменение. По этой причине любому приложению AngularJS, использующему IndexedDB, вероятно, потребуется использовать $apply.

После любого действия, которое может изменить массив сетки, например перемещения пользователя, вызывается функция сохранения, которая добавляет или обновляет запись с соответствующим ключом на основе обновленного значения сетки:

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

Остальные изменения заключаются в вызове вышеуказанных функций в соответствующее время. Вы можете просмотреть коммит, показывающий все изменения. Обратите внимание, что мы вызываем восстановление только для основной головоломки, а не для трех сложных головоломок. Мы используем тот факт, что у трех сложных головоломок есть атрибут API, поэтому для них мы просто выполняем обычную перетасовку.

Что, если бы мы хотели сохранить и восстановить сложные головоломки? Это потребует некоторой реструктуризации. В каждой из расширенных головоломок пользователь может настроить исходный файл изображения и размеры головоломки. Таким образом, нам пришлось бы увеличить значение, хранящееся в IndexedDB, чтобы включить эту информацию. Что еще более важно, нам нужен способ обновить их из восстановления. Это слишком много для и без того длинного примера.

Заключение

В большинстве случаев веб-хранилище — лучший выбор для хранения данных сеанса. Он полностью поддерживается всеми основными браузерами и предлагает гораздо больший объем памяти, чем файлы cookie.

Вы должны использовать файлы cookie, если ваш сервер уже настроен на их использование или если вам нужно, чтобы данные были доступны на всех вкладках всех окон, но вы также хотите убедиться, что они будут удалены при закрытии браузера.

Вы уже используете идентификатор фрагмента для хранения данных сеанса, характерных для этой страницы, таких как идентификатор фотографии, на которую смотрит пользователь. Хотя вы можете встроить другие данные сеанса в идентификатор фрагмента, на самом деле это не дает никаких преимуществ перед веб-хранилищем или файлами cookie.

Использование IndexedDB, вероятно, потребует гораздо больше кода, чем любой другой метод. Но если значения, которые вы сохраняете, являются сложными объектами JavaScript, которые будет сложно сериализовать, или если вам нужна транзакционная модель, то это может оказаться целесообразным.