跨頁面重新加載持久化數據:Cookie、IndexedDB 和中間的一切
已發表: 2022-03-11假設我正在訪問一個網站。 我右鍵單擊其中一個導航鏈接並選擇在新窗口中打開鏈接。 應該發生什麼? 如果我像大多數用戶一樣,我希望新頁面的內容與我直接單擊鏈接的內容相同。 唯一的區別應該是頁面出現在一個新窗口中。 但是,如果您的網站是單頁應用程序 (SPA),您可能會看到奇怪的結果,除非您對此案例進行了仔細計劃。
回想一下,在 SPA 中,典型的導航鏈接通常是片段標識符,以井號 (#) 開頭。 直接點擊鏈接不會重新加載頁面,所以保存在 JavaScript 變量中的所有數據都會被保留。 但如果我在新選項卡或窗口中打開鏈接,瀏覽器會重新加載頁面,重新初始化所有 JavaScript 變量。 因此,綁定到這些變量的任何 HTML 元素都會以不同的方式顯示,除非您已採取措施以某種方式保留該數據。
如果我明確地重新加載頁面,例如按 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 對象,或者如果您需要事務模型,那麼它可能是值得的。