在多種情況下,我需要發送一個包含某些數據的HTTP 請求來記錄用戶執行的操作,例如導航到其他頁面或提交表單。考慮一下這個例子,它在點擊鏈接時將一些信息發送到外部服務:
<a href="https://www.php.cn/link/3cbfb2330b21840b385a45c958602663">Go to Page</a> document.getElementById('link').addEventListener('click', (e) => { fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: "data" }) }); });
這裡沒有什麼特別複雜的地方。鏈接可以像往常一樣工作(我沒有使用e.preventDefault()
),但在該行為發生之前,會觸發一個POST 請求。無需等待任何響應。我只想將其發送到我正在訪問的任何服務。
乍一看,您可能會認為該請求的調度是同步的,之後我們將繼續從頁面導航離開,而其他服務器成功處理該請求。但事實證明,情況並非總是如此。
當發生某些事件終止瀏覽器中的頁面時,不能保證正在處理的HTTP 請求會成功(有關頁面的生命週期“終止”和其他狀態的更多信息)。這些請求的可靠性可能取決於多種因素——網絡連接、應用程序性能,甚至外部服務的配置本身。
因此,在這些時刻發送數據可能遠非可靠,如果您依賴這些日誌來做出數據敏感的業務決策,則可能會出現一個潛在的重大問題。
為了說明這種不可靠性,我設置了一個小型Express 應用程序,其中包含一個使用上面代碼的頁面。當點擊鏈接時,瀏覽器導航到/other
,但在發生這種情況之前,會發出一個POST 請求。
雖然一切都在發生,但我打開了瀏覽器的“網絡”選項卡,並且正在使用“慢速3G”連接速度。頁面加載後,我清除了日誌,一切看起來都很安靜:
但是,一旦點擊鏈接,事情就會出錯。當導航發生時,請求會被取消。
這讓我們幾乎無法確信外部服務實際上能夠處理該請求。為了驗證此行為,當我們使用window.location
以編程方式導航時,也會發生這種情況:
document.getElementById('link').addEventListener('click', (e) => { e.preventDefault(); // 請求已排隊,但在導航發生後立即被取消。 fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: 'data' }), }); window.location = e.target.href; });
無論導航如何或何時發生以及活動頁面何時終止,這些未完成的請求都可能被放棄。
問題的根本原因是,默認情況下,XHR 請求(通過fetch
或XMLHttpRequest
)是異步且非阻塞的。一旦請求排隊,請求的實際工作就會傳遞給後台的瀏覽器級API。
就性能而言,這是很好的——您不希望請求佔用主線程。但這也意味著,當頁面進入“終止”狀態時,它們有被放棄的風險,無法保證任何後台工作都能完成。以下是Google 對該特定生命週期狀態的總結:
一旦頁面開始卸載並被瀏覽器從內存中清除,它就處於終止狀態。在此狀態下,無法啟動任何新任務,如果正在進行的任務運行時間過長,則可能會被終止。
簡而言之,瀏覽器的設計假設是,當頁面被關閉時,無需繼續處理其排隊的任何後台進程。
避免此問題的最明顯方法可能是,盡可能地延遲用戶操作,直到請求返迴響應。過去,這是通過使用XMLHttpRequest
中支持的同步標誌來錯誤地完成的。但是使用它會完全阻塞主線程,導致許多性能問題——我過去已經寫過一些關於這方面的內容——所以這個想法甚至不應該被考慮。事實上,它即將退出平台(Chrome v80 已經將其刪除了)。
相反,如果您要採取這種方法,最好等待Promise 解析為返回的響應。返回後,您可以安全地執行該行為。使用我們之前的代碼片段,這可能看起來像這樣:
document.getElementById('link').addEventListener('click', async (e) => { e.preventDefault(); // 等待響應返回…… await fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: 'data' }), }); // ……然後導航離開。 window.location = e.target.href; });
這可以完成工作,但有一些非微不足道的缺點。
首先,它會通過延遲所需行為的發生來影響用戶體驗。收集分析數據肯定有益於業務(並有益於未來的用戶),但這遠非理想,因為它讓當前的用戶為實現這些好處付出代價。更不用說,作為一個外部依賴項,服務本身的任何延遲或其他性能問題都會反映給用戶。如果來自您的分析服務的超時導致客戶無法完成高價值操作,那麼每個人都會輸。
其次,這種方法並不像最初聽起來那樣可靠,因為某些終止行為無法以編程方式延遲。例如, e.preventDefault()
在延遲用戶關閉瀏覽器選項卡方面毫無用處。因此,充其量,它只會涵蓋收集某些用戶操作的數據,但不足以完全信任它。
值得慶幸的是,大多數瀏覽器都內置了保留未完成HTTP 請求的選項,而無需影響用戶體驗。
如果在使用fetch()
時將keepalive
標誌設置為true
,則即使啟動該請求的頁面已終止,相應的請求也會保持打開狀態。使用我們的初始示例,這將使實現看起來像這樣:
<a href="https://www.php.cn/link/3cbfb2330b21840b385a45c958602663">Go to Page</a> document.getElementById('link').addEventListener('click', (e) => { fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: "data" }), keepalive: true }); });
當點擊該鏈接並發生頁面導航時,不會發生請求取消:
相反,我們得到了一個(未知)狀態,僅僅是因為活動頁面從未等待接收任何響應。
這樣的單行代碼很容易修復,尤其是在它是常用瀏覽器API 的一部分時。但是,如果您正在尋找具有更簡單界面的更集中的選項,則還有另一種方法,其瀏覽器支持幾乎相同。
Navigator.sendBeacon()
函數專門用於發送單向請求(信標)。一個基本的實現如下所示,它發送一個帶有字符串化JSON 和“text/plain” Content-Type
的POST:
navigator.sendBeacon('/log', JSON.stringify({ some: "data" }));
但是此API 不允許您發送自定義標頭。因此,為了將我們的數據發送為“application/json”,我們需要進行一個小小的調整併使用Blob:
<a href="https://www.php.cn/link/3cbfb2330b21840b385a45c958602663">Go to Page</a> document.getElementById('link').addEventListener('click', (e) => { const blob = new Blob([JSON.stringify({ some: "data" })], { type: 'application/json; charset=UTF-8' }); navigator.sendBeacon('/log', blob); });
最終,我們得到了相同的結果——即使在頁面導航後也能完成請求。但是還有一些事情正在發生,這可能會使其優於fetch()
:信標以低優先級發送。
為了演示,以下是在同時使用帶有keepalive
的fetch()
和sendBeacon()
時在“網絡”選項卡中顯示的內容:
默認情況下, fetch()
獲取“高”優先級,而信標(上面標註為“ping”類型)具有“最低”優先級。對於對頁面功能不重要的請求,這是一件好事。直接來自信標規範:
此規範定義了一個接口,該接口[……]最大限度地減少了與其他時間關鍵型操作的資源競爭,同時確保此類請求仍被處理並傳遞到目的地。
換句話說, sendBeacon()
確保其請求不會妨礙對您的應用程序和用戶體驗真正重要的請求。
值得一提的是,越來越多的瀏覽器支持ping
屬性。當附加到鏈接時,它會發出一個小的POST 請求:
<a href="https://www.php.cn/link/fef56cae0dfbabedeadb64bf881ab64f" ping="http://localhost:3000/log"> Go to Other Page </a>
這些請求標頭將包含單擊鏈接的頁面(ping-from),以及該鏈接的href 值(ping-to):
<code>headers: { 'ping-from': 'http://localhost:3000/', 'ping-to': 'https://www.php.cn/link/fef56cae0dfbabedeadb64bf881ab64f' 'content-type': 'text/ping' // ...其他标头},</code>
它在技術上類似於發送信標,但有一些值得注意的限制:
總而言之,如果您只發送簡單的請求並且不想編寫任何自定義JavaScript,那麼ping
是一個不錯的工具。但是,如果您需要發送更多內容,它可能不是最好的選擇。
使用帶有keepalive
的fetch()
或sendBeacon()
發送最後一秒請求肯定存在權衡。為了幫助辨別哪種方法最適合不同的情況,以下是一些需要考慮的事項:
我選擇深入研究瀏覽器處理頁面終止時進程內請求的方式是有原因的。不久前,在我們開始在提交表單時立即觸發請求後,我們的團隊發現特定類型的分析日誌的頻率突然發生了變化。這種變化是突然且顯著的——與我們歷史上看到的相比下降了約30%。
深入研究此問題的原因以及避免再次出現此問題的可用工具挽救了一天。因此,如果有什麼的話,我希望了解這些挑戰的細微之處能夠幫助某人避免我們遇到的某些痛苦。快樂記錄!
以上是當用戶離開頁面時,可靠地發送HTTP請求的詳細內容。更多資訊請關注PHP中文網其他相關文章!