手把手教你搭建一個無框架埋點體系

做者:Lilly Jiang, Aiqing Dongjavascript

背景

埋點體系構成

通常來講,一個完整的埋點體系由如下三個部分構成:html

  • 應用
  • 數據分析平臺
  • 數據平臺 SDK

埋點上報是將應用層事件上傳至上層平臺的過程。比方說,在某購物網站上,用戶點擊了「收藏」按鈕,此時,一個點擊事件就生成了,這一事件會被上報至一個數據分析平臺。這樣,相關的數據分析師、產品經理、運營等同窗即可以在數據分析平臺,經過這些上報的事件數據分析,得出應用中能夠優化的方方面面。因而可知,埋點上報是每一個產品走向卓越的重要一環。前端

經過以上描述,咱們認識了埋點上報過程的兩大主角:應用與數據分析平臺。從前端技術的角度來講,咱們一般還須要第三個角色的助攻,那就是數據平臺 SDK. 這個 SDK 封裝了數據分析平臺的各類接口,暴露出簡單的方法讓咱們進行調用,實現簡易的埋點上傳。java

兩種埋點事件

咱們能夠把應用層事件分爲兩大類:git

  • 「頁面事件」:一種是通用的「頁面事件」,好比說用戶在應用某個頁面的停留及活躍時長,咱們但願這種全局的埋點只用在項目初始化時注入一次,不須要在代碼中進行維護。
  • 「觸發事件」:另外一種則是自定義的「觸發事件」,好比點擊某個特定的按鈕,開啓某個特定的流程,這種事件須要前端同窗在代碼中手動注入埋點。

咱們爲這兩種事件分別開發了一套埋點上傳 SDK。下面,咱們就來詳細地講解一下這兩套 SDK 的技術知識。github

處理「頁面事件」的 SDK - monitor-tracer

monitor-tracer 是一個用來監控頁面及組件可見時長和活躍時長的前端 SDK,同時也是 Monitor 前端埋點體系的一個核心組成部分。web

背景

爲了更好地理解用戶對各個業務功能的使用情況,從而進行相應的產品優化和調整:typescript

  • 對於通常的網頁應用,咱們須要對用戶在應用某個頁面的停留及活躍時長進行相應的統計;npm

  • 對於大盤 / 看板 / 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) 之間相互轉化。

生命週期狀態 描述
active 網頁可見,且具備焦點
passive 網頁可見,但處於失焦狀態
hidden 網頁不可見,但未被瀏覽器凍結,通常由用戶切換到別的 tab 或最小化瀏覽器觸發
frozen 網頁被瀏覽器凍結(一些後臺任務例如定時器、fetch 等被掛起以節約 CPU 資源)
terminated 網頁被瀏覽器卸載並從內存中清理。通常用戶主動將網頁關閉時觸發此狀態
discarded 網頁被瀏覽器強制清理。通常由系統資源嚴重不足引發

生命週期狀態之間的轉化關係以下圖所示:

生命週期狀態轉化

由以上信息,咱們能夠得出頁面生命週期狀態和頁面可見狀態之間的映射關係:

生命週期狀態 可見狀態
active passive visible
hidden terminated frozen discarded invisible

所以,咱們只須要監聽頁面生命週期的變化並記錄其時間,就能夠相應獲取頁面可見性的統計數據。

監聽頁面生命週期變化

在制定 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 事件來在頁面生命週期發生變化時得到通知,並在生命週期狀態爲 activepassive 時標記頁面爲 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 會監聽如下的六種事件:

事件 描述
keydown 用戶敲擊鍵盤時觸發
mousedown 用戶點擊鼠標按鍵時觸發
mouseover 用戶移動鼠標指針時觸發
touchstart 用戶手指接觸觸摸屏時觸發(僅限觸屏設備)
touchend 用戶手指離開觸摸屏時觸發(僅限觸屏設備)
scroll 用戶滾動頁面時觸發

一旦監聽到以上事件,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-pvdata-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-pvdata-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 visibilityopacity 樣式屬性判斷;
  • 頁面是否可見 - 根據頁面可見性判斷。

判斷組件是否在瀏覽器 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: hiddenopacity: 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 是如何經過如下三種埋點注入的方式,解耦業務邏輯與埋點邏輯的。

下面咱們來看一下 monitormonitor-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.targetButton 上查找是否有與 hover 事件相關的指令(即屬性)。Buttonmonitor-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 裝飾器將 descriptorwritable 屬性設置爲 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 的邏輯,即被裝飾的函數。

defineDecoratorcomposedFn 中, 咱們用 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 都提供了適合你的場景的使用方式。

技術棧

開發人員 Credits

  • monitor SDK - [Lilly Jiang]
  • monitor-tracer SDK
    • 網頁可見及活躍時長、babel 插件 - [Aiqing Dong]
    • 組件可見時長 - [Yuling Chen]
相關文章
相關標籤/搜索