離開頁面時,你知道如何可靠地發送一個 HTTP 請求嗎?
在某些情況下,當用戶跳轉到其他頁面或者提交一個表單的時候,我需要發送一個 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 請求能夠成功完成(了解更多[1]關于頁面的“終止”以及頁面生命周期的其他狀態)。這些請求的可信度取決于多個因素 —— 網絡連接、程序性能甚至是外部服務器自身的配置。
因此,這種情況下發出的數據可靠性很糟,如果你的業務決策依賴這些日志數據,這可能會帶來一個潛在的重大隱患。
為了說明這種場景的不可靠性,我編寫了一個基于 Express 的簡單應用,并使用以上代碼實現了一個頁面。當點擊鏈接時,瀏覽器會導航到 /other,但此之前,會觸發一個 POST 請求。
開始之前,我會將開發者工具的“網絡”標簽打開,使用“低速3G”連接速度。一旦頁面加載完成,我就清除日志,事情看起來相當正常:
圖片1
但是一旦我單擊了鏈接,事情就不太對了。當頁面導航發生的時候,POST 請求就被取消了。
圖片2
這使得我們對外部服務實際上能夠處理完這個請求沒有足夠的信心。為了驗證這個行為,當我們以編程方式使用 ??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。
從性能考慮,這是正確的行為——你并不會希望主線程被請求給堵塞。但是這會帶來一個風險,就是當頁面進入“終止”狀態時,這些請求會被拋棄,這就導致了在后臺運行的服務不能保證正確完成。這是谷歌對于這個特定生命周期狀態的總結[2]:
頁面瀏覽器開始卸載頁面并對其內存清理時,該頁面就進入終止狀態。在此狀態下,不會執行任何新任務[3],同時正在處理中的任務如果運行時間過長可能會被殺死。
簡單來說,瀏覽器的設計是基于這樣的假設:只要頁面關閉時,后臺隊列中的任何進程都不需要再繼續執行。
所以我們有沒有別的選擇?
似乎避免這個問題最直接的方法是盡可能地延遲用戶操作,直到請求的響應返回。在過去,通過使用 XMLHttpRequest 支持的同步標志[4]來實現。但這是錯誤的,因為使用這種方式會完全的阻斷主線程,從而造成一大堆的性能問題——關于這個問題我曾寫過一些東西[5]——所以不要考慮這種方式了。事實上,平臺也正在移除這種方式(Chrome v80+ 已經將其移除[6])。
即使你仍打算采用這種方式,也最好使用 Promise 并在其響應返回時執行 resolve。這樣你就可以安全地執行該行為。對上面我們示例的代碼進行修改:
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() 方法時,如果把 keeplive 標志[7]設置為 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
事實上,我們是留下了一個(unknown)狀態,這只是因為活動頁面不會等待接收任何類型的響應。
只需要添加這樣一行代碼,使得修復這個問題看起來很簡單,特別是當它被常見瀏覽器的 API 支持時。但如果你想尋找一個更專業的接口方式,還有另外一種幾乎相同受到瀏覽器支持的方法。
使用 Navigator.sendBeacon() 方法
??sendbeacon()?
? 方法專門用于發送單向請求(beacons[8])。一個基本的實現是這樣的,發送一個帶有 JSON 字符串和一個 Content-Type 是 "text/plain" 的 POST 請求:
navigator.sendBeacon('/log', JSON.stringify({
some: "data"
}));
但是這個 API 并不允許你設置自定義的 headers。所以,為了方便我們使用 "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() 更有優勢: beacons 以低優先級發送。
為了演示說明,以下是 Network 選項卡中同時使用帶 keepalive 的 fetch() 和 sendBeacon() 時的情況:
圖片4
默認情況下,fetch() 獲得一個 “高” 優先級,而 beacon(上圖中的 “ping” 類型) 具有 “最低” 優先級。對于那些對頁面功能不是很重要的請求,這是一件好事。直接引用 Beacon規范[9]:
該規范定義了一個接口,該接口 […] 在確保此類請求仍然得到處理并交付到目的地的情況下,最大限度地減少了其與其他時間敏感操作的資源競爭。
換個說法就是,sendBeacon() 方法確保了那些程序中真正的關鍵過程和用戶體驗不會受到影響。
給 ping 屬性榮譽提名
值得一提的是越來越多的瀏覽器開始支持 ping 屬性[10]。當在鏈接上設置該屬性時,鏈接被點擊時會觸發一個小型的 POST 請求:
<a href="http://localhost:3000/other" ping="http://localhost:3000/log">
Go to Other Page
</a>
這些請求 headers 里會帶著鏈接所在頁面的地址(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. 大部分瀏覽器支持的很好,但不是所有[11]。在撰寫本文時,Firefox還沒有默認啟用這個功能。
3. 你不能使用其發送自定義的數據。如前面提到的,除了請求本身包含的 header 信息外,你最多在 header 中額外獲得幾個 ping-*。
考慮以上所有因素,如果你只是要求發送簡單的請求,并且不想編寫任何自定義 JavaScript,那么 ping 是一個很好的工具。但如果你需要發送一些更有意義的東西,這就不是最好的選擇。
那么,究竟應該如何選擇?
是使用 keep-alive 標志的 fetch,還是用 sendBeacon 來發送頁面終止時的請求肯定需要權衡。以下建議或許可以幫助你在不同情況下做出正確的選擇:
以下情況可以選擇 fetch() + keepalive:
- 你需要簡單的發送自定義 headers 的請求
- 你需要使用 GET 而非 POST
- 你需要兼容老舊的瀏覽器(例如 IE),并已經有了一個 fetch 方法的 polyfill
以下情況使用 sendBeacon() 或許更好:
- 你只需要發送一個簡單的服務請求,而不需要太多的定制化
- 你喜歡更簡約更優雅的代碼方式
- 你需要保證該請求不會和其他更重要的請求競爭資源
不要再踩我踩過的坑
我之所以會去深入探究頁面終止時瀏覽器是如何處理進行中的請求,是因為一段時間以前,我的團隊發現,當我們開始在表單提交時發送特定分析請求后,該類型的分析日志的收集率突然發生了變化。這一變化是突然而顯著的——比之前下降了約30%。
通過深入研究這個問題產生的原因,找到了避免它的工具,從而挽救了局面。所以,如果可以的話,我希望我對這些小挑戰的理解,能夠幫助你們避免那些我們曾踩過的坑。讓記日志變得更加愉快!
參考資料
[1]了解更多: ?https://developers.google.com/web/updates/2018/07/page-lifecycle-api?
[2]這是谷歌對于這個特定生命周期狀態的總結: ?https://developers.google.com/web/updates/2018/07/page-lifecycle-api#states?
[3]新任務: ?https://html.spec.whatwg.org/multipage/webappapis.html#queue-a-task?
[4]同步標志: ?https://xhr.spec.whatwg.org/#synchronous-flag?
[5]關于這個問題我曾寫過一些東西: ?https://macarthur.me/posts/use-web-workers-for-your-event-listeners?
[6]已經將其移除: ?https://developers.google.com/web/updates/2019/12/chrome-80-deps-rems?
[7]keeplive 標志: ?https://fetch.spec.whatwg.org/#request-keepalive-flag?
[8]beacons: ?https://w3c.github.io/beacon/#sec-processing-model?
[9]Beacon規范: ?https://www.w3.org/TR/beacon/?
[10]ping 屬性: ?https://css-tricks.com/the-ping-attribute-on-anchor-links/?
[11]但不是所有: ?https://caniuse.com/ping?
[12]參考原文: ??https://css-tricks.com/send-an-http-request-on-page-exit/??