如何在用户離開頁面時可靠地發送 HTTP 請求

語言: CN / TW / HK

集美美圖 3.0 發佈,慶祝建黨偉業和香港回顧祖國25週年,活動期間註冊即送7天體驗會員!

有幾次,當用户執行導航到不同頁面或提交表單等操作時,我需要發送帶有一些數據的 HTTP 請求以進行記錄。考慮這個在點擊鏈接時向外部服務發送一些信息的人為示例:

<a href="/some-other-page" id="link">Go to Page</a>

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

這裏沒有什麼非常複雜的事情發生。該鏈接可以正常運行(我沒有使 e.preventDefault() ),但在該行為發生之前,會在單擊時觸發 POST 請求。無需等待任何形式的響應。我只是希望它被髮送到我正在訪問的任何服務。

乍一看,您可能希望該請求的分派是同步的,之後我們將繼續導航離開頁面,而其他服務器成功地處理該請求。但事實證明,情況並非總是如此。

瀏覽器不保證保留打開的HTTP請求

當瀏覽器中發生終止頁面的情況時,並不能保證進程內的HTTP請求會成功(參見更多關於“終止”和頁面生命週期的其他狀態)。這些請求的可靠性可能取決於幾個方面——網絡連接、應用程序性能,甚至外部服務本身的配置。

因此,在這些時刻發送數據可能是不可靠的,如果您依賴這些日誌來做出數據敏感的業務決策,那麼這可能會帶來一個潛在的重大問題。

為了幫助説明這種不可靠性,我使用上面包含的代碼設置了一個帶有頁面的小型 Express 應用程序。單擊鏈接時,瀏覽器會導航到 /other,但在此之前,會觸發 POST 請求。

當一切都發生時,我打開了瀏覽器的網絡選項卡,並且我使用的是“慢 3G”連接速度。一旦頁面加載並且我已經清除了日誌,事情看起來很安靜:

1.webp

但是一旦鏈接被點擊,事情就會出錯,當導航發生時,請求被取消。

2.webp

這使得我們對外部服務是否能夠處理請求缺乏信心。為了驗證這種行為,當我們使用window.location以編程方式導航時也會發生這種情況:

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

無論導航以何種方式或何時發生,以及活動頁面何時終止,那些未完成的請求都有被放棄的風險。

但是為什麼被取消了呢?

問題的根源在於,默認情況下,XHR 請求(通過 fetch 或 XMLHttpRequest)是異步且非阻塞的。一旦請求被排隊,請求的實際工作就會被移交給幕後的瀏覽器級 API。

由於它與性能有關,這很好——你不希望請求佔用主線程。但這也意味着當頁面進入“終止”狀態時,它們有被遺棄的風險,無法保證任何幕後工作都能完成。以下是 Google 對特定生命週期狀態的總結:

一旦頁面開始被瀏覽器卸載並從內存中清除,頁面就處於終止狀態。在這種狀態下沒有新的任務可以啟動,並且正在進行的任務如果運行時間過長可能會被殺死。

簡而言之,瀏覽器的設計假設當一個頁面被關閉時,沒有必要繼續處理它排隊的任何後台進程。

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

避免這個問題最明顯的方法可能是,儘可能地延遲用户操作,直到請求返回響應。在過去,通過使用XMLHttpRequest中支持的同步標誌來實現這一點是錯誤的。使用它會完全阻塞主線程,導致大量的性能問題——我在過去寫過一些這方面的文章——所以這個想法甚至不應該被接受。事實上,它正在退出平台(Chrome v80+已經刪除了它)。

相反,如果您打算採用這種類型的方法,那麼最好在響應返回時等待Promise解析。在它回來之後,您可以安全地執行該行為。使用我們之前的代碼片段,它可能看起來像這樣:

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

這可以完成工作,但也有一些不小的缺點。

首先,它會延遲所需行為的發生,從而損害用户體驗 。收集分析數據肯定會給業務(以及未來的用户)帶來好處,但讓當前用户為實現這些好處而支付成本並不理想。更不用説,作為一個外部依賴項,服務本身的任何延遲或其他性能問題都會暴露給用户。如果分析服務的暫停導致客户無法完成一項高價值的行動,那麼所有人都是輸家。

其次,這種方法並不像它最初聽起來那麼可靠,因為一些終止行為無法通過編程延遲 。例如, e.preventDefault() 無法延遲某人關閉瀏覽器選項卡。因此,它充其量只能涵蓋為某些用户操作收集數據,但不足以全面信任它。

指示瀏覽器保留未完成的請求

值得慶幸的是,有一些選項可以保留絕大多數瀏覽器中內置的未完成的 HTTP 請求,並且不需要損害用户體驗。

使用Fetch的keepalive標誌

如果在使用fetch()時將keepalive標誌設置為true,那麼相應的請求將保持打開狀態,即使發起該請求的頁面被終止。使用我們最初的例子,它的實現如下所示:

<a href="/some-other-page" id="link">Go to Page</a>

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

當點擊該鏈接並進行頁面導航時,不會發生請求取消:

3.webp

相反,我們得到的是一個(未知)狀態,原因很簡單,活動頁面從來沒有等待接收任何響應。

像這樣的一行程序很容易修復,特別是當它是常用瀏覽器API的一部分時。但是,如果您正在尋找一種功能更集中、界面更簡單的選擇,那麼還有另一種方法,它實際上具有相同的瀏覽器支持。

使用 Navigator.sendBeacon()

Navigator.sendBeacon() 函數專門用於發送單向請求(beacon)。一個基本的實現看起來像這樣,發送一個帶有字符串化 JSON 和“text/plain” Content-Type 的 POST:

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

但是此 API 不允許您發送自定義標頭。因此,為了讓我們以“application/json”的形式發送數據,我們需要做一些小調整並使用 Blob:

<a href="/some-other-page" id="link">Go to Page</a>

<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>

最後,我們得到了相同的結果——即使在頁面導航之後也允許完成的請求。但是還有一些事情可能使它比 fetch() 更有優勢:beacon以低優先級發送。

為了演示,當同時使用帶有 keepalive 的 fetch() 和 sendBeacon() 時,Network 選項卡中顯示的內容如下:

4.webp

默認情況下,fetch() 獲得“高”優先級,而beacon(上面稱為“ping”類型)具有“最低”優先級。對於對頁面功能不重要的請求,這是一件好事。直接取自 Beacon 規範:

該規範定義了一個接口,[…]最大限度地減少與其他時間關鍵操作的資源爭用,同時確保此類請求仍然被處理並交付到目的地。

換句話説,sendBeacon() 確保它的請求不會妨礙那些對您的應用程序和用户體驗真正重要的請求。

因為ping屬性而被光榮提及

值得一提的是,越來越多的瀏覽器支持 ping 屬性。當附加到鏈接時,它會觸發一個小的 POST 請求:

<a href="http://localhost:3000/other" ping="http://localhost:3000/log">
  Go to Other Page
</a>

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

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

它在技術上類似於發送beacon,但有一些明顯的限制:

  1. 它嚴格限制在鏈接上的使用 ,如果您需要跟蹤與其他交互相關的數據,例如按鈕點擊或表單提交,這將使其無法啟動。

  2. 瀏覽器支持很好,但不是很好 。在撰寫本文時,Firefox 特別沒有默認啟用它。

  3. 您無法隨請求一起發送任何自定義數據 。正如前面提到的,您最多隻能得到幾個 ping-* 頭文件,以及隨程序一起出現的任何其他頭文件。

綜合考慮,如果您可以發送簡單的請求並且不想編寫任何自定義 JavaScript,那麼 ping 是一個很好的工具。但是,如果您需要發送更多實質內容,則可能不是最好的選擇。

那麼,我應該選擇哪一個呢?

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

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

  • 您需要輕鬆地隨請求傳遞自定義標頭。

  • 您想向服務發出 GET 請求,而不是 POST。

  • 您正在支持較舊的瀏覽器(如 IE)並且已經加載了 fetch polyfill。

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

  • 您正在進行簡單的服務請求,不需要進行太多定製。

  • 您更喜歡更簡潔、更優雅的 API。

  • 您希望確保您的請求不會與應用程序中發送的其他高優先級請求競爭。

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

作者:Alex MacArthur

翻譯:前端全棧開發者

往期精文