|
@@ -1,255 +1,178 @@
|
|
|
+/* ============================================================
|
|
|
+ * exposureDetector.js
|
|
|
+ * 支持 Light DOM + open Shadow DOM 的曝光统计
|
|
|
+ * ============================================================ */
|
|
|
export function exposureDetector(options = {}) {
|
|
|
- // 检测 IntersectionObserver 是否支持
|
|
|
+ /* ---------- 0. 环境检测 ---------- */
|
|
|
if (typeof IntersectionObserver === 'undefined') {
|
|
|
const error = new Error('IntersectionObserver 不受支持');
|
|
|
- options.error?.(error) || console.error(error.message);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 初始化全局存储对象
|
|
|
- if (!window.__exposure__data__) {
|
|
|
- window.__exposure__data__ = {};
|
|
|
+ (options.error || console.error)(error);
|
|
|
+ return () => {};
|
|
|
}
|
|
|
|
|
|
+ /* ---------- 1. 默认配置 ---------- */
|
|
|
const defaultOptions = {
|
|
|
- threshold: 90,
|
|
|
- container: 'body',
|
|
|
- error: (error) => {
|
|
|
- console.error('曝光检测错误:', error);
|
|
|
- },
|
|
|
- callback: () => {},
|
|
|
- progressiveIntervals: [3000, 6000, 9000], // 渐进式上报间隔,默认为固定值
|
|
|
- reportThreshold: 10, // 默认队列长度大于10时上报
|
|
|
- minExposureDuration: 1000 // 默认曝光时长超过1秒才进入队列
|
|
|
+ threshold: 90, // 默认曝光百分比阈值
|
|
|
+ container: 'body', // 观察的根容器
|
|
|
+ minExposureDuration: 1000, // ms,至少曝光多久才上报
|
|
|
+ progressiveIntervals: [3000, 6000, 9000], // 渐进式上报间隔
|
|
|
+ reportThreshold: 10, // 队列长度 > N 立即上报
|
|
|
+ error: console.error,
|
|
|
+ callback: () => {}, // 收到批量上报数据时的回调
|
|
|
+ custom: null // 自定义判定函数 (ratio)=>boolean
|
|
|
};
|
|
|
-
|
|
|
const settings = { ...defaultOptions, ...options };
|
|
|
|
|
|
- // 确保 progressiveIntervals 是一个有效的数组
|
|
|
- if (!Array.isArray(settings.progressiveIntervals)) {
|
|
|
- settings.progressiveIntervals = [settings.progressiveIntervals];
|
|
|
- }
|
|
|
-
|
|
|
- const container = document.querySelector(settings.container);
|
|
|
- if (!container) {
|
|
|
- settings.error(new Error(`未找到容器 ${settings.container}`));
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 队列相关变量
|
|
|
+ /* ---------- 2. 全局变量 ---------- */
|
|
|
+ if (!window.__exposure__data__) window.__exposure__data__ = {};
|
|
|
const reportQueue = [];
|
|
|
- let isReporting = false;
|
|
|
let reportTimeoutId = null;
|
|
|
- let reportIntervalCount = 0; // 用于记录当前是第几次上报
|
|
|
+ let reportIntervalCount = 0;
|
|
|
+ let isReporting = false;
|
|
|
+
|
|
|
+ const rootEl = document.querySelector(settings.container) || document.body;
|
|
|
|
|
|
- // 页面离开前上报
|
|
|
- function handleBeforeUnload() {
|
|
|
- if (reportQueue.length > 0 && !isReporting) {
|
|
|
- handleReport();
|
|
|
+ /* ---------- 3. 调试开关 ---------- */
|
|
|
+ const debugLog = (...args) => {
|
|
|
+ if (document.cookie.includes('debug')) console.log('[exposure]', ...args);
|
|
|
+ };
|
|
|
+
|
|
|
+ /* ---------- 4. 工具:深度查找 ---------- */
|
|
|
+ function querySelectorAllDeep(root, selector) {
|
|
|
+ const res = [];
|
|
|
+ const walker = document.createTreeWalker(root, Node.ELEMENT_NODE, null, false);
|
|
|
+ let node;
|
|
|
+ while ((node = walker.nextNode())) {
|
|
|
+ if (node.matches?.(selector)) res.push(node);
|
|
|
+ if (node.shadowRoot) res.push(...querySelectorAllDeep(node.shadowRoot, selector));
|
|
|
}
|
|
|
+ return res;
|
|
|
}
|
|
|
|
|
|
- // 上报处理函数
|
|
|
- function handleReport() {
|
|
|
+ /* ---------- 5. 上报逻辑 ---------- */
|
|
|
+ function handleReport(force = false) {
|
|
|
+ if (reportQueue.length === 0 || (isReporting && !force)) return;
|
|
|
isReporting = true;
|
|
|
- const reportData = [...reportQueue];
|
|
|
+ const data = [...reportQueue];
|
|
|
reportQueue.length = 0;
|
|
|
- // 这里可以将reportData发送到服务器
|
|
|
- debugLog('上报数据:', reportData);
|
|
|
+ settings.callback(data);
|
|
|
+ debugLog('上报曝光数据:', data);
|
|
|
isReporting = false;
|
|
|
-
|
|
|
- // 更新上报计数器
|
|
|
reportIntervalCount++;
|
|
|
-
|
|
|
- settings.callback(reportData);
|
|
|
}
|
|
|
|
|
|
- // 启动定时上报(基于队列新增数据)
|
|
|
function startReportTimer() {
|
|
|
- if (reportTimeoutId) {
|
|
|
- clearTimeout(reportTimeoutId);
|
|
|
- }
|
|
|
-
|
|
|
- // 根据渐进式规则计算当前的上报间隔
|
|
|
- const currentInterval = settings.progressiveIntervals[Math.min(
|
|
|
- reportIntervalCount,
|
|
|
- settings.progressiveIntervals.length - 1
|
|
|
- )];
|
|
|
-
|
|
|
- reportTimeoutId = setTimeout(() => {
|
|
|
- if (reportQueue.length > 0 && !isReporting) {
|
|
|
- handleReport();
|
|
|
- }
|
|
|
- }, currentInterval);
|
|
|
-
|
|
|
- // 调试日志
|
|
|
- debugLog(`启动定时上报,间隔: ${currentInterval}ms,计数: ${reportIntervalCount}`);
|
|
|
+ clearTimeout(reportTimeoutId);
|
|
|
+ const interval = settings.progressiveIntervals[
|
|
|
+ Math.min(reportIntervalCount, settings.progressiveIntervals.length - 1)
|
|
|
+ ];
|
|
|
+ reportTimeoutId = setTimeout(() => handleReport(), interval);
|
|
|
}
|
|
|
|
|
|
- // 数量上报
|
|
|
function checkReportThreshold() {
|
|
|
- if (reportQueue.length >= settings.reportThreshold && !isReporting) {
|
|
|
- handleReport();
|
|
|
- }
|
|
|
+ if (reportQueue.length >= settings.reportThreshold) handleReport();
|
|
|
}
|
|
|
|
|
|
- // 创建 Intersection Observer 实例
|
|
|
- const observer = new IntersectionObserver((entries) => {
|
|
|
- entries.forEach(entry => {
|
|
|
- if (entry.target.hasAttribute('data-exposure')) {
|
|
|
- const exposureName = entry.target.dataset.exposure;
|
|
|
- const exposurePercentage = Math.round(entry.intersectionRatio * 100);
|
|
|
- let elementThreshold = settings.threshold;
|
|
|
- if (entry.target.hasAttribute('data-exposure-threshold')) {
|
|
|
- const thresholdValue = entry.target.getAttribute('data-exposure-threshold');
|
|
|
- const parsedThreshold = parseInt(thresholdValue, 10);
|
|
|
- if (!isNaN(parsedThreshold) && parsedThreshold >= 0 && parsedThreshold <= 100) {
|
|
|
- elementThreshold = parsedThreshold;
|
|
|
- } else {
|
|
|
- debugLog(`元素 ${exposureName} 的阈值无效: ${thresholdValue}。使用默认阈值。`);
|
|
|
- }
|
|
|
+ /* ---------- 6. IntersectionObserver ---------- */
|
|
|
+ const io = new IntersectionObserver(
|
|
|
+ entries => {
|
|
|
+ entries.forEach(entry => {
|
|
|
+ const el = entry.target;
|
|
|
+ const name = el.dataset.exposure;
|
|
|
+ const ratio = Math.round(entry.intersectionRatio * 100);
|
|
|
+
|
|
|
+ let threshold = settings.threshold;
|
|
|
+ if (el.dataset.exposureThreshold) {
|
|
|
+ const t = parseInt(el.dataset.exposureThreshold, 10);
|
|
|
+ if (!Number.isNaN(t) && t >= 0 && t <= 100) threshold = t;
|
|
|
}
|
|
|
|
|
|
- let shouldTrigger = false;
|
|
|
- if (settings.custom) {
|
|
|
- shouldTrigger = settings.custom(exposurePercentage);
|
|
|
- } else {
|
|
|
- shouldTrigger = exposurePercentage >= elementThreshold;
|
|
|
- }
|
|
|
+ const shouldTrigger = settings.custom
|
|
|
+ ? settings.custom(ratio)
|
|
|
+ : ratio >= threshold;
|
|
|
|
|
|
- if (shouldTrigger) {
|
|
|
- // 如果元素刚开始曝光,或者有 data-exposure-loop 属性且之前离开过,重置计时
|
|
|
- if (!window.__exposure__data__[exposureName] || entry.target.hasAttribute('data-exposure-loop')) {
|
|
|
- // 初始化或重置数据
|
|
|
- if (!window.__exposure__data__[exposureName]) {
|
|
|
- window.__exposure__data__[exposureName] = {};
|
|
|
- }
|
|
|
-
|
|
|
- // 清除之前的定时器(如果有)
|
|
|
- if (window.__exposure__data__[exposureName].timer) {
|
|
|
- clearTimeout(window.__exposure__data__[exposureName].timer);
|
|
|
- }
|
|
|
-
|
|
|
- // 获取历史曝光次数
|
|
|
- let exposureCount = 1;
|
|
|
- if (window.__exposure__data__[exposureName].count) {
|
|
|
- exposureCount = window.__exposure__data__[exposureName].count + 1;
|
|
|
- }
|
|
|
-
|
|
|
- // 重置开始时间并启动新的定时器
|
|
|
- const startTime = Date.now();
|
|
|
- window.__exposure__data__[exposureName].startTime = startTime;
|
|
|
- window.__exposure__data__[exposureName].timer = setTimeout(() => {
|
|
|
- const exposureDuration = Date.now() - startTime;
|
|
|
- if (exposureDuration >= settings.minExposureDuration) {
|
|
|
- // 更新曝光数据
|
|
|
- window.__exposure__data__[exposureName] = {
|
|
|
- count: exposureCount,
|
|
|
- threshold: exposurePercentage,
|
|
|
- time: Date.now()
|
|
|
- };
|
|
|
-
|
|
|
- // 将数据添加到队列
|
|
|
- reportQueue.push({
|
|
|
- name: exposureName,
|
|
|
- count: exposureCount,
|
|
|
- threshold: exposurePercentage,
|
|
|
- time: Date.now()
|
|
|
- });
|
|
|
- debugLog(`元素 ${exposureName} 已添加到上报队列。队列长度: ${reportQueue.length}`);
|
|
|
-
|
|
|
- // 启动定时上报
|
|
|
- startReportTimer();
|
|
|
-
|
|
|
- // 检查是否需要上报
|
|
|
- checkReportThreshold();
|
|
|
- }
|
|
|
- }, settings.minExposureDuration);
|
|
|
-
|
|
|
- // 保留当前的曝光百分比
|
|
|
- window.__exposure__data__[exposureName].threshold = exposurePercentage;
|
|
|
-
|
|
|
- debugLog(`元素 ${exposureName} 曝光开始于 ${window.__exposure__data__[exposureName].startTime}`);
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 元素离开曝光区域时,清除定时器并重置状态
|
|
|
- if (window.__exposure__data__[exposureName]?.startTime) {
|
|
|
- clearTimeout(window.__exposure__data__[exposureName].timer);
|
|
|
- delete window.__exposure__data__[exposureName].timer;
|
|
|
- delete window.__exposure__data__[exposureName].startTime;
|
|
|
- debugLog(`元素 ${exposureName} 曝光结束`);
|
|
|
+ if (!shouldTrigger) {
|
|
|
+ // 离开可视区域
|
|
|
+ if (window.__exposure__data__[name]?.timer) {
|
|
|
+ clearTimeout(window.__exposure__data__[name].timer);
|
|
|
+ delete window.__exposure__data__[name].timer;
|
|
|
+ debugLog(`离开:${name}`);
|
|
|
}
|
|
|
+ return;
|
|
|
}
|
|
|
|
|
|
- // 如果没有 data-exposure-loop 属性,则停止观察该元素
|
|
|
- if (!entry.target.hasAttribute('data-exposure-loop')) {
|
|
|
- observer.unobserve(entry.target);
|
|
|
- debugLog(`元素 ${exposureName} 不再被观察`);
|
|
|
+ // 开始计时
|
|
|
+ if (!window.__exposure__data__[name] || el.hasAttribute('data-exposure-loop')) {
|
|
|
+ if (!window.__exposure__data__[name]) window.__exposure__data__[name] = {};
|
|
|
+ const count = (window.__exposure__data__[name].count || 0) + 1;
|
|
|
+ const start = Date.now();
|
|
|
+
|
|
|
+ clearTimeout(window.__exposure__data__[name].timer);
|
|
|
+ window.__exposure__data__[name].timer = setTimeout(() => {
|
|
|
+ reportQueue.push({
|
|
|
+ name,
|
|
|
+ count,
|
|
|
+ threshold: ratio,
|
|
|
+ time: Date.now()
|
|
|
+ });
|
|
|
+ window.__exposure__data__[name].count = count;
|
|
|
+ checkReportThreshold();
|
|
|
+ startReportTimer();
|
|
|
+ debugLog(`触发曝光:${name}`);
|
|
|
+ }, settings.minExposureDuration);
|
|
|
}
|
|
|
- }
|
|
|
- });
|
|
|
- }, {
|
|
|
- root: container,
|
|
|
- threshold: Array.from({ length: 101 }, (_, i) => i / 100)
|
|
|
- });
|
|
|
-
|
|
|
- // 初始观察容器中的所有符合条件的元素
|
|
|
- const elements = container.querySelectorAll('[data-exposure]');
|
|
|
- elements.forEach(element => {
|
|
|
- observer.observe(element);
|
|
|
- const exposureName = element.dataset.exposure;
|
|
|
- debugLog(`元素 ${exposureName} 正在被观察`);
|
|
|
- });
|
|
|
-
|
|
|
- // 监听容器的 DOM 变化,自动添加新元素的观察器
|
|
|
- const mutationObserver = new MutationObserver((mutations) => {
|
|
|
- mutations.forEach(mutation => {
|
|
|
- mutation.addedNodes.forEach(node => {
|
|
|
- if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('data-exposure')) {
|
|
|
- observer.observe(node);
|
|
|
- const exposureName = node.dataset.exposure;
|
|
|
- debugLog(`新元素 ${exposureName} 正在被观察`);
|
|
|
- } else if (node.querySelectorAll) {
|
|
|
- const newElements = node.querySelectorAll('[data-exposure]');
|
|
|
- newElements.forEach(element => {
|
|
|
- observer.observe(element);
|
|
|
- const exposureName = element.dataset.exposure;
|
|
|
- debugLog(`新元素 ${exposureName} 正在被观察`);
|
|
|
+
|
|
|
+ if (!el.hasAttribute('data-exposure-loop')) io.unobserve(el);
|
|
|
+ });
|
|
|
+ },
|
|
|
+ { root: rootEl, threshold: Array.from({ length: 101 }, (_, i) => i / 100) }
|
|
|
+ );
|
|
|
+
|
|
|
+ /* ---------- 7. 递归监听所有 Shadow Root 的 MutationObserver ---------- */
|
|
|
+ function observeDeep(root = document) {
|
|
|
+ const mo = new MutationObserver(records => {
|
|
|
+ records.forEach(rec => {
|
|
|
+ rec.addedNodes.forEach(n => {
|
|
|
+ if (n.nodeType !== Node.ELEMENT_NODE) return;
|
|
|
+ // 查找并观察
|
|
|
+ querySelectorAllDeep(n, '[data-exposure]').forEach(el => {
|
|
|
+ io.observe(el);
|
|
|
+ debugLog(`观察新节点:${el.dataset.exposure}`);
|
|
|
});
|
|
|
- }
|
|
|
+ // 若本身是 Shadow Host,继续监听其 shadowRoot
|
|
|
+ if (n.shadowRoot) observeDeep(n.shadowRoot);
|
|
|
+ });
|
|
|
});
|
|
|
});
|
|
|
- });
|
|
|
+ mo.observe(root, { childList: true, subtree: true });
|
|
|
+ return mo;
|
|
|
+ }
|
|
|
|
|
|
- mutationObserver.observe(container, {
|
|
|
- childList: true,
|
|
|
- subtree: true
|
|
|
- });
|
|
|
+ /* ---------- 8. 初始化 ---------- */
|
|
|
+ // 8.1 先观察当前已存在的节点
|
|
|
+ querySelectorAllDeep(rootEl, '[data-exposure]').forEach(el => io.observe(el));
|
|
|
|
|
|
- // 监听页面离开事件
|
|
|
- window.addEventListener('beforeunload', handleBeforeUnload);
|
|
|
- debugLog('已添加页面离开事件监听器');
|
|
|
+ // 8.2 监听后续动态节点(含 Shadow DOM)
|
|
|
+ const deepMo = observeDeep(document);
|
|
|
|
|
|
- // 返回一个函数用于清理观察器
|
|
|
+ // 8.3 页面离开前上报
|
|
|
+ window.addEventListener('beforeunload', () => handleReport(true));
|
|
|
+
|
|
|
+ /* ---------- 9. 返回清理函数 ---------- */
|
|
|
return () => {
|
|
|
- observer.disconnect();
|
|
|
- mutationObserver.disconnect();
|
|
|
- if (reportTimeoutId) {
|
|
|
- clearTimeout(reportTimeoutId);
|
|
|
- }
|
|
|
- window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
|
- debugLog('所有观察器和监听器已清理');
|
|
|
+ io.disconnect();
|
|
|
+ deepMo.disconnect();
|
|
|
+ clearTimeout(reportTimeoutId);
|
|
|
+ window.removeEventListener('beforeunload', handleReport);
|
|
|
+ debugLog('已清理所有观察器');
|
|
|
};
|
|
|
}
|
|
|
|
|
|
-// 封装的 debugLog 函数
|
|
|
-function debugLog(...args) {
|
|
|
- if (isDebugMode()) {
|
|
|
- console.log(...args);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 检查 cookie 中是否包含 debug 标志
|
|
|
-function isDebugMode() {
|
|
|
- return document.cookie.includes('debug');
|
|
|
-}
|
|
|
+/* ============================================================
|
|
|
+ * 使用示例
|
|
|
+ * ============================================================ */
|
|
|
+// exposureDetector({
|
|
|
+// container: '#app',
|
|
|
+// callback: data => console.table(data),
|
|
|
+// debug: true // 打开 cookie 中 debug=1 即可看日志
|
|
|
+// });
|