import { uuidV4 } from '@tencent/merlin-core'; import { BehaviorCatcherConfig, ExposeHTMLElement, DomDatasetBoolean, ReporterContext } from '../types'; import { ExposeRoot } from './ExposeRoot'; import { BehaviorCatcher } from './common'; import { BehaviorCatcherMap } from '../BehaviorCatcherMap'; interface ExposeTargetConfig extends BehaviorCatcherConfig { /** 配置中指定的 exposeRootName */ rootName?: string; /** 在曝光语义下临时隐藏,用来解决 liteapp 下 v-show="false" 的元素仍被 IntersectionObserver 计算的问题 */ hidden?: boolean; /** 只曝光一次 */ once: boolean; /** 是否上报结束曝光 */ conceal: boolean; context?: ReporterContext; } export class ExposeTarget extends BehaviorCatcher { /** 配置中指定的 exposeRootName */ rootName?: string; private exposeId: string; /** 当前绑定的 exposeRoot */ private exposeRoot?: ExposeRoot; private context?: ReporterContext; /** IntersectionObserver 控制的曝光状态 */ private isExposing = false; /** 上一次触发 expose 的时间戳 */ private lastExposeTime = 0; /** 在曝光语义下临时隐藏,用来解决 liteapp 下 v-show="false" 的元素仍被 IntersectionObserver 计算的问题 */ private hidden = false; /** 当前元素首次曝光的 Id */ private firstExposeId = ''; /** 是否上报结束曝光 */ private conceal: boolean; /** 是否只曝光一次 */ private once: boolean; constructor(config: ExposeTargetConfig) { super(config); this.context = config.context; this.rootName = config.rootName; this.hidden = !!config.hidden; this.conceal = config.conceal; this.once = config.once; const newExposeId = uuidV4(); this.exposeId = newExposeId; this.firstExposeId = newExposeId; if (config.rootName && config.rootName.length > 0 && this.el.dataset.mlRootName !== config.rootName) { this.el.dataset.mlRootName = config.rootName; // 保存在 DOM 上 } } /** 是否已经绑定了 exposeRoot */ get hasExposeRoot() { return !!this.exposeRoot; } /** 曝光根节点元素 */ get exposeRootEl() { return this.exposeRoot?.el; } /** 根据配置找到对应 ExposeRoot,并启动 observe */ bindExposeRoot(exposeRootMap: BehaviorCatcherMap) { const exposeRoot = !this.rootName ? exposeRootMap.get(this.getCurrentExposeRootElement() ?? document.body) : exposeRootMap.find((root) => root.name === this.rootName); if (!exposeRoot) { // 指定了目前不存在的 exposeRoot,可能是后续再加入的 exposeRoot,待加入时由 exposeRoot 主动找到该 exposeTarget 并绑定 return; } if (this.exposeRoot && this.exposeRoot.el === exposeRoot.el) { // 重复绑定,不做处理 return; } if (this.exposeRoot) { // 有其他 root,先解绑 this.unbindExposeRoot(); } this.exposeRoot = exposeRoot; exposeRoot.observe(this); } /** 双向解绑 */ unbindExposeRoot() { this.exposeRoot?.unObserver(this); this.exposeRoot = undefined; } updateConfig(config: ExposeTargetConfig) { this.rootName = config.rootName; const originHidden = this.hidden; if (this.isExposing) { // 当前 IntersectionObserver 认为曝光,但是 hidden 变化,本地处理 if (originHidden && !config.hidden) { // 隐 -> 显 this.localHandleExpose(); } else if (!originHidden && !!config.hidden) { // 显 -> 隐 this.localSettleExpose(); } } this.hidden = !!config.hidden; if (config.rootName && config.rootName.length > 0 && this.el.dataset.mlRootName !== config.rootName) { this.el.dataset.mlRootName = config.rootName; // 保存在 DOM 上 } } /** 发生曝光时更新内部状态并上报曝光 */ handleExpose() { this.isExposing = true; if (!this.hidden) { this.localHandleExpose(); } } /** 结束曝光时清算并上报曝光结束 */ settleExpose() { if (!this.isExposing) { // 没有触发 expose 就发生了结束 // this.context?.reporter?.log('没有触发 expose 就发生了结束', this.el); return; } this.isExposing = false; if (!this.hidden) { this.localSettleExpose(); } } destroy() { if (this.lastExposeTime && this.isExposing) { // destroy 时若还在曝光中,则上报结束曝光 this.settleExpose(); } // 清空绑定关系 this.unbindExposeRoot(); } private localHandleExpose() { if (this.once && this.exposeId !== this.firstExposeId) return; // 已经曝光过且只需要曝光一次 this.lastExposeTime = Date.now(); this.context?.reporter?.reportElementExpose({ key: this.key, extInfo: this.extInfo, exposeId: this.exposeId, }); } private localSettleExpose() { if (this.once && this.exposeId !== this.firstExposeId) return; // 已经曝光过且只需要曝光一次 if (this.lastExposeTime === 0) { return; } const exposeDuration = Date.now() - this.lastExposeTime; const settledExposeId = this.exposeId; this.exposeId = uuidV4(); this.lastExposeTime = 0; this.context?.reporter?.reportElementConceal({ key: this.key, extInfo: this.extInfo, exposeId: settledExposeId, allTime: exposeDuration, }); } /** 沿 DOM 向上找到 ExposeRoot */ private getCurrentExposeRootElement(): ExposeHTMLElement | null { const helper = (el: ExposeHTMLElement) => { const parent = el.parentElement || (el.parentNode as ExposeHTMLElement); if (!parent) { return null; } if (parent === document.body) { return parent; } return parent?.dataset.mlRoot === DomDatasetBoolean.TRUE ? parent : helper(parent); }; return helper(this.el); } }