@miot-rn/common-component 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/service/specs/index.js +63 -32
  2. package/dist/service/specs/spec.js +6 -0
  3. package/dist/service/specs/std-spec.js +10 -1
  4. package/dist/service/specs/subscribe-logging.test.d.ts +17 -0
  5. package/dist/service/specs/subscribe-logging.test.js +217 -0
  6. package/dist/service/track/TrackFlatList.d.ts +3 -0
  7. package/dist/service/track/TrackFlatList.js +19 -0
  8. package/dist/service/track/TrackScrollContext.d.ts +10 -0
  9. package/dist/service/track/TrackScrollContext.js +2 -0
  10. package/dist/service/track/TrackScrollView.d.ts +3 -0
  11. package/dist/service/track/TrackScrollView.js +20 -0
  12. package/dist/service/track/TrackService.d.ts +10 -0
  13. package/dist/service/track/TrackService.js +104 -0
  14. package/dist/service/track/index.d.ts +12 -0
  15. package/dist/service/track/index.js +9 -0
  16. package/dist/service/track/intersect.d.ts +8 -0
  17. package/dist/service/track/intersect.js +15 -0
  18. package/dist/service/track/intersect.test.d.ts +1 -0
  19. package/dist/service/track/intersect.test.js +189 -0
  20. package/dist/service/track/types.d.ts +16 -0
  21. package/dist/service/track/types.js +1 -0
  22. package/dist/service/track/useCardTrack.d.ts +14 -0
  23. package/dist/service/track/useCardTrack.js +61 -0
  24. package/dist/service/track/useExposeOnVisible.d.ts +11 -0
  25. package/dist/service/track/useExposeOnVisible.js +74 -0
  26. package/dist/service/track/usePageTrack.d.ts +7 -0
  27. package/dist/service/track/usePageTrack.js +101 -0
  28. package/dist/service/track/useTrack.d.ts +7 -0
  29. package/dist/service/track/useTrack.js +23 -0
  30. package/dist/service/track/useTrackScrollProvider.d.ts +24 -0
  31. package/dist/service/track/useTrackScrollProvider.js +81 -0
  32. package/dist/specs/instance-parser.js +13 -11
  33. package/dist/store/useGlobalSpecManager.d.ts +3 -0
  34. package/dist/store/useGlobalSpecManager.js +395 -54
  35. package/dist/store/useGlobalSpecManager.test.d.ts +1 -0
  36. package/dist/store/useGlobalSpecManager.test.js +247 -0
  37. package/dist/utils/subscribe-log.d.ts +1 -0
  38. package/dist/utils/subscribe-log.js +37 -0
  39. package/package.json +2 -2
@@ -0,0 +1,8 @@
1
+ export interface Frame {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export declare function visibleRatio(item: Frame, viewport: Frame): number;
8
+ export declare function isVisible(item: Frame, viewport: Frame, threshold: number): boolean;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 矩形相交可见比例(item 在 viewport 内的高度 / item 自身高度)。
3
+ * 横向不相交直接返回 0,避免 carousel 里横向滚出视口的 item 被误判为可见。
4
+ */
5
+ export function visibleRatio(item, viewport) {
6
+ if (item.height <= 0 || item.width <= 0) return 0;
7
+ const vOverlap = Math.max(0, Math.min(item.y + item.height, viewport.y + viewport.height) - Math.max(item.y, viewport.y));
8
+ const hOverlap = Math.max(0, Math.min(item.x + item.width, viewport.x + viewport.width) - Math.max(item.x, viewport.x));
9
+ if (hOverlap <= 0) return 0;
10
+ return vOverlap / item.height;
11
+ }
12
+ export function isVisible(item, viewport, threshold) {
13
+ if (threshold <= 0) return visibleRatio(item, viewport) > 0;
14
+ return visibleRatio(item, viewport) >= threshold;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,189 @@
1
+ /// <reference types="jest" />
2
+
3
+ /**
4
+ * 视口相交判定的纯几何用例。
5
+ *
6
+ * 这套判定算错了不会抛错:曝光要么静默丢失、要么重复上报,日志和肉眼都发现不了。
7
+ * 埋点基础层其余部分已在 oversea 线上验证过,只有这个纯函数值得单独钉住边界。
8
+ *
9
+ * 需要长期钉住的两条非直觉行为(改动时若断言变红,说明语义被换掉了,不是测试过时):
10
+ * 1. 比例只按**纵向**算,横向只是「有没有相交」的开关 —— 横向露一半、纵向全露,比例仍是 1。
11
+ * 2. item 比视口高时比例永远 < 1,threshold=1 会导致该 item 永不曝光。
12
+ */
13
+
14
+ import { visibleRatio, isVisible } from "./intersect";
15
+
16
+ /** 典型竖屏滚动容器视口:纵向占据 y=100~700 */
17
+ const viewport = {
18
+ x: 0,
19
+ y: 100,
20
+ width: 390,
21
+ height: 600
22
+ };
23
+ const item = o => ({
24
+ x: 0,
25
+ y: 100,
26
+ width: 390,
27
+ height: 100,
28
+ ...o
29
+ });
30
+ describe('visibleRatio', () => {
31
+ it('完全落在视口内 → 1', () => {
32
+ expect(visibleRatio(item({
33
+ y: 300
34
+ }), viewport)).toBe(1);
35
+ });
36
+ it('完全在视口下方 → 0', () => {
37
+ expect(visibleRatio(item({
38
+ y: 800
39
+ }), viewport)).toBe(0);
40
+ });
41
+ it('完全在视口上方 → 0', () => {
42
+ expect(visibleRatio(item({
43
+ y: -200
44
+ }), viewport)).toBe(0);
45
+ });
46
+ it('上边缘与视口顶部相切(贴着但没进来)→ 0', () => {
47
+ expect(visibleRatio(item({
48
+ y: 0,
49
+ height: 100
50
+ }), viewport)).toBe(0);
51
+ });
52
+ it('下边缘与视口底部相切 → 0', () => {
53
+ expect(visibleRatio(item({
54
+ y: 700
55
+ }), viewport)).toBe(0);
56
+ });
57
+ it('从底部露出一半 → 0.5', () => {
58
+ expect(visibleRatio(item({
59
+ y: 650,
60
+ height: 100
61
+ }), viewport)).toBe(0.5);
62
+ });
63
+ it('从顶部滚出一半 → 0.5', () => {
64
+ expect(visibleRatio(item({
65
+ y: 50,
66
+ height: 100
67
+ }), viewport)).toBe(0.5);
68
+ });
69
+ it('item 比视口高 → 比例按视口高度截断,永远拿不到 1', () => {
70
+ // 1200 高的长卡片纵向铺满视口,可见比例只有 600/1200
71
+ expect(visibleRatio(item({
72
+ y: 0,
73
+ height: 1200
74
+ }), viewport)).toBe(0.5);
75
+ });
76
+ it('横向完全滚出视口(carousel 场景)→ 0,即使纵向完全相交', () => {
77
+ expect(visibleRatio(item({
78
+ x: 400,
79
+ width: 100,
80
+ y: 300
81
+ }), viewport)).toBe(0);
82
+ });
83
+ it('横向相切 → 0', () => {
84
+ expect(visibleRatio(item({
85
+ x: 390,
86
+ width: 100,
87
+ y: 300
88
+ }), viewport)).toBe(0);
89
+ });
90
+ it('横向只露一部分、纵向全露 → 1(比例只按纵向算)', () => {
91
+ expect(visibleRatio(item({
92
+ x: 195,
93
+ width: 390,
94
+ y: 300
95
+ }), viewport)).toBe(1);
96
+ });
97
+ it('零高度 → 0,不产出 NaN', () => {
98
+ const r = visibleRatio(item({
99
+ height: 0,
100
+ y: 300
101
+ }), viewport);
102
+ expect(r).toBe(0);
103
+ expect(Number.isNaN(r)).toBe(false);
104
+ });
105
+ it('零宽度 → 0', () => {
106
+ expect(visibleRatio(item({
107
+ width: 0,
108
+ y: 300
109
+ }), viewport)).toBe(0);
110
+ });
111
+ it('负尺寸(measureInWindow 异常值)→ 0,不产出负比例', () => {
112
+ expect(visibleRatio(item({
113
+ height: -100,
114
+ y: 300
115
+ }), viewport)).toBe(0);
116
+ expect(visibleRatio(item({
117
+ width: -390,
118
+ y: 300
119
+ }), viewport)).toBe(0);
120
+ });
121
+ });
122
+ describe('isVisible', () => {
123
+ it('threshold=0 → 相交一个像素即可见', () => {
124
+ expect(isVisible(item({
125
+ y: 699,
126
+ height: 100
127
+ }), viewport, 0)).toBe(true);
128
+ });
129
+ it('threshold=0 → 相切不算可见', () => {
130
+ expect(isVisible(item({
131
+ y: 700
132
+ }), viewport, 0)).toBe(false);
133
+ });
134
+ it('threshold=0 → 横向滚出不算可见', () => {
135
+ expect(isVisible(item({
136
+ x: 400,
137
+ width: 100,
138
+ y: 300
139
+ }), viewport, 0)).toBe(false);
140
+ });
141
+ it('比例恰好等于 threshold → 可见(取 >=,边界不丢曝光)', () => {
142
+ expect(visibleRatio(item({
143
+ y: 650,
144
+ height: 100
145
+ }), viewport)).toBe(0.5);
146
+ expect(isVisible(item({
147
+ y: 650,
148
+ height: 100
149
+ }), viewport, 0.5)).toBe(true);
150
+ });
151
+ it('比例差一个像素低于 threshold → 不可见', () => {
152
+ expect(visibleRatio(item({
153
+ y: 651,
154
+ height: 100
155
+ }), viewport)).toBeCloseTo(0.49, 5);
156
+ expect(isVisible(item({
157
+ y: 651,
158
+ height: 100
159
+ }), viewport, 0.5)).toBe(false);
160
+ });
161
+ it('threshold=1 → 只有完整落入视口才算可见', () => {
162
+ expect(isVisible(item({
163
+ y: 300
164
+ }), viewport, 1)).toBe(true);
165
+ expect(isVisible(item({
166
+ y: 650,
167
+ height: 100
168
+ }), viewport, 1)).toBe(false);
169
+ });
170
+ it('threshold=1 且 item 比视口高 → 永不可见(调用方不能对长卡片用 threshold=1)', () => {
171
+ expect(isVisible(item({
172
+ y: 0,
173
+ height: 1200
174
+ }), viewport, 1)).toBe(false);
175
+ expect(isVisible(item({
176
+ y: 100,
177
+ height: 1200
178
+ }), viewport, 1)).toBe(false);
179
+ });
180
+ it('负 threshold 按 0 处理', () => {
181
+ expect(isVisible(item({
182
+ y: 699,
183
+ height: 100
184
+ }), viewport, -1)).toBe(true);
185
+ expect(isVisible(item({
186
+ y: 700
187
+ }), viewport, -1)).toBe(false);
188
+ });
189
+ });
@@ -0,0 +1,16 @@
1
+ export type TrackEventType = 'click' | 'expose' | 'view' | 'control' | 'scroll' | 'drag' | 'play';
2
+ export type TrackItemType = 'ref' | 'card' | 'button' | 'item' | 'dialog' | 'area';
3
+ export interface TrackParams {
4
+ ref?: string;
5
+ sub_ref?: string;
6
+ item_type?: TrackItemType;
7
+ item_name?: string;
8
+ item_content?: string;
9
+ card_name?: string;
10
+ duration?: number;
11
+ [key: string]: unknown;
12
+ }
13
+ export interface TrackServiceConfig {
14
+ getCommonParams: () => Record<string, unknown>;
15
+ debug?: boolean;
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { RefObject } from 'react';
2
+ import { MeasurableNode } from './TrackScrollContext';
3
+ import type { TrackEventType, TrackParams } from './types';
4
+ interface UseCardTrackOptions {
5
+ cardName: string;
6
+ viewRef?: RefObject<MeasurableNode | null>;
7
+ threshold?: number;
8
+ }
9
+ export declare function useCardTrack({ cardName, viewRef, threshold }: UseCardTrackOptions): {
10
+ trackEvent: (eventTypes: TrackEventType[], params?: TrackParams) => void;
11
+ trackExpose: (itemName: string, extra?: TrackParams) => void;
12
+ trackClick: (itemName: string, extra?: TrackParams) => void;
13
+ };
14
+ export {};
@@ -0,0 +1,61 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import { reportTrack } from "./TrackService";
3
+ import { useExposeOnVisible } from "./useExposeOnVisible";
4
+ export function useCardTrack({
5
+ cardName,
6
+ viewRef,
7
+ threshold = 0
8
+ }) {
9
+ const fallbackRef = useRef(null);
10
+ const effectiveRef = viewRef ?? fallbackRef;
11
+ const hasExplicitRef = !!viewRef;
12
+ useExposeOnVisible({
13
+ viewRef: effectiveRef,
14
+ threshold,
15
+ enabled: !!cardName,
16
+ forceMountFallback: !hasExplicitRef,
17
+ onExpose: () => {
18
+ reportTrack(['expose'], {
19
+ item_type: 'card',
20
+ card_name: cardName
21
+ });
22
+ }
23
+ });
24
+
25
+ /** 通用事件上报(自动带 card_name) */
26
+ const trackEvent = useCallback((eventTypes, params = {}) => {
27
+ reportTrack(eventTypes, {
28
+ card_name: cardName,
29
+ ...params
30
+ });
31
+ }, [cardName]);
32
+
33
+ /** 元素曝光 */
34
+ const trackExpose = useCallback((itemName, extra) => {
35
+ reportTrack(['expose'], {
36
+ card_name: cardName,
37
+ item_type: 'button',
38
+ item_name: itemName,
39
+ ...extra
40
+ });
41
+ }, [cardName]);
42
+
43
+ /** 元素点击(自动附带卡片级 click) */
44
+ const trackClick = useCallback((itemName, extra) => {
45
+ reportTrack(['click'], {
46
+ item_type: 'card',
47
+ card_name: cardName
48
+ });
49
+ reportTrack(['click'], {
50
+ card_name: cardName,
51
+ item_type: 'button',
52
+ item_name: itemName,
53
+ ...extra
54
+ });
55
+ }, [cardName]);
56
+ return {
57
+ trackEvent,
58
+ trackExpose,
59
+ trackClick
60
+ };
61
+ }
@@ -0,0 +1,11 @@
1
+ import { RefObject } from 'react';
2
+ import { MeasurableNode } from './TrackScrollContext';
3
+ interface UseExposeOnVisibleOptions {
4
+ viewRef: RefObject<MeasurableNode | null>;
5
+ onExpose: () => void;
6
+ threshold?: number;
7
+ enabled?: boolean;
8
+ forceMountFallback?: boolean;
9
+ }
10
+ export declare function useExposeOnVisible({ viewRef, onExpose, threshold, enabled, forceMountFallback, }: UseExposeOnVisibleOptions): void;
11
+ export {};
@@ -0,0 +1,74 @@
1
+ import { useContext, useEffect, useRef } from 'react';
2
+ import { InteractionManager } from 'react-native';
3
+ import { TrackScrollContext } from "./TrackScrollContext";
4
+ import { isVisible } from "./intersect";
5
+ /**
6
+ * viewRef 进入最近 TrackScrollContext 视口时触发一次 onExpose。
7
+ *
8
+ * - 没有 Provider 或 forceMountFallback → mount 触发降级(与升库前等价,保持向后兼容)
9
+ * - 有 Provider → 订阅 scroll/layout 通知;命中即 unsubscribe,稳态零开销
10
+ * - 初次检查走 InteractionManager 等 react-navigation 转场动画结束(v2 约 250ms)再测
11
+ */
12
+ export function useExposeOnVisible({
13
+ viewRef,
14
+ onExpose,
15
+ threshold = 0,
16
+ enabled = true,
17
+ forceMountFallback = false
18
+ }) {
19
+ const ctx = useContext(TrackScrollContext);
20
+ const exposedRef = useRef(false);
21
+ const onExposeRef = useRef(onExpose);
22
+ onExposeRef.current = onExpose;
23
+ useEffect(() => {
24
+ if (!enabled || exposedRef.current) return undefined;
25
+
26
+ // 没有 Provider 或调用方未挂 viewRef → mount 触发降级
27
+ if (forceMountFallback || !ctx) {
28
+ exposedRef.current = true;
29
+ onExposeRef.current();
30
+ return undefined;
31
+ }
32
+ let canceled = false;
33
+ let unsub;
34
+ const fire = () => {
35
+ if (exposedRef.current) return;
36
+ exposedRef.current = true;
37
+ onExposeRef.current();
38
+ unsub?.();
39
+ };
40
+ const check = () => {
41
+ if (exposedRef.current || canceled) return;
42
+ const itemNode = viewRef.current;
43
+ const containerNode = ctx.containerRef.current;
44
+ if (!itemNode || !containerNode) return;
45
+ itemNode.measureInWindow((ix, iy, iw, ih) => {
46
+ if (exposedRef.current || canceled) return;
47
+ if (iw <= 0 || ih <= 0) return;
48
+ containerNode.measureInWindow((cx, cy, cw, ch) => {
49
+ if (exposedRef.current || canceled) return;
50
+ const item = {
51
+ x: ix,
52
+ y: iy,
53
+ width: iw,
54
+ height: ih
55
+ };
56
+ const viewport = {
57
+ x: cx,
58
+ y: cy,
59
+ width: cw,
60
+ height: ch
61
+ };
62
+ if (isVisible(item, viewport, threshold)) fire();
63
+ });
64
+ });
65
+ };
66
+ unsub = ctx.subscribe(check);
67
+ const handle = InteractionManager.runAfterInteractions(check);
68
+ return () => {
69
+ canceled = true;
70
+ unsub?.();
71
+ handle.cancel?.();
72
+ };
73
+ }, [ctx, viewRef, threshold, enabled, forceMountFallback]);
74
+ }
@@ -0,0 +1,7 @@
1
+ interface UsePageTrackOptions {
2
+ ref: string;
3
+ subRef?: string;
4
+ navigation?: any;
5
+ }
6
+ export declare function usePageTrack({ ref, subRef, navigation }: UsePageTrackOptions): void;
7
+ export {};
@@ -0,0 +1,101 @@
1
+ import { useEffect, useLayoutEffect, useRef } from 'react';
2
+ import { AppState } from 'react-native';
3
+ import { setPageRef, reportTrack } from "./TrackService";
4
+ /**
5
+ * 页面级埋点 hook。
6
+ *
7
+ * 协议(基于 react-navigation 2.16 + RN 0.61):
8
+ * - setPageRef 在 useLayoutEffect 中执行(不能放 render 期:render 期副作用违反 React 规则,
9
+ * 且栈式导航下前一页仍挂载并会因 spec 订阅频繁重渲染,render 期 setPageRef 会把 _currentRef
10
+ * 抢回本页造成串档)。useLayoutEffect 早于所有子组件的 useEffect 执行,仍能保证子卡曝光时
11
+ * _currentRef 已就绪;依赖 [ref, subRef] 使其仅在页面标识变化时同步,重渲染不再触发。
12
+ * - expose / view 由 willFocus / willBlur 驱动;v2 stack push/pop 不会卸载当前页,
13
+ * 所以 willFocus 必须负责把 _currentRef 切回当前页。
14
+ * - Android 的页面 view 实际由 setPageRef→updatePluginPageRef(带公参) 在每次 ref 变化时
15
+ * 由 Native 自主产出;这里的 sendView 仅对 iOS 生效(reportTrack 内部按平台分流)。
16
+ * - AppState 切前后台仅当前 focused 页响应,避免栈内多个 usePageTrack 实例多发。
17
+ * - 没有 navigation 时(modal/wrapper)降级为 mount/unmount 触发。
18
+ */
19
+ export function usePageTrack({
20
+ ref,
21
+ subRef,
22
+ navigation
23
+ }) {
24
+ const isFocusedRef = useRef(false);
25
+ const startRef = useRef(Date.now());
26
+
27
+ // 页面 ref 同步:仅在副作用阶段执行,且只在 ref/subRef 变化时触发(见上方协议说明)。
28
+ useLayoutEffect(() => {
29
+ setPageRef(ref, subRef || ref);
30
+ }, [ref, subRef]);
31
+ useEffect(() => {
32
+ // 每次 effect 重跑时归零:防止 useRef.current 在 [ref,subRef] 变化时保留旧值,导致新周期
33
+ // 的 handleFocus 首次调用即 already=true 被短路。
34
+ isFocusedRef.current = false;
35
+ const sendExpose = () => {
36
+ // 切回页时把 _currentRef 切回当前页;同值由 setPageRef 内部 dedupe 短路
37
+ setPageRef(ref, subRef || ref);
38
+ // 显式带上本页 ref/sub_ref,避免因 focus/blur 时序被相邻页覆盖导致串档
39
+ // 页面级点位按事件区分:expose 专属 item_name='page_expose',view 专属 duration
40
+ reportTrack(['expose'], {
41
+ ref,
42
+ sub_ref: subRef || ref,
43
+ item_type: 'ref',
44
+ item_name: 'page_expose'
45
+ });
46
+ startRef.current = Date.now();
47
+ };
48
+ // 页面停留 view:Android 由 setPageRef 的 updatePluginPageRef 驱动(reportTrack 内部会跳过 view),
49
+ // iOS 走显式 reportEventRefChannel('view'),duration 在 JS 侧按 expose→blur 计算。
50
+ // 显式带上本页 ref/sub_ref,防止 focus/blur 时序被相邻页的 setPageRef 覆盖导致串档。
51
+ const sendView = () => {
52
+ reportTrack(['view'], {
53
+ ref,
54
+ sub_ref: subRef || ref,
55
+ item_type: 'ref',
56
+ duration: Date.now() - startRef.current
57
+ });
58
+ };
59
+ const handleFocus = () => {
60
+ if (isFocusedRef.current) return;
61
+ isFocusedRef.current = true;
62
+ sendExpose();
63
+ };
64
+ const handleBlur = () => {
65
+ if (!isFocusedRef.current) return;
66
+ isFocusedRef.current = false;
67
+ sendView();
68
+ };
69
+ const unsubWillFocus = navigation?.addListener?.('willFocus', handleFocus);
70
+ const unsubDidFocus = navigation?.addListener?.('didFocus', handleFocus);
71
+ const unsubWillBlur = navigation?.addListener?.('willBlur', handleBlur);
72
+ const unsubDidBlur = navigation?.addListener?.('didBlur', handleBlur);
73
+
74
+ // 挂载时无条件补发一次 focus。isFocusedRef 幂等保证不会重复:
75
+ // - 无 navigation:兜底触发(modal/wrapper 场景)。
76
+ // - initial route:Navigation/INIT 只派发 willFocus 不派发 didFocus,且 willFocus 早于本
77
+ // useEffect 注册监听,addListener 抓不到;同时 screen 的 _isFocused 永远保持 false 使
78
+ // isFocused() 永远返回 false,任何条件式补发都失效——只能无条件补发(MIIO-131315)。
79
+ // - push 进入的二级页:willFocus 已早于本 useEffect 派发,同理需要无条件补发。
80
+ // - 已在真派发链路中收到 focus:isFocusedRef 已 true,此处被短路。
81
+ handleFocus();
82
+
83
+ // RN 0.61 的 AppState 旧 API:addEventListener 不返回 subscription,必须 removeEventListener
84
+ const handleAppState = state => {
85
+ if (!isFocusedRef.current) return;
86
+ if (state === 'background') sendView();else if (state === 'active') sendExpose();
87
+ };
88
+ AppState.addEventListener('change', handleAppState);
89
+ return () => {
90
+ if (isFocusedRef.current) {
91
+ isFocusedRef.current = false;
92
+ sendView();
93
+ }
94
+ AppState.removeEventListener('change', handleAppState);
95
+ unsubWillFocus?.remove?.();
96
+ unsubDidFocus?.remove?.();
97
+ unsubWillBlur?.remove?.();
98
+ unsubDidBlur?.remove?.();
99
+ };
100
+ }, [ref, subRef]);
101
+ }
@@ -0,0 +1,7 @@
1
+ import { reportTrack } from './TrackService';
2
+ import type { TrackParams } from './types';
3
+ export declare function useTrack(): {
4
+ track: typeof reportTrack;
5
+ click: (itemName: string, extra?: TrackParams) => void;
6
+ expose: (itemName: string, itemType?: any, extra?: TrackParams) => void;
7
+ };
@@ -0,0 +1,23 @@
1
+ import { useCallback } from 'react';
2
+ import { reportTrack } from "./TrackService";
3
+ export function useTrack() {
4
+ const click = useCallback((itemName, extra) => {
5
+ reportTrack(['click'], {
6
+ item_type: 'button',
7
+ item_name: itemName,
8
+ ...extra
9
+ });
10
+ }, []);
11
+ const expose = useCallback((itemName, itemType = 'item', extra) => {
12
+ reportTrack(['expose'], {
13
+ item_type: itemType,
14
+ item_name: itemName,
15
+ ...extra
16
+ });
17
+ }, []);
18
+ return {
19
+ track: reportTrack,
20
+ click,
21
+ expose
22
+ };
23
+ }
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
3
+ import { TrackScrollContextValue } from './TrackScrollContext';
4
+ interface ForwardedHandlers {
5
+ onScroll?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void;
6
+ onLayout?: () => void;
7
+ onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void;
8
+ }
9
+ interface ScrollProps {
10
+ ref: React.MutableRefObject<any>;
11
+ onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => void;
12
+ onLayout: () => void;
13
+ onMomentumScrollEnd: (e: NativeSyntheticEvent<NativeScrollEvent>) => void;
14
+ scrollEventThrottle: number;
15
+ }
16
+ interface ProviderHandle {
17
+ scrollProps: ScrollProps;
18
+ ctxValue: TrackScrollContextValue;
19
+ Provider: React.FC<{
20
+ children: React.ReactNode;
21
+ }>;
22
+ }
23
+ export declare function useTrackScrollProvider(forwarded?: ForwardedHandlers): ProviderHandle;
24
+ export {};
@@ -0,0 +1,81 @@
1
+ import React, { useCallback, useMemo, useRef } from 'react';
2
+ import { TrackScrollContext } from "./TrackScrollContext";
3
+ /**
4
+ * 用法:
5
+ * const tracker = useTrackScrollProvider({ onScroll: myOnScroll });
6
+ * return (
7
+ * <tracker.Provider>
8
+ * <SubpageLayout
9
+ * scrollRef={tracker.scrollProps.ref}
10
+ * onScroll={tracker.scrollProps.onScroll}
11
+ * >...</SubpageLayout>
12
+ * </tracker.Provider>
13
+ * );
14
+ *
15
+ * RAF 合并通知:scroll 16ms × N listeners × 2 次 measureInWindow 容易形成 native bridge 风暴。
16
+ * RAF 合并到每帧最多一次广播;listener 命中即 unsubscribe,稳态后只剩未曝光的卡片。
17
+ * onMomentumScrollEnd 兜底:RAF 节流可能丢最后一帧,滚动停止时再补一次。
18
+ */
19
+ export function useTrackScrollProvider(forwarded = {}) {
20
+ const containerRef = useRef(null);
21
+ const listeners = useRef(new Set());
22
+ const rafScheduled = useRef(false);
23
+ const forwardedRef = useRef(forwarded);
24
+ forwardedRef.current = forwarded;
25
+ const notify = useCallback(() => {
26
+ if (rafScheduled.current) return;
27
+ rafScheduled.current = true;
28
+ requestAnimationFrame(() => {
29
+ rafScheduled.current = false;
30
+ Array.from(listeners.current).forEach(cb => {
31
+ try {
32
+ cb();
33
+ } catch (e) {
34
+ // listener 自身异常不能阻断其它卡片曝光
35
+ // eslint-disable-next-line no-console
36
+ console.warn('[TrackScroll] listener error', e);
37
+ }
38
+ });
39
+ });
40
+ }, []);
41
+ const subscribe = useCallback(cb => {
42
+ listeners.current.add(cb);
43
+ return () => {
44
+ listeners.current.delete(cb);
45
+ };
46
+ }, []);
47
+ const onScroll = useCallback(e => {
48
+ notify();
49
+ forwardedRef.current.onScroll?.(e);
50
+ }, [notify]);
51
+ const onLayout = useCallback(() => {
52
+ notify();
53
+ forwardedRef.current.onLayout?.();
54
+ }, [notify]);
55
+ const onMomentumScrollEnd = useCallback(e => {
56
+ notify();
57
+ forwardedRef.current.onMomentumScrollEnd?.(e);
58
+ }, [notify]);
59
+ const ctxValue = useMemo(() => ({
60
+ containerRef,
61
+ subscribe
62
+ }), [subscribe]);
63
+ const Provider = useMemo(() => function TrackScrollProviderImpl({
64
+ children
65
+ }) {
66
+ return /*#__PURE__*/React.createElement(TrackScrollContext.Provider, {
67
+ value: ctxValue
68
+ }, children);
69
+ }, [ctxValue]);
70
+ return {
71
+ scrollProps: {
72
+ ref: containerRef,
73
+ onScroll,
74
+ onLayout,
75
+ onMomentumScrollEnd,
76
+ scrollEventThrottle: 16
77
+ },
78
+ ctxValue,
79
+ Provider
80
+ };
81
+ }