|
@@ -1,6 +1,7 @@
|
|
/* ============================================================
|
|
/* ============================================================
|
|
* exposureDetector.js
|
|
* exposureDetector.js
|
|
* 支持 Light DOM + open Shadow DOM 的曝光统计
|
|
* 支持 Light DOM + open Shadow DOM 的曝光统计
|
|
|
|
+ * 改动:新增 scan() 方法、防抖扫描、debug 日志
|
|
* ============================================================ */
|
|
* ============================================================ */
|
|
export function exposureDetector(options = {}) {
|
|
export function exposureDetector(options = {}) {
|
|
/* ---------- 0. 环境检测 ---------- */
|
|
/* ---------- 0. 环境检测 ---------- */
|
|
@@ -12,14 +13,14 @@ export function exposureDetector(options = {}) {
|
|
|
|
|
|
/* ---------- 1. 默认配置 ---------- */
|
|
/* ---------- 1. 默认配置 ---------- */
|
|
const defaultOptions = {
|
|
const defaultOptions = {
|
|
- threshold: 90, // 默认曝光百分比阈值
|
|
|
|
- container: 'body', // 观察的根容器
|
|
|
|
- minExposureDuration: 1000, // ms,至少曝光多久才上报
|
|
|
|
- progressiveIntervals: [3000, 6000, 9000], // 渐进式上报间隔
|
|
|
|
- reportThreshold: 10, // 队列长度 > N 立即上报
|
|
|
|
|
|
+ threshold: 90,
|
|
|
|
+ container: 'body',
|
|
|
|
+ minExposureDuration: 1000,
|
|
|
|
+ progressiveIntervals: [3000, 6000, 9000],
|
|
|
|
+ reportThreshold: 10,
|
|
error: console.error,
|
|
error: console.error,
|
|
- callback: () => {}, // 收到批量上报数据时的回调
|
|
|
|
- custom: null // 自定义判定函数 (ratio)=>boolean
|
|
|
|
|
|
+ callback: () => {},
|
|
|
|
+ custom: null
|
|
};
|
|
};
|
|
const settings = { ...defaultOptions, ...options };
|
|
const settings = { ...defaultOptions, ...options };
|
|
|
|
|
|
@@ -43,7 +44,12 @@ export function exposureDetector(options = {}) {
|
|
const walker = document.createTreeWalker(root, Node.ELEMENT_NODE, null, false);
|
|
const walker = document.createTreeWalker(root, Node.ELEMENT_NODE, null, false);
|
|
let node;
|
|
let node;
|
|
while ((node = walker.nextNode())) {
|
|
while ((node = walker.nextNode())) {
|
|
- if (node.matches?.(selector)) res.push(node);
|
|
|
|
|
|
+ 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));
|
|
if (node.shadowRoot) res.push(...querySelectorAllDeep(node.shadowRoot, selector));
|
|
}
|
|
}
|
|
return res;
|
|
return res;
|
|
@@ -92,7 +98,6 @@ export function exposureDetector(options = {}) {
|
|
: ratio >= threshold;
|
|
: ratio >= threshold;
|
|
|
|
|
|
if (!shouldTrigger) {
|
|
if (!shouldTrigger) {
|
|
- // 离开可视区域
|
|
|
|
if (window.__exposure__data__[name]?.timer) {
|
|
if (window.__exposure__data__[name]?.timer) {
|
|
clearTimeout(window.__exposure__data__[name].timer);
|
|
clearTimeout(window.__exposure__data__[name].timer);
|
|
delete window.__exposure__data__[name].timer;
|
|
delete window.__exposure__data__[name].timer;
|
|
@@ -101,7 +106,6 @@ export function exposureDetector(options = {}) {
|
|
return;
|
|
return;
|
|
}
|
|
}
|
|
|
|
|
|
- // 开始计时
|
|
|
|
if (!window.__exposure__data__[name] || el.hasAttribute('data-exposure-loop')) {
|
|
if (!window.__exposure__data__[name] || el.hasAttribute('data-exposure-loop')) {
|
|
if (!window.__exposure__data__[name]) window.__exposure__data__[name] = {};
|
|
if (!window.__exposure__data__[name]) window.__exposure__data__[name] = {};
|
|
const count = (window.__exposure__data__[name].count || 0) + 1;
|
|
const count = (window.__exposure__data__[name].count || 0) + 1;
|
|
@@ -128,51 +132,81 @@ export function exposureDetector(options = {}) {
|
|
{ root: rootEl, threshold: Array.from({ length: 101 }, (_, i) => i / 100) }
|
|
{ root: rootEl, threshold: Array.from({ length: 101 }, (_, i) => i / 100) }
|
|
);
|
|
);
|
|
|
|
|
|
- /* ---------- 7. 递归监听所有 Shadow Root 的 MutationObserver ---------- */
|
|
|
|
|
|
+ /* ---------- 7. 防抖扫描 & 全量扫描 ---------- */
|
|
|
|
+ let scanTimer = null;
|
|
|
|
+ function debounceScan() {
|
|
|
|
+ clearTimeout(scanTimer);
|
|
|
|
+ scanTimer = setTimeout(() => {
|
|
|
|
+ debugLog('防抖扫描:开始');
|
|
|
|
+ scan();
|
|
|
|
+ }, 200);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ function scan() {
|
|
|
|
+ const nodes = querySelectorAllDeep(rootEl, '[data-exposure]');
|
|
|
|
+ debugLog(`扫描到 ${nodes.length} 个节点`);
|
|
|
|
+ nodes.forEach(el => {
|
|
|
|
+ io.observe(el);
|
|
|
|
+ debugLog(`观察节点:${el.dataset.exposure}`, el);
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /* ---------- 8. 递归监听所有 Shadow Root 的 MutationObserver ---------- */
|
|
function observeDeep(root = document) {
|
|
function observeDeep(root = document) {
|
|
const mo = new MutationObserver(records => {
|
|
const mo = new MutationObserver(records => {
|
|
|
|
+ let needScan = false;
|
|
records.forEach(rec => {
|
|
records.forEach(rec => {
|
|
rec.addedNodes.forEach(n => {
|
|
rec.addedNodes.forEach(n => {
|
|
if (n.nodeType !== Node.ELEMENT_NODE) return;
|
|
if (n.nodeType !== Node.ELEMENT_NODE) return;
|
|
- // 查找并观察
|
|
|
|
- querySelectorAllDeep(n, '[data-exposure]').forEach(el => {
|
|
|
|
- io.observe(el);
|
|
|
|
- debugLog(`观察新节点:${el.dataset.exposure}`);
|
|
|
|
- });
|
|
|
|
- // 若本身是 Shadow Host,继续监听其 shadowRoot
|
|
|
|
|
|
+ needScan = true;
|
|
if (n.shadowRoot) observeDeep(n.shadowRoot);
|
|
if (n.shadowRoot) observeDeep(n.shadowRoot);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
+ if (needScan) debounceScan();
|
|
});
|
|
});
|
|
mo.observe(root, { childList: true, subtree: true });
|
|
mo.observe(root, { childList: true, subtree: true });
|
|
return mo;
|
|
return mo;
|
|
}
|
|
}
|
|
|
|
|
|
- /* ---------- 8. 初始化 ---------- */
|
|
|
|
- // 8.1 先观察当前已存在的节点
|
|
|
|
- querySelectorAllDeep(rootEl, '[data-exposure]').forEach(el => io.observe(el));
|
|
|
|
|
|
+ /* ---------- 9. 初始化 ---------- */
|
|
|
|
+ // 9.1 文档 ready 后再扫一次,防止首屏节点遗漏
|
|
|
|
+ function ready(fn) {
|
|
|
|
+ if (document.readyState !== 'loading') fn();
|
|
|
|
+ else document.addEventListener('DOMContentLoaded', fn);
|
|
|
|
+ }
|
|
|
|
+ ready(() => {
|
|
|
|
+ debugLog('文档 ready,执行首次全量扫描');
|
|
|
|
+ scan();
|
|
|
|
+ });
|
|
|
|
|
|
- // 8.2 监听后续动态节点(含 Shadow DOM)
|
|
|
|
|
|
+ // 9.2 监听后续动态节点(含 Shadow DOM)
|
|
const deepMo = observeDeep(document);
|
|
const deepMo = observeDeep(document);
|
|
|
|
|
|
- // 8.3 页面离开前上报
|
|
|
|
|
|
+ // 9.3 页面离开前上报
|
|
window.addEventListener('beforeunload', () => handleReport(true));
|
|
window.addEventListener('beforeunload', () => handleReport(true));
|
|
|
|
|
|
- /* ---------- 9. 返回清理函数 ---------- */
|
|
|
|
- return () => {
|
|
|
|
- io.disconnect();
|
|
|
|
- deepMo.disconnect();
|
|
|
|
- clearTimeout(reportTimeoutId);
|
|
|
|
- window.removeEventListener('beforeunload', handleReport);
|
|
|
|
- debugLog('已清理所有观察器');
|
|
|
|
|
|
+ /* ---------- 10. 返回清理函数 + scan 方法 ---------- */
|
|
|
|
+ return {
|
|
|
|
+ destroy: () => {
|
|
|
|
+ io.disconnect();
|
|
|
|
+ deepMo.disconnect();
|
|
|
|
+ clearTimeout(reportTimeoutId);
|
|
|
|
+ clearTimeout(scanTimer);
|
|
|
|
+ window.removeEventListener('beforeunload', handleReport);
|
|
|
|
+ debugLog('已清理所有观察器');
|
|
|
|
+ },
|
|
|
|
+ scan // 手动触发全量扫描
|
|
};
|
|
};
|
|
}
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
/* ============================================================
|
|
* 使用示例
|
|
* 使用示例
|
|
* ============================================================ */
|
|
* ============================================================ */
|
|
-// exposureDetector({
|
|
|
|
|
|
+// const detector = exposureDetector({
|
|
// container: '#app',
|
|
// container: '#app',
|
|
-// callback: data => console.table(data),
|
|
|
|
-// debug: true // 打开 cookie 中 debug=1 即可看日志
|
|
|
|
|
|
+// callback: data => console.table(data)
|
|
// });
|
|
// });
|
|
|
|
+// // 路由切换后手动补扫
|
|
|
|
+// detector.scan();
|
|
|
|
+// // 销毁
|
|
|
|
+// detector.destroy();
|