一文幫你搞定H5、小程式、Taro長列表曝光埋點

2023-07-14 12:00:29

對於很多前端同學來說,「埋點」常常是一個不願面對卻又無法逃避的話題。為什麼這麼說呢,相信很多前端同學都深有體會:首先埋點這個事基本是前端「獨享」的,伺服器端基本不太涉及;其次新增埋點,往往看起來很簡單但實際做起來很麻煩,很多時候為了獲取一些埋點需要的資訊甚至要對已經寫好的程式碼進行傷筋動骨的修改。

雖然前端埋點費時費力,做起來沒什麼成就感,但是埋點作為收集線上業務資料(使用者購買行為、活動轉化等)的重要途徑,為產品策略調整提供了重要資料支撐,特別是在像618、雙11等大促活動中,埋點資料採集對於促銷活動的策略制定、及時調整及最終收益效果的驗證都至關重要,因此又是一件研發同學必須要認真對待的事情。本文結合多年來各平臺專案實踐經驗,總結了埋點類需求的開發實戰經驗及技巧,希望通過本文的分享能讓更多讀者在開發中儘量少走彎路,準確高效完成埋點開發任務,保證業務在大促及常態運營中的穩定資料支撐。

言歸正傳,對於各種型別的埋點來說,曝光埋點往往最為複雜、需要用到的技術也最全面、如果實現方式不合理可能造成的影響也最大,因此本文將重點介紹曝光埋點尤其是長列表(或捲動檢視)內元素曝光埋點的實現思路及避坑技巧;

1. 監聽列表內元素曝光的常見方法

長列表(或捲動檢視)中元素的曝光埋點,關鍵是如何監聽子元素的「曝光」事件。「曝光」即元素進入到了螢幕的可見區域,也就是能被使用者看到了,這是人類的直觀視覺感受,那麼如何用程式碼的方式來判定呢?目前大概有這麼三種方法:1.根據介面下發分頁資料估算可見元素;2.監聽捲動檢視的捲動事件,實時計算元素相對位置;3. 利用瀏覽器(或其他平臺如小程式、Taro)標準API監聽元素與可見區域的相交變化。下面分別介紹一下這三種方法的具體原理、適用範圍及優缺點。

1. 1 方式一:根據介面下發分頁資料估算可見元素

實現思路:長列表的資料往往通過分頁介面進行載入,可以利用這一特性,以單頁資料返回的維度粗略估算元素的可見性,具體說就是以每一次的介面返回的資料當做當前可見的元素的列表;

優點:

  • 這種方式的好處是簡單:僅僅根據分頁介面每次請求的資料進行元素曝光的判斷,計算很簡單;

缺點:

  • 缺點就是誤差太大:一方面分頁介面單次請求的資料也往往會超出一屏,另一方面列表內元素的高度可能也是不同的、分頁返回的資料條數也可能存在差異,這種方式來計算元素的曝光誤差太大;

由於缺點很明顯,誤差太大,現在很少有人這麼來實現曝光埋點,但是在很多精度要求不高的場景或者年代很久的程式碼中還能看到這種實現方式

1. 2 方式二:監聽捲動事件,實時計算元素相對位置

實現思路:監聽長列表(或捲動檢視容器)的捲動事件,通過平臺UI基礎介面(如瀏覽器DOM介面getBoundingClientRect)實時獲取元素座標(包括位置和大小資訊等),並計算同可視區域的相對狀態(是否有重疊)來判定元素是否「可見」;

優點:

  • 相比方式一,精度有了很大的改進,如果計算的方式正確,計算結果可以說是準確的;
  • 另外由於使用的是平臺內的通用基礎能力介面,相容性較好;

缺點:

  • 計算量大,效能損耗嚴重:這種計算方式需要監聽捲動檢視的捲動事件,在捲動回撥事件內實時進行列表內所有元素的位置座標計算(獲取所有元素的位置並同當前可見區域進行對比),這樣帶來的計算量是相當大的,往往會造成頁面的效能問題(如滑動卡頓);
  • 程式碼分散、邏輯複雜:除了需要監聽捲動檢視的捲動事件,還要在首屏資料載入或者資料重新整理時,額外進行一次計算,整體複雜度及對頁面的效能影響都比較大;
  • 其他問題:可能引發其他額外操作,如在H5中getBoundingClientRect() 的頻繁呼叫也可能引發瀏覽器的樣式重計算和佈局; iframe 裡,無法直接存取內部元素等等;

這種方式雖然計算量大、邏輯複雜、效能較差(當然也可以進行一些效能上的優化,代價是程式碼複雜度變的更高,不利於後續更新維護),但是計算結果是準確的,在沒有出現方法三中的Web端標準介面(2016)之前,在計算精度要求嚴格的場景下,這視乎是唯一的選擇;

1. 3 方式三:利用瀏覽器標準API監聽元素可視區變化

實現思路:Intersection Observer API做為一個專門用於監聽頁面元素相交變化的Web標準API介面,在2016年首先在Chrone瀏覽器中提供,並在隨後的幾年內得到了各主流瀏覽器的支援;利用該介面提供的非同步查詢元素相對於其他元素或視窗位置的能力,可以高效的對頁面內元素的相交(可見性)變化進行監聽;

優點:

  • 效能更高: 瀏覽器底層實現,並進行了相應優化,效能沒有問題:監聽不會在主執行緒進行(只要回撥方法會在主執行緒觸發)
  • 計算量小:這裡的計算量小是指的我們web開發者需要進行的計算操作,因為大部分的計算瀏覽器API內已經幫我們計算好了,我們只需要根據需求場景在此基礎上進行簡單的處理即可滿足需求;
  • 計算更結果準確:瀏覽器API實現的計算結果是比較準確的,這塊毋庸置疑;
  • 程式碼更優雅:大部分的監聽、計算邏輯都在API內部實現了,開發者的程式碼量不會太多太複雜,程式碼更簡潔從而更利用後續維護;

缺點:

  • 需要新瀏覽器支援(根據檔案描述的瀏覽器相容情況其實已經滿足絕大多數的使用場景),太低版本的瀏覽器不支援,如果需要相容,也有辦法,通過官方提供的polyfill可以解決(引入polyfill,當然不可避免的帶來程式碼體積的增量,專案中實測打包後js檔案體積增大了8kb,這算是唯一的缺點吧);(w3c 官方提供了對應 polyfill)

基於以上,這種方式是目前最推薦的一種實現元素曝光監聽的方式,具體怎麼用呢,下面對H5、小程式、Taro的使用場景分別來介紹一下。

2. 列表內元素曝光事件監聽的具體實現

2. 1 Web(H5)端

簡單來說,利用Intersection Observer API來進行檢視元素的可見性觀察主要分成這麼幾個步驟:建立觀察者、對目標元素新增觀察、處理觀察結果(回撥)、停止觀察;

2. 1. 1 具體使用方法:

第一步:建立一個觀察者(IntersectionObserver)

首先我們需要建立一個觀察者IntersectionObserver ,用於監聽目標元素相對於根檢視(可以是父檢視或當前視窗)的相交狀態變化情況(即元素的「可見」狀態)。具體建立方式是利用Web標準API:IntersectionObserver構造方法,具體程式碼如下:

let observer = new IntersectionObserver(callback, options);
  • 其中callback 就是當監聽到元素位置變化時觸發的回撥方法,具體定義及用法會在第三步處理觀察結果中具體介紹;

  • options是客製化觀察模式的引數,引數定義如下

    interface IntersectionObserverInit {
        root?: Element | Document | null;
        rootMargin?: string;
        threshold?: number | number[];
    }
    
    • root: 用於指定觀察的參照區域,一般是目標元素的父檢視容器或整個檢視視窗(必須是目標元素的父級元素。如果未指定或者為null,則預設為瀏覽器視窗)
    • rootMargin:參照區域(root)的外邊距,類似於 CSS 中的 margin 屬性,比如 "10px 20px 30px 40px" (top, right, bottom, left),用於對參照物的區域範圍進行調整(收縮或擴張);
    • threshold:相交比例閾值,用於客製化需要觀察的相交比例的臨界值;元素的交集(相交比例)發生變化時並不是每次變化都會執行回撥方法,只有當相交比例達到設定的閾值時才會觸發回撥(callback);可以是單一數值(number)也可以是一組數值;例如當設定為0.25時,只有當相交達到0.25時(增大到0.25或減小到0.25都會觸發)才會觸發回撥;如果是一組數值的話,相交比例達到其中任意值時也都會觸發回撥;(備註:除此外,元素首次新增觀察時也會觸發一次回撥,不論是否達到閾值)

例如上圖中的threshold設定狀態,每當元素滑動到虛線位置與父檢視邊界相交時就會觸發回撥

第二步:對目標元素新增觀察

有了觀察者後,就可以對目標元素進行觀察了,具體程式碼如下:

let target = document.querySelector('#listItem');
observer.observe(target);

需要注意新增觀察的時機,要保證在目標元素建立好以後再新增觀察;如果是動態建立的元素(例如分頁載入資料),需要在每次建立完元素後再次對新增的元素新增觀察;

第三步:處理觀察結果

當被觀察的目標元素與參照檢視(root)相交的比例達到設定的閾值時,就會觸發我們註冊的回撥方法(callback),回撥方法的定義如下:

interface IntersectionObserverCallback {
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver): void;
}

可以看到該回撥方法內可以接收到兩個引數:

  • entries :IntersectionObserverEntry的陣列,是相交狀態發生變化的元素的集合,每個IntersectionObserverEntry物件內有6個屬性:

    • time:發生相交的時間戳,單位毫秒。(發生交集變化的時間相對於檔案建立的時間)
    • target:被觀察的目標元素,是一個 DOM 節點物件;
    • rootBounds: root 元素(參照區域)的矩形邊界
    • boundingClientRect:目標元素的邊界資訊,邊界的計算方式與 Element.getBoundingClientRect() 相同。
    • intersectionRect:目標元素同root元素的相交區域
    • intersectionRatio:目標元素同根元素相交的部分尺寸與目標元素整體尺寸的比值,即intersectionRect 與 boundingClientRect 的比值;
    • isIntersecting:目標元素同根元素是否相交(根據設定的閾值判定)
  • observer:當前觀察者;

有了這些資訊,就可以輕鬆監測目標元素的可見狀態變化,方面進行後續的埋點上報、資料記錄、延遲載入等各種處理;

註冊的回撥函數將會在主執行緒中被執行,所以該函數執行速度要儘可能的快。如果有一些耗時的操作需要執行,建議使用 Window.requestIdleCallback() 方法。

第四部:停止觀察

如果需要停止觀察,可以在合適的時間解除對某個元素的觀察或終止對所有目標元素的觀察;

// 停止觀察某個目標元素
observer.unobserve(target)

// 終止對所有目標元素可見性變化的觀察,關閉監視器
observer.disconnect()

2. 1. 2 Tips

需要注意的是在Intersection Observer API 的V2版本新增了一個 isVisible 屬性(新版Chrome瀏覽器已經支援,Safari等其他瀏覽器內不支援),用來標識元素是否「可見」(因為即使元素在可視區域內,也有肯能因為被其他元素遮擋、樣式屬性hiden等影響導致元素不能被看到);官方說明中,為了保證效能,這個欄位的值不一定是準確的,除非特殊場景,不建議使用這個欄位,大部分場景isIntersecting就夠了;

感興趣的可以檢視檔案說明:Intersection Observer V2 Explained

2. 1. 3 Intersection Observer API的瀏覽器支援情況

瀏覽器 平臺 支援的版本 釋出時間
Chrome PC 51 2016-05-25
Chrome Android Android 51 2016-06-08
WebView Android Android 51 2016-06-08
Safari macOS 12.1 2019-03-25
Safari on iOS iOS 12.2 2019-03-25
Edge PC 15 2017-04-05
Firefox PC 55 2017-08-08
Firefox for Android Android 55 2017-08-08
Opera PC 38 2016-06-08
Opera Android Android 41 2016-10-25

可以根據具體使用場景(支援的瀏覽器版本情況)來決定是否直接使用標準API還是需要新增polyfill或其他方式來相容低版本瀏覽器;

2. 2 小程式端(微信小程式)

同Web端介面類似,微信小程式提供了對應的小程式版本API介面,功能同web端的Intersection Observer API類似,使用方式也基本相同,只是部分細節存在差異;具體步驟:

第一步:建立一個觀察者(IntersectionObserver)

通過微信小程式框架提供的IntersectionObserver wx.createIntersectionObserver(Object component, Object options)方法,可以方便的建立觀察者;

Page({
  data: {
    appear: false
  },
  onLoad() {
    this._observer = wx.createIntersectionObserver(this,{
      thresholds: [0.2, 0.5]
    })
    //其他處理...
  },
  onUnload() {
    if (this._observer) this._observer.disconnect()
  }
})

類比web端的IntersectionObserver構造方法,不同的是小程式裡這一步不需要設定回撥方法,回撥方法放到後面新增觀察的時候註冊;

入參說明:component一般需要傳當前頁面或元件範例;options可定義觸發閾值、是否同時觀測多個目標節點等資訊

第二步:指定參照節點(參照區域)

不同於web端的建立時指定,小程式端提供了兩個單獨介面用於指定參照節點(參照區域)

  • IntersectionObserver IntersectionObserver.relativeTo(string selector, Object margins) :使用選擇器指定一個節點,作為參照區域之一,即以該節點的佈局區域作為參照區域。
  • IntersectionObserver IntersectionObserver.relativeToViewport(Object margins): 指定頁面顯示區域作為參照區域之一

範例:

this._observer = this._observer.relativeTo('.scroll-view')

同樣可以通過margins來對參照區域進行擴充套件(或收縮);如果有多個參照節點,則會取它們佈局區域的 交集 作為參照區域。

第三步:開啟觀察

通過前兩步建立好觀察者,設定好相關引數(觸發閾值、是否多目標等)並指定參照區域後,就可以對目標元素進行觀察了。這裡通過選擇器的方式(web端是元素範例)來指定目標元素,同時這裡需要指定相交狀態變化的回撥方法:IntersectionObserver.observe(string targetSelector, function callback)

範例:

this._observer.observe('.ball', (res) => {
    console.log(res);
    this.setData({
        appear: res.intersectionRatio > 0
    })
})

第四步:處理回撥

當元素相對於參照區域的相交情況發生變化(同web端一致,觸發時機由第一步建立觀察者時設定的thresholds閾值決定)就會觸發相應的回撥方法。回撥方法內接受的引數同web端基本一致,但也存在差異:

  • 小程式端是單個觸發,回撥方法的入參是單個元素(對比web端是多個一起回撥,入參是變化元素的陣列);
  • 小程式端入參內同時包含目標節點的節點ID及自定義資料;

第五步:停止監聽

IntersectionObserver.disconnect()
停止監聽。回撥函數將不再觸發

if (this._observer) this._observer.disconnect()

Tips

注意:在元件內,如果在attached元件生命週期函數內新增內部子元素的相交變化觀察可能無法監聽成功,原因是此時元件佈局還未完成,元件內節點未完成建立;

2. 3 Taro框架內(Taro3+React)

Taro內也提供了對應的IntersectionObserver的API,其API的定義及使用方式基本是同微信小程式端對齊的;Taro本身是支援H5、小程式等多端的,其IntersectionObserver介面內部對H5、微信小程式、京東小程式等各平臺進行了對齊抹平,具體來說在H5端是按照微信小程式端的格式進行的封裝,其內部實現是呼叫的Web端的Intersection Observer API,在小程式端由於標準對齊,基本上就是橋接對應平臺小程式原生的介面;

由於介面定義及使用方式同微信小程式對齊,這裡就不再贅述Taro端的具體使用方式,需要說明的是由於Taro框架的特殊性(相比小程式原生方式多了一層),在用Taro進行小程式端滑動曝光監聽開發時,有幾個容易出錯或需要特殊處理的點:

1. 監聽不生效的問題

由於Taro執行時機制,在Taro元件的資料更新方法(例如setState)執行後立刻新增監聽可能會不生效,原因是對應的由資料驅動的小程式元素範例此時還未完成建立或掛載,需要新增延遲或在Taro.nextTick回撥內執行(Taro最新版本已經預設將observe方法新增到Taro.nextTick內執行);如果遇到新增監聽不生效的情況,可以嘗試這個方法;

Taro.nextTick(() => {
    //將監聽新增時機(延遲作到下一個時間片再執行),解決監聽新增時元素尚未建立導致的監聽無效問題
    expoObjserver.relativeTo('.clpScrollView').observe('.coupon-item', result => {
        console.log(result.intersectionRatio);
        //...
    });
});

2. 建立Observer需傳入原生元件範例

在建立observer時需要傳入小程式的頁面或者元件範例,而在Taro元件或頁面內直接使用this獲取的是Taro層的頁面或元件的範例,兩者是不同的;

那麼如何獲取小程式層的元件範例呢:

  • 在純Taro專案中可以直接使用Taro.getCurrentInstance().page獲取小程式頁面的範例;
  • 如果是把 Taro 元件編譯為原生自定義元件的混合模式,可以通過 props.$scope 獲取到小程式的自定義元件物件範例

3. 回撥方法內如何獲取目標元素的其他資訊?

如果建立及設定正確,隨著列表的滑動或其他元素的位置變化,對應的回撥方法應該會被觸發,在回撥方法內我們需要接收回撥的入引數並進行處理(例如上報相關業務資訊)。根據Taro檔案定義,回撥方法的入參是ObserveCallbackResult型別:

interface ObserveCallbackResult {
      /** 目標邊界 */
      boundingClientRect: BoundingClientRectResult
      /** 相交比例 */
      intersectionRatio: number
      /** 相交區域的邊界 */
      intersectionRect: IntersectionRectResult
      /** 參照區域的邊界 */
      relativeRect: RelativeRectResult
      /** 相交檢測時的時間戳 */
      time: number
    }

可以看到其中只有intersectionRatio、time等位置相交的相關資訊,但是卻沒有自定義資料欄位(作為對比微信小程式提供的回撥方法內除了這些還包括節點 ID、節點自定義資料屬性dataset等資訊) ,那麼在Taro內如何獲取目標元素上的其他資料資訊呢?

實際偵錯發現,雖然檔案中沒有id、dataset,但是實際返回值內是有這倆欄位的,哈哈,看到這裡是不是覺得沒啥問題了,以為只是Taro檔案跟我們開了個小小的玩笑;先別高興的太早,雖然實際返回值裡有dataset欄位,但是不幸的是dataset欄位始終是空的,實際返回結果(result)範例如下:

{
    "id": "_n_82",
    "dataset": {},
    "time": 1687763057268,
    "boundingClientRect": {
        "x": 89.109375,
        "y": 19,
        ...
    },
    "intersectionRatio": 1,
    "intersectionRect": {
        "x": 89.109375,
        "y": 19,
        ...
    },
    "relativeRect": {
        "x": 82.109375,
        ...
    }
}

what?why?看到這裡估計大家有想砸鍵盤的衝動,先彆著急,我們先來分析一下為什麼dataset是空呢?
這是由於dataset是小程式的特殊的模版屬性,主要作用是可以在事件回撥的 event 物件中獲取到 dataset 相關資料,Taro對於這些能力是部分支援的,Taro通過在邏輯層的模擬已經支援在事件回撥物件中通過 event.target.dataset 或 event.currentTarget.dataset 獲取到。但是由於是在邏輯層模擬實現的,並沒有真正在模板設定這個屬性。所以在小程式中有一些 API(如:createIntersectionObserver)獲取到頁面的節點的時候,是獲取不到dataset的。

上一點所說的,Taro 對於小程式 dataset 的模擬是在小程式的邏輯層實現的。並沒有真正在模板設定這個屬性。
但在小程式中有一些 API(如:createIntersectionObserver)獲取到頁面的節點的時候,由於節點上實際沒有對應的屬性而獲取不到。

--來自Taro官方檔案: Taro-React-dataset

既然在回撥傳參中直接取值是空,那我們該怎麼獲取元素上的自定義資料呢?

方案一:taro-plugin-inject方案

官方給出的解決方案是使用taro-plugin-inject外掛,向子元素內注入一些通用屬性;實際驗證發現,利用外掛插入後回撥的dataset中確實能看到有對應的屬性,但是該方法插入的屬性只能是統一的固定值,無法根據實際資料動態設定屬性值,因此該方案不能滿足訴求。

//專案 config/index.js 中的 plugins 設定:
  plugins: [
    [
      '@tarojs/plugin-inject',
      {
        components: {
          View: {
            'data-index': "'dataIndex'",
            'data-info': "'dateInfo'"
          },
        },
      },
    ]
  ],
  
//實際返回值
{
    "id": "_n_82",
    "dataset": {
        "index": "dataIndex",
        "info": "dateInfo"
    },
    "time": 1687766275879,
    ...
}
方案二:存取Taro虛擬DOM

根據Taro官方檔案關於React框架使用差異的描述(Taro-React-生命週期觸發機制),Taro3在小程式邏輯層上實現了一份遵循Web標準 BOM 和 DOM API。通過這些API可以獲取對應的虛擬DOM節點(TaroElement物件),既然是邏輯層實現的,那麼節點上應該也能看到對應的dataset資訊。

Taro DOM Reference檔案內的TaroElement欄位說明也證實了這一點。

那麼具體如何實現呢?回撥引數中雖然沒有我們想要的自定義資料欄位,但是可以拿到節點id資訊,可以通過Taro提供的document.getElementById();API利用節點id獲取對應的Taro虛擬DOM節點,從該節點上拿到我們需要的dataset資訊,程式碼如下:

Taro.nextTick(() => {
	//將監聽新增時機(延遲作到下一個時間片再執行),解決監聽新增時元素尚未建立導致的監聽無效問題
	expoObjserver.relativeTo('.clpScrollView').observe('.coupon-item', result => {
		if (!result?.id) return;
		// !!!獲取虛擬DOM節點
		const tarTaroElement = document.getElementById(result?.id);
		const dataInfo = tarTaroElement?.dataset; //拿到dataset資訊
		console.log(tarTaroElement);
		console.log(dataInfo);
       //...
	});
});

至此,我們就拿到了想要的自定義資料(業務資料),後續就是根據述求隨意的使用這些資料了,Taro內列表滑動元素曝光埋點搞定~

總結一下,通過上面幾種常見方案和各平臺內的具體實現的介紹,長列表滑動元素的曝光監聽問題應該不再是難事,搞定了滑動元素曝光監聽,基於此之上的曝光埋點或者其他高階玩法(如長列表優化-資源惰性載入、無限迴圈捲動等)後續我們都可以從容應對。

參考資料

作者:京東零售 丁鑫

來源:京東雲開發者社群