前端用户行为追踪的那些坑我替你踩过了

UX艳蕾 前端 阅读 2,283
赞 15 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个数据可视化项目,客户需要实时监控用户的操作行为,包括页面停留时间、点击热力图、滚动轨迹这些。说实话刚开始接到需求的时候有点懵,之前做的项目都是简单埋点上报,这次要求的颗粒度细太多了。

前端用户行为追踪的那些坑我替你踩过了

一开始想着直接用现成的统计工具比如百度统计或者Google Analytics,但仔细一想不行,客户的隐私政策比较严格,所有数据都得自己存自己处理,而且还要支持实时分析。没办法,只能自己搞一套用户行为采集系统。

技术选型这块折腾了好几天,最后选择了基于MutationObserver监听DOM变化,配合事件委托捕获用户交互,再通过WebSocket实时推送到后端。说实话这套方案当时觉得挺复杂,但现在回过头看还行吧,至少能满足需求。

核心实现代码

整个系统的核心就是行为采集模块,这部分写了不少代码,主要分三个部分:DOM变动监听、用户交互捕获、数据格式化上传。

class UserBehaviorTracker {
    constructor() {
        this.events = [];
        this.pageStartTime = Date.now();
        this.init();
    }

    init() {
        // 监听DOM变动
        this.observeDOM();
        // 捕获用户交互
        this.captureEvents();
        // 定时上报
        setInterval(() => {
            this.reportEvents();
        }, 3000);
    }

    observeDOM() {
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.type === 'childList' || mutation.type === 'attributes') {
                    this.recordEvent({
                        type: 'dom_change',
                        element: mutation.target.tagName,
                        action: mutation.type,
                        timestamp: Date.now()
                    });
                }
            });
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true,
            attributes: true
        });
    }

    captureEvents() {
        const events = ['click', 'scroll', 'input', 'focus', 'blur'];
        
        events.forEach(eventType => {
            document.addEventListener(eventType, (e) => {
                this.handleUserEvent(e, eventType);
            }, true);
        });
    }

    handleUserEvent(event, eventType) {
        let targetInfo = {};
        
        if (event.target) {
            targetInfo = {
                tagName: event.target.tagName,
                className: event.target.className,
                id: event.target.id,
                textContent: event.target.textContent?.substring(0, 50),
                rect: event.target.getBoundingClientRect()
            };
        }

        this.recordEvent({
            type: eventType,
            target: targetInfo,
            x: event.clientX || 0,
            y: event.clientY || 0,
            timestamp: Date.now()
        });
    }

    recordEvent(eventData) {
        this.events.push({
            ...eventData,
            pageUrl: window.location.href,
            sessionId: this.getSessionId()
        });

        // 防止数组过大
        if (this.events.length > 100) {
            this.reportEvents();
        }
    }

    getSessionId() {
        if (!localStorage.getItem('session_id')) {
            const sessionId = Date.now().toString(36) + Math.random().toString(36).substr(2);
            localStorage.setItem('session_id', sessionId);
        }
        return localStorage.getItem('session_id');
    }

    async reportEvents() {
        if (this.events.length === 0) return;

        try {
            await fetch('https://jztheme.com/api/user-behavior', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    events: this.events,
                    pageStayTime: Date.now() - this.pageStartTime,
                    userAgent: navigator.userAgent
                })
            });

            this.events = []; // 清空已上报的事件
        } catch (error) {
            console.error('上报用户行为失败:', error);
            // 失败时暂存本地,下次重试
            localStorage.setItem('pending_events', JSON.stringify(this.events));
        }
    }
}

// 初始化
new UserBehaviorTracker();

最大的坑:性能问题

开始测试的时候发现页面卡得要命,特别是复杂的管理后台页面,每秒能产生几百个DOM变动事件,CPU直接干到100%。这个问题折腾了我好几天才找到原因,主要是MutationObserver的回调函数执行太频繁了。

解决方法是加了个防抖机制,在观察器回调里用setTimeout限制触发频率:

observeDOM() {
    let debounceTimer = null;
    
    const observer = new MutationObserver((mutations) => {
        // 防抖处理
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(() => {
            mutations.forEach((mutation) => {
                if (mutation.type === 'childList' || mutation.type === 'attributes') {
                    this.recordEvent({
                        type: 'dom_change',
                        element: mutation.target.tagName,
                        action: mutation.type,
                        timestamp: Date.now()
                    });
                }
            });
        }, 100); // 100ms内最多执行一次
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true
    });
}

但这样又引入了新的问题:有些快速的DOM变动会被漏掉。后来改成记录变动次数而不是具体细节,减少了内存占用,页面终于能正常跑了。

数据存储和后端处理

前端数据采集完成后,后端需要用Node.js+MongoDB来接收和存储。考虑到数据量可能很大,设计了分表策略,按天切割数据表,避免单表过大影响查询效率。

// 后端接收接口示例
app.post('/api/user-behavior', async (req, res) => {
    try {
        const { events, pageStayTime, userAgent } = req.body;
        const collectionName = behavior_${formatDate(new Date())};
        
        const db = client.db('user_behavior');
        await db.collection(collectionName).insertMany(events.map(event => ({
            ...event,
            pageStayTime,
            userAgent,
            createdAt: new Date()
        })));
        
        res.json({ success: true });
    } catch (error) {
        console.error('保存用户行为数据失败:', error);
        res.status(500).json({ error: '服务器内部错误' });
    }
});

function formatDate(date) {
    return date.toISOString().split('T')[0].replace(/-/g, '');
}

数据库索引也得合理设计,按session_id和timestamp建了复合索引,查询单次会话的行为轨迹会快不少。

隐私合规考虑

做这个功能的时候还得考虑GDPR这些隐私法规,敏感信息不能随便收集。所以对输入框内容只记录长度不记录具体内容,密码框直接过滤掉,IP地址也做了脱敏处理。

用户退出登录的时候会清除本地的session_id,重新登录生成新的会话标识。还有个用户授权开关,在用户同意后再启动行为追踪,这个在实际项目中很重要,不然容易踩法律红线。

最终效果评估

上线后跑了几周,整体还算稳定。平均每个页面会话能收集到几十到几百条行为记录,数据量控制在可接受范围内。客户反馈说数据准确度还不错,热力图和用户路径分析都能正常使用。

不过还是有些小问题没完全解决:比如移动端的touch事件处理不够完善,某些特殊页面的DOM变动检测会有延迟,但这些问题影响不大,暂时就没花精力去优化。性能方面通过各种优化手段,现在页面卡顿基本没有了,用户无感知。

回顾与反思

这套用户行为采集系统总的来说算是成功了,虽然过程中踩了不少坑,但学到了很多东西。特别是对浏览器API的深度使用有了更深理解,MutationObserver这种平时不太用的API,这次也算彻底摸透了。

如果再来一次的话,可能会考虑用现有的开源方案比如OpenReplay或者rrweb,自己从零搭建确实挺费劲的。不过自己写的好处是可控性强,可以根据具体需求定制,灵活性高一些。

以上是我踩坑后的总结,希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论