Kaynağa Gözat

feat: 优化曝光,支持shadow dom

zhangyuhan 6 gün önce
ebeveyn
işleme
45bb6910a8
1 değiştirilmiş dosya ile 134 ekleme ve 211 silme
  1. 134 211
      src/module/exposure.js

+ 134 - 211
src/module/exposure.js

@@ -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 即可看日志
+// });