从零开发前端埋点SDK:设计、实现与工程化实践

从零开发前端埋点SDK:设计、实现与工程化实践 1. 项目概述为什么我们要自己造一个埋点SDK的轮子“前端埋点SDK”这个词对于任何一个在一线做过数据相关需求的前端工程师来说都绝不陌生。无论是产品经理想分析用户点击了哪个按钮还是运营同学想追踪某个活动页面的转化漏斗最终这些需求都会落到我们头上去“埋点”。市面上成熟的方案很多神策、GrowingIO、友盟……功能强大开箱即用。那为什么我们还要从零开始自己开发一个并且发布到npm上呢这绝不是为了重复造轮子而是一次深入理解数据采集底层逻辑、掌握工程化全链路、并打造属于自己团队技术资产的绝佳实践。在我过去多年的项目经历中直接使用第三方SDK常常会遇到一些“痒点”比如SDK体积过大影响首屏性能某些定制化上报逻辑无法实现或者对上报过程的控制力太弱出了问题排查像黑盒。自己动手开发一个轻量、可控、可扩展的埋点SDK不仅能完美契合自身业务场景更能让你对“数据从用户行为到数据仓库”的整个链条有透彻的理解。这个过程会涉及模块化设计、错误处理、性能优化、打包构建、版本管理、持续集成等一系列前端工程化的核心技能。最终将它发布到npm则是完成了从开发到交付的闭环体验一把作为开源库作者的感觉。接下来我就带你一步步拆解这个项目从设计思路到代码实现再到用Rollup打包并发布到npm的全过程。2. 核心设计思路与架构拆解在动手写第一行代码之前我们必须想清楚这个SDK要做什么以及怎么做。一个基础的埋点SDK核心功能无外乎三点采集数据、处理数据、上报数据。但如何设计得优雅、健壮、易用里面就有很多门道了。2.1 需求定义与功能边界首先我们要明确这个SDK的MVP最小可行产品应该包含哪些功能。基于常见的业务场景我将其核心功能定义如下多类型事件采集至少支持代码埋点手动在业务代码中调用和曝光埋点自动监听元素是否进入视口。通用参数与自定义参数每次上报都能自动携带设备信息、页面信息等通用参数同时允许业务方传入自定义参数。可靠的网络上报支持不同的上报策略如即时上报、批量上报、优先使用sendBeacon等并具备重试机制。良好的开发者体验提供清晰的API完善的TypeScript类型提示以及详细的错误日志。对性能的影响最小化SDK自身要轻量异步加载不能阻塞主线程。确定了功能接下来就是技术选型。为了保持轻量和现代化我们选择用TypeScript来编写以获得更好的类型安全和开发体验。打包工具选择Rollup因为它特别适合打包库文件可以轻松生成多种格式ES Module, CommonJS, UMD的产物并且能实现高效的Tree Shaking。2.2 核心架构设计整个SDK的架构可以划分为几个核心层这样职责清晰便于维护和扩展。采集层Collector负责“抓取”数据。包括监听点击事件、监听元素曝光、执行开发者手动调用的API等。这一层要尽量减少对业务代码的侵入。处理层Processor负责“加工”数据。比如过滤无效数据、为数据添加唯一IDuuid、补充时间戳和通用属性如url,userAgent,screenResolution等。发送层Sender负责“运送”数据。这是最需要稳健性的部分需要处理不同的发送方式img,ajax,sendBeacon、队列管理、失败重试、节流控制等。核心层Core负责“调度”和“配置”。它是一个单例对外提供统一的init初始化方法和track等API并管理上述各层的实例和生命周期。这样的分层设计使得未来如果我们想增加一种新的采集方式比如错误监控只需要在采集层新增一个模块如果想改变上报策略也只需修改发送层其他部分完全不受影响。注意在设计初期就考虑好扩展性非常重要。我见过不少临时起意写的埋点工具最后因为业务需求变化而变得难以维护代码里充满了if-else。清晰的架构是避免这一切的基础。3. 一步步实现SDK核心模块有了清晰的设计图我们就可以开始编码了。我会按照从内到外的顺序先实现最底层、最独立的模块。3.1 实现工具函数与类型定义首先创建一些通用的工具函数和TypeScript接口定义。这能让我们的代码更健壮。src/types.ts- 定义核心数据类型// 定义事件类型 export type EventType click | exposure | custom | pageview | error; // 定义单个埋点事件的接口 export interface TrackerEvent { eventId: string; // 事件唯一标识由业务方定义如 home_banner_click eventType: EventType; timestamp: number; // 事件发生的时间戳 pageUrl: string; // 当前页面URL referrerUrl: string; // 来源页URL // 通用属性 properties: { [key: string]: any; // 自定义参数 // 可以包含一些自动采集的通用属性 _device?: string; _os?: string; _browser?: string; _screen?: string; }; } // SDK配置项接口 export interface TrackerConfig { appId: string; // 项目唯一标识 endpoint: string; // 数据上报的服务器地址 autoTrack?: { click?: boolean; // 是否自动追踪点击事件 exposure?: boolean; // 是否自动追踪曝光事件 pageView?: boolean; // 是否自动发送页面浏览事件 }; batchSend?: boolean; // 是否开启批量上报 batchSize?: number; // 批量上报的大小 sendTimeout?: number; // 单次发送超时时间 useSendBeacon?: boolean; // 是否优先使用 sendBeacon API (在页面卸载时更可靠) }src/utils.ts- 实现通用工具函数import { TrackerEvent } from ./types; // 生成唯一ID (简化版UUID) export function generateUUID(): string { return xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx.replace(/[xy]/g, function(c) { const r (Math.random() * 16) | 0; const v c x ? r : (r 0x3) | 0x8; return v.toString(16); }); } // 获取当前页面URL export function getPageUrl(): string { return window.location.href; } // 获取来源页URL export function getReferrerUrl(): string { return document.referrer; } // 获取设备及浏览器信息 (简化版) export function getDeviceInfo() { const ua navigator.userAgent; return { _device: /Mobile|Android|iPhone/i.test(ua) ? mobile : desktop, _os: (() { if (/Windows/i.test(ua)) return Windows; if (/Mac OS/i.test(ua)) return MacOS; if (/Linux/i.test(ua)) return Linux; if (/Android/i.test(ua)) return Android; if (/iOS|iPhone|iPad/i.test(ua)) return iOS; return Unknown; })(), _browser: (() { if (/Chrome/i.test(ua) !/Edg/i.test(ua)) return Chrome; if (/Firefox/i.test(ua)) return Firefox; if (/Safari/i.test(ua) !/Chrome/i.test(ua)) return Safari; if (/Edg/i.test(ua)) return Edge; return Unknown; })(), _screen: ${window.screen.width}x${window.screen.height} }; } // 安全地执行JSON.stringify处理循环引用 export function safeStringify(obj: any): string { try { return JSON.stringify(obj); } catch (error) { console.warn([Tracker] Failed to stringify object:, error); return {}; } }3.2 构建稳健的数据发送层Sender发送层是SDK的“出口”它的稳定性直接决定了数据是否能成功送达。我们需要考虑多种上报方式和错误处理。src/sender.ts- 实现数据发送器import { TrackerEvent } from ./types; import { safeStringify } from ./utils; export class Sender { private endpoint: string; private queue: TrackerEvent[] []; private isSending false; private batchSize: number; private useSendBeacon: boolean; constructor(endpoint: string, batchSize: number 5, useSendBeacon: boolean true) { this.endpoint endpoint; this.batchSize batchSize; this.useSendBeacon useSendBeacon; } // 发送单个或批量事件 public send(event: TrackerEvent | TrackerEvent[]): void { const events Array.isArray(event) ? event : [event]; this.queue.push(...events); // 如果达到批量大小立即发送 if (this.queue.length this.batchSize) { this.flush(); } else { // 否则尝试延迟发送防抖避免频繁请求 this.debouncedFlush(); } } // 立即发送队列中的所有数据 public flush(): void { if (this.isSending || this.queue.length 0) { return; } const eventsToSend this.queue.splice(0, this.batchSize); // 每次最多发送batchSize条 this.isSending true; this.doSend(eventsToSend).finally(() { this.isSending false; // 发送完成后如果队列里还有数据继续发送 if (this.queue.length 0) { setTimeout(() this.flush(), 0); } }); } // 实际的发送逻辑 private async doSend(events: TrackerEvent[]): Promisevoid { const data { events }; const dataStr safeStringify(data); // 策略1页面卸载时优先使用 sendBeacon (同步可靠) // 这里我们需要一个标志位来判断是否在页面卸载中通常由核心层传递 // 为了简化我们先实现常规发送 // 策略2使用 img 标签 (GET请求跨域友好但数据量有限) // 策略3使用 fetch POST (主流方式) try { // 使用fetch发送并设置超时 const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), 5000); // 5秒超时 const response await fetch(this.endpoint, { method: POST, headers: { Content-Type: application/json, }, body: dataStr, signal: controller.signal, keepalive: this.useSendBeacon, // 类似sendBeacon的行为在页面卸载时也能发送 }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(HTTP error! status: ${response.status}); } console.log([Tracker] Events sent successfully:, events.length); } catch (error) { console.error([Tracker] Failed to send events:, error); // 发送失败将数据重新放回队列头部等待重试 // 注意生产环境应考虑最大重试次数和延迟重试策略 this.queue.unshift(...events); // 简单重试3秒后再次尝试 setTimeout(() this.flush(), 3000); } } // 简单的防抖函数用于延迟发送 private debounceTimer: any null; private debouncedFlush() { clearTimeout(this.debounceTimer); this.debounceTimer setTimeout(() this.flush(), 1000); // 1秒后发送 } // 页面卸载前强制发送所有剩余数据使用sendBeacon public sendBeaconOnUnload(): boolean { if (this.queue.length 0) return true; if (!navigator.sendBeacon) return false; const data { events: this.queue }; const blob new Blob([safeStringify(data)], { type: application/json }); const success navigator.sendBeacon(this.endpoint, blob); if (success) { this.queue.length 0; // 清空队列 console.log([Tracker] Beacon sent on page unload.); } return success; } }实操心得在doSend方法中我提供了fetchwithkeepalive和sendBeacon两种策略来应对页面卸载场景。keepalive选项在fetch中可以让请求在页面关闭后继续完成而sendBeacon是专门为此设计的API更可靠但只支持少量数据。在实际项目中可以根据数据量大小和浏览器兼容性做动态选择。失败重试机制是必须的但要注意避免无限重试通常我会设置最大重试次数比如3次并且每次重试的间隔时间递增指数退避算法。3.3 实现自动采集层AutoTracker自动采集能极大提升开发效率。这里我们实现最常见的两种点击追踪和曝光追踪。src/auto-tracker.ts- 实现自动采集import { TrackerEvent, EventType } from ./types; import { getPageUrl } from ./utils; type TrackCallback (event: OmitTrackerEvent, timestamp | pageUrl | referrerUrl) void; export class AutoTracker { private trackCallback: TrackCallback; private clickEnabled: boolean; private exposureEnabled: boolean; private observer: IntersectionObserver | null null; private exposedSet: SetElement new Set(); // 记录已曝光过的元素避免重复上报 constructor(trackCallback: TrackCallback, clickEnabled: boolean, exposureEnabled: boolean) { this.trackCallback trackCallback; this.clickEnabled clickEnabled; this.exposureEnabled exposureEnabled; } public init(): void { if (this.clickEnabled) { this.bindClickEvent(); } if (this.exposureEnabled IntersectionObserver in window) { this.bindExposureEvent(); } else if (this.exposureEnabled) { console.warn([Tracker] IntersectionObserver is not supported, exposure tracking disabled.); } } // 1. 自动点击追踪 private bindClickEvent(): void { // 使用事件委托监听document的点击事件性能更好 document.addEventListener(click, (e: MouseEvent) { // 找到被点击的元素 const target e.target as HTMLElement; if (!target) return; // 向上查找带有 data-track-click 属性的元素 const trackElement this.findTrackElement(target, data-track-click); if (trackElement) { const eventId trackElement.getAttribute(data-track-click); const properties this.getDataAttributes(trackElement, data-track-param-); this.trackCallback({ eventId: eventId || unknown_click, eventType: click, properties: { ...properties, _element: trackElement.tagName, _text: trackElement.textContent?.trim().slice(0, 50) // 记录部分文本便于分析 } }); } }, true); // 使用捕获阶段确保能监听到所有点击 } // 2. 自动曝光追踪基于IntersectionObserver private bindExposureEvent(): void { this.observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting entry.intersectionRatio 0.5) { // 元素50%进入视口时触发 const target entry.target as HTMLElement; if (this.exposedSet.has(target)) return; // 防止重复曝光 const eventId target.getAttribute(data-track-exposure); const properties this.getDataAttributes(target, data-track-param-); this.trackCallback({ eventId: eventId || unknown_exposure, eventType: exposure, properties: { ...properties, _element: target.tagName } }); this.exposedSet.add(target); // 上报后停止观察该元素除非业务需要持续曝光追踪 this.observer?.unobserve(target); } }); }, { root: null, // 相对于视口 rootMargin: 0px, threshold: 0.5 // 50%可见时触发 }); // 查找所有需要曝光追踪的元素并开始观察 document.querySelectorAll([data-track-exposure]).forEach(el { this.observer?.observe(el); }); } // 工具方法向上查找带有指定属性的元素 private findTrackElement(el: HTMLElement, attrName: string): HTMLElement | null { let current: HTMLElement | null el; while (current current ! document.body) { if (current.hasAttribute(attrName)) { return current; } current current.parentElement; } return null; } // 工具方法获取元素上所有以prefix开头的data属性并转换为对象 private getDataAttributes(el: HTMLElement, prefix: string): Recordstring, any { const attrs: Recordstring, any {}; for (const attr of el.attributes) { if (attr.name.startsWith(prefix)) { const key attr.name.replace(prefix, ); // 尝试解析JSON否则直接存储字符串 try { attrs[key] JSON.parse(attr.value); } catch { attrs[key] attr.value; } } } return attrs; } // 销毁监听器防止内存泄漏 public destroy(): void { document.removeEventListener(click, this.bindClickEvent); if (this.observer) { this.observer.disconnect(); this.observer null; } this.exposedSet.clear(); } }注意事项曝光追踪的IntersectionObserver的threshold参数设置很有讲究。设为0.5意味着元素一半进入视口才触发避免了元素刚露个头就上报的情况数据更准确。同时我们用一个Set来记录已曝光元素避免在元素反复进出视口时产生重复上报这在单页应用SPA中非常有用。事件委托Event Delegation用于点击追踪是性能最佳实践只需要一个监听器而不是给每个可点击元素都绑一个。3.4 组装核心SDK单例Core最后我们将所有模块组装起来提供一个简洁易用的API给开发者。src/core.ts- 实现SDK核心import { TrackerConfig, TrackerEvent, EventType } from ./types; import { Sender } from ./sender; import { AutoTracker } from ./auto-tracker; import { generateUUID, getPageUrl, getReferrerUrl, getDeviceInfo } from ./utils; export default class Tracker { private static instance: Tracker; private config: TrackerConfig; private sender: Sender; private autoTracker: AutoTracker | null null; private isInitialized false; // 私有构造函数强制单例模式 private constructor(config: TrackerConfig) { this.config { autoTrack: { click: false, exposure: false, pageView: false }, batchSend: false, batchSize: 5, sendTimeout: 5000, useSendBeacon: true, ...config // 用户配置覆盖默认配置 }; // 初始化发送器 this.sender new Sender( this.config.endpoint, this.config.batchSize, this.config.useSendBeacon ); // 初始化自动采集器 if (this.config.autoTrack?.click || this.config.autoTrack?.exposure) { this.autoTracker new AutoTracker( (event) this.trackInternal(event.eventId, event.eventType, event.properties), this.config.autoTrack.click || false, this.config.autoTrack.exposure || false ); } // 绑定页面卸载事件确保数据不丢失 this.bindUnloadEvent(); } // 获取单例实例 public static getInstance(config?: TrackerConfig): Tracker { if (!Tracker.instance config) { Tracker.instance new Tracker(config); } else if (!Tracker.instance) { throw new Error(Tracker must be initialized with config first.); } return Tracker.instance; } // 初始化SDK public static init(config: TrackerConfig): Tracker { if (Tracker.instance) { console.warn([Tracker] Already initialized.); return Tracker.instance; } Tracker.instance new Tracker(config); Tracker.instance.isInitialized true; const instance Tracker.instance; // 启动自动采集 if (instance.autoTracker) { // 延迟初始化确保DOM已加载 if (document.readyState loading) { document.addEventListener(DOMContentLoaded, () { instance.autoTracker?.init(); // 自动发送页面浏览事件 if (instance.config.autoTrack?.pageView) { instance.trackPageView(); } }); } else { instance.autoTracker.init(); if (instance.config.autoTrack?.pageView) { instance.trackPageView(); } } } console.log([Tracker] SDK initialized successfully.); return instance; } // 手动追踪自定义事件主要API public track(eventId: string, eventType: EventType custom, properties?: Recordstring, any): void { if (!this.isInitialized) { console.error([Tracker] SDK not initialized. Call Tracker.init() first.); return; } this.trackInternal(eventId, eventType, properties); } // 内部追踪方法组装完整事件数据 private trackInternal(eventId: string, eventType: EventType, customProperties?: Recordstring, any): void { const baseEvent: OmitTrackerEvent, eventId | eventType | properties { timestamp: Date.now(), pageUrl: getPageUrl(), referrerUrl: getReferrerUrl(), }; const event: TrackerEvent { ...baseEvent, eventId, eventType, properties: { ...getDeviceInfo(), // 注入设备信息 ...customProperties, // 用户自定义参数 } }; // 将事件交给发送器 this.sender.send(event); } // 追踪页面浏览事件 private trackPageView(): void { this.trackInternal(page_view, pageview, { page_title: document.title, page_path: window.location.pathname, }); } // 绑定页面卸载事件 private bindUnloadEvent(): void { // 注意beforeunload/pagehide事件中不能进行复杂的异步操作 // 我们使用 sendBeacon 进行同步上报 const handler () { this.sender.sendBeaconOnUnload(); }; window.addEventListener(pagehide, handler); // 标准推荐 window.addEventListener(beforeunload, handler); // 兼容旧浏览器 } // 手动触发立即上报可用于测试或关键操作后 public flush(): void { this.sender.flush(); } // 销毁SDK清理资源 public destroy(): void { this.autoTracker?.destroy(); this.sender.flush(); // 尝试发送剩余数据 // 清理单例实例 (Tracker as any).instance null; this.isInitialized false; console.log([Tracker] SDK destroyed.); } }至此一个具备核心功能的埋点SDK就完成了。它支持自动点击/曝光采集、手动代码埋点、数据批量上报、页面卸载保送等功能。接下来我们需要让它能作为一个正式的npm包被使用。4. 使用Rollup进行工程化打包与构建我们不能直接把TypeScript源码发布到npm。我们需要将它打包成多种格式ESM, CommonJS并生成类型声明文件。Rollup是完成这个任务的绝佳工具。4.1 配置Rollup首先安装必要的依赖npm install -D rollup rollup/plugin-node-resolve rollup/plugin-commonjs rollup/plugin-typescript rollup-plugin-terser typescript tslib然后创建rollup.config.jsrollup.config.jsimport resolve from rollup/plugin-node-resolve; import commonjs from rollup/plugin-commonjs; import typescript from rollup/plugin-typescript; import { terser } from rollup-plugin-terser; import pkg from ./package.json assert { type: json }; export default { input: src/core.ts, // 入口文件 output: [ { file: pkg.main, // package.json 中的 main 字段 - dist/index.cjs.js format: cjs, // CommonJS 格式用于Node.js环境 sourcemap: true }, { file: pkg.module, // package.json 中的 module 字段 - dist/index.esm.js format: esm, // ES Module 格式用于现代打包工具和浏览器 sourcemap: true }, { file: pkg.unpkg || dist/index.umd.js, // UMD格式可直接通过script标签引入 name: XiaoManTracker, // 全局变量名 format: umd, sourcemap: true } ], plugins: [ resolve(), // 解析 node_modules 中的第三方模块 commonjs(), // 将 CommonJS 模块转换为 ES6 typescript({ tsconfig: ./tsconfig.json }), // 编译TypeScript terser() // 代码压缩 ] };4.2 配置TypeScript与Package.jsontsconfig.json{ compilerOptions: { target: ES2015, module: ESNext, lib: [DOM, ES2015], declaration: true, // 生成 .d.ts 类型声明文件 declarationDir: ./dist/types, // 声明文件输出目录 outDir: ./dist, strict: true, moduleResolution: node, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true }, include: [src/**/*], exclude: [node_modules, dist, **/*.test.ts] }package.json的关键部分配置{ name: xiaoman-tracker-sdk, version: 1.0.0, description: A lightweight front-end tracking SDK., main: dist/index.cjs.js, module: dist/index.esm.js, unpkg: dist/index.umd.js, types: dist/types/core.d.ts, // 类型声明入口 files: [ dist, README.md ], scripts: { build: rollup -c, // 执行rollup构建 dev: rollup -c -w, // 监听模式用于开发 prepublishOnly: npm run build // 发布前自动构建 }, keywords: [tracking, analytics, sdk, frontend], author: Your Name, license: MIT, devDependencies: { // ... 上面安装的rollup相关依赖 } }现在运行npm run build你会在dist目录下得到打包好的文件index.cjs.js- CommonJS版本index.esm.js- ES Module版本index.umd.js- UMD版本可直接通过script标签引入types/目录 - 包含所有TypeScript声明文件。5. 本地测试、发布npm包与版本管理在发布之前我们必须在真实环境中测试SDK是否工作正常。5.1 创建测试页面与本地测试我们可以创建一个简单的demo.html来测试UMD包demo.html!DOCTYPE html html head titleTracker SDK Test/title /head body h1测试埋点SDK/h1 button>npm install xiaoman-tracker-sdk在入口文件如main.js/ts中初始化import Tracker from xiaoman-tracker-sdk; Tracker.init({ appId: your_app_id, endpoint: https://your-data-collection-server.com/api/track, autoTrack: { click: true, exposure: true, pageView: true } }); // 之后在业务组件中手动埋点 Tracker.getInstance().track(product_purchase, custom, { product_id: 123, price: 99.9 });在HTML模板中使用自动埋点!-- Vue/React JSX 中类似 -- button >问题可能原因解决方案npm publish失败提示403 Forbidden1. 未登录 npm。2. 包名已被占用。3. 使用了作用域包但未设置访问权限。1. 运行npm login重新登录。2. 在package.json中更换一个唯一的包名。3. 对于作用域包使用npm publish --access public。构建后文件体积过大1. 未进行代码压缩和Tree Shaking。2. 引入了未使用的第三方库。1. 确保 Rollup 配置中使用了terser等压缩插件。2. 检查package.json的dependencies和devDependencies将仅用于开发的库移到devDependencies。使用rollup-plugin-visualizer分析包体积。TypeScript 类型声明文件未生成或路径错误tsconfig.json中declaration或declarationDir配置错误。确保tsconfig.json中设置了declaration: true和declarationDir: ./dist/types并且package.json的types字段指向正确的入口文件如dist/types/core.d.ts。6.2 运行时问题问题可能原因解决方案SDK初始化后自动点击/曝光埋点不生效1. DOM未加载完成就初始化了AutoTracker。2. 选择器[data-track-click]未正确绑定。3. 事件委托被阻止冒泡。1. 确保在DOMContentLoaded事件后或Vue/React的mounted生命周期后初始化SDK或像我们代码中那样做延迟初始化。2. 检查元素属性是否正确书写。3. 事件委托依赖事件冒泡如果子元素调用了e.stopPropagation()则监听不到。可考虑调整监听阶段或检查业务代码。页面关闭时数据丢失1. 未绑定pagehide/beforeunload事件。2. 使用fetch或XMLHttpRequest在页面卸载时被浏览器取消。3. 数据量太大sendBeacon发送失败。1. 确保绑定了卸载事件。2. 优先使用navigator.sendBeacon()或fetch的keepalive: true选项。3. 在卸载事件中只发送最关键的数据或提前进行批量上报减少卸载时的数据压力。上报请求被浏览器插件如AdBlock拦截请求URL或域名被广告拦截规则匹配。1. 避免使用track,log,analytics等敏感词作为上报接口路径。2. 考虑将上报服务集成到自己的业务域名下而不是单独的统计域名。3. 做好降级处理请求失败不影响主流程。在单页应用SPA中页面切换后曝光埋点失效IntersectionObserver观察的元素在路由切换后被移除新页面元素未被观察。需要在路由变化后如Vue的router.afterEach, React Router的useEffect重新查找并观察新页面中带有>