一、基本原理

● 1、利用xpath的唯一性,绑定打点元素添加事件进行发送数据打点
● 2、后台管理系统搭建一个可视化选取打点元素的功能并保存配置
● 3、前端根据页面URL获取到打点配置进行初始化(通过xpath绑定事件)
基本流程如图所示:

二、前端发送打点数据方式

前端有几种方案进行发送打点数据

1、传统ajax请求

利用传统的ajax请求进行发送数据,缺点是容易阻塞请求,对用户不友好
而且弊端很大,用户关闭页面时会截断请求,也就是发送会终止掉,用于记录浏览时长不适用
axios.post(url, data); // 以axios为例

2、动态图片

我们可以通过在 beforeunload 事件处理器中创建一个图片元素并设置它的 src 属性的方法来延迟卸载以保证数据的发送,因为绝大多数浏览器会延迟卸载以保证图片的载入,所以数据可以在卸载事件中发送。

const sendLog = (url, data) => {
  let img = document.createElement('img');
  const params = [];
  Object.keys(data).forEach((key) => {
    params.push(`${key}=${encodeURIComponent(data[key])}`);
  });
  img.onload = () => img = null;
  img.src = `${url}?${params.join('&')}`;
};

3. sendBeacon

为了解决上述问题,便有了 navigator.sendBeacon 方法,使用该方法发送请求,可以保证数据有效送达,且不会阻塞页面的卸载或加载,并且编码比起上述方法更加简单。

export const sendBeacon = (url, analyticsData) => {
    const apiUrl = config.apiRoot + url
    let data = getParams(analyticsData)
    // 兼容不支持sendBeacon的浏览器
    if (!navigator.sendBeacon) {
        const client = new XMLHttpRequest()
        // 第三个参数表示同步发送
        client.open('POST', apiUrl, false)
        client.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
        client.send(data)
        return
    }
    const formData = new FormData()
    Object.keys(analyticsData).forEach((key) => {
        let value = analyticsData[key]
        if (typeof value !== 'string') {
            // formData只能append string 或 Blob
            value = JSON.stringify(value)
        }
        formData.append(key, value)
    })
    navigator.sendBeacon(apiUrl, formData)
}

最后我们使用了动态图片的方式,因为阿里云提供的阿里云-采集-通过WebTracking采集日志应对大量数据采集不造成网站本身服务器压力

三、搭建SDK

利用webpack搭建项目,打包出单个sdk的js文件包,前端引入sdk即可(此部分不做赘述了,感兴趣可以搜索webpack相关资料)用 webpack 写一个简单的 JS SDK
● 可视化选取xpath-参考插件
SDK的主要功能:

  1. 暴露出初始化方法,以及打点的方法(为了支持手动打点)
  2. 添加选取xpath功能,并暴露给后台管理系统使用
  3. 根据链接URL读取到打点配置列表
  4. 初始化绑定打点事件功能
  5. 进入页面记一次打点
  6. 记录浏览时长
  7. SDK与父级iframe通信功能(为了传递数据给后台管理系统)
    记录游览时长示例:
// 统计时长
const viewTime = (data) => {
  let startTime = new Date().getTime() // 浏览开始时间
  let endTime = null // 浏览结束时间
  // 页面卸载触发
  window.addEventListener('unload', () => {
    endTime = new Date().getTime()
    let params = {
      viewTime: (endTime - startTime) / 1000,
      eventType: 'view',
      accessId: ACCESS_ID
    }
    params = Object.assign(params, data)
    sendLog(params)
  }, false)
}
// 选取xpath跨域跨页面通信
import Postmate from 'postmate'
import Inspector from '../plugins/inspect' // 选取xpath节点插件

let childIframe = null
const myInspect = new Inspector()
const getXpathForm = function (options) {
    myInspect.setOptions(options, (data) => {
        let params = {
            xpath: data,
            route: window.QWK_ANALYSIS_SDK_OPTIONS?.route || ''
        }
        childIframe.emit('send-data-event', params)
    })
}
export default {
    // 和父级iframe通信
    initMessage () {
        // 开发模式下启用选节点调试
        if (process.env.BUILD_ENV === 'dev') {
            document.querySelector('#selected').onclick = () => {
                myInspect.setOptions({
                    deactivate: true
                }, (data) => {
                    console.log(data)
                })
            }
        }
        const handshake = new Postmate.Model({
            // iframe父级获取xpath
            getXpath: (options) => {
                getXpathForm(options)
            },
            // 移除选取
            deactivate: () => {
                myInspect.deactivate()
            }
        })
        // When parent <-> child handshake is complete, events may be emitted to the parent
        handshake.then((child) => {
            childIframe = child
        })
    }
}
// 导出SDK
// main.js入口文件
import { init } from './lib/init'
import action from './lib/action'
import selectXpath from './lib/select-xpath'
// import { documentReady } from './plugins/common'

// 初始化选取xpath的跨域通信
selectXpath.initMessage()

// documentReady(() => {
//     // 初始化
//     // init().then(res => res)
// })

// 导出SDK模块
export {
    init,
    action
}

四、后台管理系统搭建可视化选取xpath

第一步:第三方网站引入SDK

在sdk中写一个选取xpath功能并暴露出来给后台管理系统调用
● 可视化选取xpath-参考插件

第二步:搭建管理系统

搭建一个加载网站的iframe,如图所示:

我们需要在这里调用SDK中的选取网站xpath功能的方法,这就必须和加载的iframe中的网站通信
因为是iframe加载的第三方网站,会有跨域问题,所以我们需要一个插件来实现这一功能 postmate
GitHub链接

<template>
    <div class="iframe-box" ref="content">
    </div>
</template>

<script>
    import Postmate from 'postmate'

    export default {
        name: 'WebIframe',
        props: {
            src: {
                type: String,
                default: ''
            },
            options: {
                type: Object,
                default: () => ({})
            }
        },
        data () {
            return {
                $child: null
            }
        },
        mounted () {
            this.$nextTick(() => this.initMessage())
        },
        methods: {
            initMessage () {
                let handshake = new Postmate({
                    container: this.$refs.content,
                    url: this.src,
                    name: 'web-iframe-name',
                    classListArray: ['iframe-class-content'],
                })
                handshake.then((child) => {
                    this.$child = child
                    child.on('send-data-event', (data) => {
                        this.$emit('selected', data)
                    })
                })
            },
            // 获取选取xpath
            getXpath () {
                let options = {
                    clipboard: false, // 是否自动复制
                    deactivate: true, // 选择之后销毁
                    ...this.options
                }
                try {
                    this.$child.call('getXpath', options)
                } catch (e) {
                    this.$errMsg('加载SDK失败,请重新加载网站试试~也可能当前网站未引入用户行为轨迹跟踪SDK,请联系相关人员添加')
                }
            },
            // 移除选取弹层
            remove () {
                this.$child && this.$child.call('deactivate')
            }
        }
    }
</script

选取节点结果,效果如图:

五、遇到的问题以及解决方案

1、后台管理系统iframe加载第三方网站通信问题

这里因为是通过iframe来加载第三方网站进行可视化打点的,所以需要父级iframe和第三方网站进行通信,但是会有跨域问题,跨域问题解决方案有很多种,这里使用基于postmessage的第三方插件postmate来解决

2、动态路由问题

遇到比如文章详情的页面,因为文章详情会有很多的链接 https://www.baidu.com/article... 像这样的,后面的detail/id跟随着很多id,这样的页面不可能每篇文章都去配置一下的,这样就需要做动态路由配置统一的动态参数用其他字符标识去加载配置。
方案:
①、在配置中添加动态路由标识,通过后端去读取数据库进行匹配动态路由(需要后端去做大量匹配)
②、纯前端操作,前端sdk和后台管理系统互相传递动态路由
经过讨论,我们选用了第二种,动态路由通过前端初始化sdk的时候去传入,这时候sdk中接收到传进来的动态路由,就根据这个路由去加载配置,SDK传递给后台可视化选取xpath配置,也要通过这个路由去保存配置
这时候两边就可以一一对应上了。我们定义了这样的路由 https://www.baidu.com/article...{id},其中{id}为动态参数

3、动态节点绑定不到事件问题

由于动态节点是在加载页面时dom没有生成,这时候就初始化去绑定事件是查找不到dom节点的,因此该节点的打点就是失效的。为了解决这个问题,我们可以通过全局点击事件去查找这个节点,利用document的点击去查找这个动态节点,然后得到当前点击的target对比xpath查找到的节点相等,说明当前点击的节点就是xpath需要绑定事件的节点,此时发送对应的数据即可

let { data } = await getConfig(route)
let eventList = data.filter((m) => m.eventType !== 'visible')
let viewList = data.filter((m) => m.eventType === 'visible')
let dynamicList = [] // 动态生成的节点
// 点击事件或者其他事件
eventList.forEach((item) => {
  const node = item.xpath && document.evaluate(item.xpath, document).iterateNext()
  if (!node) {
    // 找不到节点,说明有可能是动态生成的节点
    dynamicList.push(item)
    return
  }
  node.addEventListener(item.eventType || 'click', () => {
    action.track(item)
  })
})
// 通过document的点击开查找动态生成的节点
let dynamicClickList = dynamicList.filter((m) => m.eventType === 'click')
if (dynamicClickList && dynamicClickList.length) {
  document.onclick = (event) => {
    const target = event.target || window.event.target
    const parentNode = target.parentNode
    for (let item of dynamicClickList) {
      // 先把查找到的节点给存下来
      item.node = document.evaluate(item.xpath, document).iterateNext() || item.node
    }
    let xpathItem = dynamicClickList.find((m) => {
      return m.xpath && (target === m.node || parentNode === m.node)
    })
    // 查找到节点,发送打点
    xpathItem && delete xpathItem.node && action.track(xpathItem)
  }
}

4、目标进入可视区域

使用场景:有些横向滚动切换的元素,需要目标进入到用户可见的区域时进行打点,于是就有这样的需求
IntersectionObserver 参考文档链接

let observer = null // 可视区域
let isTrackList = [] // 已经打点过的
if ('IntersectionObserver' in window) {
  // 创建一个监听节点可视区域
  observer = new IntersectionObserver(entries => {
    const image = entries[0]
    // 进入可视区域
    if (image.isIntersecting) {
      // 当前可视区域的打点配置
      let current = viewList.find((m) => m.xpath && image.target === document.evaluate(m.xpath, document).iterateNext())
      // 已经打点过的
      let trackEd = isTrackList.find((m) => m.id === current.id)
      // 已经打过的点不再打
      if (current && !trackEd) {
        isTrackList.push(current)
        action.track(current)
      }
    }
  })
}
viewList.forEach((item) => {
  const node = item.xpath && document.evaluate(item.xpath, document).iterateNext()
  if (!node) {
    return
  }
  // 监听节点
  observer.observe(node)
})
03-05 13:58