|
@@ -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();
|