起因
- 产品:我想知道用户到底有没有看到过这个商品(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>
}
以上的实现应该完成了一个基础的功能,但是会有以下问题:
- 每个dom都要绑定ref,比较麻烦。更好的做法是在更顶层的组件上做绑定操作。
- 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。