有幾次,當(dāng)用戶執(zhí)行導(dǎo)航到不同頁面或提交表單等操作時(shí),我需要發(fā)送帶有一些數(shù)據(jù)的 HTTP 請(qǐng)求以進(jìn)行記錄??紤]這個(gè)在點(diǎn)擊鏈接時(shí)向外部服務(wù)發(fā)送一些信息的人為示例:


(相關(guān)資料圖)

Go to Page<script>document.getElementById("link").addEventListener("click", (e) => { fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: "data" }) });});</script>

這里沒有什么非常復(fù)雜的事情發(fā)生。該鏈接可以正常運(yùn)行(我沒有使e.preventDefault()),但在該行為發(fā)生之前,會(huì)在單擊時(shí)觸發(fā) POST 請(qǐng)求。無需等待任何形式的響應(yīng)。我只是希望它被發(fā)送到我正在訪問的任何服務(wù)。

乍一看,您可能希望該請(qǐng)求的分派是同步的,之后我們將繼續(xù)導(dǎo)航離開頁面,而其他服務(wù)器成功地處理該請(qǐng)求。但事實(shí)證明,情況并非總是如此。

瀏覽器不保證保留打開的HTTP請(qǐng)求

當(dāng)瀏覽器中發(fā)生終止頁面的情況時(shí),并不能保證進(jìn)程內(nèi)的HTTP請(qǐng)求會(huì)成功(參見更多關(guān)于“終止”和頁面生命周期的其他狀態(tài))。這些請(qǐng)求的可靠性可能取決于幾個(gè)方面——網(wǎng)絡(luò)連接、應(yīng)用程序性能,甚至外部服務(wù)本身的配置。因此,在這些時(shí)刻發(fā)送數(shù)據(jù)可能是不可靠的,如果您依賴這些日志來做出數(shù)據(jù)敏感的業(yè)務(wù)決策,那么這可能會(huì)帶來一個(gè)潛在的重大問題。為了幫助說明這種不可靠性,我使用上面包含的代碼設(shè)置了一個(gè)帶有頁面的小型 Express 應(yīng)用程序。單擊鏈接時(shí),瀏覽器會(huì)導(dǎo)航到 /other,但在此之前,會(huì)觸發(fā) POST 請(qǐng)求。當(dāng)一切都發(fā)生時(shí),我打開了瀏覽器的網(wǎng)絡(luò)選項(xiàng)卡,并且我使用的是“慢 3G”連接速度。一旦頁面加載并且我已經(jīng)清除了日志,事情看起來很安靜:

1.webp

但是一旦鏈接被點(diǎn)擊,事情就會(huì)出錯(cuò),當(dāng)導(dǎo)航發(fā)生時(shí),請(qǐng)求被取消。

2.webp

這使得我們對(duì)外部服務(wù)是否能夠處理請(qǐng)求缺乏信心。為了驗(yàn)證這種行為,當(dāng)我們使用window.location以編程方式導(dǎo)航時(shí)也會(huì)發(fā)生這種情況:

document.getElementById("link").addEventListener("click", (e) => {+ e.preventDefault(); // Request is queued, but cancelled as soon as navigation occurs. fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: "data" }), });+ window.location = e.target.href;});

無論導(dǎo)航以何種方式或何時(shí)發(fā)生,以及活動(dòng)頁面何時(shí)終止,那些未完成的請(qǐng)求都有被放棄的風(fēng)險(xiǎn)。

但是為什么被取消了呢?

問題的根源在于,默認(rèn)情況下,XHR 請(qǐng)求(通過 fetch 或 XMLHttpRequest)是異步且非阻塞的。一旦請(qǐng)求被排隊(duì),請(qǐng)求的實(shí)際工作就會(huì)被移交給幕后的瀏覽器級(jí) API。由于它與性能有關(guān),這很好——你不希望請(qǐng)求占用主線程。但這也意味著當(dāng)頁面進(jìn)入“終止”狀態(tài)時(shí),它們有被遺棄的風(fēng)險(xiǎn),無法保證任何幕后工作都能完成。以下是 Google 對(duì)特定生命周期狀態(tài)的總結(jié):

一旦頁面開始被瀏覽器卸載并從內(nèi)存中清除,頁面就處于終止?fàn)顟B(tài)。在這種狀態(tài)下沒有新的任務(wù)可以啟動(dòng),并且正在進(jìn)行的任務(wù)如果運(yùn)行時(shí)間過長(zhǎng)可能會(huì)被殺死。

簡(jiǎn)而言之,瀏覽器的設(shè)計(jì)假設(shè)當(dāng)一個(gè)頁面被關(guān)閉時(shí),沒有必要繼續(xù)處理它排隊(duì)的任何后臺(tái)進(jìn)程。

那么,我們有哪些選擇呢?

避免這個(gè)問題最明顯的方法可能是,盡可能地延遲用戶操作,直到請(qǐng)求返回響應(yīng)。在過去,通過使用XMLHttpRequest中支持的同步標(biāo)志來實(shí)現(xiàn)這一點(diǎn)是錯(cuò)誤的。使用它會(huì)完全阻塞主線程,導(dǎo)致大量的性能問題——我在過去寫過一些這方面的文章——所以這個(gè)想法甚至不應(yīng)該被接受。事實(shí)上,它正在退出平臺(tái)(Chrome v80+已經(jīng)刪除了它)。相反,如果您打算采用這種類型的方法,那么最好在響應(yīng)返回時(shí)等待Promise解析。在它回來之后,您可以安全地執(zhí)行該行為。使用我們之前的代碼片段,它可能看起來像這樣:

document.getElementById("link").addEventListener("click", async (e) => { e.preventDefault(); // Wait for response to come back... await fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: "data" }), }); // ...and THEN navigate away. window.location = e.target.href;});

這可以完成工作,但也有一些不小的缺點(diǎn)。首先,它會(huì)延遲所需行為的發(fā)生,從而損害用戶體驗(yàn)。收集分析數(shù)據(jù)肯定會(huì)給業(yè)務(wù)(以及未來的用戶)帶來好處,但讓當(dāng)前用戶為實(shí)現(xiàn)這些好處而支付成本并不理想。更不用說,作為一個(gè)外部依賴項(xiàng),服務(wù)本身的任何延遲或其他性能問題都會(huì)暴露給用戶。如果分析服務(wù)的暫停導(dǎo)致客戶無法完成一項(xiàng)高價(jià)值的行動(dòng),那么所有人都是輸家。其次,這種方法并不像它最初聽起來那么可靠,因?yàn)橐恍┙K止行為無法通過編程延遲。例如, e.preventDefault() 無法延遲某人關(guān)閉瀏覽器選項(xiàng)卡。因此,它充其量只能涵蓋為某些用戶操作收集數(shù)據(jù),但不足以全面信任它。

指示瀏覽器保留未完成的請(qǐng)求

值得慶幸的是,有一些選項(xiàng)可以保留絕大多數(shù)瀏覽器中內(nèi)置的未完成的 HTTP 請(qǐng)求,并且不需要損害用戶體驗(yàn)。

使用Fetch的keepalive標(biāo)志

如果在使用fetch()時(shí)將keepalive標(biāo)志設(shè)置為true,那么相應(yīng)的請(qǐng)求將保持打開狀態(tài),即使發(fā)起該請(qǐng)求的頁面被終止。使用我們最初的例子,它的實(shí)現(xiàn)如下所示:

Go to Page<script> document.getElementById("link").addEventListener("click", (e) => { fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: "data" }), keepalive: true }); });</script>

當(dāng)點(diǎn)擊該鏈接并進(jìn)行頁面導(dǎo)航時(shí),不會(huì)發(fā)生請(qǐng)求取消:

3.webp

相反,我們得到的是一個(gè)(未知)狀態(tài),原因很簡(jiǎn)單,活動(dòng)頁面從來沒有等待接收任何響應(yīng)。像這樣的一行程序很容易修復(fù),特別是當(dāng)它是常用瀏覽器API的一部分時(shí)。但是,如果您正在尋找一種功能更集中、界面更簡(jiǎn)單的選擇,那么還有另一種方法,它實(shí)際上具有相同的瀏覽器支持。

使用 Navigator.sendBeacon()

Navigator.sendBeacon() 函數(shù)專門用于發(fā)送單向請(qǐng)求(beacon)。一個(gè)基本的實(shí)現(xiàn)看起來像這樣,發(fā)送一個(gè)帶有字符串化 JSON 和“text/plain” Content-Type 的 POST:

navigator.sendBeacon("/log", JSON.stringify({ some: "data"}));

但是此 API 不允許您發(fā)送自定義標(biāo)頭。因此,為了讓我們以“application/json”的形式發(fā)送數(shù)據(jù),我們需要做一些小調(diào)整并使用 Blob:

Go to Page<script> document.getElementById("link").addEventListener("click", (e) => { const blob = new Blob([JSON.stringify({ some: "data" })], { type: "application/json; charset=UTF-8" }); navigator.sendBeacon("/log", blob)); });</script>

最后,我們得到了相同的結(jié)果——即使在頁面導(dǎo)航之后也允許完成的請(qǐng)求。但是還有一些事情可能使它比 fetch() 更有優(yōu)勢(shì):beacon以低優(yōu)先級(jí)發(fā)送。為了演示,當(dāng)同時(shí)使用帶有 keepalive 的 fetch() 和 sendBeacon() 時(shí),Network 選項(xiàng)卡中顯示的內(nèi)容如下:

4.webp

默認(rèn)情況下,fetch() 獲得“高”優(yōu)先級(jí),而beacon(上面稱為“ping”類型)具有“最低”優(yōu)先級(jí)。對(duì)于對(duì)頁面功能不重要的請(qǐng)求,這是一件好事。直接取自 Beacon 規(guī)范:

該規(guī)范定義了一個(gè)接口,[…]最大限度地減少與其他時(shí)間關(guān)鍵操作的資源爭(zhēng)用,同時(shí)確保此類請(qǐng)求仍然被處理并交付到目的地。

換句話說,sendBeacon() 確保它的請(qǐng)求不會(huì)妨礙那些對(duì)您的應(yīng)用程序和用戶體驗(yàn)真正重要的請(qǐng)求。

因?yàn)閜ing屬性而被光榮提及

值得一提的是,越來越多的瀏覽器支持 ping 屬性。當(dāng)附加到鏈接時(shí),它會(huì)觸發(fā)一個(gè)小的 POST 請(qǐng)求:

Go to Other Page

這些請(qǐng)求標(biāo)頭將包含單擊鏈接的頁面(ping-from),以及該鏈接的 href 值(ping-to):

headers: { "ping-from": "http://localhost:3000/", "ping-to": "http://localhost:3000/other" "content-type": "text/ping" // ...other headers},

它在技術(shù)上類似于發(fā)送beacon,但有一些明顯的限制:

它嚴(yán)格限制在鏈接上的使用,如果您需要跟蹤與其他交互相關(guān)的數(shù)據(jù),例如按鈕點(diǎn)擊或表單提交,這將使其無法啟動(dòng)。

瀏覽器支持很好,但不是很好。在撰寫本文時(shí),F(xiàn)irefox 特別沒有默認(rèn)啟用它。

您無法隨請(qǐng)求一起發(fā)送任何自定義數(shù)據(jù)。正如前面提到的,您最多只能得到幾個(gè)ping-*頭文件,以及隨程序一起出現(xiàn)的任何其他頭文件。

綜合考慮,如果您可以發(fā)送簡(jiǎn)單的請(qǐng)求并且不想編寫任何自定義 JavaScript,那么 ping 是一個(gè)很好的工具。但是,如果您需要發(fā)送更多實(shí)質(zhì)內(nèi)容,則可能不是最好的選擇。

那么,我應(yīng)該選擇哪一個(gè)呢?

使用 fetch 和 keepalive 或 sendBeacon() 發(fā)送您的最后一秒請(qǐng)求肯定存在權(quán)衡。為了幫助辨別哪種方法最適合不同的情況,需要考慮以下幾點(diǎn):

如果出現(xiàn)以下情況,您可能會(huì)使用 fetch() + keepalive:

您需要輕松地隨請(qǐng)求傳遞自定義標(biāo)頭。您想向服務(wù)發(fā)出 GET 請(qǐng)求,而不是 POST。您正在支持較舊的瀏覽器(如 IE)并且已經(jīng)加載了 fetch polyfill。

但在以下情況下 sendBeacon() 可能是更好的選擇:

您正在進(jìn)行簡(jiǎn)單的服務(wù)請(qǐng)求,不需要進(jìn)行太多定制。您更喜歡更簡(jiǎn)潔、更優(yōu)雅的 API。您希望確保您的請(qǐng)求不會(huì)與應(yīng)用程序中發(fā)送的其他高優(yōu)先級(jí)請(qǐng)求競(jìng)爭(zhēng)。

原文:https://css-tricks.com/send-an-http-request-on-page-exit/作者:Alex MacArthur

標(biāo)簽: HTTP