diff --git a/src/constants/tracking.js b/src/constants/tracking.js new file mode 100644 index 00000000..53a52663 --- /dev/null +++ b/src/constants/tracking.js @@ -0,0 +1,204 @@ +// src/constants/tracking.js +// PostHog 事件追踪优先级配置 + +/** + * 事件优先级枚举 + * + * 用于决定事件的追踪时机,优化性能和用户体验。 + * + * @enum {string} + */ +export const EVENT_PRIORITY = { + /** + * 关键事件 - 立即发送,不可延迟 + * 示例:登录、注册、支付、订阅购买 + */ + CRITICAL: 'critical', + + /** + * 高优先级事件 - 立即发送 + * 示例:详情打开、搜索提交、关注操作、分享操作 + */ + HIGH: 'high', + + /** + * 普通优先级事件 - 空闲时发送 + * 示例:列表查看、筛选应用、排序变更 + */ + NORMAL: 'normal', + + /** + * 低优先级事件 - 空闲时发送,可批量合并 + * 示例:鼠标移动、滚动事件、hover 事件 + */ + LOW: 'low', +}; + +/** + * Community 页面(新闻催化分析)事件优先级映射 + * + * 映射规则: + * - CRITICAL: 无(Community 页面无关键业务操作) + * - HIGH: 用户明确的交互操作(点击、打开详情、搜索、跳转) + * - NORMAL: 被动浏览事件(页面加载、列表查看、筛选、排序) + * - LOW: 暂未使用 + * + * @type {Object} + */ +export const COMMUNITY_EVENT_PRIORITIES = { + // ==================== 普通优先级(空闲时追踪)==================== + + /** + * 页面浏览事件 - NORMAL + * 触发时机:用户进入 Community 页面 + * 延迟原因:页面加载时避免阻塞渲染 + */ + 'Community Page Viewed': EVENT_PRIORITY.NORMAL, + + /** + * 新闻列表查看 - NORMAL + * 触发时机:新闻列表加载完成 + * 延迟原因:避免阻塞列表渲染 + */ + 'News List Viewed': EVENT_PRIORITY.NORMAL, + + /** + * 新闻筛选应用 - NORMAL + * 触发时机:用户应用筛选条件(重要性、日期、行业) + * 延迟原因:筛选操作频繁,避免阻塞 UI 更新 + */ + 'News Filter Applied': EVENT_PRIORITY.NORMAL, + + /** + * 新闻排序变更 - NORMAL + * 触发时机:用户切换排序方式(最新、最热、收益率) + * 延迟原因:排序操作频繁,避免阻塞 UI 更新 + */ + 'News Sorted': EVENT_PRIORITY.NORMAL, + + /** + * 新闻标签页点击 - NORMAL + * 触发时机:用户点击新闻详情中的标签页(相关股票、相关概念、时间线) + * 延迟原因:标签切换高频,延迟追踪不影响用户体验 + */ + 'News Tab Clicked': EVENT_PRIORITY.NORMAL, + + // ==================== 高优先级(立即追踪)==================== + + /** + * 新闻文章点击 - HIGH + * 触发时机:用户点击新闻卡片 + * 立即追踪原因:关键交互操作,需要准确记录点击位置和时间 + */ + 'News Article Clicked': EVENT_PRIORITY.HIGH, + + /** + * 新闻详情打开 - HIGH + * 触发时机:打开新闻详情弹窗或页面 + * 立即追踪原因:关键交互操作,需要准确记录查看时间 + */ + 'News Detail Opened': EVENT_PRIORITY.HIGH, + + /** + * 搜索查询提交 - HIGH + * 触发时机:用户提交搜索关键词 + * 立即追踪原因:用户明确操作,需要准确记录搜索意图 + */ + 'Search Query Submitted': EVENT_PRIORITY.HIGH, + + /** + * 搜索无结果 - HIGH + * 触发时机:搜索返回 0 个结果 + * 立即追踪原因:重要的用户体验指标,需要及时发现问题 + */ + 'Search No Results': EVENT_PRIORITY.HIGH, + + /** + * 相关股票点击 - HIGH + * 触发时机:用户从新闻详情点击相关股票 + * 立即追踪原因:重要的跳转行为,需要准确记录导流效果 + */ + 'Stock Clicked': EVENT_PRIORITY.HIGH, + + /** + * 相关概念点击 - HIGH + * 触发时机:用户从新闻详情点击相关概念 + * 立即追踪原因:重要的跳转行为,需要准确记录导流效果 + */ + 'Concept Clicked': EVENT_PRIORITY.HIGH, + + /** + * 事件关注操作 - HIGH + * 触发时机:用户点击关注按钮 + * 立即追踪原因:关键业务操作,需要准确记录关注行为 + */ + 'Event Followed': EVENT_PRIORITY.HIGH, + + /** + * 事件取消关注 - HIGH + * 触发时机:用户取消关注事件 + * 立即追踪原因:关键业务操作,需要准确记录取关原因 + */ + 'Event Unfollowed': EVENT_PRIORITY.HIGH, +}; + +/** + * requestIdleCallback 配置 + * + * @type {Object} + */ +export const IDLE_CALLBACK_CONFIG = { + /** + * 超时时间(毫秒) + * 即使浏览器不空闲,也会在此时间后强制执行追踪 + * + * 设置为 2000ms 的原因: + * - 足够长:避免在用户快速操作时阻塞主线程 + * - 足够短:确保用户快速关闭页面前也能发送事件 + * - 平衡点:2 秒是用户注意力的典型持续时间 + */ + timeout: 2000, +}; + +/** + * 获取事件优先级 + * + * @param {string} eventName - 事件名称 + * @returns {string} 事件优先级(CRITICAL | HIGH | NORMAL | LOW) + */ +export const getEventPriority = (eventName) => { + return COMMUNITY_EVENT_PRIORITIES[eventName] || EVENT_PRIORITY.NORMAL; +}; + +/** + * 判断事件是否需要立即追踪 + * + * @param {string} eventName - 事件名称 + * @returns {boolean} 是否立即追踪 + */ +export const shouldTrackImmediately = (eventName) => { + const priority = getEventPriority(eventName); + return priority === EVENT_PRIORITY.CRITICAL || priority === EVENT_PRIORITY.HIGH; +}; + +/** + * 判断事件是否可以延迟追踪 + * + * @param {string} eventName - 事件名称 + * @returns {boolean} 是否可以延迟追踪 + */ +export const canTrackIdle = (eventName) => { + const priority = getEventPriority(eventName); + return priority === EVENT_PRIORITY.NORMAL || priority === EVENT_PRIORITY.LOW; +}; + +// ==================== 默认导出 ==================== + +export default { + EVENT_PRIORITY, + COMMUNITY_EVENT_PRIORITIES, + IDLE_CALLBACK_CONFIG, + getEventPriority, + shouldTrackImmediately, + canTrackIdle, +}; diff --git a/src/utils/trackingHelpers.js b/src/utils/trackingHelpers.js new file mode 100644 index 00000000..a8057e4e --- /dev/null +++ b/src/utils/trackingHelpers.js @@ -0,0 +1,337 @@ +// src/utils/trackingHelpers.js +// PostHog 追踪性能优化工具 - 使用 requestIdleCallback 延迟非关键事件 + +import { shouldTrackImmediately } from '../constants/tracking'; + +/** + * requestIdleCallback Polyfill + * Safari 和旧浏览器不支持 requestIdleCallback,使用 setTimeout 降级 + * + * @param {Function} callback - 回调函数 + * @param {Object} options - 配置选项 + * @param {number} options.timeout - 超时时间(毫秒) + * @returns {number} 定时器 ID + */ +const requestIdleCallbackPolyfill = (callback, options = {}) => { + const timeout = options.timeout || 2000; + const start = Date.now(); + + return setTimeout(() => { + callback({ + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), + }); + }, 1); +}; + +/** + * cancelIdleCallback Polyfill + * + * @param {number} id - 定时器 ID + */ +const cancelIdleCallbackPolyfill = (id) => { + clearTimeout(id); +}; + +// 使用原生 API 或 polyfill +const requestIdleCallbackCompat = + typeof window !== 'undefined' && window.requestIdleCallback + ? window.requestIdleCallback.bind(window) + : requestIdleCallbackPolyfill; + +const cancelIdleCallbackCompat = + typeof window !== 'undefined' && window.cancelIdleCallback + ? window.cancelIdleCallback.bind(window) + : cancelIdleCallbackPolyfill; + +// ==================== 待发送事件队列 ==================== + +/** + * 待发送事件队列(用于批量发送优化) + * @type {Array<{trackFn: Function, args: Array}>} + */ +let pendingEvents = []; + +/** + * 已调度的 idle callback ID(防止重复调度) + * @type {number|null} + */ +let scheduledCallbackId = null; + +/** + * 刷新待发送事件队列 + * 立即执行所有待发送的追踪事件 + */ +const flushPendingEvents = () => { + if (pendingEvents.length === 0) return; + + const eventsToFlush = [...pendingEvents]; + pendingEvents = []; + + eventsToFlush.forEach(({ trackFn, args }) => { + try { + trackFn(...args); + } catch (error) { + console.error('❌ [trackingHelpers] Failed to flush event:', error); + } + }); + + if (process.env.NODE_ENV === 'development') { + console.log( + `%c✅ [trackingHelpers] Flushed ${eventsToFlush.length} pending event(s)`, + 'color: #10B981; font-weight: bold;' + ); + } +}; + +/** + * 处理空闲时执行待发送事件 + * + * @param {IdleDeadline} deadline - 空闲时间信息 + */ +const processIdleEvents = (deadline) => { + scheduledCallbackId = null; + + // 如果超时或队列为空,强制刷新 + if (deadline.didTimeout || pendingEvents.length === 0) { + flushPendingEvents(); + return; + } + + // 在空闲时间内尽可能多地处理事件 + while (pendingEvents.length > 0 && deadline.timeRemaining() > 0) { + const { trackFn, args } = pendingEvents.shift(); + try { + trackFn(...args); + } catch (error) { + console.error('❌ [trackingHelpers] Failed to track event:', error); + } + } + + // 如果还有未处理的事件,继续调度 + if (pendingEvents.length > 0) { + scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, { + timeout: 2000, + }); + } +}; + +// ==================== 公共 API ==================== + +/** + * 在浏览器空闲时追踪事件(非关键事件优化) + * + * 使用 requestIdleCallback API 延迟事件追踪到浏览器空闲时执行, + * 避免阻塞主线程,提升页面交互响应速度。 + * + * **适用场景**: + * - 页面浏览事件(page_viewed) + * - 列表查看事件(list_viewed) + * - 筛选/排序事件(filter_applied, sorted) + * - 低优先级交互事件 + * + * **不适用场景**: + * - 关键业务事件(登录、支付、关注) + * - 用户明确操作事件(按钮点击、详情打开) + * - 需要实时追踪的事件 + * + * @param {Function} trackFn - PostHog 追踪函数(如 track, trackPageView) + * @param {...any} args - 传递给追踪函数的参数 + * + * @example + * import { trackEventIdle } from '@utils/trackingHelpers'; + * import { trackEvent } from '@lib/posthog'; + * + * // 延迟追踪页面浏览事件 + * trackEventIdle(trackEvent, 'page_viewed', { page: '/community' }); + * + * // 延迟追踪筛选事件 + * trackEventIdle(track, 'news_filter_applied', { importance: 'high' }); + */ +export const trackEventIdle = (trackFn, ...args) => { + if (!trackFn || typeof trackFn !== 'function') { + console.warn('⚠️ [trackingHelpers] trackFn must be a function'); + return; + } + + // 添加到待发送队列 + pendingEvents.push({ trackFn, args }); + + if (process.env.NODE_ENV === 'development') { + console.log( + `%c⏱️ [trackingHelpers] Event queued for idle execution (queue: ${pendingEvents.length})`, + 'color: #8B5CF6; font-weight: bold;', + args[0] // 事件名称 + ); + } + + // 如果没有已调度的 callback,调度一个新的 + if (scheduledCallbackId === null) { + scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, { + timeout: 2000, // 2秒超时保护,确保事件不会无限延迟 + }); + } +}; + +/** + * 立即追踪事件(关键事件) + * + * 同步执行追踪,不延迟。用于需要实时追踪的关键业务事件。 + * + * **适用场景**: + * - 关键业务事件(登录、注册、支付、订阅) + * - 用户明确操作(按钮点击、详情打开、搜索提交) + * - 高优先级交互事件(关注、分享、评论) + * - 需要准确时序的事件 + * + * @param {Function} trackFn - PostHog 追踪函数 + * @param {...any} args - 传递给追踪函数的参数 + * + * @example + * import { trackEventImmediate } from '@utils/trackingHelpers'; + * import { trackEvent } from '@lib/posthog'; + * + * // 立即追踪登录事件 + * trackEventImmediate(trackEvent, 'user_logged_in', { method: 'password' }); + * + * // 立即追踪详情打开事件 + * trackEventImmediate(track, 'news_detail_opened', { news_id: 123 }); + */ +export const trackEventImmediate = (trackFn, ...args) => { + if (!trackFn || typeof trackFn !== 'function') { + console.warn('⚠️ [trackingHelpers] trackFn must be a function'); + return; + } + + try { + trackFn(...args); + + if (process.env.NODE_ENV === 'development') { + console.log( + `%c⚡ [trackingHelpers] Event tracked immediately`, + 'color: #F59E0B; font-weight: bold;', + args[0] // 事件名称 + ); + } + } catch (error) { + console.error('❌ [trackingHelpers] Failed to track event immediately:', error); + } +}; + +/** + * 智能追踪包装器 + * + * 根据事件优先级自动选择立即追踪或空闲时追踪。 + * 使用 `shouldTrackImmediately()` 判断事件优先级,简化调用方代码。 + * + * **适用场景**: + * - 业务代码不需要关心事件优先级细节 + * - 统一的追踪接口,自动优化性能 + * - 易于维护和扩展 + * + * **优先级规则**(由 `src/constants/tracking.js` 配置): + * - CRITICAL / HIGH → 立即追踪(`trackEventImmediate`) + * - NORMAL / LOW → 空闲时追踪(`trackEventIdle`) + * + * @param {Function} trackFn - PostHog 追踪函数(如 `track` from `usePostHogTrack`) + * @param {string} eventName - 事件名称(需在 `tracking.js` 中定义优先级) + * @param {Object} properties - 事件属性 + * + * @example + * import { smartTrack } from '@/utils/trackingHelpers'; + * import { usePostHogTrack } from '@/hooks/usePostHogRedux'; + * import { RETENTION_EVENTS } from '@/lib/constants'; + * + * const { track } = usePostHogTrack(); + * + * // 自动根据优先级选择追踪方式 + * smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { news_id: 123 }); + * smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, { total_count: 30 }); + */ +export const smartTrack = (trackFn, eventName, properties = {}) => { + if (!trackFn || typeof trackFn !== 'function') { + console.warn('⚠️ [trackingHelpers] smartTrack: trackFn must be a function'); + return; + } + + if (!eventName || typeof eventName !== 'string') { + console.warn('⚠️ [trackingHelpers] smartTrack: eventName must be a string'); + return; + } + + // 根据事件优先级选择追踪方式 + if (shouldTrackImmediately(eventName)) { + // 高优先级事件:立即追踪 + trackEventImmediate(trackFn, eventName, properties); + } else { + // 普通优先级事件:空闲时追踪 + trackEventIdle(trackFn, eventName, properties); + } +}; + +/** + * 页面卸载前刷新所有待发送事件 + * + * 在 beforeunload 事件中调用,确保页面关闭前发送所有待发送的追踪事件。 + * 防止用户快速关闭页面时丢失事件数据。 + * + * **使用方式**: + * ```javascript + * import { flushPendingEventsBeforeUnload } from '@utils/trackingHelpers'; + * + * useEffect(() => { + * window.addEventListener('beforeunload', flushPendingEventsBeforeUnload); + * return () => { + * window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload); + * }; + * }, []); + * ``` + */ +export const flushPendingEventsBeforeUnload = () => { + // 取消已调度的 idle callback + if (scheduledCallbackId !== null) { + cancelIdleCallbackCompat(scheduledCallbackId); + scheduledCallbackId = null; + } + + // 立即刷新所有待发送事件 + flushPendingEvents(); + + if (process.env.NODE_ENV === 'development') { + console.log( + '%c🔄 [trackingHelpers] Flushed pending events before unload', + 'color: #3B82F6; font-weight: bold;' + ); + } +}; + +/** + * 获取当前待发送事件数量(调试用) + * + * @returns {number} 待发送事件数量 + */ +export const getPendingEventsCount = () => { + return pendingEvents.length; +}; + +/** + * 清空待发送事件队列(测试用) + */ +export const clearPendingEvents = () => { + if (scheduledCallbackId !== null) { + cancelIdleCallbackCompat(scheduledCallbackId); + scheduledCallbackId = null; + } + pendingEvents = []; +}; + +// ==================== 默认导出 ==================== + +export default { + trackEventIdle, + trackEventImmediate, + smartTrack, + flushPendingEventsBeforeUnload, + getPendingEventsCount, + clearPendingEvents, +}; diff --git a/src/views/Community/hooks/useCommunityEvents.js b/src/views/Community/hooks/useCommunityEvents.js index 3c30ba5f..9ba79062 100644 --- a/src/views/Community/hooks/useCommunityEvents.js +++ b/src/views/Community/hooks/useCommunityEvents.js @@ -1,10 +1,12 @@ // src/views/Community/hooks/useCommunityEvents.js // 新闻催化分析页面事件追踪 Hook +// 性能优化:使用 requestIdleCallback 延迟非关键事件追踪 import { useCallback, useEffect } from 'react'; -import { usePostHogTrack } from '../../../hooks/usePostHogRedux'; -import { RETENTION_EVENTS } from '../../../lib/constants'; -import { logger } from '../../../utils/logger'; +import { usePostHogTrack } from '@/hooks/usePostHogRedux'; +import { RETENTION_EVENTS } from '@/lib/constants'; +import { logger } from '@/utils/logger'; +import { smartTrack } from '@/utils/trackingHelpers'; /** * 新闻催化分析(Community)事件追踪 Hook @@ -15,9 +17,9 @@ import { logger } from '../../../utils/logger'; export const useCommunityEvents = ({ navigate } = {}) => { const { track } = usePostHogTrack(); - // 🎯 页面浏览事件 - 页面加载时触发 + // 🎯 页面浏览事件 - 页面加载时触发(空闲时追踪) useEffect(() => { - track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, { + smartTrack(track, RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, { timestamp: new Date().toISOString(), }); logger.debug('useCommunityEvents', '📰 Community Page Viewed'); @@ -33,7 +35,7 @@ export const useCommunityEvents = ({ navigate } = {}) => { * @param {string} params.industryFilter - 行业筛选 */ const trackNewsListViewed = useCallback((params = {}) => { - track(RETENTION_EVENTS.NEWS_LIST_VIEWED, { + smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, { total_count: params.totalCount || 0, sort_by: params.sortBy || 'new', importance_filter: params.importance || 'all', @@ -60,7 +62,7 @@ export const useCommunityEvents = ({ navigate } = {}) => { return; } - track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { + smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { news_id: news.id, news_title: news.title || '', importance: news.importance || 'unknown', @@ -90,7 +92,7 @@ export const useCommunityEvents = ({ navigate } = {}) => { return; } - track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, { + smartTrack(track, RETENTION_EVENTS.NEWS_DETAIL_OPENED, { news_id: news.id, news_title: news.title || '', importance: news.importance || 'unknown', @@ -115,7 +117,7 @@ export const useCommunityEvents = ({ navigate } = {}) => { return; } - track(RETENTION_EVENTS.NEWS_TAB_CLICKED, { + smartTrack(track, RETENTION_EVENTS.NEWS_TAB_CLICKED, { tab_name: tabName, news_id: newsId, timestamp: new Date().toISOString(), @@ -136,7 +138,7 @@ export const useCommunityEvents = ({ navigate } = {}) => { * @param {string} filters.industryCode - 行业代码 */ const trackNewsFilterApplied = useCallback((filters = {}) => { - track(RETENTION_EVENTS.NEWS_FILTER_APPLIED, { + smartTrack(track, RETENTION_EVENTS.NEWS_FILTER_APPLIED, { importance: filters.importance || 'all', date_range: filters.dateRange || 'all', industry_classification: filters.industryClassification || 'all', @@ -159,7 +161,7 @@ export const useCommunityEvents = ({ navigate } = {}) => { return; } - track(RETENTION_EVENTS.NEWS_SORTED, { + smartTrack(track, RETENTION_EVENTS.NEWS_SORTED, { sort_by: sortBy, previous_sort: previousSort, timestamp: new Date().toISOString(), @@ -179,7 +181,7 @@ export const useCommunityEvents = ({ navigate } = {}) => { const trackNewsSearched = useCallback((query, resultCount = 0) => { if (!query) return; - track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, { + smartTrack(track, RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, { query, result_count: resultCount, has_results: resultCount > 0, @@ -187,9 +189,9 @@ export const useCommunityEvents = ({ navigate } = {}) => { timestamp: new Date().toISOString(), }); - // 如果没有搜索结果,额外追踪 + // 如果没有搜索结果,额外追踪(高优先级,立即发送) if (resultCount === 0) { - track(RETENTION_EVENTS.SEARCH_NO_RESULTS, { + smartTrack(track, RETENTION_EVENTS.SEARCH_NO_RESULTS, { query, context: 'community_news', timestamp: new Date().toISOString(), @@ -215,7 +217,7 @@ export const useCommunityEvents = ({ navigate } = {}) => { return; } - track(RETENTION_EVENTS.STOCK_CLICKED, { + smartTrack(track, RETENTION_EVENTS.STOCK_CLICKED, { stock_code: stock.code, stock_name: stock.name || '', source: 'news_related_stocks', @@ -242,7 +244,7 @@ export const useCommunityEvents = ({ navigate } = {}) => { return; } - track(RETENTION_EVENTS.CONCEPT_CLICKED, { + smartTrack(track, RETENTION_EVENTS.CONCEPT_CLICKED, { concept_code: concept.code, concept_name: concept.name || '', source: 'news_related_concepts', diff --git a/src/views/Community/index.js b/src/views/Community/index.js index 351f2dd3..72f92d6d 100644 --- a/src/views/Community/index.js +++ b/src/views/Community/index.js @@ -5,7 +5,7 @@ import { useSelector, useDispatch } from 'react-redux'; import { fetchPopularKeywords, fetchHotEvents -} from '../../store/slices/communityDataSlice'; +} from '@/store/slices/communityDataSlice'; import { Box, Container, @@ -32,9 +32,10 @@ import { useEventData } from './hooks/useEventData'; import { useEventFilters } from './hooks/useEventFilters'; import { useCommunityEvents } from './hooks/useCommunityEvents'; -import { logger } from '../../utils/logger'; -import { useNotification } from '../../contexts/NotificationContext'; -import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme'; +import { logger } from '@/utils/logger'; +import { useNotification } from '@/contexts/NotificationContext'; +import { PROFESSIONAL_COLORS } from '@/constants/professionalTheme'; +import { flushPendingEventsBeforeUnload } from '@/utils/trackingHelpers'; // 导航栏已由 MainLayout 提供,无需在此导入 @@ -96,6 +97,15 @@ const Community = () => { dispatch(fetchHotEvents()); }, [dispatch]); + // ⚡ 页面卸载前刷新待发送的 PostHog 事件(性能优化) + useEffect(() => { + window.addEventListener('beforeunload', flushPendingEventsBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload); + }; + }, []); + // 🎯 追踪新闻列表查看(当事件列表加载完成后) useEffect(() => { if (events && events.length > 0 && !loading) {