手写埋点sdk包发布并简单使用

1,580 阅读9分钟

前言

在日常开发中,我们免不了要检测一些数据,和一些用户的操作行为。比如,用户对什么商品感兴趣,一天有多少用户访问了我们的页面,在什么时间用户最多等等。关于埋点,其实已经有很多非常优秀的工具了,比如百度统计growingio。但是我们在公司开发的时候,为了节约成本,都可能会自研一套自己的埋点系统。本文将创建一个非常简单的埋点工具。让您了解到一些埋点相关的知识

tips:源码有一定改动,改成无侵入式的了(maybe)

github地址

monitor

安装

pnpm add chovrio-track
npm install chovrio-track
cnpm install chovrio-track

工具包的开发

项目基础结构搭建

项目依赖:typescript + rollup

	pnpm inin # 可用npm yarn
	pnpm add @types/node -D # node的一些类型 可以不装
	pnpm add rollup -D # 打包工具
	pnpm add typescript -D # 语言
	pnpm add rollup-plugin-dts -D
 	pnpm add rollup-plugin-typescript2 -D

	pnpm add typescript rollup @types/node rollup-plugin-dts rollup-plugin-typescript2 -D # 一步安装

安装好依赖后执行 npx tsc --init 生成 tsconfig.json 文件

手动创建 rollup.config.js 文件,并配置

import path from "path";
import ts from "rollup-plugin-typescript2";
import dts from "rollup-plugin-dts";
import { fileURLToPath } from "node:url";
const __filenameNew = fileURLToPath(import.meta.url);
const __dirnameNew = path.dirname(__filenameNew);
export default [
  // 打包ts文件生成js
  {
    // 入口文件路径
    input: "./src/core/index.ts",
    // 打包生成文件存储位置
    output: [
      // 打包生成esmodule规范js文件
      {
        file: path.resolve(__dirnameNew, "./dist/index.esm.js"),
        // 生成文件的规范类型
        format: "es",
      },
      // 打包生成commanjs规范js文件
      {
        file: path.resolve(__dirnameNew, "./dist/index.cjs.js"),
        format: "cjs",
      },
      // 打包生成umd规范js文件
      {
        file: path.resolve(__dirnameNew, "./dist/index.js"),
        // 全局变量名
        name: "Tracker",
        format: "umd",
      },
      // 立即执行函数 这个没有什么必要
      {
        format: "iife",
        name: "Tracker",
        file: "./dist/index.iife.js",
      },
    ],
    plugins: [ts()],
  },
  // 打包ts文件生成.d.ts文件
  {
    input: "./src/core/index.ts",
    output: {
      file: path.resolve(__dirnameNew, "./dist/index.d.ts"),
    },
    plugins: [dts()],
  },
];

配置 package.json 文件的 scripts 脚本

	pnpm pkg set scripts.build="rollup -c"

此时我们执行 pnpm build 会发现有以下报错

image-20230211111631761

根据提示我们在 package.json 中 添加

{
 ...
 "type":"module"
 ...
}

再次打包又会有报错

image-20230211111919039

所以我们将 tsconfig.json 中的 "module":"commanjs"改为 "module":"ESNEXT"

再次打包就不会有报错了(注意在入口文件 core/index.ts 随便写点内容,不然空文件是打包不了的)

image-20230211112030189

此时项目的基本结构如下,也算基本搭建好了架子,可以往里面填充内容了

image-20230211112201720

SDK 包开发

首先我们在 types/index.ts写入以下类型

/**
 * @requestUrl 接口地址
 * @historyTracker history上报
 * @hashTracker hash上报
 * @domTracker 携带Tracker-key 点击事件上报
 * @sdkVersionsdk版本
 * @extra透传字段
 * @jsError js 和 promise 报错异常上报
 */

export interface DefaultOptons {
  uuid: string | undefined;
  requestUrl: string | undefined;
  historyTracker: boolean;
  hashTracker: boolean;
  domTracker: boolean;
  sdkVersion: string | number;
  extra: Record<string, any> | undefined;
  jsError: boolean;
}

//必传参数 requestUrl
export interface Options extends Partial<DefaultOptons> {
  requestUrl: string;
}

core/index.ts写入以下内容

import type { DefaultOptons, Options } from "../types/index";
import pkg from "../../package.json";

export default class Tracker {
  public data: Options;
  constructor(options: Options) {
    // 按传入配置修改默认配置
    this.data = Object.assign(this.initDef(), options);
  }
  private initDef(): DefaultOptons {
    return <DefaultOptons>{
      historyTracker: false,
      hashTracker: false,
      domTracker: false,
      jsError: false,
      sdkVersion: pkg.version,
    };
  }
}

因为使用 import 是无法导入 .json 文件的所以代码第二行会报错,我们需要在tsconfig.json中设置

{
    ...
    "compilerOptions" : {
        "moduleResolution": "node", // 设置模块解析策略为node 也可以使用--moduleResolution来指定,但得用 tsc工具
        "resolveJsonModule": true, // 把json文件视作模块
    }
    ...
}

关于解析策略有一篇不错的文章 tsconfig 之 moduleResolution 详解

也可以采用第二种解决办法,在根目录新建一个 type.d.ts文件

// type.d.ts

declare module "*.json" {
  const value: any;
  export default value;
}

这样我们也可以导入 json 文件,但是没有代码提示应该。

但是 rollup 如果要打包 json 文件我们必须要借助插件。

	pnpm add @rollup/plugin-json -D

并在 rollup.config.js 中配置,这里就不多做介绍了,直接引入配置即可

因为浏览器有两种路由方式:hash路由普通路由,hash 路由就是带锚点的

我们先写普通路由的埋点

普通路由监听

SDK 内容

首先,在 Tracker 类里面新增两个个私有方法 installTrackercaptureEvents 并在构造器中调用 installTracker 此时core/index.ts代码如下

import type { DefaultOptons, Options } from "../types/index";
import { version } from "../../package.json";

export default class Tracker {
  public data: Options;
  constructor(options: Options) {
    // 按传入配置修改默认配置
    this.data = Object.assign(this.initDef(), options);
    this.installTracker();
  }
  private initDef(): DefaultOptons {
    return <DefaultOptons>{
      historyTracker: false,
      hashTracker: false,
      domTracker: false,
      jsError: false,
      sdkVersion: version,
    };
  }
  private captureEvents<T>(EventList: string[], targetKey: string, data?: T) {
    EventList.forEach((event) => {
      console.log(event);
    });
  }
  private installTracker() {
    if (this.data.historyTracker) {
      this.captureEvents(
        ["pushState", "replaceState", "popstate"],
        "history-pv",
        {
          code: 10001,
          message: "用户浏览记录 ",
        }
      );
    }
  }
}

在这里我们监听了 "pushState", "replaceState", "popstate"三个事件(只要 Tracker 实例一旦创建),就不做测试了,监听到了事件就得上报(可以做历史浏览记录)。所以我们 Tracker 类中新增一个上报函数 reportTracker

// core/index.ts
  private reportTracker<T>(data: T) {
    const params = Object.assign(this.data, data, { time: new Date() });
    let headers = {
      type: "application/x-www-form-urlencoded",
    };
    let blob = new Blob([JSON.stringify(params)], headers);
    navigator.sendBeacon(this.data.requestUrl, blob);
  }

该函数使用了 Blob 以及 navigator.sendBeacon,简单说一下它们的作用,blob 可以将数据转换成二进制流,转换后的数据与之前数据的对比图,这样在发送网络请求的时候就可以减少数据大小,提高请求速度(文件的分片上传也可以使用 Blob)。navigator.sendBeacon 也是为了上报提速的,同时可以保证会把数据发出去,不拖延卸载流程。这里有两篇文章,感兴趣的可以去看一看,JS 中的 Navigator.sendBeacon() 是干什么的?Javascript 中 Blob 介绍

image-20230211121433794

写到这里,我们就不得不写一些服务端代码来进行测试了,因为这里只是测试,所以我们一切从简

服务端代码

我们在 tracker 文件夹同级新建一个 server 文件夹

	cd ..
	mkdir server
	cd server
	pnpm init
	pnpm add nodemon -D
	pnpm add koa @koa/cors koa-router
	pnpm pkg set scripts.start="nodemon index.js"

server/index.js代码如下

const koa = require("koa");
const Router = require("koa-router");
const cors = require("@koa/cors");
const app = new koa();
const router = new Router();
router.post("/tracker/update", (ctx) => {
  console.log(ctx.request);
  ctx.body = "上报成功";
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
  console.log("server running at http://localhost:3000");
});

写好服务端代码后直接pnpm start运行,然后新建测试文件夹

	cd ..
	mkdir test

创建index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button>路由跳转</button>
    <script src="./index.js" type="module"></script>
    <script>
      const btn = document.querySelector("button");
      btn.onclick = () => {
        history.pushState({ name: "chovrio" }, "test", "/tracker");
      };
    </script>
  </body>
</html>

创建index.js

import Tracker from "../tracker/dist/index.js";
new Tracker({
  historyTracker: true,
});

我们开始测试,发现当我们点击按钮进行路由跳转的时候,update 请求并没有发出,当我们进行浏览器路由前进后退的时候,请求发出了,但是它们的事件类型都是 popstate,replaceState 和 pushState 事件都没有执行。为什么呢?因为 window 上没有这两个事件,所以我们得自定义事件。

image-20230211153929585

自定义事件

utils/createEvent.ts中写入以下代码

export const createHistoryEvent = <T extends keyof History>(type: T) => {
  // 首先获得原函数
  const origin = history[type];
  // 返回一个函数
  return function (this: any) {
    // 新函数的this为原函数
    const res = origin.apply(this, arguments);
    // 新建一个事件为传入参数
    const e = new Event(type);
    // 通过window派发这个事件(只有派发后才能监听到)
    window.dispatchEvent(e);
    // 返回新函数
    return res;
  };
};

然后我们在core/index.tsTracker类中初始化默认配置的时候,就创建派发新的路由事件,改写后initDef如下

  private initDef(): DefaultOptons {
    window.history["pushState"] = createHistoryEvent("pushState");
    window.history["replaceState"] = createHistoryEvent("replaceState");
    return <DefaultOptons>{
      historyTracker: false,
      hashTracker: false,
      domTracker: false,
      jsError: false,
      sdkVersion: version,
    };
  }

然后我们重新进行打包,再次测试就有效果了

image-20230211155814402

普通路由埋点暂时 over

hash 路由监听

hash 路由埋点实现就非常简单了,因为window本身就拥有hashChange事件,并且因为只是锚点后内容的改变,浏览器并不会刷新,我们在installTracker函数中新增判断。重新打包

  private installTracker() {
	// 前面内容就不重复复制了
    if (this.data.hashTracker) {
      this.captureEvents(["hashchange"], "hash-pv", {
        code: 10001,
        type: "hash-history",
        message: "用户浏览记录 ",
      });
    }
  }

测试通过。记得new Tracker实例的时候把 hashTracker 设置为true

image-20230211160827461

dom 事件监听

改写类型

首先,我们要明确什么样的元素发生什么事件才产生埋点,所以我们修改types/index.ts下的 DefaultOptions

export interface DefaultOptons {
  uuid: string | undefined;
  requestUrl: string | undefined;
  historyTracker: boolean;
  hashTracker: boolean;
  domTracker: boolean;
  sdkVersion: string | number;
  extra: Record<string, any> | undefined;
  jsError: boolean;
  elementEvent?: Array<keyof WindowEventMap>;
  element?: ElementMap;
}
export type ElementMap = Map<string, keyof WindowEventMap>;

然后我们在installTracker中新增判断

if (this.data.domTracker) {
  this.targetKeyReport();
}
新增方法

然后新增事件监听,这里遍历事件map

  private targetKeyReport() {
    if (this.data.element) {
      for (const [keyTarget, event] of this.data.element) {
        window.addEventListener(event, (e) => {
          const target = e.target as HTMLElement;
          const targetKey = target.getAttribute(keyTarget);
          if (targetKey) {
            this.reportTracker({
              event,
              targetKey,
              data: {
                code: 10003,
                type: "dom",
                message: "用户操作记录 ",
                keyTarget,
                targetKey,
              },
            });
          }
        });
      }
    }
  }

重新打包一下再次测试 这里我们相当于监听了 click 事件,和发生事件元素身上是否存在 eat 属性

// index.js
import Tracker from "./dist/index.js";
const map = new Map();
map.set("eat", "click");
const tracker = new Tracker({
  requestUrl: "http://localhost:3000/tracker/update",
  historyTracker: true,
  hashTracker: true,
  domTracker: true,
  element: map,
  jsError: true,
});
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button eat="吃饭">路由跳转</button>
    <script src="./index.js" type="module"></script>
    <script>
      const btn = document.querySelector("button");
      btn.onclick = () => {
        console.log(111);
      };
    </script>
  </body>
</html>

image-20230211170511341

全局错误监听(重要)

首先我们要知道,浏览器环境下会出现的错误类型,简单来说就两种。异步错误和同步错误。异步错误大部分都产生自网络请求,而同步错误则是发生在代码执行过程中。捕获错误是一件非常重要的事情(你也不想网站突然崩掉吧)。当我们这里只是做的错误上报。

了解如何监听错误

我们改写index.html文件内容如下,点击按钮即可产生错误并监听到。只要是在promise中捕获到的错误事件都是unhandledrejection,在这里使用 fetch 只是为了方便

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button eat="吃饭">路由跳转</button>
    <script src="./index.js" type="module"></script>
    <script>
      const btn = document.querySelector("button");
      btn.onclick = () => {
        fetch("http://111111");
        throw new Error("错误发生了");
      };
      window.addEventListener("unhandledrejection", (e) => {
        console.log("我是reject", e);
      });
      window.addEventListener("error", (e) => {
        console.log("我是error", e);
      });
    </script>
  </body>
</html>
监听错误

我们在installTracker函数中新增判断jsError是否开启

if (this.data.jsError) {
  this.jsError();
}

再在 Tracker 中新增几个函数

  private errorEvent() {
    window.addEventListener("error", (event) => {
      this.reportTracker({
        event: "error",
        targetKey: "同步错误",
        data: {
          code: 10004,
          message: event.message,
        },
      });
    });
  }
  private promiseReject() {
    window.addEventListener("unhandledrejection", (event) => {
      event.promise.catch((error) => {
        this.reportTracker({
          event: "promise",
          targetKey: "异步错误",
          error: {
            code: 10005,
            message: error,
          },
        });
      });
    });
  }
  private jsError() {
    this.errorEvent();
    this.promiseReject();
  }

再次进行打包进行测试(注意在index.js中设置jsError:true)

image-20230211224728217

服务端代码的实现

服务端只需要写好基本代码就能跑了,至于获得数据后怎么处理就全凭喜好了,基本代码如下

const koa = require("koa");
const Router = require("koa-router");
const cors = require("@koa/cors");
const path = require("path");
const app = new koa();
const bodyParser = require("koa-bodyparser");
const router = new Router();
router.post("/tracker/update", (ctx) => {
  console.log(ctx.request.body);
  const body = ctx.request.body;
  for (const key in body) {
    console.log(key);
    console.log(body[key]);
  }
  ctx.body = "上报成功";
});
app.use(cors()).use(bodyParser());
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
  console.log("server running at http://localhost:3000");
});

数据内容展示

image-20230211231633625

借鉴文章,功能和大佬差不多,有一些自定义的修改