import { ClickTarget, ExposeRoot, ExposeTarget } 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 LiteBehaviorCollectorOptions { /** 是否自动处理页面栈 * @default true * @desc 监听 routeEnter 和 unload 事件,自动推栈并上报页面进入和离开事件。若设为 `false`,需要至少主动 push 一次,否则将无法正常上报 */ route?: boolean; /** 从 currentRoute 到上报用页面名称 pageName 的映射 */ routeNameMap?: Record | ((currentRoute: string) => string); /** 对特定 pageName,自动提取 query 字段放入 pageInfo * @example { 'SomePage': { fid: 'feedId' } } // 会将 SomePage 的 query 中 fid 提取出来放到 pageInfo.feedId 中 * @example { 'SomePage': (query?: { obj: { fid: string } }) => ({ feedId: query?.obj?.fid }) } // 会将 SomePage 的 query 中 obj.fid 提取出来放到 pageInfo.feedId 中 */ pageInfoMap?: Record< string, Record | ((query?: Record) => Record) >; /** 元素曝光相关设置 */ expose?: { /** 同一元素曝光只上报一次 * @default false */ once?: boolean; /** 是否上报曝光结束 * @default false */ conceal?: boolean; }; } export class LiteBehaviorCollector extends Collector { private isExposeKickStarted = false; private exposeTargetMap = new BehaviorCatcherMap(ExposeTarget); private exposeRootMap = new BehaviorCatcherMap(ExposeRoot); private clickTargetMap = new BehaviorCatcherMap(ClickTarget); private context: ReporterContext = {}; private shouldHandleRoute = true; private lastRouteEnterTime = 0; private routeNameMap: Record | ((currentRoute: string) => string) = {}; private pageInfoMap: LiteBehaviorCollectorOptions['pageInfoMap'] = {}; private thisHandleRouteEnter = this.handleRouteEnter.bind(this); private thisHandleUnload = this.handleUnload.bind(this); private exposeConfig = { once: false, conceal: false, }; constructor(options?: LiteBehaviorCollectorOptions) { super(); if (options?.route !== undefined) { this.shouldHandleRoute = options.route; } if (options?.routeNameMap !== undefined) { this.routeNameMap = options.routeNameMap; } if (options?.pageInfoMap !== undefined) { this.pageInfoMap = options.pageInfoMap; } if (options?.expose?.once !== undefined) { this.exposeConfig.once = options.expose.once; } if (options?.expose?.conceal !== undefined) { this.exposeConfig.conceal = options.expose.conceal; } } init(reporter: BehaviorReporter) { super.init(reporter); this.context.reporter = reporter; this.registerRoute(); this.listenClickEvent(); } destroy() { this.settle(); super.destroy(); } settle() { super.settle(); this.thisHandleUnload(); // 由 LiteAdapter 对 unload 事件的监听触发,此处作为钩子使用 this.exposeTargetMap.settle(); // this.deregisterRoute(); } /** 监听点击 */ click(el: HTMLElement, extInfo?: Record) { this.clickTargetMap.register(el, extInfo); } /** * 监听曝光 * 1. 如果有指定 rootName 则在 dom 上挂载上该标记 * 2. 向上逐级查找,查到 exposeRoot 为止,记录下 root,并让 root 监听自身 * 3. 如果反复 expose 相同 el, 则更新参数 */ expose( el: ExposeHTMLElement, config?: { extInfo?: { key: string }; rootName?: string; hidden?: boolean; }, ) { const curExposeTarget = this.exposeTargetMap.register(el, config?.extInfo, { rootName: config?.rootName, hidden: config?.hidden, 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[]; // 客户端实现有 bug,只要设置了这个值就会始终返回 true,不要用 }; }, ) { 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 (contains(item.el, target)) { this.reporter?.reportElementClick({ key: item.key, extInfo: item.extInfo, }); } }); }, true, ); } private handleRouteEnter(options?: { path?: string; query?: Record }) { if (lite?.router) { this.lastRouteEnterTime = Date.now(); // 将原始路径映射为 routeName const rawPath = options?.path || lite.router.currentRoute; let name = rawPath; if (typeof this.routeNameMap === 'function') { name = this.routeNameMap(rawPath); } else if (this.routeNameMap) { name = this.routeNameMap[rawPath] || rawPath; } const query = options?.query || lite.router.currentQuery || undefined; // 从 query 中提取 extInfo const extInfo = {}; if (query && this.pageInfoMap?.[name]) { const pageInfoMapThisPage = this.pageInfoMap[name]; if (typeof pageInfoMapThisPage === 'function') { const customMapRes = pageInfoMapThisPage(query); if (customMapRes && typeof customMapRes === 'object') { Object.entries(customMapRes).forEach(([k, v]) => { if (typeof v === 'string' || typeof v === 'number') { extInfo[k] = `${v}`; } }); } } else { const queryKeyToPageInfoKeyMap = this.pageInfoMap[name]; Object.entries(query).forEach(([k, v]) => { if (typeof v === 'string' || typeof v === 'number') { extInfo[queryKeyToPageInfoKeyMap[k]] = `${v}`; } }); } } this.reporter?.pushPage({ name, extInfo, }); this.reporter?.reportPageEnter({ // pageEnter extInfo 默认带上所有 query extInfo: query, }); } } private handleUnload() { if (lite?.router && this.lastRouteEnterTime) { this.reporter?.reportPageLeave({ stayTime: Date.now() - this.lastRouteEnterTime, }); this.lastRouteEnterTime = 0; } // this.reporter?.settle(); } /** 注册页面栈相关 */ private registerRoute() { if (lite?.router && this.shouldHandleRoute) { lite.addEventListener('routeEnter', this.thisHandleRouteEnter); // lite.addEventListener('unload', this.thisHandleUnload); } } // /** 解除页面栈相关注册 */ // private deregisterRoute() { // if (lite?.router && this.shouldHandleRoute) { // lite.removeEventListener('routeEnter', this.thisHandleRouteEnter); // lite.removeEventListener('unload', this.thisHandleUnload); // } // } }