監聽DOM曝光事件 IntersectionObserver

原由

- 產品:我想知道用戶到底有沒有看到過這個商品(DOM節點)?react

- 開發:這個簡單,我監聽頁面滾動,等商品出如今屏幕中,我就發個 埋點 記錄一下。git

- 產品:那你看這種呢,點旁邊的按鈕這個產品才顯示。github

- 開發:那我在 按鈕點擊事件 裏面發 埋點。bash

- 產品:其實咱這還有一種狀況,就是。。。服務器

- 開發 :好了!什麼都別說了,我 setInterval, 包你滿意!dom


- but. 做爲開發你能作的,遠不止於此。異步


通過

  接下來咱們會有三種實現方式,分別是:工具

    1. 事件監聽ui

    2. 定時器spa

    3. IntersectionObserver


在開始以前,假設本身有一些工具方法

// 判斷dom元素是否在視口中
const isElementInViewPort = (el) => { /* 返回一個布爾值 */ }
//  把數據發送到服務器
const sendLog = (value) => { /**/ }複製代碼

1. 事件監聽

       最初的想法是使用window.scroll、window.resize進行全局監聽。對於特殊狀況,好比點擊出現的商品,則使用更特殊的處理方式。

       注意:對於特殊的商品,要採用特殊的dom結構。所以會比較麻煩。

dom:

// 普通商品
<div data-expose="商品1">...</div>
<div data-expose="商品2">...</div>


// 點擊纔出現的商品
<div>...</div>
<buttton class="expose-click">顯示商品</button>複製代碼

js: 

// 針對scroll
window.on('scroll', () => {
    const elements = document.querySelectorAll('[data-expose]');
    const elementList = [...elements];
    elementList.forEach((element) => {
       // 若是有發送成功的標記,則直接return
       if(element.getAttribute('[has-exposed]')) return;        
       if (isElementInViewPort(element)) {
            const logValue = element.getAttribute('[data-expose]')
            sendLog(logValue)
            // 曝光成功以後,則打一個標記
            element.setAttribute('[has-exposed]', "1")
       }
    })
})
// 針對點擊
const target = document.querySelectorAll(".expose-click")
target.on('click', (e) => {
    const target = e.target;
    if(target.getAttribute('[has-exposed]')) return
    const logValue = "點擊出現的商品"
    sendLog(LogValue)
    target.setAttribute('[has-exposed]', "1")
})複製代碼

2.  定時器

    由於方法1中,dom結構不一樣,而且方法不夠通用,因此思考新的方式。

    使用setInterval,則不用考慮元素是怎麼出現的,只要出如今了屏幕中,則定時器會自動發現須要曝光的內容。

dom:

<div data-expose="商品1">...</div>
<div data-expose="商品2">...</div>
...

<div data-expose="點擊出現的商品">...</div>
<buttton>顯示商品</button>複製代碼

js:

const observeTimer = setInterval(() => {
    const elements = document.querySelectorAll('[data-expose]');
    const elementList = [...elements];
    elementList.forEach((element) => {
        // 若是有發送成功的標記,則直接return
        if (element.getAttribute('[has-exposed]')) return;

        if (isElementInViewPort(element)) {
            const logValue = element.getAttribute('[data-expose]')
            sendLog(logValue)
            // 曝光成功以後,則打一個標記
            element.setAttribute('[has-exposed]',  "1")
        }
    })
}, 500)複製代碼


3. IntersectionObserver

ok. 咱們的終極方案來了 IntersectionObserver。你們能夠看到以下這段話:

IntersectionObserver 接口提供了一種異步觀察目標元素與其祖先元素或頂級文檔視窗(viewport)交叉狀態的方法。

嗯。 嗯??這不就是專門用來作這個事情的嘛?

而後就看一下兼容性,着實通常。

可是w3c官方提供了polyfill  (每一個提案 到 Working Draft階段一般會提供1-2個polyfill). 而後兼容性就能夠說是起飛了。


基本實現以下:

observerLibrary.js

let observer;
const init = (options) => {
    //IntersectionObserver接受一個callback, 參數是全部被觀察的對象
    return observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
            let { target } = entry;
            // 若是dom在視口中,則發送埋點
            if(entry.isEntersecting) {
                const logValue = target.getAttribute('data-expose')
                sendLog(logValue)
                target.setAttribute('has-exposed', "1")
                // 中止監聽
                observer.unobserve(target)
            }
        })
    }, {thresholds: options.thresholds || 0, rootMargin: options.rootMargin || {}})
}
const getObserver = () => observer || init()

const observe = () => getObserver().observe;

const unobserve = () =>  getObserver().unobserve

const stopObserve = () => {
    if(!observer) return;
    observer.disconnect()
}

export default {
    init,
    observe,
    unobserve,
    stopObserve
}複製代碼

myObserver.js

import { init, observe, unobserve, stopObserve } from "observerLibrary"

// 須要一個機會去配置observer, 例如交叉比例達到多大時纔算曝光
const options = {}
init(options)

export default {
    observe,
    unobserve,
    stopObserve
}複製代碼

component.js

import React from "react"
import { observe, unobserve } from "./myObserver"

const App = (props) => {
    return <div>
        <div data-expose="商品1" ref={observe}>...</div>
        <div data-expose="商品2" ref={observe}>...</div>
    </div>
}複製代碼


以上的實現應該完成了一個基礎的功能,可是會有如下問題:

  1. 每一個dom都要綁定ref,比較麻煩。更好的作法是在更頂層的組件上作綁定操做。
  2. dom從頁面上移除後, observer仍然會對這個dom進行observe,致使內存泄露。

所以,咱們能夠加強一下如今的實現:

  • dom不直接交給observer管理,而是維護一個observe隊列
  • observe某個dom的時候,去observe全部帶有‘data-expose’的子節點
  • observe的時機,作一個 document.body.contains(element) 的check
  • unobserve某個dom的時候,unobserve全部帶有‘data-expose’的子節點


最終的observe方式實現以下:

let subjectList = [];
const observe = (el) => {
    if(!el) return;
    // 過濾掉不存在在document上的節點
    subjectList = filterObserveList(subjectList)
    const newList = getElementWithExpose(el);
    const newItemsNotInOldList = getNewItemsNotInOldList(observeList, newList);
    observeList = addNewListToList(observeList, newList);
    const observer = getObserver();
    newItemsNotInOldList.forEach((item) => {
        observer.observe(item);
    })
}

/*
 * 考慮到dom可能被意外移除,因此考慮當某個target已經不在document.body中的時候,執行unobserve(target)
 * */
function filterObserveList(list = []) {
    const contains = document.body.contains;
    const observer = getViewabilityObserver();
    const effectiveObserveList = [];
    list.forEach((item, key) => {
        if (contains(item)) {
            effectiveObserveList.push(item);
        } else {
            observer.unobserve(item);
        }
    });
    return effectiveObserveList;
};

function getElementWithExpose(el) {
    if (!el) return [];
    const children = Array.from(el.querySelectorAll(`[data-expose]`));
    const isIncludeSelf = !!el.getAttribute('data-expose');
    const rawResult = isIncludeSelf ? [...children, el] : children;
    return rawResult.filter(item => !item.getAttribute('has-exposed');
};

function getNewItemsNotInOldList(list = [], newList = []) {
    return newList.filter(item => !list.includes(item))
};

function addNewListToList(list = [], newList = []) {
    return Array.from(new Set([...list, ...newList]))
};複製代碼


此外,還有一些其餘注意事項,須要使用者去特殊處理。好比:

  • 有些tab插件,隱藏非激活狀態的方式是 設置 height 爲 0,若是在視口中,會被認爲可見
  • visibility: hidden的元素在視口中, 會被認爲可見

結果

完整代碼能夠訪問 github

相關文章
相關標籤/搜索