监听DOM曝光事件 IntersectionObserver

3,673 阅读5分钟

起因

- 产品:我想知道用户到底有没有看到过这个商品(DOM节点)?

- 开发:这个简单,我监听页面滚动,等商品出现在屏幕中,我就发个 埋点 记录一下。

- 产品:那你看这种呢,点旁边的按钮这个产品才显示。

- 开发:那我在 按钮点击事件 里面发 埋点。

- 产品:其实咱这还有一种情况,就是。。。

- 开发 :好了!什么都别说了,我 setInterval, 包你满意!


- but. 作为开发你能做的,远不止于此。


经过

  接下来我们会有三种实现方式,分别是:

    1. 事件监听

    2. 定时器

    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