JS | 前端监控平台搭建的一些感悟 🤔

1,003 阅读6分钟

写在开头

Repo github.com/LuckyChou71…

Preview http://124.223.71.181:3004/dom

Npm www.npmjs.com/package/@na…

本文主要记录一下上面这个前端监控系统的实现 [ 框架-React ]

主要是一些JS事件的内容 文末会有系统的搭建的一些内容

USER EVENT

需求: 监听有点击事件的元素被触发了( 点击无绑定事件的元素不应有反应 )

首先来回顾一下事件绑定的几种方式

  • 在 dom 元素中直接绑定
  • 在 js 事件中获取 dom 事件绑定
  • 事件监听 addEventListener

想象一下下面的代码 最后点击 button 的时候 会有几个事件被触发呢🤔

<!-- 方式一 直接在DOM上绑定 -->
<button id="btn" onclick="handleClick()">click</button>

<script>
  const handleClick = () => {
    console.log('A');
  };

  window.onload = () => {
    // 方式二 在 js 事件中获取 dom 事件绑定
    document.querySelector('#btn').onclick = () => {
      console.log('B');
    };
    document.querySelector('#btn').onclick = () => {
      console.log('C');
    };
    // 方式三 事件监听 addEventListener
    document.querySelector('#btn').addEventListener('click', () => {
      console.log('D');
    });
    document.querySelector('#btn').addEventListener('click', () => {
      console.log('E');
    });
  };
</script>

答案是输出 C D E

这是因为 方法一和方法二 只能绑定一个方法 多次绑定后者会覆盖前者

所以 --> B 覆盖 A --> C 覆盖 B --> 最后输出 C

而方法三则可以绑定多个方法

为了实现开头的这个需求 这边我只考虑 addEventListener 这种绑定方式

所以重写 addEventListener 这个方法就好了

const oldAddEventListener = EventTarget.prototype.addEventListener;
const newAddEventListener = function (eventType, callback, options) {
  const newCb = (e) => {
    callback.call(window, e);
    // do something
    // console.log('[addEventListener触发了]', e.target);
  };
  oldAddEventListener.call(window, eventType, newCb, options);
};
EventTarget.prototype.addEventListener = newAddEventListener;

HTTP REQUEST

需求: 监听请求URL以及成功与否

xhr

我们发起一次 ajax 请求的代码如下

function reqListener() {
  console.log('[addEventListener]', JSON.parse(this.response));
}

var xhr = new XMLHttpRequest();
xhr.addEventListener('load', reqListener);
xhr.onreadystatechange = () => {
  if (xhr.readyState === XMLHttpRequest.DONE) {
    console.log('[onreadystatechange]', JSON.parse(xhr.response));
  }
};
xhr.open('GET', 'xxx');
xhr.send();

其中 xhr.send() 用于发送这个请求 所以我们改写这个方法

而监听响应 可以有 onreadystatechangeaddEventListener 两种方式

所以我们两种都要兼顾

  • addEventListener

    • loadstart 当程序开始加载时,loadstart 事件将被触发
    • load 请求完成
    • loadend 加载进度停止之后被触发 比如 error 之后触发
    • progress 在请求接收到数据的时候被周期性触发
    • error 请求遇到错误
    • abort 请求终止
  • onreadystatechange

    • 0 UNSENT 代理被创建,但尚未调用 open() 方法。
    • 1 OPENED open() 方法已经被调用。
    • 2 HEADERS_RECEIVED send() 方法已经被调用,并且头部和状态已经可获得。
    • 3 LOADING 下载中; responseText 属性已经包含部分数据。
    • 4 DONE 下载操作已完成。
var xmlHttp = window.XMLHttpRequest;
var oldSend = xmlHttp.prototype.send;
var handleEvent = function (event) {
  // do something
  // console.log(event);
};
xmlHttp.prototype.send = function () {
  // this就是XMLHttpRequest这个对象
  if (this['addEventListener']) {
    this.addEventListener('load', handleEvent);
    this.addEventListener('error', handleEvent);
  } else {
    var oldStateChange = this['onreadystatechange'];
    this['onreadystatechange'] = function (event) {
      if (this.readyState === 4) {
        handleEvent(event);
      }
      oldStateChange && oldStateChange.apply(this, arguments);
    };
  }
  return oldSend.apply(this, arguments);
};

fetch

fetch('http://xxx');

同上 就是重写方法

if (!window.fetch) return;
const oldFetch = window.fetch;
const newFetch = function () {
  const response = oldFetch.apply(this, arguments);
  // do something
  // console.log(arguments) 包含了 url 和 options
  // response.then((res) => console.log(res)); 包含了请求结果 成功与否可以在这里获取
  return response;
};
window.fetch = newFetch.bind(window);

ROUTE

需求: 监听路由变化 ( FROM W TO W )

hash

URL 的 hash 也就是锚点(#), 本质上是改变 window.location 的 href 属性

我们可以通过直接赋值 location.hash 来改变 href, 但是页面不发生刷新

<div id="app">
  <a href="#/home">home</a>
  <a href="#/about">about</a>
  <div class="router-view"></div>
</div>

<script>
  // 1.获取router-view
  const routerViewEl = document.querySelector('.router-view');

  // 2.监听hashchange
  window.addEventListener('hashchange', (e) => {
    // do something
    // console.log(e) HashChangeEvent包含了 newUrl oldURL 等信息
    switch (location.hash) {
      case '#/home':
        routerViewEl.innerHTML = 'home';
        break;
      case '#/about':
        routerViewEl.innerHTML = 'about';
        break;
      default:
        routerViewEl.innerHTML = 'default';
    }
  });
</script>

hash 的优势就是兼容性更好,在老版 IE 中都可以运行,但是缺陷是有一个# 显得不像一个真实的路径

history

history 接口是 HTML5 新增的, 它有六种模式改变 URL 而不刷新页面

  • replaceState:替换原来的路径

  • pushState:使用新的路径

  • popState:路径的回退

  • go:向前或向后改变路径

  • forward:向前改变路径

  • back:向后改变路径

<div id="app">
  <a href="/home">home</a>
  <a href="/about">about</a>
  <div class="router-view"></div>
</div>

<script>
  // 1.获取router-view
  const routerViewEl = document.querySelector('.router-view');

  // 2.监听所有的a元素
  const aEls = document.getElementsByTagName('a');
  for (let aEl of aEls) {
    aEl.addEventListener('click', (e) => {
      e.preventDefault();
      const href = aEl.getAttribute('href');
      console.log(href);
      history.pushState({}, '', href);
      historyChange();
    });
  }

  // 3.执行设置页面操作
  function historyChange() {
    switch (location.pathname) {
      case '/home':
        routerViewEl.innerHTML = 'home';
        break;
      case '/about':
        routerViewEl.innerHTML = 'about';
        break;
      default:
        routerViewEl.innerHTML = 'default';
    }
  }
</script>

上面这个例子 只是说明 history 同样可以在不刷新页面的情况下实现路由的切换 我们并未做到路由的监听

要实现路由的监听 我们需要知道如下事件

window.addEventListener('onpopstate',(e)=>{})

每当激活同一文档中不同的历史记录条目时 就是路由变化时 这个事件就会被触发

但是很遗憾 history.pushState / history.replaceState 不会触发该事件

所以我们需要重写这两个方法来监听路由变化 (以pushState为例)

const oldHistoryPushState = history.pushState;
const newHistoryPushState = function (state, title, url) {
  // do something
  // { from: window.location.pathname, to: url }
  oldHistoryPushState.call(window.history, state, title, url);
};
window.history.pushState = newHistoryPushState;

JS ERROR

需求: 错误监控

静态资源加载异常

<script>
  function errorHandler(error) {
    console.log('onerror捕获到异常', error);
  }
</script>
<!-- 静态资源加载异常 -->
<script src="https://nanshu.js" onerror="errorHandler(this)"></script>

当有异常时 会直接输出加载异常的标签 注意上面的this不能省略

<script src="https://nanshu.js" onerror="errorHandler(this)"></script>

但是这种方法 代码侵入太严重了 需要接入方做处理 所以可以用

window.addEventListener('error',()=>{}) 下文会对这个方法做出解释

JS 代码错误

用 cry-catch 捕获

// 无法捕获异步场景
try {
  jsError;
} catch (error) {
  console.log('try-catch捕获到异常>>>', error);
}

try {
  setTimeout(() => {
    undefined.map((v) => v);
  }, 1000);
} catch (error) {
  console.log('try-catch捕获到异常>>>', error);
}

JS 语法错误

  • Error:错误的基类,其他错误都继承自该类型
  • EvalError:Eval 函数执行异常
  • RangeError:数组越界
  • ReferenceError:尝试引用一个未被定义的变量时,将会抛出此异常
  • SyntaxError:语法解析不合理
  • TypeError:类型错误,用来表示值的类型非预期类型时发生的错误
  • URIError:以一种错误的方式使用全局 URI 处理函数而产生的错误
/**
 * @param {String}  message    错误信息
 * @param {String}  source     出错文件
 * @param {Number}  lineno     行号
 * @param {Number}  colno      列号
 * @param {Object}  error      Error对象(对象)
 * @description 无法捕获静态资源异常和 JS 代码错误
 */
window.onerror = function (message, source, lineno, colno, error) {
  // do something
};

// 只有一个大的错误对象 但是可以捕获静态资源加载异常 
// 用 event.target.localName 有无来判断是否是静态资源加载异常
window.addEventListener(
  'error',
  (event) => {
    // do something
    // console.log(event);
  },
  true
);

unhandledrejection

捕获未对 rejected 状态做处理的 Promise

Promise.reject('ops').catch((e) => {});
Promise.reject('ops');

// 捕获未对 rejected 状态做处理的 Promise
window.addEventListener('unhandledrejection', (event) => {
  // do something
  // console.log('unhandledrejection', event);
});

React 错误

React16后 我们可以用 getDerivedStateFromErrorcomponentDidCatch 来捕获错误

他们的函数签名如下 注意 getDerivedStateFromError 是静态方法 这意味着你无法使用

this.setState 来更新state 但是它可以返回一个obj 用于直接更新state

static getDerivedStateFromError(error) {
  // 返回值会直接影响state
  return { hasError: true, error };
}

componentDidCatch(error, errorInfo) {
    // do something
}

注意 这两者不必一起使用

@nanshu/monitor

最后说回 如何搭建一个这样的平台

参考自 github.com/mitojs/mito…

我的项目结构如下

lib
├─ components 
│  ├─ errorBoundary.tsx 错误边界 ( 兜底UI 主要展示错误的堆栈信息 )
│  └─ table.tsx 展示日志信息的Table
├─ core
│  ├─ context.ts
│  ├─ init.ts 初始化开启哪些插件的方法
│  ├─ instance.ts 存储错误信息的实例对象
│  ├─ privider.tsx 
│  └─ subscribe.ts 发布订阅模式
├─ index.tsx
├─ plugins 一些插件
│  ├─ dom.ts
│  ├─ error.ts
│  ├─ fetch.ts
│  ├─ hashRouter.ts
│  ├─ historyRoute.ts
│  ├─ unhandledrejection.ts
│  └─ xhr.ts
└─ types 类型声明文件
   └─ monitor.ts

先贴一下发布订阅的代码

/**
 * @file 发布订阅
 * @author chou
 */

export class MEmitter {
  eventMap: { [key: string]: Function[] };

  constructor() {
    this.eventMap = {};
  }

  on(type: string, handler: Function) {
    if (!(handler instanceof Function)) {
      throw new Error('请传一个函数');
    }

    if (!this.eventMap[type]) {
      this.eventMap[type] = [];
    }

    this.eventMap[type].push(handler);
  }

  emit(type: string, ...params: any) {
    if (this.eventMap[type]) {
      this.eventMap[type].forEach((handler) => {
        handler(...params);
      });
    }
  }

  off(type: string, handler: Function) {
    if (this.eventMap[type]) {
      this.eventMap[type].splice(this.eventMap[type].indexOf(handler) >>> 0, 1);
    }
  }
}

export const subscribe = new MEmitter();

这里记录一下如何实现这个插件系统

插件的数据格式遵循如下格式

interface BasePluginType<T extends EventTypes> {
  /**
   * @description 事件类型
   */
  name: T;
  /**
   * @description 监控事件 eg.重写一些方法
   */
  monitor: (emit: (eventName: T, data: any) => void) => void;
  /**
   * @description 转换数据格式
   */
  transform: (collectedData: any) => IStackItem;
  /**
   * @description 拿到数据进行一些操作 例如report
   */
  consumer: (transformData: IStackItem) => void;
}

然后是 启用插件的方法

const subscribe = new MEmitter();

plugins.forEach((item) => {
const plugin = Plugins[item] as BasePluginType<any>;
// 触发事件
// 注意 这里一定要绑定 this 到 实例化的 subscribe 上
plugin.monitor.call(globalThis, subscribe.emit.bind(subscribe));
const wrapperTransform = (...args: any) => {
  // 先执行transform
  const res = plugin.transform?.apply(this, args);
  // 拿到transform返回的数据并传入
  plugin.consumer?.call(this, res);
};
// 为每一个插件注册事件
subscribe.on(plugin.name, wrapperTransform);
});

拿一个hashChange的插件举例

import { BasePluginType, EventCategories } from '../types/monitor';
import { EventTypes } from '../types/monitor';
import { instance } from '../core/instance';

export const hashRoutePlugin: BasePluginType<EventTypes.HASHCHANGE> = {
  name: EventTypes.HASHCHANGE,
  monitor: (notify) => {
    window.addEventListener('hashchange', (e: HashChangeEvent) => {
      notify(EventTypes.HASHCHANGE, e);
    });
  },
  transform: (data: HashChangeEvent) => {
    return {
      category: EventCategories.ROUTE,
      data: {
        from: data.oldURL,
        to: data.newURL,
      },
      level: 'info',
      time: +new Date(),
      type: EventTypes.HASHCHANGE,
    };
  },
  consumer: (data) => {
    instance.put(data);
  },
};

如果想看更全的代码 可以戳文章开头的链接哦

🎉 🎉 🎉