事件模型與停留時間
事件模型決定一次瀏覽在資料裡留下幾列、每列各自承載什麼。起點的模型是一次瀏覽一列、在頁面載入時寫下(模組二建立的那個)——它能回答「某頁被打開幾次」,回答不了「打開之後發生了什麼」。頁面被打開一秒就關掉,跟被讀了三分鐘、中途捲動了十次,在資料上完全相同。
補上這個能力需要第二則事件,而第二則事件的設計比第一則複雜:載入的時機明確且必定發生,離開的時機則有多種可能,且不保證每次都能送出。
進入與離開拆成兩則事件
模型是每次瀏覽產生兩列:進入事件在頁面載入時送出,離開事件在頁面不再可見時送出,兩列靠 session 識別碼與路徑配對起來。
1// send():把物件轉成 JSON 字串、用 navigator.sendBeacon 送出(模組二建立)
2// deviceType():回傳 mobile / tablet / desktop(模組二建立)
3// visitorId / sessionId:兩層隨機識別碼(見〈訪客識別與 opt-out〉)
4var base = { path: location.pathname, lang: navigator.language, dev: deviceType(),
5 vid: visitorId, sid: sessionId };
6
7// 進入:載入時立刻送,帶來源網址
8send(Object.assign({ t: "view", ref: document.referrer || "" }, base));
9
10// 離開:帶停留秒數與是否互動過
11var arrivedAt = Date.now();
12var interacted = false;
13var leaveSent = false;
14
15["scroll", "pointerdown", "keydown"].forEach(function (evt) {
16 addEventListener(evt, function () { interacted = true; }, { once: true, passive: true });
17});
18
19function sendLeave() {
20 if (leaveSent) return;
21 leaveSent = true;
22 send(Object.assign({
23 t: "leave",
24 dur: Math.round((Date.now() - arrivedAt) / 1000),
25 act: interacted ? 1 : 0
26 }, base));
27}拆成兩列而不是合併成一列,是為了讓兩件事各自失敗。 合併的設計會是「等到離開時才送一列,裡面同時放路徑與停留時間」——它看起來省一半的列數,代價是離開事件送不出去時,這次瀏覽在資料裡完全不存在。而離開事件送不出去是會實際發生的,至少有這些途徑:行動裝置被系統回收記憶體、瀏覽器被強制關閉、裝置斷電、網路在切換基地台時中斷、廣告或隱私擴充功能攔截這個請求、以及接收端在流量尖峰撞到併發上限而拒絕。拆開之後,最壞的結果只損失品質欄位,計數的基準線不受影響。
這份丟失清單在後面還有一個用途。辨識自動化流量會用「沒有離開事件」當作機器的候選訊號,而上面每一條途徑都會讓真人產生同樣的形態——行動流量佔比高的站尤其明顯。判讀那個訊號時要一併想起這一段。
這個取捨的另一面是列數翻倍。試算表容量的影響見模組三的資料模型與容量邊界,執行配額(併發上限與觸發器時間)的影響見模組五的配額碰撞。個人網站的量體下這個代價可以接受;量體大到需要壓縮時,正確的方向是在接收端合併而不是在前端少送——前端少送會讓資料從一開始就不存在。
用 visibilitychange 觸發離開事件
離開事件的觸發時機有三個候選,可靠度差距很大。
beforeunload 不適用。 行動版 Safari 不支援它,所以 iOS 上的所有瀏覽器都不會觸發;而在 Firefox,註冊這個監聽器會讓頁面失去進入 bfcache 的資格——那是瀏覽器把整個頁面狀態留在記憶體裡、讓上一頁 / 下一頁能瞬間還原的機制,失去資格代表讀者按返回時要重新載入——為了統計而拖慢讀者的返回操作,取捨方向是錯的。各家瀏覽器一致排除出 bfcache 的其實是 unload,現代瀏覽器的 beforeunload 本身已不影響 bfcache,但 Firefox 這個例外加上行動端完全不支援,已經足以讓它出局。
pagehide 涵蓋頁面卸載與進入快取兩種時機(event.persisted 可區分兩者),比 beforeunload 可靠,但漏掉「切換到別的分頁或別的 app」這個最常見的離開方式。
visibilitychange 進入 hidden 狀態涵蓋範圍最廣:切換分頁、導覽到新頁、切換 app、行動裝置鎖定螢幕、最小化或關閉瀏覽器都會走到。規格把「轉為 hidden」描述成頁面最後一個能可靠觀察到的事件。兩者都註冊、靠旗標防止重複送出:
1addEventListener("visibilitychange", function () {
2 if (document.visibilityState === "hidden") sendLeave();
3});
4addEventListener("pagehide", sendLeave);sendLeave 開頭那個 leaveSent 旗標在這裡是必要的而非防禦性的——一次真實的離開通常會同時觸發這兩個事件。
它帶來一個容易漏掉的交互作用:頁面進入 bfcache 時 JS 狀態整個保留下來,讀者按返回還原頁面之後,leaveSent 仍然是 true、arrivedAt 仍然停在上一次載入的時刻。不處理的話,這次閱讀不會產生任何記錄,而在快取裡待的那段時間會被算進上一筆的停留秒數。修法是監聽 pageshow,在 event.persisted 為真時把三個狀態變數重置並補送一則進入事件:
1addEventListener("pageshow", function (evt) {
2 if (!evt.persisted) return; // 一般載入不需要處理
3 leaveSent = false;
4 interacted = false;
5 arrivedAt = Date.now();
6 send(Object.assign({ t: "view", ref: document.referrer || "" }, base));
7});送出方式是 navigator.sendBeacon(見beacon 知識卡),它的設計目標正是這個場景:頁面正在關閉時,一般的請求會隨著頁面銷毀而被取消,而 sendBeacon 把資料排進瀏覽器的傳送佇列、不阻擋頁面卸載。
它保證的是「不延遲卸載」而非「一定送達」——排進佇列之後的傳送結果不會回報,行動裝置被切走或強制關閉時仍可能沒送出去。同步能得知的只有排隊本身成不成功:佇列有 64 KiB 的上限,超過或排隊失敗時回傳 false。流量事件的 payload 遠小於這個上限,但要監控收集鏈健康度時,這個回傳值是唯一在瀏覽器端可見的失敗訊號。
停留秒數對應的是可見時長
這個欄位的語意需要精確界定,否則會被當成閱讀時長使用而得出錯誤結論。它量的是「頁面從載入到不再可見之間經過的時間」,這與讀者實際花在這篇文章上的時間有系統性的差距。
差距來自兩個方向。切換到別的分頁去查資料再切回來,會被切成多段——第一段在切走時就結束了,切回來不會產生新的進入事件(頁面沒有重新載入),所以回來之後的閱讀時間完全沒有被記錄。反過來,開著分頁去做別的事,如果沒有切換到其他分頁(例如直接離開電腦),這段時間會全部算進停留秒數。
站內導覽時還有一個可以驗證的現象:離開事件與下一頁進入事件的時間戳落在同一兩秒內,因為舊頁進入 hidden 與新頁開始載入是同一個動作的兩面。時間戳由接收端在寫入時補上,所以這個間隔還包含網路傳輸與接收端排隊,不是純粹的瀏覽器事件間隔。用它重建連續的閱讀路徑時,配對條件要留這個寬容範圍。
這個欄位因此適合分辨量級、不適合精確比較,而分界由誤差來源決定:中途切換到別的分頁會讓後半段完全不計,所以量測誤差可以達到一次分頁切換的完整時長——查一個名詞、回一則訊息都是數十秒的量級。比較的粒度要大於這個誤差,否則差異可能全部來自切換行為而非閱讀。分桶而不是比大小,是這個限制下唯一穩健的用法。
互動旗標
act 記錄的是「這次瀏覽期間有沒有發生過捲動、點擊或按鍵」。它用 { once: true } 註冊,第一次觸發後就移除監聽器——需要的資訊只是「有沒有發生過」,不是次數,持續監聽只會增加沒有用途的執行成本。
passive: true 這個選項對捲動事件是必要的:它向瀏覽器宣告這個監聽器不會取消預設行為,讓捲動不必等待 JS 執行完成。統計程式碼不該是頁面捲動卡頓的來源。
這個旗標的判讀價值在於它與停留秒數的組合。停留六秒且沒有任何互動,與停留六秒且捲動過,是兩種完全不同的閱讀行為——前者是頁面被打開然後放著,後者是有人真的在看。單獨看秒數分不出這兩者。
資料模型變更會讓既有的報表公式換語意
這個變更有一個容易被漏掉的下游影響:在單事件模型下寫的統計公式,在雙事件模型下仍然合法執行,但回答的已經不是同一個問題。
具體的例子是判斷「這次 session 只看了一頁」。單事件模型下,這等價於「同一個 session 識別碼只出現一次」:
1COUNTIFS(session欄, 當列session) = 1改成雙事件之後,每次瀏覽至少產生兩列,這個條件從此永遠不成立。公式沒有報錯、沒有回傳錯誤值,只是那個分類的計數變成恆為零——而零看起來就像「沒有這種流量」。正確的寫法要把事件型別納入條件:
1COUNTIFS(session欄, 當列session, 事件欄, "view") = 1同一個影響也及於模組四的每日彙總:那段 group by 對每一列加一,在單事件模型下等於數瀏覽次數,改成雙事件之後多數瀏覽會被數兩次。倍率不是固定的二——離開事件本來就會丟失一部分,實際倍率落在一與二之間、且隨行動裝置佔比浮動。這正是這類失效難察覺的原因:固定倍數還有機會被看出來,浮動的比例只會讓數字「看起來變多了」。修法是在迴圈裡先篩掉非進入事件,模組四的程式碼旁已標明這個前提。
因此資料模型的變更範圍必須包含下游的分析邏輯,把公式與彙總程式的重算當成同一次變更的一部分,而不是之後再說。延後處理會失去對照:之後回頭看時,看到的數字已經是新邏輯算出來的,沒有東西可以比對。這類失效的完整判讀方法見假故障與靜默失效的診斷,抽象層的原則見 #250 資料多出一種形狀時,既有分析邏輯靜默換語意。
下一步
事件模型完成後,資料裡有了停留時間與互動訊號。這兩個欄位除了描述閱讀行為,也比環境屬性難偽裝,因此是分辨真人與機器的主要依據——前提是先知道它們在真人身上也會缺席。怎麼組合它們與其他訊號、以及怎麼量出自己站上的真人基線,見辨識自動化流量。
#automation #beacon #analytics #visibilitychange #sendbeacon #data-model