@miot-rn/common-component 1.0.2 → 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.
- package/dist/service/specs/index.js +63 -32
- package/dist/service/specs/spec.js +6 -0
- package/dist/service/specs/std-spec.js +31 -22
- package/dist/service/specs/subscribe-logging.test.d.ts +17 -0
- package/dist/service/specs/subscribe-logging.test.js +217 -0
- package/dist/service/track/TrackFlatList.d.ts +3 -0
- package/dist/service/track/TrackFlatList.js +19 -0
- package/dist/service/track/TrackScrollContext.d.ts +10 -0
- package/dist/service/track/TrackScrollContext.js +2 -0
- package/dist/service/track/TrackScrollView.d.ts +3 -0
- package/dist/service/track/TrackScrollView.js +20 -0
- package/dist/service/track/TrackService.d.ts +10 -0
- package/dist/service/track/TrackService.js +104 -0
- package/dist/service/track/index.d.ts +12 -0
- package/dist/service/track/index.js +9 -0
- package/dist/service/track/intersect.d.ts +8 -0
- package/dist/service/track/intersect.js +15 -0
- package/dist/service/track/intersect.test.d.ts +1 -0
- package/dist/service/track/intersect.test.js +189 -0
- package/dist/service/track/types.d.ts +16 -0
- package/dist/service/track/types.js +1 -0
- package/dist/service/track/useCardTrack.d.ts +14 -0
- package/dist/service/track/useCardTrack.js +61 -0
- package/dist/service/track/useExposeOnVisible.d.ts +11 -0
- package/dist/service/track/useExposeOnVisible.js +74 -0
- package/dist/service/track/usePageTrack.d.ts +7 -0
- package/dist/service/track/usePageTrack.js +101 -0
- package/dist/service/track/useTrack.d.ts +7 -0
- package/dist/service/track/useTrack.js +23 -0
- package/dist/service/track/useTrackScrollProvider.d.ts +24 -0
- package/dist/service/track/useTrackScrollProvider.js +81 -0
- package/dist/specs/instance-parser.js +13 -11
- package/dist/store/useGlobalSpecManager.d.ts +5 -2
- package/dist/store/useGlobalSpecManager.js +402 -63
- package/dist/store/useGlobalSpecManager.test.d.ts +1 -0
- package/dist/store/useGlobalSpecManager.test.js +247 -0
- package/dist/utils/subscribe-log.d.ts +1 -0
- package/dist/utils/subscribe-log.js +37 -0
- package/package.json +2 -2
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Service } from 'miot';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
let _config = null;
|
|
4
|
+
let _currentRef = '';
|
|
5
|
+
let _currentSubRef = '';
|
|
6
|
+
const _debugLog = [];
|
|
7
|
+
|
|
8
|
+
/** 初始化,插件入口调用一次 */
|
|
9
|
+
export function initTrackService(config) {
|
|
10
|
+
_config = config;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 读取已注册的埋点公参。
|
|
15
|
+
*
|
|
16
|
+
* 给自带上报通道的第三方组件用(如 miot/ui/smartScene 的 trackParams):那些组件不经过本文件的
|
|
17
|
+
* reportTrack,公参必须由调用方拼进它们的载荷,从这里取可保证与 reportTrack 同一来源、不会漂移。
|
|
18
|
+
* 未 init 时返回空对象,调用方无需判空。
|
|
19
|
+
*/
|
|
20
|
+
export function getCommonParams() {
|
|
21
|
+
return _config ? _config.getCommonParams() : {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 设置当前页面 ref(同步到 Native),并驱动 Android 的页面 view。
|
|
26
|
+
*
|
|
27
|
+
* 双端差异(与已上线的 stdcamera / 门铃 / miot.plugin.spec 完全一致):
|
|
28
|
+
* - Android:页面 view 由 Native「内部 view 机制」自主产出、SDK 计算 duration,只依赖插件
|
|
29
|
+
* 通过 updatePluginPageRef 设置的 ref/subRef,且**必须携带插件公参**
|
|
30
|
+
* (package_name/package_id/plugin_id/plugin_version/did/device_model...),否则 Native
|
|
31
|
+
* 不知道当前是哪个插件页、无法产出该页 view。只要每次进入/切换页面都用带公参的
|
|
32
|
+
* updatePluginPageRef 设置新 ref,Native 就会在下一次 ref 变化(或插件退出)时结算并上报
|
|
33
|
+
* 上一页的 view(含 from_ref 来源链)。
|
|
34
|
+
* - iOS:不依赖公参,view 由 reportTrack 显式经 reportEventRefChannel('view') 上报。
|
|
35
|
+
*
|
|
36
|
+
* 同值去重:Native 端每次调用都会把 old current_ref 当作下一条事件的 from_ref,同 (ref,subRef)
|
|
37
|
+
* 重复调用会让 from_ref 串档为自己。这里在 JS 侧短路。
|
|
38
|
+
*/
|
|
39
|
+
export function setPageRef(ref, subRef) {
|
|
40
|
+
const sub = subRef || ref;
|
|
41
|
+
if (ref === _currentRef && sub === _currentSubRef) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
_currentRef = ref;
|
|
45
|
+
_currentSubRef = sub;
|
|
46
|
+
// Native updatePluginPageRef 的 JSDoc 要求 key 为 ref / subRef(驼峰);同时带 sub_ref(下划线)
|
|
47
|
+
// 兼容事件 schema。
|
|
48
|
+
// Android 的页面 view 由 Native 依据这里的 refParams 自主产出(见下文平台差异说明),
|
|
49
|
+
// 必须把 item_type 一并塞进来,否则 Native 生成的 view 事件缺 item_type(MIIO-130722)。
|
|
50
|
+
const refParams = {
|
|
51
|
+
ref,
|
|
52
|
+
subRef: sub,
|
|
53
|
+
sub_ref: sub,
|
|
54
|
+
item_type: 'ref'
|
|
55
|
+
};
|
|
56
|
+
// Android 的 view 机制强依赖此处携带的插件公参(见上方说明);iOS 无需。
|
|
57
|
+
if (Platform.OS === 'android' && _config) {
|
|
58
|
+
Object.assign(refParams, _config.getCommonParams());
|
|
59
|
+
}
|
|
60
|
+
Service.smarthome.updatePluginPageRef?.(refParams);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 核心上报方法
|
|
65
|
+
* 底层 SDK reportEventRefChannel 接受单个事件类型,
|
|
66
|
+
* 遍历 eventTypes 逐个上报,确保每条事件独立发送。
|
|
67
|
+
*/
|
|
68
|
+
export function reportTrack(eventTypes, params = {}) {
|
|
69
|
+
if (!_config) return;
|
|
70
|
+
const finalParams = {
|
|
71
|
+
ref: _currentRef,
|
|
72
|
+
sub_ref: _currentSubRef,
|
|
73
|
+
..._config.getCommonParams(),
|
|
74
|
+
...params
|
|
75
|
+
};
|
|
76
|
+
for (const eventType of eventTypes) {
|
|
77
|
+
if (_config.debug) {
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.log('[Track]', eventType, finalParams);
|
|
80
|
+
_debugLog.push({
|
|
81
|
+
time: Date.now(),
|
|
82
|
+
event: eventType,
|
|
83
|
+
params: {
|
|
84
|
+
...finalParams
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
if (_debugLog.length > 200) _debugLog.shift();
|
|
88
|
+
}
|
|
89
|
+
// 页面 view 的双端差异(与 stdcamera / 门铃 / miot.plugin.spec 一致):
|
|
90
|
+
// - Android:view 由 Native 内部机制依据 updatePluginPageRef(带公参) 自主产出、SDK 计算
|
|
91
|
+
// duration,**不能**经 reportEventRefChannel(平台侧不消费、且会与内部机制冲突)。此处跳过——
|
|
92
|
+
// Android view 已由 setPageRef 在每次页面切换时驱动。
|
|
93
|
+
// - iOS:view 必须显式经 reportEventRefChannel('view') 上报,SDK 使用 JS 计算的 duration。
|
|
94
|
+
if (eventType === 'view' && Platform.OS === 'android') {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
Service.smarthome.reportEventRefChannel?.(eventType, finalParams);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 调试用:获取最近上报记录 */
|
|
102
|
+
export function getTrackLog() {
|
|
103
|
+
return _debugLog;
|
|
104
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { initTrackService, setPageRef, reportTrack, getTrackLog, getCommonParams } from './TrackService';
|
|
2
|
+
export { usePageTrack } from './usePageTrack';
|
|
3
|
+
export { useCardTrack } from './useCardTrack';
|
|
4
|
+
export { useTrack } from './useTrack';
|
|
5
|
+
export { useExposeOnVisible } from './useExposeOnVisible';
|
|
6
|
+
export { TrackScrollView } from './TrackScrollView';
|
|
7
|
+
export { TrackFlatList } from './TrackFlatList';
|
|
8
|
+
export { useTrackScrollProvider } from './useTrackScrollProvider';
|
|
9
|
+
export { TrackScrollContext } from './TrackScrollContext';
|
|
10
|
+
export type { TrackScrollContextValue, ScrollListener, MeasurableNode } from './TrackScrollContext';
|
|
11
|
+
export type { TrackEventType, TrackItemType, TrackParams, TrackServiceConfig } from './types';
|
|
12
|
+
export type { Frame } from './intersect';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { initTrackService, setPageRef, reportTrack, getTrackLog, getCommonParams } from "./TrackService";
|
|
2
|
+
export { usePageTrack } from "./usePageTrack";
|
|
3
|
+
export { useCardTrack } from "./useCardTrack";
|
|
4
|
+
export { useTrack } from "./useTrack";
|
|
5
|
+
export { useExposeOnVisible } from "./useExposeOnVisible";
|
|
6
|
+
export { TrackScrollView } from "./TrackScrollView";
|
|
7
|
+
export { TrackFlatList } from "./TrackFlatList";
|
|
8
|
+
export { useTrackScrollProvider } from "./useTrackScrollProvider";
|
|
9
|
+
export { TrackScrollContext } from "./TrackScrollContext";
|
|
@@ -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,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
|
+
}
|