手把手教你搭建一個無框架埋點體系
背景
埋點體系構成
一般來說,一個完整的埋點體系由以下三個部分構成:
-
應用
-
數據分析平臺
-
數據平臺 SDK
埋點上報是將應用層事件上傳至上層平臺的過程。比方說,在某購物網站上,用戶點擊了「收藏」按鈕,此時,一個點擊事件就生成了,這一事件會被上報至一個數據分析平臺。這樣,相關的數據分析師、產品經理、運營等同學便可以在數據分析平臺,通過這些上報的事件數據分析,得出應用中可以優化的方方面面。由此可見,埋點上報是每個產品走向卓越的重要一環。
通過以上描述,我們認識了埋點上報過程的兩大主角:應用與數據分析平臺。從前端技術的角度來說,我們通常還需要第三個角色的助攻,那就是數據平臺 SDK. 這個 SDK 封裝了數據分析平臺的各種接口,暴露出簡單的方法讓我們進行調用,實現簡易的埋點上傳。
兩種埋點事件
我們可以把應用層事件分爲兩大類:
-
「頁面事件」:一種是通用的「頁面事件」,比如說用戶在應用某個頁面的停留及活躍時長,我們希望這種全局的埋點只用在項目初始化時注入一次,不需要在代碼中進行維護。
-
「觸發事件」:另一種則是自定義的「觸發事件」,比如點擊某個特定的按鈕,開啓某個特定的流程,這種事件需要前端同學在代碼中手動注入埋點。
我們爲這兩種事件分別開發了一套埋點上傳 SDK。下面,我們就來詳細地講解一下這兩套 SDK 的技術知識。
處理「頁面事件」的 SDK - monitor-tracer
monitor-tracer
是一個用來監控頁面及組件可見時長和活躍時長的前端 SDK,同時也是 Monitor 前端埋點體系的一個核心組成部分。
背景
爲了更好地理解用戶對各個業務功能的使用狀況,從而進行相應的產品優化和調整:
-
對於一般的網頁應用,我們需要對用戶在應用某個頁面的停留及活躍時長進行相應的統計;
-
對於大盤 / 看板 / dashboard 類型的頁面(如下圖所示),我們希望在頁面維度的基礎上,更進一步地統計每個組件對用戶的可見時長,從而對它們的排列順序和內容進行優化。
一個 dashboard 類頁面
基於以上需求,我們開發了 monitor-tracer
SDK, 旨在實現對 「頁面可見、活躍時長」 及 「組件可見時長」 的統計。
名詞解釋
-
頁面 (Page) - 在瀏覽器中打開的網頁,不同頁面以路徑
location.pathname
來作區分; -
頁面可見時長 - 一個頁面對用戶可見的累計時長;
-
頁面活躍時長 - 用戶在頁面上進行有效的鼠標、鍵盤及觸控活動的累計時長;
-
組件 (Component) - DOM 元素的集合,是頁面的組成部分。一個頁面內可包含多個組件;
-
組件可見時長 - 一個組件對用戶可見的累計時長。
其關係爲:
-
頁面活躍時長 ≤ 頁面可見時長;
-
組件可見時長 ≤ 頁面可見時長;
-
一個頁面不可見時,則一定不活躍,且其中的所有組件一定也都不可見。
頁面可見及活躍時長統計
在我們的設計中,衡量一個頁面的停留及活躍時長需要兩個重要的指標:
-
可見性 (visibility)
-
visible
- 頁面在當前瀏覽器的 viewport 中,且瀏覽器窗口未被最小化; -
invisible
- 頁面不在當前瀏覽器的 viewport 中,或因瀏覽器最小化導致其無法被看到。 -
活躍性 (activity)
-
active
- 用戶在網頁中有活動(例如鼠標、鍵盤活動及頁面滾動等); -
inactive
- 用戶在網頁中沒有任何活動。
只要能獲取到這四種狀態發生的時間戳,就可以按下圖所示方法,累加計算出頁面從載入到退出的可見和活躍時長:
計算頁面可見和活躍時長
獲取頁面可見性數據
Web Lifecycle
Google 公司在 2018 年 7 月份提出了一套用來描述網頁生命週期的 Page Lifecycle API 規範,本 SDK 便是基於這套規範來監聽頁面可見性變化的。規範指出,一個網頁從載入到銷燬的過程中,會通過瀏覽器的各種事件在以下六種生命週期狀態 (Lifecycle State) 之間相互轉化。
生命週期狀態之間的轉化關係如下圖所示:
生命週期狀態轉化
由以上信息,我們可以得出頁面生命週期狀態和頁面可見狀態之間的映射關係:
因此,我們只需要監聽頁面生命週期的變化並記錄其時間,就可以相應獲取頁面可見性的統計數據。
監聽頁面生命週期變化
在制定 Page Lifecycle 規範的同時,Google Chrome 團隊也開發了 PageLifecycle.js
SDK, 實現了這套規範中描述的生命週期狀態的監聽,併兼容了 IE 9 以上的所有瀏覽器。出於易用性及穩定性的考慮,我們決定使用這個 SDK 來進行生命週期的監聽。由於 PageLifecycle.js
本身使用 JavaScript 編寫,我們爲其添加了類型定義並封裝爲了兼容 TypeScript 的 @byted-cg/page-lifecycle-typed
SDK. PageLifecycle.js
的使用方法如下:
import lifecycleInstance, {
StateChangeEvent,
} from "@byted-cg/page-lifecycle-typed";
lifecycleInstance.addEventListener("statechange", (event: StateChangeEvent) => {
switch (event.newState) {
case "active":
case "passive":
// page visible, do something
break;
case "hidden":
case "terminated":
case "frozen":
// page invisible, do something else
break;
}
});
通過 PageLifecycle.js
, 我們便可以監聽 statechange
事件來在頁面生命週期發生變化時獲得通知,並在生命週期狀態爲 active
和 passive
時標記頁面爲 visible
狀態,在生命週期狀態爲其他幾個時標記頁面爲 invisible
狀態,更新最後一次可見的時間戳,並累加頁面可見時間。
PageLifecycle.js
的缺陷
對於我們的需求來說,PageLifecycle.js
本身存在如下兩個缺陷。我們也針對這兩個缺陷做了一些改進。
-
無法監控到單頁應用 (SPA) 中頁面的變化
在單頁應用中,頁面通常依靠 history 或 hash 路由的變化來切換,頁面本身並不會重新載入,因此
PageLifecycle.js
無法感知頁面的切換。爲了應對這種情況,我們在 monitor-tracer 中手動添加了針對路由變化事件(popstate
replacestate
等)的監聽。如果發現頁面的路由發生了變化,會認爲當前頁面進入了terminated
生命週期,從而進行相應的處理。這裏對路由變化進行監聽的邏輯複用了我們之前開發的 @byted-cg/puzzle-router SDK, 有興趣的同學可以參考。 -
無法捕獲
discarded
生命週期discarded
生命週期發生在網頁被瀏覽器強制清除時。此時網頁已經被銷燬並從內存中清理,無法向外傳遞任何事件,因此PageLifecycle.js
也就無法推送discarded
事件。這種情況一旦發生,就會造成被清除的網頁統計數據的丟失。爲了應對此場景,monitor-tracer
會在頁面進入invisible
狀態時,將現有的頁面時長統計數據存儲使用JSON.stringify
序列化並儲存在localStorage
當中。如果頁面恢復visible
狀態,則會把localStorage
中的數據清空;而如果頁面被清除,則會在下一次進入頁面時先將localStorage
中存儲的上一個頁面的數據通過事件推送出去。這樣就最大程度地保證了頁面即使被強制清除,其數據也能被送出而不至丟失。
獲取頁面活躍性數據
相較於頁面可見性,頁面活躍性的判斷要更加直截了當一些,直接通過以下方法判斷頁面狀態爲 active
還是 inactive
即可。
active
判斷標準
通過監聽一系列的瀏覽器事件,我們就可以判斷用戶是否在當前頁面上有活動。monitor-tracer
會監聽以下的六種事件:
一旦監聽到以上事件,monitor-tracer
就會將頁面標記爲 active
狀態,並記錄當前時間戳,累加活躍時長。
inactive
判斷標準
以下兩種情況下,頁面將會被標記爲 inactive
狀態:
-
超過一定的時間閾值(默認爲 30 秒,可在初始化 SDK 時自定義)沒有監測到表示頁面活躍的六種事件;
-
頁面狀態爲
invisible
. 因爲如果頁面對用戶不可見,那麼它一定是不活躍的。
頁面被標記爲 inactive
後,monitor-tracer
會記錄當前時間戳並累加活躍時長。
組件可見時長統計
統計組件級別活躍時長需要兩個條件,一是拿到所有需要統計的 DOM 元素,二是對這些 DOM 元素按照一定的標準進行監控。
獲取需要統計的 DOM 元素
監聽 DOM 結構變化
獲取 DOM 元素要求我們對整個 DOM 結構的改變進行監控。monitor-tracer
使用了 MutationObserver
API, DOM 的任何變動,比如節點的增減、屬性的變動、文本內容的變動,都可以通過這個 API 得到通知。
概念上,它很接近事件,可以理解爲 DOM 發生變動就會觸發 MutationObserver
事件。但是,它與事件有一個本質不同:事件是同步觸發,也就是說,DOM 的變動立刻會觸發相應的事件,而 MutationObserver
則是異步觸發,DOM 的變動並不會馬上觸發,而是要等到當前所有 DOM 操作都結束才觸發。
這樣的設計是爲了應對 DOM 變動頻繁的特點,節省性能。舉例來說,如果文檔中連續插入 1000 個 <p></p>
標籤,就會連續觸發 1000 個插入事件,執行每個事件的回調函數,這很可能造成瀏覽器的卡頓。而 MutationObserver
完全不同,只在 1000 個標籤都插入結束後纔會觸發,而且只觸發一次。
MutationObserver
API 的用法如下:
const observer = new MutationObserver(function (mutations, observer) {
mutations.forEach(function (mutation) {
console.log(mutation.target); // target: 發生變動的 DOM 節點
});
});
observer.observe(document.documentElement, {
childList: true, //子節點的變動(指新增,刪除或者更改)
attributes: true, // 屬性的變動
characterData: true, // 節點內容或節點文本的變動
subtree: true, // 表示是否將該觀察器應用於該節點的所有後代節點
attributeOldValue: false, // 表示觀察 attributes 變動時,是否需要記錄變動前的屬性值
characterDataOldValue: false, // 表示觀察 characterData 變動時,是否需要記錄變動前的值。
attributeFilter: false, // 表示需要觀察的特定屬性,比如['class','src']
});
observer.disconnect(); // 用來停止觀察。調用該方法後,DOM 再發生變動則不會觸發觀察器
標記需要監聽的元素
爲了在衆多 DOM 元素中找到需要監聽的元素,我們需要一個方法來標記這些元素。monitor-tracer
SDK 規定,一個組件如果需要統計活躍時長,則需要爲其添加一個 monitor-pv
或 data-monitor-pv
屬性。在使用 MutationObserver
掃描 DOM 變化時,monitor-tracer
會將有這兩個屬性的 DOM 元素收集到一個數組裏,以供監聽。例如下面兩個組件:
<div monitor-pv='{ "event": "component_one_pv", "params": { ... } }'>
Component One
</div>
<div>Component Two</div>
Component One 因爲添加了 monitor-pv
屬性,會被記錄並統計可見時長。而 Component Two 則不會。
babel-plugin-tracer
插件
如果需要監控的組件是通過一些組件庫(例如 Ant Design 或 ByDesign)編寫的,那麼爲其添加 monitor-pv
或 data-monitor-pv
這樣的自定義屬性可能會被組件自身過濾從而不會出現在最終生成的 DOM 元素上,導致組件的監控不生效。爲了解決類似的問題,我們開發了 @byted-cg/babel-plugin-tracer
babel 插件。此插件會在編譯過程中尋找添加了 monitor-pv
屬性的組件,並在其外層包裹一個自定義的 <monitor></monitor>
標籤。例如:
import { Card } from 'antd';
const Component = () => {
return <Card monitor-pv={{ event: "component_one_pv", params: { ... } }}>HAHA</Card>
}
如果不添加插件,那麼最終生成的 DOM 爲:
<div class="ant-card">
<!-- ... Ant Design Card 組件 -->
</div>
可見 monitor-pv
屬性經過組件過濾後消失了。
而安裝 babel 插件後,最終編譯生成的 DOM 結構爲:
<monitor
is="custom"
data-monitor-pv='{ "event": "component_one_pv", "params": { ... } }'
>
<div class="ant-card">
<!-- ... Ant Design Card 組件 -->
</div>
</monitor>
monitor-pv
屬性得到了保留,並且插件自動爲其添加了 data-
前綴,以應對 React 16 之前版本僅支持 data-
開頭的自定義屬性的問題;同時將傳入的對象使用 JSON.stringify
轉換成了 DOM 元素 attribute 唯一支持的 string
類型。
由於自定義標籤沒有任何樣式,所以包裹該標籤也不會影響到原有組件的樣式。monitor-tracer
SDK 在掃描 DOM 元素後,會同時收集所有 <monitor></monitor>
標籤中的元素的信息,並對其包裹的元素進行監控。
判斷 DOM 元素可見性
對組件可見性的判斷可分爲三個維度:
-
組件是否在瀏覽器 viewport 中 - 使用
IntersectionObserver
API 判斷; -
組件樣式是否可見 - 根據元素 CSS 的
display
visibility
及opacity
樣式屬性判斷; -
頁面是否可見 - 根據頁面可見性判斷。
判斷組件是否在瀏覽器 viewport 中
這裏我們使用了 IntersectionObserver
API. 該 API 提供了一種異步檢測目標元素與祖先元素或 viewport 相交情況變化的方法。
過去,相交檢測通常要用到事件監聽,並且需要頻繁調用 Element.getBoundingClientRect
方法以獲取相關元素的邊界信息。事件監聽和調用 Element.getBoundingClientRect
都是在主線程上運行,因此頻繁觸發、調用可能會造成性能問題。這種檢測方法極其怪異且不優雅。
而 IntersectionObserver
API 會註冊一個回調函數,每當被監視的元素進入或者退出另外一個元素時(或者 viewport),或者兩個元素的相交部分大小發生變化時,該回調方法會被觸發執行。這樣,我們網站的主線程不需要再爲了監聽元素相交而辛苦勞作,瀏覽器會自行優化元素相交管理。
IntersectionObserver
API 的用法如下:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(function (entry) {
/**
entry.boundingClientRect // 目標元素的矩形區域的信息
entry.intersectionRatio // 目標元素的可見比例,即 intersectionRect 佔 boundingClientRect 的比例,完全可見時爲 1,完全不可見時小於等於 0
entry.intersectionRect // 目標元素與視口(或根元素)的交叉區域的信息
entry.isIntersecting // 標示元素是已轉換爲相交狀態 (true) 還是已脫離相交狀態 (false)
entry.rootBounds // 根元素的矩形區域的信息, getBoundingClientRect 方法的返回值,如果沒有根元素(即直接相對於視口滾動),則返回 null
entry.target // 被觀察的目標元素,是一個 DOM 節點對象
entry.time // 可見性發生變化的時間,是一個高精度時間戳,單位爲毫秒
**/
});
},
{
threshold: [0, 0.25, 0.5, 0.75, 1], //該屬性決定了什麼時候觸發回調函數。它是一個數組,每個成員都是一個門檻值,默認爲 [0],即交叉比例 (intersectionRatio) 達到 0 時觸發回調函數
}
);
observer.observe(document.getElementById("img")); // 開始監聽一個目標元素
observer.disconnect(); // 停止全部監聽工作
如果一個組件和 viewport 的相交比例小於某個值(默認爲 0.25),那麼這個組件就會被標記爲 invisible
. 反之,如果比例大於某個值(默認爲 0.75),那麼這個組件就會被標記爲 visible
。
判斷組件 CSS 樣式是否可見
如果元素的 CSS 樣式設爲了 visibility: hidden
或 opacity: 0
,那麼即使其與 viewport 的相交比例爲 1,對用戶來說也是不可見的。因此我們需要額外判斷目標元素的 CSS 屬性是否爲可見。
如果一個組件的樣式被設置爲了以下之一,那麼它就會被標記爲 invisible
.
-
visibility: hidden
-
display: none
-
opacity: 0
判斷頁面是否可見
當頁面不可見時,所有組件自然都不可見,因此在頁面爲 invisible
的狀態下,monitor-tracer
會將需監控的所有組件的狀態也標記爲 invisible
。
處理「觸發事件」的 SDK - monitor
monitor
SDK 的定位
數據平臺 SDK 的單一的埋點上報方式,無法滿足我們開發中對 clean code 的極致追求
數據平臺的 SDK 往往只提供了上報埋點的函數式方法,雖然可以滿足我們的日常開發需求,但是並不能解決我們在寫埋點代碼時的兩大痛點:
-
只能逐個進行埋點上報
-
埋點邏輯與業務邏輯的耦合
我們希望埋點代碼可以輕易地添加、修改與刪除,並且對業務代碼沒有影響。因此,我們基於 TypeScript 開發對框架無感的 monitor
SDK. 它支持逐個上傳多個埋點,並且接受返回埋點的函數,將其返回值上報;它提供了三種方式注入埋點,覆蓋了所有場景,將埋點與業務代碼完全分離。
可以看出,monitor
既是一個數據處理器,又是一個方法庫。更直觀一些,使用 monitor
後,我們的應用在上報埋點時的流程如下:
埋點上報流程
埋點由應用層發送給 monitor
後,monitor
首先會對數據進行處理,再調用數據平臺 SDK, 將埋點事件上報給數據平臺。
在對 monitor
有了初步瞭解後,這篇文章將主要講解 monitor
是如何通過以下三種埋點注入的方式,解耦業務邏輯與埋點邏輯的。
下面我們來看一下 monitor
和 monitor-tracer
SDK 具體的技術設計及實現方法。
三種埋點注入方式
類指令式
monitor
提供了類指令方式注入埋點。例如,下段代碼用 monitor-click
指令注入了埋點。在此按鈕被點擊 (click) 時,monitor-click
所對應的值,即一個事件,就會被上報。
// 指令式埋點示例
<Button
monitor-click={JSON.stringify({
type: "func_operation",
params: { value: 3 },
})}
>
Click Me
</Button>
這是如何實現的呢?爲什麼僅僅給組件加了一個 monitor-click
屬性,monitor
就會在這個按鈕被點擊時上報埋點了呢?
實現與原理
其實,monitor
SDK 在初始化時,會給當前的 document
對象加上一系列 Event Listeners, 監聽 hover
click
input
focus
等事件。當監聽器被觸發時,monitor
會從觸發事件的 target
對象開始,逐級向上遍歷,查看當前元素是否有對應此事件的指令,如果有,則上報此事件,直至遇到一個沒有事件指令的元素節點。以下示意圖展示了類指令式埋點的上報流程:
類指令上報流程
逐級上報過程
以如下代碼爲例,當光標 hover 到 Button
時,document
對象上所安裝的監聽 hover
事件的函數便會執行。這個函數首先在 event.target
即 Button
上查找是否有與 hover
事件相關的指令(即屬性)。Button
有 monitor-hover
這個指令,此時函數便上傳此指令所對應的事件,即 { type: 'func_operation', params: { value: 1 }}
。
接下來,函數向上一層,到了 Button
的父元素,即 div
, 重複上述過程,它找到了 data-monitor-hover
這個指令,便同樣地上報了對應的埋點事件。而到了 section
這一層,雖然其有 data-monitor-click
指令,但此指令並不對 hover
事件進行響應,因此,這個逐級上報埋點的過程結束了。
// 指令式埋點實現逐級上報
<section
data-monitor-click={JSON.stringify({
type: 'func_operation',
params: { value: 3 },
})}
>
<div
data-monitor-hover={JSON.stringify({
type: 'func_operation',
params: { value: 2 },
})}
>
<Button
monitor-hover={JSON.stringify({
type: 'func_operation',
params: { value: 1 },
})}
>
Click Me
</Button>
</div>
</section>
類指令式埋點注入適合簡單的埋點上報,清晰的與業務代碼實現了分離。但是如果我們需要在上報事件前,對所上報的數據進行處理,那麼這種方式就無法滿足了。並且,並不是所有的場景都可以被 DOM 事件所覆蓋。如果我想在用戶在搜索框輸入某個值時,上報埋點,那麼我就需要對用戶輸入的值進行分析,而不能在 input
事件每次觸發時都上報埋點。
裝飾器式
裝飾器本質上是一個高階函數。它接受一個函數,返回另一個被修飾的函數。因此,我們很自然地想到用裝飾器將埋點邏輯注入到業務函數,既實現了埋點與業務代碼的分離,又能夠適應於複雜的埋點場景。
下面的代碼使用了 @monitorBefore
修飾器。@monitorBefore
所接收的函數的返回值,即是要上報的事件。在 handleSearch
函數被調用的時候,monitor
會首先上報埋點事件,然後再執行 handleSearch
函數的邏輯。
// @monitorBefore 使用示例
@monitorBefore((value: string) => ({
type: 'func_operation',
params: { keyword: value },
}))
handleSearch() {
console.log(
'[Decorators Demo]: this should happen AFTER a monitor event is sent.',
);
}
return (
<AutoComplete
onSearch={handleSearch}
/>
)
從 @readonly
理解裝飾器原理
裝飾器是如何實現將埋點邏輯和業務邏輯相整合的呢?在我們詳細解讀 @monitorBefore
之前,讓我們先從一個常用的裝飾器 @readonly
講起吧。
裝飾器應用於一個類的單個成員上,包括類的屬性、方法、getters 和 setters. 在被調用時,裝飾器函數會接收 3 個參數:
-
target
- 裝飾器所在的類 -
name
- 被裝飾的函數的名字 -
descriptor
- 被裝飾的函數的屬性描述符
// @readonly裝飾器的代碼實現
readonly = (target, name, descriptor) => {
console.log(descriptor);
descriptor.writable = false;
return descriptor;
};
上述代碼通過 console.log
輸出的結果爲:
代碼輸出結果
以我們常見的 @readonly
爲例,它的實現方法如上。通過在上述代碼中 log 出來 descriptor
, 我們得知 descriptor
的屬性分別爲:
-
writable
- 被裝飾的函數是否能被賦值運算符改變; -
enumerable
- 被裝飾的函數是否出現在對象的枚舉屬性中; -
configurable
- 被裝飾的函數的描述符是否能夠被改變,是否能夠從對象上被刪除; -
value
- 被裝飾的函數的值,即其對應的函數對象。
可見,@readonly
裝飾器將 descriptor
的 writable
屬性設置爲 false
, 並返回這個 descriptor
, 便成功將其裝飾的類成員設置爲只讀態。
我們以如下方式使用 @readonly
裝飾器:
class Example {
@readonly
a = 10;
@readonly
b() {}
}
@monitorBefore
的實現
@monitorBefore
裝飾器要比 @readonly
複雜一些,它是如何將埋點邏輯與業務邏輯融合,生成一個新的函數的呢?
首先,我們來看看下面這段代碼。monitorBefore
接收一個埋點事件 event
作爲參數,並返回了一個函數。返回的函數的參數與上面講過的 @readonly
所接收的參數一致。
// monitorBefore 函數源代碼
monitorBefore = (event: MonitorEvent) => {
return (target: object, name: string, descriptor: object) =>
this.defineDecorator(event, descriptor, this.before);
};
// @monitorBefore 使用方式
@monitorBefore((value: string) => ({
type: 'func_operation',
params: { keyword: value },
}))
handleSearch() {
console.log(
'[Decorators Demo]: this should happen AFTER a monitor event is sent.',
);
}
return (
<AutoComplete
onSearch={handleSearch}
/>
)
在編譯時,@monitorBefore
接收了一個 event
參數,並返回瞭如下函數:
f = (target: object, name: string, descriptor: object) =>
this.defineDecorator(event, descriptor, this.before);
而後,編譯器又調用函數 f
, 把當前的類、被裝飾的函數名稱與其屬性描述符傳給f
. 函數 f
返回的 this.defineDecorator(event, descriptor, this.before)
會被解析爲一個新的 descriptor
對象,它的 value
會在運行時被調用,也就是說會在 onSearch
被觸發時所調用。
現在,讓我們詳細解讀 defineDecorator
函數是如何改變生成一個新的 descriptor
的吧。
// defineDecorator 函數源代碼
before = (event: MonitorEvent, fn: () => any) => {
const that = this;
return function (this: any) {
const _event = that.evalEvent(event)(...arguments);
that.sendEvent(_event);
return fn.apply(this, arguments);
};
};
defineDecorator = (
event: MonitorEvent,
descriptor: any,
decorator: (event: MonitorEvent, fn: () => any) => any
) => {
if (isFunction(event) || isObject(event) || isArray(event)) {
const wrapperFn = decorator(event, descriptor.value);
function composedFn(this: any) {
return wrapperFn.apply(this, arguments);
}
set(descriptor, "value", composedFn);
return descriptor;
} else {
console.error(
`[Monitor SDK @${decorator}] the event argument be an object, an array or a function.`
);
}
};
monitorBefore = (event: MonitorEvent) => {
return (target: object, name: string, descriptor: object) =>
this.defineDecorator(event, descriptor, this.before);
};
defineDecorator
函數接收三個參數:
-
event
- 需要上報的埋點; -
descriptor
- 被裝飾的函數的屬性描述符; -
decorator
- 一個高階函數。它接收一個埋點事件與一個回調,返回一個函數,用來進行埋點上報,並而後執行回調。
decorator
首先返回了一個函數 wrapperFn
. 在被調用時,wrapperFn
會先上報埋點,然後執行 descriptor.value
的邏輯,即被裝飾的函數。
在 defineDecorator
的 composedFn
中, 我們用 wrapperFn.apply(this, arguments)
將調用被裝飾的函數時傳入的參數透傳給 wrapperFn
。
最後,我們將 composedFn
設爲 descriptor.value
, 這樣,我們就成功生成了一個融合了埋點邏輯與業務邏輯的新函數。
裝飾器埋點注入方式十分整潔,能夠清晰地與業務代碼區分。不論是增添、修改還是刪除埋點,都無需顧慮會對業務代碼造成改動。但是其侷限性也是顯而易見的,裝飾器只能用於類組件,現在我們常用的函數式組件是無法使用裝飾器的。
React 鉤子
爲了能夠在函數式組件中,實現裝飾器埋點帶來的功能,我們還支持了埋點鉤子 useMonitor
. 與裝飾器的原理相同,useMonitor
接收一個埋點函數,一個業務函數,返回一個新的函數將二者融合,既實現了代碼層面上的清晰分離,又覆蓋了全場景的埋點注入。
// useMonitor 源代碼
useMonitor = (fn: () => any, event: MonitorEvent) => {
if (!event) return fn;
const that = this;
return function (this: any) {
const _event = that.evalEvent(event)(...arguments);
that.sendEvent(_event);
return fn.apply(this, arguments);
};
};
useMonitor
的實現較爲簡單,只是一個高階函數,不像裝飾器需要語法解析。它返回了一個函數,在被調用時會先上傳埋點事件,在執行業務邏輯。其使用方式如下:
// useMonitor 使用示例
const Example = (props: object) => {
const handleChange = useMonitor(
// 業務邏輯
(value: string) => {
console.log("The user entered", value);
},
// 埋點邏輯
(value: string) => {
return {
type: "func_operation",
params: { value },
};
}
);
return <Search onSearch={handleChange} />;
};
小結
上述三種埋點方式,覆蓋了所有使用場景。不論你是用 React, Vue, 還是原生 JavaScript, 不論你是使用類組件,還是函數式組件,不論你的埋點是否需要複雜的前置邏輯,monitor
SDK 都提供了適合你的場景的使用方式。
技術棧
-
編程語言 - TypeScript
-
網頁生命週期監聽 -
PageLifecycle.js
-
事件監聽、派發 -
wolfy87-eventemitter
-
DOM 結構變動監聽 -
MutationObserver
API -
DOM 元素與 viewport 交叉狀態監聽 -
IntersectionObserver
API
開發人員 Credits
-
monitor
SDK - Lilly Jiang -
monitor-tracer
SDK -
網頁可見及活躍時長、babel 插件 - Aiqing Dong
-
組件可見時長 - Yuling Chen
參考鏈接
[3]: https://p-vcloud.byteimg.com/tos-cn-i-em5hxbkur4/804c4a9d7e83438ba3aed6845d50099e~tplv-em5hxbkur4-noop.image?width=2835&height=1541
[4]: https://p-vcloud.byteimg.com/tos-cn-i-em5hxbkur4/e5e21973cae44bb3b664c2b17dfbcd90~tplv-em5hxbkur4-noop.image?width=3360&height=1450
[5]: https://developers.google.com/web/updates/2018/07/page-lifecycle-api
[6]: https://p-vcloud.byteimg.com/tos-cn-i-em5hxbkur4/4e681b1967b749dea5302363745b8293~tplv-em5hxbkur4-noop.image?width=3280&height=2218
[7]: https://github.com/GoogleChromeLabs/page-lifecycle
[8]: https://bnpm.bytedance.net/package/@byted-cg/page-lifecycle-typed
[9]: https://bnpm.bytedance.net/package/@byted-cg/puzzle-router
[10]: https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver
[11]: https://bnpm.bytedance.net/package/@byted-cg/babel-plugin-tracer
[12]: https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver
[13]: https://developer.mozilla.org/zh-CN/docs/Glossary/Viewport
[14]: https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect
[15]: https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect
[17]: https://www.typescriptlang.org/
[18]: https://www.npmjs.com/package/wolfy87-eventemitter
[19]: lark://applink.feishu.cn/client/chat/open?openId=ou_e3a625ed9cd25fa9ed6cba3877590992
[20]: lark://applink.feishu.cn/client/chat/open?openId=ou_7197d8059c28e24828f73d6278ecfd02
[21]: http://tosv.byted.org/obj/cg-fe/monitor/monitor_doc / 埋點上報路徑示意圖. png
[22]: http://tosv.byted.org/obj/cg-fe/monitor/monitor_doc / 類指令式埋點的上報流程. png
[23]: http://tosv.byted.org/obj/cg-fe/monitor/monitor_doc/readonly_output.png?width=773&height=72
[24]: https://code.byted.org/cg/monitor
[27]: lark://applink.feishu.cn/client/chat/open?openId=ou_bad7c4f8d9542c7abf228ea574a16941
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/TcaOUBMBBEGQoQPAjYXb_Q