import { ClickTarget, ExposeRoot, ExposeTarget, VideoTarget, HoverTarget } from './behaviorCatchers'; import { BehaviorReporter, BehaviorReportItemData } from '@tencent/merlin-behavior'; import { contains, isPaternalExposeElements } from './utils'; import { ExposeHTMLElement, ReporterContext } from './types'; import { Collector } from '@tencent/merlin-core'; import { BehaviorCatcherMap } from './BehaviorCatcherMap'; export interface WebBehaviorCollectorOptions { /** 元素曝光相关设置 */ expose?: { /** 同一元素曝光只上报一次 * @default false */ once?: boolean; /** 是否上报曝光结束 * @default false */ conceal?: boolean; }; } export class WebBehaviorCollector extends Collector { private isExposeKickStarted = false; private exposeTargetMap = new BehaviorCatcherMap(ExposeTarget); private exposeRootMap = new BehaviorCatcherMap(ExposeRoot); private clickTargetMap = new BehaviorCatcherMap(ClickTarget); private hoverTargetMap = new BehaviorCatcherMap(HoverTarget); private videoTargetMap = new BehaviorCatcherMap(VideoTarget); private garbageMap: WeakMap = new WeakMap(); private handleBuffer: Map = new Map(); private rootMutationNodeBuffer: Map = new Map(); private mutationProcessList: MutationRecord[] = []; private batch: () => void; private context: ReporterContext = {}; private mutationObserver = new MutationObserver(this.mutationCallback.bind(this)); private exposeConfig = { once: false, conceal: false, }; constructor(options?: WebBehaviorCollectorOptions) { super(); if (options?.expose?.once !== undefined) { this.exposeConfig.once = options.expose.once; } if (options?.expose?.conceal !== undefined) { this.exposeConfig.conceal = options.expose.conceal; } this.batch = this.mutationBatch.bind(this); } init(reporter: BehaviorReporter) { super.init(reporter); this.context.reporter = reporter; this.listenClickEvent(); // Q: 这里让用户手动开启还是自动开启呢? this.mutationObserver.observe(document.body, { childList: true, subtree: true, }); } destroy() { this.settle(); super.destroy(); } settle() { super.settle(); this.videoTargetMap.settle(); this.exposeTargetMap.settle(); } /** 监听点击 */ click(el: HTMLElement, extInfo?: Record) { this.clickTargetMap.register(el, extInfo); } /** 监听 hover */ hover(el: HTMLElement, extInfo?: Record) { this.hoverTargetMap.register(el, extInfo, { context: this.context, }); } /** 取消监听 hover */ deleteHover(el: HTMLElement) { this.hoverTargetMap.deregister(el); } /** 监听 video */ video(el: HTMLElement, extInfo?: Record) { this.videoTargetMap.register(el, extInfo, { context: this.context, }); } /** 取消监听 video */ deleteVideo(el: HTMLElement) { this.videoTargetMap.deregister(el); } /** * 监听曝光 * 1. 如果有指定 rootName 则在 dom 上挂载上该标记 * 2. 向上逐级查找,查到 exposeRoot 为止,记录下 root,并让 root 监听自身 * 3. 如果反复 expose 相同 el, 则更新参数 */ expose( el: ExposeHTMLElement, config?: { extInfo?: { key: string }; rootName?: string; }, ) { const curExposeTarget = this.exposeTargetMap.register(el, config?.extInfo, { rootName: config?.rootName, context: { reporter: this.reporter, }, once: this.exposeConfig.once, conceal: this.exposeConfig.conceal, }); if (this.isExposeKickStarted) { // 已经 kickStarted(运行时),立刻绑定 exposeRoot curExposeTarget.bindExposeRoot(this.exposeRootMap); } } /** 取消监听曝光 */ deleteExpose(el: ExposeHTMLElement) { this.exposeTargetMap.deregister(el); } /** * 注册曝光根节点,并新建一个 Observer * * 1. 将某个 DOM 元素(称为 curRoot)挂载上 exposeRoot 标记 * 2. 查询所有 rootName 为空的 exposeTarget 元素是否为自身子元素,如果是,更新监听关系 */ exposeRoot( el: ExposeHTMLElement, config?: { name?: string; exposeConfig?: { rootMargin?: string; threshold?: number | number[]; }; }, ) { const { name, exposeConfig } = config ?? {}; // if (name && this.exposeRootMap.find((v) => v.name === name)) { // // TODO: 是否可能在生产环境运行时抛出?若可能,需要用 reporter.handleError 来捕获 // throw Error(`duplicate rootName: ${name}`); // } const curExposeRoot = this.exposeRootMap.register( el, undefined, this.exposeRootMap.has(el) ? { name, } : { name, // 该节点如果没有监听器,就创建一个 observer: new IntersectionObserver( (entries: IntersectionObserverEntry[]) => { entries.forEach((entry) => { const target = this.exposeTargetMap.get(entry.target as HTMLElement); if (!target) return; // 说明 observer observe 了意外元素 if (entry.isIntersecting) { // 非曝光 -> 曝光 target.handleExpose(); } else { // 曝光 -> 非曝光 target.settleExpose(); } }); }, { root: el, threshold: 0.2, ...exposeConfig, }, ), }, ); if (this.isExposeKickStarted) { // 如果是运行时,对于被当前 exposeRoot 包含的 exposeTarget 重新建立监听关系 this.exposeTargetMap.catcherMap.forEach((target) => { if (!target.rootName && isPaternalExposeElements(curExposeRoot.el, target.el)) { // 没有 rootName,如果没有绑定 root,则绑定;如果已经绑定了 root,且新 root 不包含老 root,则重新绑定 if (!target.hasExposeRoot || !contains(curExposeRoot.el, target.exposeRootEl)) { target.unbindExposeRoot(); target.bindExposeRoot(this.exposeRootMap); } } else if (target.rootName === curExposeRoot.name && isPaternalExposeElements(curExposeRoot.el, target.el)) { // 有 rootName 且恰好对应,绑定 target.unbindExposeRoot(); target.bindExposeRoot(this.exposeRootMap); } }); } } /** 注销曝光根节点 */ deleteExposeRoot(el: ExposeHTMLElement) { this.exposeRootMap.deregister(el); } /** 启动 expose,对当前 exposeTargetMap 中的所有 target,找到它的 exposeRoot 并启动 observe */ kickStartExpose() { this.exposeRoot(document.body, { name: 'body', }); this.exposeTargetMap.catcherMap.forEach((target) => { target.bindExposeRoot(this.exposeRootMap); }); this.isExposeKickStarted = true; } private listenClickEvent() { document.body.addEventListener( 'click', (event) => { this.clickTargetMap.catcherMap.forEach((item) => { const target = event.target as Node; if (item.el.contains(target)) { this.reporter?.reportElementClick({ key: item.key, extInfo: item.extInfo, }); } }); }, true, ); } private processRemovedNodes(nodeList: NodeList) { const len = nodeList.length; for (let i = 0; i < len; i++) { const node = nodeList[i]; if (!(node instanceof HTMLElement)) continue; if (this.rootMutationNodeBuffer.has(node)) continue; this.rootMutationNodeBuffer.set(node, 'removed'); // this.videoTargetMap.forEach((value, key, map) => { // if (node.contains(key)) { // this.handleBuffer.set(key, false); // // value.destroy(); // // map.delete(key); // } // }); } } private processAddedNodes(nodeList: NodeList) { const len = nodeList.length; for (let i = 0; i < len; i++) { const node = nodeList[i]; if (!(node instanceof HTMLElement)) continue; if (this.rootMutationNodeBuffer.has(node)) continue; this.rootMutationNodeBuffer.set(node, 'added'); // this.videoTargetMap.forEach((value, key, map) => { // if (removeNode.contains(key)) { // value.destroy(); // map.delete(key); // } // }); } } /** * 对每一个通过 mutation 获取到的 "节点变更" 进行从后到前的遍历 * 对于一个 Node 而言,我们只取最后一次的操作结果,删除 增加 删除,则取删除 * @private */ private processMutationBuffer() { const nodes = Array.from(this.rootMutationNodeBuffer.keys()); for (let i = nodes.length - 1; i > -1; i--) { const node = nodes[i]; if (this.rootMutationNodeBuffer.get(node) === 'removed') { // 节点移除时,检查移除的节点的子树是否包含被 collector 监听过的节点 this.videoTargetMap.catcherMap.forEach((_, key) => { if (node.contains(key)) { this.handleBuffer.set(key, false); // value.destroy(); // map.delete(key); } }); } else { let videos: HTMLVideoElement[]; // 节点插入时,检查它和他的子节点是否被 collector 监听过 if (node instanceof HTMLVideoElement) { videos = [node]; } else { videos = Array.prototype.slice.call(node.getElementsByTagName('video')); } videos.forEach((value) => { if (this.handleBuffer.has(value) || this.garbageMap.has(value)) { this.handleBuffer.set(value, true); } }); } } this.rootMutationNodeBuffer.clear(); } /** * 决策需要 "恢复" 的 video, 就从 garbage 中取回并且恢复 (如果 garbage 中没有,说明不需要恢复) * 决策需要 "回收" 的 video,则停止监听后,扔回去 garbage * @private */ private processHandleBuffer() { this.handleBuffer.forEach((value, node) => { if (node instanceof HTMLVideoElement) { if (value) { const garbage = this.garbageMap.get(node); if (garbage) { this.videoTargetMap.catcherMap.set(node, garbage); garbage.startMonitor(); this.garbageMap.delete(node); } } else { const garbage = this.videoTargetMap.get(node); if (garbage) { this.videoTargetMap.catcherMap.delete(node); garbage.stopMonitor(); this.garbageMap.set(node, garbage); } } } }); this.handleBuffer.clear(); } /** * 批处理 mutation * @private */ private mutationBatch() { // 从最后的 mutation 开始处理,因为一个 node 的移除和增加的最后结果决定这个 node 最终是否存在, 比如移除和插入, 结果就是插入。 // processRemovedNodes processAddedNodes 处理之后,node 的 removed 还是 added 处理决策缓存在 rootMutationNodeBuffer 里 for (let i = this.mutationProcessList.length - 1; i > -1; i--) { const { removedNodes, addedNodes } = this.mutationProcessList[i]; if (removedNodes.length) { this.processRemovedNodes(removedNodes); } if (addedNodes.length) { this.processAddedNodes(addedNodes); } } // 一般监听的 target 比如 video target 并非 mutation 直接操作的节点(可能是他的子节点,因为 mutation 只会响应直接被移除的那个节点) this.processMutationBuffer(); // processMutationBuffer 处理完之后,把一个 video 应该 从 垃圾桶 中取回来,和要扔进垃圾桶的决策放在 handleBuffer 中, // processHandleBuffer 会实施这些变更 this.processHandleBuffer(); this.mutationProcessList.length = 0; } /** * 使用 requestAnimationFrame 解决 mutation 调用过于频繁的问题 * @param mutations * @private */ private mutationCallback(mutations: MutationRecord[]) { if (!this.mutationProcessList.length) requestAnimationFrame(this.batch); this.mutationProcessList.push(...mutations); } }