需求

有个前端推广落地页的单页面需要监控用户的访问速度、加载速度、DNS解析速度以及用户点击等各种状况。一方面给前端提供改进的参考依据,另一方面可以监控用户行为路径。找了各种代码和平台,要么收费,要么太过臃肿提供大量不需要的分析而且自定义跟踪用户路径也特别复杂。决定简单手搓一个满足自己业务需要的前端JS再把资料回传后端以备分析。

实现过程

  1. 功能实现
  • 设备信息采集:用户代理、操作系统、设备类型
  • 网络信息采集:连接类型、IP地址(通过WebRTC,也可以结合服务端获取)
  • 环境信息采集:语言、屏幕尺寸、视窗尺寸
  • 性能指标采集:DNS查询、TCP连接、首字节时间等
  • 用户行为跟踪:按钮点击事件
  • 数据上报机制:优先使用sendBeacon,降级使用fetch
  • 错误屏蔽机制:遇到任何错误完全静默处理,不在客户端或者控制台有任何输出
  1. 隐私相关处理
  • WebRTC获取IP地址存在隐私争议(需用户授权)
  • 欧盟地区需要GDPR合规声明
  • 需要增加数据过滤机制(去除PII信息)
  1. 需要改进部分
  • 跨域处理:服务器需配置CORS(Access-Control-Allow-Origin)
  • 性能优化:减少同步API调用,优化PerformanceObserver使用
  • 错误处理:增加重试机制和采样率控制
  • 数据安全:建议启用HTTPS传输

完整代码(包含中文注释)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
// 立即执行的异步函数,避免污染全局作用域
(async function () {
    'use strict'; // 启用严格模式

    // 上报地址(示例地址,实际使用请替换)
    const TARGET_URL = 'https://your-analytics-service.com/api/collect';
    
    // 初始化数据收集对象
    const collectedData = {
        uid: 'UNIQUE_USER_ID',      // 应替换为实际用户标识,后续通过 collectedData.uid赋值
        app: 'landed',             // 应用标识 根据需要自己定义
        inprogress: 'loaded',       // 初始状态 根据需要自己定义
        sdkVersion: '1.1.0-post'     // SDK版本 根据需要自己定义
    };

    /**
     * 安全获取对象属性(防崩溃)
     * @param {Object} obj - 目标对象
     * @param {string} path - 属性路径(如 'a.b.c')
     * @param {*} defaultValue - 默认返回值
     */
    function safeGet(obj, path, defaultValue = 'N/A') {
        try {
            return path.split('.').reduce((acc, part) => 
                (acc && acc[part]) ? acc[part] : defaultValue, 
                obj
            ) || defaultValue;
        } catch (e) {
            return defaultValue;
        }
    }

    // 异步操作集合
    const dataPromises = [];

    try {
        // 基础环境信息
        collectedData.timestamp = new Date().toISOString();
        collectedData.domain = safeGet(window, 'location.hostname');
        collectedData.userAgent = safeGet(navigator, 'userAgent');

        // 设备类型判断
        function getDeviceType(ua) {
            if (!ua || ua === 'N/A') return 'N/A';
            // 平板设备正则匹配
            const isTablet = /(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua);
            // 移动设备正则匹配
            const isMobile = /(mobi|iphone|ipod|blackberry|opera mini|fennec|windows phone|iemobile|psp|symbian|kindle|crios|fxos)/i.test(ua);
            return isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
        }
        collectedData.deviceType = getDeviceType(collectedData.userAgent);

        // 操作系统判断
        function getOS(ua) {
            if (!ua || ua === 'N/A') return 'N/A';
            const osMap = [
                [/windows nt 10\.0/i, 'Windows 10/11'],
                [/windows nt 6\.3/i, 'Windows 8.1'],
                [/mac os x/i, 'Mac OS X'],
                [/android/i, 'Android'],
                [/iphone|ipad|ipod/i, 'iOS'],
                [/linux/i, 'Linux']
            ];
            return osMap.find(([regex]) => regex.test(ua))?.[1] || 'Unknown';
        }
        collectedData.os = getOS(collectedData.userAgent);

        // 网络连接类型检测
        function getConnectionType() {
            if (!navigator.connection) return 'N/A_conn_api_unavailable';
            const conn = navigator.connection;
            let type = '';
            type += conn.effectiveType ? `${conn.effectiveType}` : '';
            type += conn.type ? `_${conn.type}` : '';
            type += conn.downlink ? `_${conn.downlink}Mbps` : '';
            type += conn.rtt ? `_${conn.rtt}ms` : '';
            return type || 'unknown';
        }
        collectedData.connectionType = getConnectionType();

        // 其他环境参数
        collectedData.language = safeGet(navigator, 'language');
        collectedData.referrer = document.referrer || 'direct_access';
        collectedData.screenWidth = safeGet(window, 'screen.width');
        collectedData.screenHeight = safeGet(window, 'screen.height');
        collectedData.viewportWidth = Math.max(
            window.innerWidth,
            safeGet(document.documentElement, 'clientWidth'),
            safeGet(document.body, 'clientWidth')
        );
        collectedData.viewportHeight = Math.max(
            window.innerHeight,
            safeGet(document.documentElement, 'clientHeight'),
            safeGet(document.body, 'clientHeight')
        );

        // WebRTC IP检测(需注意隐私合规)
        const ipPromise = new Promise((resolve) => {
            try {
                const pc = new RTCPeerConnection({
                    iceServers: [
                        { urls: 'stun:stun.l.google.com:19302' }
                    ],
                    iceCandidatePoolSize: 0 // 减少候选收集
                });

                // 创建数据通道触发候选收集
                pc.createDataChannel('');
                pc.createOffer()
                    .then(offer => pc.setLocalDescription(offer))
                    .catch(() => resolve('N/A_webrtc_offer_failed'));

                // 超时处理
                const timeoutId = setTimeout(() => {
                    pc.close();
                    resolve('N/A_webrtc_timeout');
                }, 2000);

                // ICE候选处理
                pc.onicecandidate = (ice) => {
                    if (!ice.candidate) {
                        clearTimeout(timeoutId);
                        pc.close();
                        return;
                    }
                    const ipRegex = /([a-f0-9:]+(%\w+)?|\d{1,3}(\.\d{1,3}){3})/i;
                    const match = ice.candidate.candidate.match(ipRegex);
                    if (match && ice.candidate.type === 'srflx') {
                        clearTimeout(timeoutId);
                        resolve(match[1]);
                        pc.close();
                    }
                };
            } catch (e) {
                resolve('N/A_webrtc_error');
            }
        }).then(ip => {
            collectedData.ipAddress = ip || 'N/A_webrtc_no_ip';
        });
        dataPromises.push(ipPromise);

    } catch (error) {
        // 错误处理(保持数据结构完整)
        ['domain', 'userAgent', 'deviceType', 'os', 'connectionType', 'language']
            .forEach(field => collectedData[field] = 'N/A_init_error');
    }

    // 性能数据采集
    const performancePromise = new Promise(resolve => {
        function collectPerf() {
            try {
                const perf = window.performance;
                if (!perf) return;

                // 使用更新的PerformanceNavigationTiming API
                const navEntry = perf.getEntriesByType('navigation')[0];
                if (navEntry) {
                    collectedData.dnsLookupTime = navEntry.domainLookupEnd - navEntry.domainLookupStart;
                    collectedData.tcpConnectTime = navEntry.connectEnd - navEntry.connectStart;
                    collectedData.ttfb = navEntry.responseStart - navEntry.requestStart;
                    collectedData.domContentLoaded = navEntry.domContentLoadedEventEnd;
                }

                // 首屏绘制指标
                if (window.PerformanceObserver) {
                    const paintObserver = new PerformanceObserver(list => {
                        list.getEntries().forEach(entry => {
                            if (entry.name === 'first-paint') {
                                collectedData.firstPaint = entry.startTime;
                            }
                            if (entry.name === 'first-contentful-paint') {
                                collectedData.FCP = entry.startTime;
                            }
                        });
                    });
                    paintObserver.observe({ entryTypes: ['paint'] });
                }

                // 资源加载统计
                const resources = perf.getEntriesByType('resource');
                if (resources) {
                    collectedData.totalResources = resources.length;
                    collectedData.resourceLoadTime = resources.reduce(
                        (sum, r) => sum + r.duration, 0
                    );
                }
            } catch (e) {
                console.error('Performance collection error:', e);
            }
        }

        // 加载完成后收集
        if (document.readyState === 'complete') {
            collectPerf();
            resolve();
        } else {
            window.addEventListener('load', () => {
                collectPerf();
                resolve();
            });
        }
    });
    dataPromises.push(performancePromise);

    // 最终数据上报
    Promise.allSettled(dataPromises).finally(() => {
        sendData();
    });

    function sendData() {
        // 数据序列化
        const reportData = JSON.stringify(collectedData);
        
        // Beacon API优先
        const sendSuccess = navigator.sendBeacon(
            TARGET_URL,
            new Blob([reportData], { type: 'application/json' })
        );

        // 失败降级到fetch
        if (!sendSuccess) {
            fetch(TARGET_URL, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: reportData,
                keepalive: true,  // 允许在页面卸载后发送
                mode: 'no-cors'   // 需要服务端支持CORS
            }).catch(e => console.error('Report failed:', e));
        }
    }

    // 按钮点击跟踪,注意替换为自己的按钮ID
    const trackButton = document.getElementById('myCustomButton');
    if (trackButton) {
        trackButton.addEventListener('click', () => {
            collectedData.inprogress = 'clicked';
            sendData();
        });
    }
})();

使用说明

  1. 功能说明:

    • 采集维度:设备信息、网络环境、性能指标、用户交互
    • 数据上报:页面加载时自动发送,按钮点击时触发增量上报
    • 兼容处理:降级策略支持到IE9+
  2. 部署要求:

    • 服务端需配置CORS: shell Access-Control-Allow-Origin: * Access-Control-Allow-Methods: POST Access-Control-Allow-Headers: Content-Type

    • 建议启用HTTPS

    • 数据处理建议:

      • 敏感字段加密(如IP地址)
      • 设置数据保留策略
      • 实现请求频率限制(采样就好)
  3. 改进建议:

    a. 隐私合规:

    • 增加用户授权机制(特别是IP和位置信息)
    • 提供opt-out选项
    • 数据匿名化处理(使用hash替代真实UID)

    b. 性能优化:

    • 添加采样率控制:
    1
    2
    
        const sampleRate = 0.1; // 10%采样率
        if (Math.random() < sampleRate) sendData();
    
    • 使用Web Worker处理复杂计算
    • 压缩上报数据(使用gzip)

    c. 错误增强(个人认为没有必要,这种采集在数据准确性上一般不是什么十分的可靠场景):

    • 增加重试机制: javascript function sendWithRetry(data, retries = 2) { return fetch(TARGET_URL, { /* config */ }) .catch(e => retries > 0 ? sendWithRetry(data, retries - 1) : Promise.reject(e)); }
    • 添加本地存储缓存(应对网络不可用)

    d. 安全增强:

    • 请求签名防伪造
    • 时效性验证(timestamp校验)
    • 实施速率限制(防DDoS)
    • 服务端只做日志记录,异步分析

    e. 可观测性:

    • 添加SDK自身监控
    • 收集客户端错误日志
    • 版本兼容性检查
  4. 注意事项:

    • 避免收集敏感个人信息(PII)
    • 欧盟地区需要GDPR合规审查
    • 移动端注意流量消耗问题(问题极小)
    • 建议在Web Worker中执行性能监控
  5. 完整改进建议清单:

    a. 服务端配置CORS头 b. 数据加密传输(TLS 1.2+) c. 实现请求签名机制 d. 添加用户授权流程 e. 增加数据过滤层(移除敏感字段) f. 优化WebRTC检测的可靠性 g. 添加SDK错误监控 h. 实施自动重试机制 i. 添加版本兼容性检查 j. 服务端只做日志记录,异步分析