跨页面重新加载持久化数据:Cookie、IndexedDB 和中间的一切

已发表: 2022-03-11

假设我正在访问一个网站。 我右键单击其中一个导航链接并选择在新窗口中打开链接。 应该发生什么? 如果我像大多数用户一样,我希望新页面的内容与我直接单击链接的内容相同。 唯一的区别应该是页面出现在一个新窗口中。 但是,如果您的网站是单页应用程序 (SPA),您可能会看到奇怪的结果,除非您对此案例进行了仔细计划。

回想一下,在 SPA 中,典型的导航链接通常是片段标识符,以井号 (#) 开头。 直接点击链接不会重新加载页面,所以保存在 JavaScript 变量中的所有数据都会被保留。 但如果我在新选项卡或窗口中打开链接,浏览器会重新加载页面,重新初始化所有 JavaScript 变量。 因此,绑定到这些变量的任何 HTML 元素都会以不同的方式显示,除非您已采取措施以某种方式保留该数据。

跨页面重新加载持久化数据:Cookie、IndexedDB 和中间的一切

跨页面重新加载持久化数据:Cookie、IndexedDB 和中间的一切
鸣叫

如果我明确地重新加载页面,例如按 F5,也会出现类似的问题。 您可能认为我不需要按 F5,因为您已经设置了一种机制来自动从服务器推送更改。 但如果我是一个典型的用户,你可以打赌我仍然会重新加载页面。 也许我的浏览器似乎错误地重新绘制了屏幕,或者我只是想确定我有最新的股票报价。

API 可能是无状态的,但人机交互不是

与通过 RESTful API 的内部请求不同,人类用户与网站的交互不是无状态的。 作为一名网络用户,我将访问您的网站视为一次会话,几乎就像一个电话。 我希望浏览器能够记住有关我的会话的数据,就像当我致电您的销售或支持热线时,我希望代表能够记住之前通话中所说的内容。

会话数据的一个明显示例是我是否已登录,如果是,作为哪个用户登录。 通过登录屏幕后,我应该能够自由浏览网站的用户特定页面。 如果我在新选项卡或新窗口中打开一个链接,然后看到另一个登录屏幕,这对用户来说不是很友好。

另一个例子是电子商务网站中购物车的内容。 如果按 F5 清空购物车,用户可能会感到不安。

在用 PHP 编写的传统多页面应用程序中,会话数据将存储在 $_SESSION 超全局数组中。 但在 SPA 中,它需要位于客户端的某个位置。 在 SPA 中存储会话数据有四个主要选项:

  • 饼干
  • 片段标识符
  • 网络存储
  • 索引数据库

四 KB 的 Cookie

Cookie 是浏览器中一种较旧的网络存储形式。 它们最初的目的是将从服务器接收到的数据存储在一个请求中,并在后续请求中将其发送回服务器。 但是在 JavaScript 中,您可以使用 cookie 存储几乎任何类型的数据,每个 cookie 的大小限制为 4 KB。 AngularJS 提供了用于管理 cookie 的 ngCookies 模块。 还有一个 js-cookies 包可以在任何框架中提供类似的功能。

请记住,您创建的任何 cookie 都会在每次请求时发送到服务器,无论是页面重新加载还是 Ajax 请求。 但是,如果您需要存储的主要会话数据是登录用户的访问令牌,那么无论如何您都希望在每次请求时将其发送到服务器。 尝试使用这种自动 cookie 传输作为为 Ajax 请求指定访问令牌的标准方法是很自然的。

您可能会争辩说,以这种方式使用 cookie 与 RESTful 架构不兼容。 但在这种情况下,这很好,因为通过 API 的每个请求仍然是无状态的,有一些输入和一些输出。 只是其中一个输入是通过 cookie 以一种有趣的方式发送的。 如果您可以安排登录 API 请求也将访问令牌发送回 cookie,那么您的客户端代码几乎不需要处理 cookie。 同样,这只是请求以不寻常的方式返回的另一个输出。

与网络存储相比,Cookie 提供了一项优势。 您可以在登录表单上提供“保持登录”复选框。 使用语义,我希望如果我不选中它,那么如果我重新加载页面或在新选项卡或窗口中打开链接,我将保持登录状态,但一旦我关闭浏览器,我保证会被注销。 如果我使用共享计算机,这是一项重要的安全功能。 正如我们稍后将看到的,Web 存储不支持这种行为。

那么这种方法在实践中如何发挥作用呢? 假设您在服务器端使用 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 解析器配置为使用 'secret' 作为 cookie 签名密钥,从而启用签名 cookie。 您需要这样做,因为尽管 LoopBack 在 cookie 'authorization' 或 'access_token' 中查找访问令牌,但它要求对此类 cookie 进行签名。 其实这个要求是没有意义的。 签署 cookie 旨在确保 cookie 未被修改。 但是你没有修改访问令牌的危险。 毕竟,您可以将访问令牌作为普通参数以无​​符号形式发送。 因此,您不必担心难以猜测 cookie 签名秘密,除非您将签名 cookie 用于其他用途。

第二个更改为 Person.login 和 Person.logout 方法设置了一些后处​​理。 对于Person.login ,您希望获取生成的访问令牌并将其作为签名的 cookie“授权”发送给客户端。 客户端可以在凭据参数中再添加一个属性,rememberme,指示是否使 cookie 持续 2 周。 默认值为真。 login 方法本身会忽略这个属性,但是后处理器会检查它。

对于Person.logout ,您要清除此 cookie。

您可以立即在 StrongLoop API Explorer 中看到这些更改的结果。 通常在发出 Person.login 请求后,您必须复制访问令牌,将其粘贴到右上角的表单中,然后单击设置访问令牌。 但是有了这些改变,你就不必做任何这些了。 访问令牌会自动保存为 cookie“授权”,并在每个后续请求中发回。 当资源管理器显示来自 Person.login 的响应标头时,它会忽略 cookie,因为 JavaScript 永远不允许查看 Set-Cookie 标头。 但请放心,cookie 就在那里。

在客户端,在重新加载页面时,您会看到 cookie“授权”是否存在。 如果是这样,您需要更新当前 userId 的记录。 可能最简单的方法是在成功登录时将 userId 存储在单独的 cookie 中,这样您就可以在页面重新加载时检索它。

片段标识符

当我访问一个作为 SPA 实现的网站时,我的浏览器地址栏中的 URL 可能类似于“https://example.com/#/my-photos/37”。 这个片段标识符部分,“#/my-photos/37”,已经是可以被视为会话数据的状态信息的集合。 在这种情况下,我可能正在查看我的一张照片,即 ID 为 37 的照片。

您可以决定在片段标识符中嵌入其他会话数据。 回想一下,在上一节中,使用存储在 cookie“授权”中的访问令牌,您仍然需要以某种方式跟踪 userId。 一种选择是将其存储在单独的 cookie 中。 但另一种方法是将其嵌入片段标识符中。 您可以决定,当我登录时,我访问的所有页面都有一个以“#/u/XXX”开头的片段标识符,其中 XXX 是 userId。 所以在前面的例子中,如果我的 userId 是 59,片段标识符可能是“#/u/59/my-photos/37”。

从理论上讲,您可以将访问令牌本身嵌入到片段标识符中,从而避免对 cookie 或 Web 存储的任何需求。 但那将是一个坏主意。 然后我的访问令牌将在地址栏中可见。 任何用相机从我肩上看过去的人都可以拍摄屏幕快照,从而访问我的帐户。

最后一点:可以设置 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 上进行路由。 如何实现这一点的全部细节超出了本文的范围。 但是如果您使用这种技术,那么显然您可以将会话数据存储在路径中而不是片段标识符中。

网络存储

Web 存储是 JavaScript 在浏览器中存储数据的一种机制。 与 cookie 一样,每个来源的 Web 存储都是独立的。 每个存储的项目都有一个名称和一个值,两者都是字符串。 但是网络存储对服务器来说是完全不可见的,它提供的存储容量比 cookie 大得多。 Web 存储有两种类型:本地存储和会话存储。

一个本地存储项在所有窗口的所有选项卡中都可见,并且即使在浏览器关闭后仍然存在。 在这方面,它的行为有点像一个过期日期很远的cookie。 因此,它适合在用户在登录表单上选中“让我登录”的情况下存储访问令牌。

会话存储项仅在创建它的选项卡中可见,并且在关闭该选项卡时消失。 这使得它的生命周期与任何 cookie 的生命周期都大不相同。 回想一下,会话 cookie 在所有窗口的所有选项卡中仍然可见。

如果您使用 AngularJS SDK for LoopBack,客户端将自动使用 Web 存储来保存访问令牌和 userId。 这发生在 js/services/lb-services.js 中的 LoopBackAuth 服务中。 它将使用本地存储,除非 rememberMe 参数为 false(通常意味着未选中“保持登录”复选框),在这种情况下它将使用会话存储。

结果是,如果我在未选中“保持登录”的情况下登录,然后在新选项卡或窗口中打开一个链接,我将不会在那里登录。 我很可能会看到登录屏幕。 您可以自行决定这是否是可接受的行为。 有些人可能认为这是一个不错的功能,您可以在其中拥有多个选项卡,每个选项卡都以不同的用户身份登录。 或者您可能决定几乎没有人再使用共享计算机,因此您可以完全省略“让我保持登录”复选框。

那么,如果您决定使用 AngularJS SDK for LoopBack,会话数据处理会是什么样子呢? 假设您在服务器端遇到了与以前相同的情况:您已经定义了一个 Person 模型,扩展了 User 模型,并且您已经通过 REST 公开了 Person 模型。 您不会使用 cookie,因此您不需要前面描述的任何更改。

在客户端,在最外层控制器的某个地方,您可能有一个变量,例如 $scope.currentUserId ,它保存当前登录用户的 userId,如果用户未登录,则为 null。然后要正确处理页面重新加载,您只需将此语句包含在该控制器的构造函数中:

 $scope.currentUserId = Person.getCurrentId();

就这么容易。 添加“Person”作为控制器的依赖项(如果还没有的话)。

索引数据库

IndexedDB 是一种用于在浏览器中存储大量数据的新工具。 您可以使用它来存储任何 JavaScript 类型的数据,例如对象或数组,而无需对其进行序列化。 所有对数据库的请求都是异步的,所以当请求完成时你会得到一个回调。

您可以使用 IndexedDB 存储与服务器上的任何数据无关的结构化数据。 一个示例可能是日历、待办事项列表或在本地玩的已保存游戏。 在这种情况下,应用程序实际上是本地应用程序,您的网站只是交付它的工具。

目前,Internet Explorer 和 Safari 仅部分支持 IndexedDB。 其他主要浏览器完全支持它。 不过,目前一个严重的限制是 Firefox 在隐私浏览模式下完全禁用 IndexedDB。

作为使用 IndexedDB 的具体示例,让我们以 Pavol Daniš 的滑动拼图应用程序为例,在每次移动后对其进行调整以保存第一个拼图的状态,即基于 AngularJS 徽标的基本 3x3 滑动拼图。 然后重新加载页面将恢复第一个拼图的状态。

我已经使用这些更改设置了存储库的一个分支,所有这些都位于 app/js/puzzle/slidingPuzzle.js 中。 正如您所看到的,即使是对 IndexedDB 的基本使用也相当复杂。 我将只展示下面的亮点。 首先,在页面加载期间调用函数 restore 来打开 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; }); } }; }

如果存在这样的记录,则其值将替换拼图的网格数组。 如果恢复游戏有任何错误,我们只是像以前一样洗牌。 请注意,grid 是一个 3x3 的 tile 对象数组,每个对象都相当复杂。 IndexedDB 的最大优点是您可以存储和检索这些值而无需序列化它们。

我们使用$apply通知 AngularJS 模型已更改,因此视图将适当更新。 这是因为更新发生在 DOM 事件处理程序中,因此 AngularJS 将无法检测到更改。 由于这个原因,任何使用 IndexedDB 的 AngularJS 应用程序都可能需要使用 $apply。

在任何会改变网格数组的动作之后,例如用户的移动,函数 save 被调用,它根据更新的网格值使用适当的键添加或更新记录:

 /* * 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 中的值以包含此信息。 更重要的是,我们需要一种从恢复中更新它们的方法。 对于这个已经很长的例子来说,这有点多。

结论

在大多数情况下,Web 存储是存储会话数据的最佳选择。 所有主要浏览器都完全支持它,并且它提供比 cookie 更大的存储容量。

如果您的服务器已设置为使用 cookie,或者如果您需要在所有窗口的所有选项卡中访问数据,您将使用 cookie,但您还希望确保在关闭浏览器时将其删除。

您已经使用片段标识符来存储特定于该页面的会话数据,例如用户正在查看的照片的 ID。 虽然您可以在片段标识符中嵌入其他会话数据,但这并没有提供任何优于 Web 存储或 cookie 的优势。

使用 IndexedDB 可能需要比任何其他技术更多的编码。 但是,如果您存储的值是难以序列化的复杂 JavaScript 对象,或者如果您需要事务模型,那么它可能是值得的。