react-track-hooks 1.0.2 → 1.0.3

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/index.cjs.js CHANGED
@@ -11,15 +11,51 @@ exports.TrackType = void 0;
11
11
  TrackType["CUSTOM"] = "custom";
12
12
  })(exports.TrackType || (exports.TrackType = {}));
13
13
 
14
+ // 环境判断工具函数
15
+ const isClient = () => {
16
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
17
+ };
18
+ /**
19
+ * 安全设置 localStorage 数据
20
+ */
21
+ const safeLocalStorageSet = (key, data) => {
22
+ if (!isClient())
23
+ return;
24
+ try {
25
+ // 限制存储大小(示例:最大500KB)
26
+ const str = JSON.stringify(data);
27
+ if (str.length > 500 * 1024) {
28
+ console.warn('localStorage数据超过500KB,截断旧数据');
29
+ // 保留最新的100条失败埋点
30
+ if (key === 'failedTracks' && Array.isArray(data)) {
31
+ localStorage.setItem(key, JSON.stringify(data.slice(-100)));
32
+ return;
33
+ }
34
+ return;
35
+ }
36
+ localStorage.setItem(key, str);
37
+ }
38
+ catch (error) {
39
+ console.error('写入localStorage失败:', error);
40
+ }
41
+ };
42
+
14
43
  // src/trackHooks.ts
15
44
  // 全局配置(默认值),并提供修改全局配置的方法
16
45
  let GLOBAL_TRACK_CONFIG = {
17
46
  trackUrl: '/api/track', // 默认上报地址
47
+ batchTrackUrl: '/api/track/batch', // 批量上报地址
18
48
  enable: true,
49
+ enableBatch: true, // 是否开启批量上报
19
50
  retryConfig: {
20
51
  maxRetryTimes: 3,
21
52
  initialDelay: 1000,
22
53
  delayMultiplier: 2
54
+ },
55
+ // 批量上报配置
56
+ batchConfig: {
57
+ batchSize: 10, // 队列达到10条时触发上报
58
+ batchInterval: 5000, // 每5秒触发一次上报
23
59
  }
24
60
  };
25
61
  /**
@@ -35,77 +71,16 @@ const getMergedDefaultConfig = () => ({
35
71
  exposureThreshold: 0.5,
36
72
  ...GLOBAL_TRACK_CONFIG // 继承全局配置(trackUrl、enable、retryConfig)
37
73
  });
38
- // ---------------------- 埋点发送 & 重试核心逻辑 ----------------------
39
- const getFailedTracks = () => {
40
- try {
41
- return JSON.parse(localStorage.getItem('failedTracks') || '[]');
42
- }
43
- catch (error) {
44
- console.error('读取失败埋点数据失败:', error);
45
- return [];
46
- }
47
- };
48
- const saveFailedTracks = (tracks) => {
49
- try {
50
- localStorage.setItem('failedTracks', JSON.stringify(tracks));
51
- }
52
- catch (error) {
53
- console.error('保存失败埋点数据失败:', error);
54
- }
55
- };
56
- /**
57
- * 重试失败的埋点(使用全局配置的 trackUrl)
58
- */
59
- const retryFailedTracks = async (force = false) => {
60
- const failedTracks = getFailedTracks();
61
- if (failedTracks.length === 0)
62
- return;
63
- const { retryConfig, trackUrl } = GLOBAL_TRACK_CONFIG;
64
- const retryableTracks = failedTracks.filter(track => {
65
- const currentRetryCount = track.retryCount || 0;
66
- const timeCondition = force || Date.now() - track.retryTime >= retryConfig.initialDelay * Math.pow(retryConfig.delayMultiplier, currentRetryCount);
67
- return currentRetryCount < retryConfig.maxRetryTimes && timeCondition;
68
- });
69
- if (retryableTracks.length === 0)
70
- return;
71
- for (const track of retryableTracks) {
72
- try {
73
- // 使用全局配置的 trackUrl
74
- await fetch(trackUrl, {
75
- method: 'POST',
76
- headers: { 'Content-Type': 'application/json' },
77
- body: JSON.stringify({
78
- ...track,
79
- timestamp: Date.now(),
80
- userAgent: navigator.userAgent,
81
- url: window.location.href,
82
- referrer: document.referrer,
83
- retryCount: (track.retryCount || 0) + 1
84
- })
85
- });
86
- console.log('埋点重试成功:', track.eventName);
87
- const index = failedTracks.findIndex(t => t.retryTime === track.retryTime && t.eventName === track.eventName);
88
- if (index > -1)
89
- failedTracks.splice(index, 1);
90
- }
91
- catch (error) {
92
- console.error('埋点重试失败:', track.eventName, error);
93
- const index = failedTracks.findIndex(t => t.retryTime === track.retryTime && t.eventName === track.eventName);
94
- if (index > -1) {
95
- failedTracks[index].retryCount = (failedTracks[index].retryCount || 0) + 1;
96
- failedTracks[index].retryTime = Date.now();
97
- }
98
- }
99
- }
100
- saveFailedTracks(failedTracks);
101
- };
102
74
  /**
103
75
  * 通用埋点发送函数(支持单个 Hook 覆盖 trackUrl)
104
76
  * @param params 埋点参数
105
77
  * @param config 单个 Hook 的配置(可覆盖 trackUrl)
106
78
  */
107
79
  const sendTrack = async (params, config) => {
108
- var _a;
80
+ var _a, _b, _c, _d, _e;
81
+ // 服务端不执行
82
+ if (!isClient())
83
+ return;
109
84
  if (!params.eventName) {
110
85
  console.warn('埋点缺少必要参数:eventName');
111
86
  return;
@@ -115,9 +90,20 @@ const sendTrack = async (params, config) => {
115
90
  const isEnable = (_a = config.enable) !== null && _a !== void 0 ? _a : GLOBAL_TRACK_CONFIG.enable;
116
91
  if (!isEnable)
117
92
  return;
93
+ // 判断批量模式(单个Hook配置 > 全局配置)
94
+ const enableBatch = (_c = (_b = config === null || config === void 0 ? void 0 : config.enableBatch) !== null && _b !== void 0 ? _b : GLOBAL_TRACK_CONFIG === null || GLOBAL_TRACK_CONFIG === void 0 ? void 0 : GLOBAL_TRACK_CONFIG.enableBatch) !== null && _c !== void 0 ? _c : true;
95
+ if (enableBatch) {
96
+ // 批量模式:入队 + 触发调度
97
+ BATCH_TRACK_QUEUE.push(params);
98
+ initBatchTimer(config);
99
+ if (BATCH_TRACK_QUEUE.length >= ((_e = (_d = GLOBAL_TRACK_CONFIG.batchConfig) === null || _d === void 0 ? void 0 : _d.batchSize) !== null && _e !== void 0 ? _e : 10)) {
100
+ processBatchQueue(config);
101
+ }
102
+ return;
103
+ }
118
104
  try {
119
105
  // 使用最终确定的 trackUrl 发送请求
120
- await fetch(finalTrackUrl, {
106
+ const response = await fetch(finalTrackUrl, {
121
107
  method: 'POST',
122
108
  headers: { 'Content-Type': 'application/json' },
123
109
  body: JSON.stringify({
@@ -128,6 +114,10 @@ const sendTrack = async (params, config) => {
128
114
  referrer: document.referrer
129
115
  })
130
116
  });
117
+ if (!response.ok) {
118
+ // 抛出包含状态码的异常,便于调试
119
+ throw new Error(`HTTP 请求失败,状态码:${response.status}`);
120
+ }
131
121
  console.log('埋点上报成功:', params);
132
122
  if (window.requestIdleCallback) {
133
123
  window.requestIdleCallback(() => retryFailedTracks(), { timeout: 2000 });
@@ -139,7 +129,12 @@ const sendTrack = async (params, config) => {
139
129
  catch (error) {
140
130
  console.error('埋点上报失败:', error);
141
131
  const failedTracks = getFailedTracks();
142
- failedTracks.push({ ...params, retryTime: Date.now(), retryCount: 0 });
132
+ failedTracks.push({
133
+ id: `${params.eventName}_${Date.now()}_${Math.random().toString(36).slice(2)}`,
134
+ ...params,
135
+ retryTime: Date.now(),
136
+ retryCount: 0
137
+ });
143
138
  saveFailedTracks(failedTracks);
144
139
  }
145
140
  };
@@ -233,34 +228,353 @@ const useTrackCustom = (eventName, customParams = {}, config = {}) => {
233
228
  const { triggerTrack } = useTrack({ eventName, type: exports.TrackType.CUSTOM, ...customParams }, config);
234
229
  return triggerTrack;
235
230
  };
231
+ // ---------------------- 重试核心逻辑 ----------------------
232
+ // 重试互斥锁,防止同时触发多次重试
233
+ let isRetryRunning = false;
234
+ const getFailedTracks = () => {
235
+ if (!isClient())
236
+ return [];
237
+ try {
238
+ const rawData = localStorage.getItem('failedTracks');
239
+ // 2. 空值直接返回空数组
240
+ if (!rawData)
241
+ return [];
242
+ const parsedData = JSON.parse(rawData);
243
+ // 3. 解析后不是数组 → 返回空数组
244
+ if (!Array.isArray(parsedData)) {
245
+ console.warn('failedTracks 存储的数据不是数组,已重置为空数组');
246
+ localStorage.setItem('failedTracks', '[]'); // 重置错误数据
247
+ return [];
248
+ }
249
+ return parsedData;
250
+ }
251
+ catch (error) {
252
+ console.error('读取失败埋点数据失败:', error);
253
+ return [];
254
+ }
255
+ };
256
+ const saveFailedTracks = (tracks) => {
257
+ if (!isClient())
258
+ return;
259
+ try {
260
+ safeLocalStorageSet('failedTracks', tracks);
261
+ }
262
+ catch (error) {
263
+ console.error('保存失败埋点数据失败:', error);
264
+ }
265
+ };
266
+ /**
267
+ * 重试失败的埋点
268
+ */
269
+ const retryFailedTracks = async (force = false) => {
270
+ if (!isClient())
271
+ return;
272
+ if (isRetryRunning) {
273
+ console.debug('已有重试流程在执行,跳过本次触发');
274
+ return;
275
+ }
276
+ isRetryRunning = true;
277
+ try {
278
+ let failedTracks = getFailedTracks();
279
+ if (failedTracks.length === 0)
280
+ return;
281
+ const { retryConfig = { maxRetryTimes: 3, initialDelay: 1000, delayMultiplier: 2 }, trackUrl, batchTrackUrl = trackUrl, // 批量接口默认使用单条接口
282
+ enableBatch = false } = GLOBAL_TRACK_CONFIG;
283
+ // 移除重复的默认值处理(前面已给retryConfig设默认值)
284
+ const maxRetryTimes = retryConfig.maxRetryTimes;
285
+ // 第一步:清理超过最大重试次数的埋点
286
+ const [expiredTracks, validTracks] = failedTracks.reduce((acc, track) => {
287
+ const currentRetryCount = track.retryCount || 0;
288
+ if (currentRetryCount >= maxRetryTimes) {
289
+ acc[0].push(track);
290
+ }
291
+ else {
292
+ acc[1].push(track);
293
+ }
294
+ return acc;
295
+ }, [[], []]);
296
+ failedTracks = validTracks;
297
+ // 第二步:筛选可重试的有效埋点 指数退避算法
298
+ const retryableTracks = failedTracks.filter(track => {
299
+ const currentRetryCount = track.retryCount || 0;
300
+ const backoffTime = retryConfig.initialDelay * Math.pow(retryConfig.delayMultiplier, currentRetryCount);
301
+ const timeCondition = force || Date.now() - track.retryTime >= backoffTime;
302
+ return timeCondition;
303
+ });
304
+ if (retryableTracks.length === 0)
305
+ return;
306
+ // 第三步:执行重试逻辑
307
+ if (enableBatch) { // 批量上报
308
+ const batchRetryTracks = retryableTracks.map(track => ({
309
+ ...track,
310
+ timestamp: Date.now(),
311
+ userAgent: navigator.userAgent,
312
+ url: window.location.href,
313
+ referrer: document.referrer,
314
+ retryCount: (track.retryCount || 0) + 1
315
+ }));
316
+ try {
317
+ const response = await fetch(batchTrackUrl, {
318
+ method: 'POST',
319
+ headers: { 'Content-Type': 'application/json' },
320
+ body: JSON.stringify(batchRetryTracks)
321
+ });
322
+ // 检查接口响应状态
323
+ if (!response.ok) {
324
+ throw new Error(`批量接口返回异常:${response.status} ${response.statusText}`);
325
+ }
326
+ console.log('批量埋点重试成功:', batchRetryTracks.length, '条');
327
+ // 过滤掉重试成功的埋点
328
+ failedTracks = failedTracks.filter(track => !retryableTracks.some(item => item.id === track.id));
329
+ }
330
+ catch (error) {
331
+ console.error('批量埋点重试失败:', error);
332
+ // 批量失败时更新重试次数和时间
333
+ retryableTracks.forEach(track => {
334
+ const index = failedTracks.findIndex(t => t.id === track.id);
335
+ if (index > -1) {
336
+ failedTracks[index].retryCount = (failedTracks[index].retryCount || 0) + 1;
337
+ failedTracks[index].retryTime = Date.now();
338
+ }
339
+ });
340
+ }
341
+ }
342
+ else { // 单条上报
343
+ for (const track of retryableTracks) {
344
+ try {
345
+ const response = await fetch(trackUrl, {
346
+ method: 'POST',
347
+ headers: { 'Content-Type': 'application/json' },
348
+ body: JSON.stringify({
349
+ ...track,
350
+ timestamp: Date.now(),
351
+ userAgent: navigator.userAgent,
352
+ url: window.location.href,
353
+ referrer: document.referrer,
354
+ retryCount: (track.retryCount || 0) + 1
355
+ })
356
+ });
357
+ // 检查单条接口响应状态
358
+ if (!response.ok) {
359
+ throw new Error(`单条接口返回异常:${response.status} ${response.statusText}`);
360
+ }
361
+ console.log('埋点重试成功:', track.eventName, '(ID:', track.id, ')');
362
+ const index = failedTracks.findIndex(t => t.id === track.id);
363
+ if (index > -1)
364
+ failedTracks.splice(index, 1);
365
+ }
366
+ catch (error) {
367
+ console.error('埋点重试失败:', track.eventName, '(ID:', track.id, ')', error);
368
+ const index = failedTracks.findIndex(t => t.id === track.id);
369
+ if (index > -1) {
370
+ failedTracks[index].retryCount = (failedTracks[index].retryCount || 0) + 1;
371
+ failedTracks[index].retryTime = Date.now();
372
+ }
373
+ }
374
+ }
375
+ }
376
+ // 第四步:保存最终的失败队列
377
+ saveFailedTracks(failedTracks);
378
+ }
379
+ catch (error) {
380
+ console.error('重试流程整体异常:', error);
381
+ }
382
+ finally {
383
+ isRetryRunning = false;
384
+ }
385
+ };
386
+ // 单例锁(全局变量)
387
+ let isRetryListenerRegistered = false;
236
388
  const useTrackRetryListener = () => {
237
389
  react.useEffect(() => {
390
+ // 单例拦截:已注册过则直接返回
391
+ if (isRetryListenerRegistered)
392
+ return;
393
+ isRetryListenerRegistered = true;
394
+ // 1.首屏渲染3秒后进行重试
238
395
  const initTimer = setTimeout(() => retryFailedTracks(), 3000);
396
+ // 2.页面切换监听 不可见->可见 进行重试
239
397
  const handleVisibilityChange = () => {
240
398
  if (!document.hidden) {
241
399
  retryFailedTracks();
242
400
  }
243
401
  };
244
402
  document.addEventListener('visibilitychange', handleVisibilityChange);
245
- let idleCallbackId = null;
403
+ // 3.周期性进行浏览器空闲重试,最迟30秒也会执行一次
404
+ let idleCallbackId = null; // 空闲执行id
405
+ let intervalId = null; // 定时器id
246
406
  const checkIdleRetry = () => {
407
+ // 先检查失败队列是否为空
408
+ const failedTracks = getFailedTracks();
409
+ if (failedTracks.length === 0) {
410
+ // 队列为空时,延迟1分钟再预约(减少空跑)
411
+ setTimeout(() => {
412
+ if (window.requestIdleCallback) {
413
+ idleCallbackId = window.requestIdleCallback(checkIdleRetry, { timeout: 30000 });
414
+ }
415
+ }, 60000);
416
+ return;
417
+ }
418
+ // 进行失败重试
247
419
  retryFailedTracks();
248
- idleCallbackId = window.requestIdleCallback(checkIdleRetry, { timeout: 30000 });
420
+ // 注册下一次的空闲回调
421
+ if (window.requestIdleCallback) {
422
+ idleCallbackId = window.requestIdleCallback(checkIdleRetry, { timeout: 30000 });
423
+ }
249
424
  };
425
+ // 启用首次空闲回调
250
426
  if (window.requestIdleCallback) {
251
427
  idleCallbackId = window.requestIdleCallback(checkIdleRetry, { timeout: 30000 });
252
428
  }
429
+ else { // 防止浏览器不支持requestIdleCallback,兜底。每30秒执行一次
430
+ intervalId = window.setInterval(retryFailedTracks, 30000);
431
+ }
253
432
  return () => {
254
- clearTimeout(initTimer);
433
+ // 清理首屏定时器
434
+ if (initTimer)
435
+ clearTimeout(initTimer);
436
+ // 清理监听
255
437
  document.removeEventListener('visibilitychange', handleVisibilityChange);
438
+ // 清理空闲/周期重试
439
+ if (intervalId)
440
+ window.clearInterval(intervalId);
256
441
  if (idleCallbackId !== null && window.cancelIdleCallback) {
257
442
  window.cancelIdleCallback(idleCallbackId);
258
443
  }
259
444
  };
260
445
  }, []);
261
446
  };
447
+ // ---------------------- 批量上报核心逻辑 ----------------------
448
+ // 内存中的批量上报队列
449
+ let BATCH_TRACK_QUEUE = [];
450
+ // 批量上报定时器
451
+ let BATCH_TIMER = null;
452
+ // 防止并发上报的锁
453
+ let isBatchUploading = false;
454
+ /**
455
+ * 批量发送埋点函数
456
+ * @param tracks 埋点数组
457
+ * @param config 配置项
458
+ */
459
+ const sendBatchTrack = async (tracks, config) => {
460
+ var _a;
461
+ if (tracks.length === 0)
462
+ return;
463
+ // 优先级:单个 Hook 配置 > 全局配置
464
+ const finalBatchUrl = config.batchTrackUrl || GLOBAL_TRACK_CONFIG.batchTrackUrl || GLOBAL_TRACK_CONFIG.trackUrl;
465
+ const isEnable = (_a = config.enable) !== null && _a !== void 0 ? _a : GLOBAL_TRACK_CONFIG.enable;
466
+ if (!isEnable)
467
+ return;
468
+ try {
469
+ // 补充公共参数
470
+ const tracksWithCommonParams = tracks.map(track => ({
471
+ ...track,
472
+ timestamp: Date.now(),
473
+ userAgent: navigator.userAgent,
474
+ url: window.location.href,
475
+ referrer: document.referrer
476
+ }));
477
+ // 发送批量请求
478
+ const response = await fetch(finalBatchUrl, {
479
+ method: 'POST',
480
+ headers: { 'Content-Type': 'application/json' },
481
+ body: JSON.stringify({ tracks: tracksWithCommonParams }) // 批量上报格式:{ tracks: [...] }
482
+ });
483
+ if (!response.ok) {
484
+ // 抛出包含状态码的异常
485
+ throw new Error(`HTTP 请求失败,状态码:${response.status}`);
486
+ }
487
+ console.log(`批量上报成功,共${tracks.length}条`, tracks);
488
+ // 批量上报成功后,尝试重试失败的埋点
489
+ if (window.requestIdleCallback) {
490
+ window.requestIdleCallback(() => retryFailedTracks(), { timeout: 2000 });
491
+ }
492
+ else {
493
+ setTimeout(() => retryFailedTracks(), 1000);
494
+ }
495
+ return true;
496
+ }
497
+ catch (error) {
498
+ return false;
499
+ }
500
+ };
501
+ /**
502
+ * 处理批量队列(核心调度逻辑)
503
+ */
504
+ const processBatchQueue = async (config) => {
505
+ // 1. 锁兜底:防止极端并发
506
+ if (isBatchUploading)
507
+ return;
508
+ isBatchUploading = true;
509
+ // 2. 同步备份+清空队列
510
+ // 浅拷贝备份当前队列数据
511
+ const tracksToUpload = [...BATCH_TRACK_QUEUE];
512
+ // 立即清空队列,新数据会入新队列,不会被重复处理
513
+ BATCH_TRACK_QUEUE = [];
514
+ // 3. 空队列直接释放锁返回
515
+ if (tracksToUpload.length === 0) {
516
+ isBatchUploading = false;
517
+ return;
518
+ }
519
+ try {
520
+ // 4. 执行批量上报
521
+ const success = await sendBatchTrack(tracksToUpload, config);
522
+ // 5. 上报失败:拆分存入失败队列
523
+ if (!success) {
524
+ console.warn(`批量上报失败,${tracksToUpload.length}条数据转入失败队列`);
525
+ const failedTracks = getFailedTracks();
526
+ const newFailedTracks = tracksToUpload.map(track => ({
527
+ id: `${track.eventName}_${Date.now()}_${Math.random().toString(36).slice(2)}`,
528
+ ...track,
529
+ retryTime: Date.now(),
530
+ retryCount: 0
531
+ }));
532
+ // 合并失败队列并保存(限制大小,避免localStorage溢出)
533
+ failedTracks.push(...newFailedTracks);
534
+ saveFailedTracks(failedTracks.slice(-100)); // 只保留最新100条
535
+ }
536
+ }
537
+ catch (error) {
538
+ // 捕获未知异常:同样存入失败队列
539
+ console.error('批量上报异常:', error);
540
+ const failedTracks = getFailedTracks();
541
+ const newFailedTracks = tracksToUpload.map(track => ({
542
+ id: `${track.eventName}_${Date.now()}_${Math.random().toString(36).slice(2)}`,
543
+ ...track,
544
+ retryTime: Date.now(),
545
+ retryCount: 0
546
+ }));
547
+ failedTracks.push(...newFailedTracks);
548
+ saveFailedTracks(failedTracks.slice(-100));
549
+ }
550
+ finally {
551
+ // 6. 释放锁(无论成功/失败/异常)
552
+ isBatchUploading = false;
553
+ // 7. 检查是否有新数据入队,有则触发下一次上报
554
+ if (BATCH_TRACK_QUEUE.length > 0) {
555
+ setTimeout(() => processBatchQueue(config), 100);
556
+ }
557
+ }
558
+ };
559
+ /**
560
+ * 初始化批量上报定时器
561
+ */
562
+ const initBatchTimer = (config) => {
563
+ // 清除旧定时器
564
+ if (BATCH_TIMER) {
565
+ clearTimeout(BATCH_TIMER);
566
+ }
567
+ const { batchConfig } = GLOBAL_TRACK_CONFIG;
568
+ if (!(GLOBAL_TRACK_CONFIG === null || GLOBAL_TRACK_CONFIG === void 0 ? void 0 : GLOBAL_TRACK_CONFIG.enableBatch))
569
+ return;
570
+ // 设置新定时器
571
+ BATCH_TIMER = setTimeout(() => {
572
+ processBatchQueue(config);
573
+ }, (batchConfig === null || batchConfig === void 0 ? void 0 : batchConfig.batchInterval) || 5000);
574
+ };
262
575
 
263
576
  exports.getFailedTracks = getFailedTracks;
577
+ exports.getMergedDefaultConfig = getMergedDefaultConfig;
264
578
  exports.retryFailedTracks = retryFailedTracks;
265
579
  exports.saveFailedTracks = saveFailedTracks;
266
580
  exports.setTrackGlobalConfig = setTrackGlobalConfig;