Browse Source

feat: 优化子应用及队列问题

zhangyuhan 6 hours ago
parent
commit
8485cf688b
3 changed files with 176 additions and 179 deletions
  1. 1 1
      package.json
  2. 174 177
      src/module/exposure.js
  3. 1 1
      src/module/types.js

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "rollup-js",
-  "version": "1.8.0",
+  "version": "1.9.0",
   "main": "index.js",
   "license": "MIT",
   "scripts": {

+ 174 - 177
src/module/exposure.js

@@ -1,212 +1,209 @@
 /* ============================================================
- * exposureDetector.js
- * 支持 Light DOM + open Shadow DOM 的曝光统计
- * 改动:新增 scan() 方法、防抖扫描、debug 日志
+ * exposureDetector.js  v3.2
+ * 1. 队列&渐进式上报完整
+ * 2. 持续可见 ≥ minExposureDuration 立即入队
+ * 3. 队列 ≥ reportThreshold 立即 flush
+ * 4. debug 全集
  * ============================================================ */
-export function exposureDetector(options = {}) {
-  /* ---------- 0. 环境检测 ---------- */
-  if (typeof IntersectionObserver === 'undefined') {
-    const error = new Error('IntersectionObserver 不受支持');
-    (options.error || console.error)(error);
-    return () => {};
-  }
 
-  /* ---------- 1. 默认配置 ---------- */
-  const defaultOptions = {
-    threshold: 90,
-    container: 'body',
-    minExposureDuration: 1000,
-    progressiveIntervals: [3000, 6000, 9000],
-    reportThreshold: 10,
-    error: console.error,
-    callback: () => {},
-    custom: null
-  };
-  const settings = { ...defaultOptions, ...options };
+/* ---------- 调试 ---------- */
+const debugLog = (...args) => {
+  if (document.cookie.includes('debug')) console.log('[exposure]', ...args);
+};
 
-  /* ---------- 2. 全局变量 ---------- */
-  if (!window.__exposure__data__) window.__exposure__data__ = {};
-  const reportQueue = [];
-  let reportTimeoutId = null;
-  let reportIntervalCount = 0;
-  let isReporting = false;
+/* ---------- 工具:深度查询(带节点校验) ---------- */
+function querySelectorAllDeep(root, selector) {
+  if (!root || !(root instanceof Node)) {
+    debugLog('querySelectorAllDeep: root 不是有效 Node', root);
+    return [];
+  }
+  const res = [];
+  const walker = document.createTreeWalker(root, Node.ELEMENT_NODE, null, false);
+  let node;
+  while ((node = walker.nextNode())) {
+    if (node.matches?.(selector)) {
+      if (node.__exposure_observed__) continue;
+      node.__exposure_observed__ = true;
+      res.push(node);
+    }
+    if (node.shadowRoot) res.push(...querySelectorAllDeep(node.shadowRoot, selector));
+  }
+  return res;
+}
 
-  const rootEl = document.querySelector(settings.container) || document.body;
+/* ---------- 全局队列 ---------- */
+if (!window.__exposure__data__) window.__exposure__data__ = {};
+const reportQueue = [];
+let reportTimeoutId = null;
+let reportIntervalCnt = 0;
+let isReporting = false;
+
+/* ---------- 上报函数 ---------- */
+function flushReport(settings, force = false) {
+  if (!force && (reportQueue.length === 0 || isReporting)) return;
+  isReporting = true;
+  const data = [...reportQueue];
+  reportQueue.length = 0;
+  settings.callback(data);
+  debugLog('flushReport:', data);
+  isReporting = false;
+  reportIntervalCnt++;
+}
 
-  /* ---------- 3. 调试开关 ---------- */
-  const debugLog = (...args) => {
-    if (document.cookie.includes('debug')) console.log('[exposure]', ...args);
-  };
+function startReportTimer(settings) {
+  if (reportTimeoutId || reportQueue.length === 0) return;
+  const interval = settings.progressiveIntervals[
+    Math.min(reportIntervalCnt, settings.progressiveIntervals.length - 1)
+    ];
+  reportTimeoutId = setTimeout(() => {
+    reportTimeoutId = null;
+    flushReport(settings);
+    startReportTimer(settings);
+  }, interval);
+  debugLog(`startReportTimer: ${interval}ms`);
+}
 
-  /* ---------- 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)) {
-        // 简单去重:已观察过的节点直接跳过
-        if (node.__exposure_observed__) continue;
-        node.__exposure_observed__ = true;
-        res.push(node);
-      }
-      if (node.shadowRoot) res.push(...querySelectorAllDeep(node.shadowRoot, selector));
-    }
-    return res;
+function checkReportThreshold(settings) {
+  if (reportQueue.length >= settings.reportThreshold) {
+    flushReport(settings);
+  } else {
+    startReportTimer(settings);
   }
+}
 
-  /* ---------- 5. 上报逻辑 ---------- */
-  function handleReport(force = false) {
-    if (reportQueue.length === 0 || (isReporting && !force)) return;
-    isReporting = true;
-    const data = [...reportQueue];
-    reportQueue.length = 0;
-    settings.callback(data);
-    debugLog('上报曝光数据:', data);
-    isReporting = false;
-    reportIntervalCount++;
+/* ---------- AutoExposure 内核 ---------- */
+class AutoExposure {
+  constructor(options = {}) {
+    this.settings   = {
+      threshold: 80,
+      minExposureDuration: 1500,
+      progressiveIntervals: [3000, 6000, 9000],
+      reportThreshold: 10,
+      ...options
+    };
+    this.threshold  = this.settings.threshold / 100;
+    this.duration   = this.settings.minExposureDuration;
+    this.seen       = new WeakSet();
+    this.timers     = new Map(); // el -> timerId
+    this.observers  = new Map();
+    this.mo         = new MutationObserver(this.onDomChange.bind(this));
+    debugLog('AutoExposure 初始化', this.settings);
   }
 
-  function startReportTimer() {
-    clearTimeout(reportTimeoutId);
-    const interval = settings.progressiveIntervals[
-      Math.min(reportIntervalCount, settings.progressiveIntervals.length - 1)
-      ];
-    reportTimeoutId = setTimeout(() => handleReport(), interval);
+  findScrollContainer(el) {
+    let cur = el;
+    while (cur && cur !== document.documentElement) {
+      const st = getComputedStyle(cur);
+      if (/(auto|scroll)/.test(st.overflow + st.overflowY)) return cur;
+      cur = cur.parentElement || cur.getRootNode()?.host;
+    }
+    return null; // viewport
   }
 
-  function checkReportThreshold() {
-    if (reportQueue.length >= settings.reportThreshold) handleReport();
+  observe(el) {
+    if (this.seen.has(el) && !el.hasAttribute('data-exposure-loop')) return;
+    const root = this.findScrollContainer(el);
+    if (!this.observers.has(root)) {
+      this.observers.set(root, new IntersectionObserver(
+        this.onIntersect.bind(this),
+        { root, threshold: [0, this.threshold, 1] }
+      ));
+    }
+    this.observers.get(root).observe(el);
   }
 
-  /* ---------- 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;
+  onIntersect(entries) {
+    entries.forEach(e => {
+      const el   = e.target;
+      const name = el.dataset.exposure;
+      const ratio = Math.round(e.intersectionRatio * 100);
+
+      const shouldTrigger = this.settings.custom
+        ? this.settings.custom(ratio)
+        : ratio >= this.settings.threshold;
+
+      if (!shouldTrigger) {
+        if (this.timers.has(el)) {
+          clearTimeout(this.timers.get(el));
+          debugLog(`离开:${name},清除计时器`);
+          this.timers.delete(el);
         }
+        return;
+      }
 
-        const shouldTrigger = settings.custom
-          ? settings.custom(ratio)
-          : ratio >= threshold;
-
-        if (!shouldTrigger) {
-          if (window.__exposure__data__[name]?.timer) {
-            clearTimeout(window.__exposure__data__[name].timer);
-            delete window.__exposure__data__[name].timer;
-            debugLog(`离开:${name}`);
-          }
-          return;
-        }
+      if (this.timers.has(el)) return;
+      debugLog(`进入:${name} 可见度 ${ratio}%,计时 ${this.duration}ms`);
+      const timer = setTimeout(() => {
+        const count = (window.__exposure__data__[name]?.count || 0) + 1;
+        const data = { name, count, threshold: ratio, time: Date.now() };
+        window.__exposure__data__[name] = { count };
+        reportQueue.push(data);
+        debugLog(`入队:${name},队列长度=${reportQueue.length}`);
+        checkReportThreshold(this.settings);
+      }, this.duration);
+      this.timers.set(el, timer);
+    });
+  }
 
-        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);
-        }
+  onDomChange() {
+    clearTimeout(this._debounceTimer);
+    this._debounceTimer = setTimeout(() => this.start(), 200);
+  }
 
-        if (!el.hasAttribute('data-exposure-loop')) io.unobserve(el);
-      });
-    },
-    { root: rootEl, threshold: Array.from({ length: 101 }, (_, i) => i / 100) }
-  );
-
-  /* ---------- 7. 防抖扫描 & 全量扫描 ---------- */
-  let scanTimer = null;
-  function debounceScan() {
-    clearTimeout(scanTimer);
-    scanTimer = setTimeout(() => {
-      debugLog('防抖扫描:开始');
-      scan();
-    }, 200);
+  scanShadow(root) {
+    querySelectorAllDeep(root, '[data-exposure]').forEach(this.observe.bind(this));
   }
 
-  function scan() {
-    const nodes = querySelectorAllDeep(rootEl, '[data-exposure]');
-    debugLog(`扫描到 ${nodes.length} 个节点`);
-    nodes.forEach(el => {
-      io.observe(el);
-      debugLog(`观察节点:${el.dataset.exposure}`, el);
+  start() {
+    debugLog('执行全量扫描');
+    querySelectorAllDeep(document, '[data-exposure]').forEach(this.observe.bind(this));
+    this.mo.observe(document.body, { childList: true, subtree: true });
+    document.querySelectorAll('*').forEach(el => {
+      if (el.shadowRoot) this.scanShadow(el.shadowRoot);
     });
   }
 
-  /* ---------- 8. 递归监听所有 Shadow Root 的 MutationObserver ---------- */
-  function observeDeep(root = document) {
-    const mo = new MutationObserver(records => {
-      let needScan = false;
-      records.forEach(rec => {
-        rec.addedNodes.forEach(n => {
-          if (n.nodeType !== Node.ELEMENT_NODE) return;
-          needScan = true;
-          if (n.shadowRoot) observeDeep(n.shadowRoot);
-        });
-      });
-      if (needScan) debounceScan();
-    });
-    mo.observe(root, { childList: true, subtree: true });
-    return mo;
+  stop() {
+    this.observers.forEach(o => o.disconnect());
+    this.mo.disconnect();
+    this.timers.forEach(clearTimeout);
+  }
+}
+
+/* ---------- 兼容外壳 ---------- */
+export function exposureDetector(options = {}) {
+  if (typeof IntersectionObserver === 'undefined') {
+    const error = new Error('IntersectionObserver 不受支持');
+    (options.error || console.error)(error);
+    return { destroy: () => {}, scan: () => {} };
   }
 
-  /* ---------- 9. 初始化 ---------- */
-  // 9.1 文档 ready 后再扫一次,防止首屏节点遗漏
-  function ready(fn) {
-    if (document.readyState !== 'loading') fn();
-    else document.addEventListener('DOMContentLoaded', fn);
+  const settings = {
+    threshold: 80,
+    minExposureDuration: 1500,
+    progressiveIntervals: [3000, 6000, 9000],
+    reportThreshold: 10,
+    callback: () => {},
+    ...options
+  };
+  if (!Array.isArray(settings.progressiveIntervals)) {
+    settings.progressiveIntervals = [settings.progressiveIntervals];
   }
-  ready(() => {
-    debugLog('文档 ready,执行首次全量扫描');
-    scan();
-  });
 
-  // 9.2 监听后续动态节点(含 Shadow DOM)
-  const deepMo = observeDeep(document);
+  const ae = new AutoExposure(settings);
+  ae.start();
 
-  // 9.3 页面离开前上报
-  window.addEventListener('beforeunload', () => handleReport(true));
+  const onBeforeUnload = () => {
+    flushReport(settings, true);
+  };
+  window.addEventListener('beforeunload', onBeforeUnload);
 
-  /* ---------- 10. 返回清理函数 + scan 方法 ---------- */
   return {
     destroy: () => {
-      io.disconnect();
-      deepMo.disconnect();
-      clearTimeout(reportTimeoutId);
-      clearTimeout(scanTimer);
-      window.removeEventListener('beforeunload', handleReport);
-      debugLog('已清理所有观察器');
+      ae.stop();
+      window.removeEventListener('beforeunload', onBeforeUnload);
+      if (reportTimeoutId) clearTimeout(reportTimeoutId);
     },
-    scan // 手动触发全量扫描
+    scan: () => ae.start()
   };
 }
-
-/* ============================================================
- * 使用示例
- * ============================================================ */
-// const detector = exposureDetector({
-//   container: '#app',
-//   callback: data => console.table(data)
-// });
-// // 路由切换后手动补扫
-// detector.scan();
-// // 销毁
-// detector.destroy();

+ 1 - 1
src/module/types.js

@@ -3,6 +3,6 @@ export const Subscribe = {
 }
 
 export const SDK_INFO = {
-  VERSION: '1.8.0',
+  VERSION: '1.9.0',
   PREFIX: 'JyTrack'
 }