写在开头
Preview http://124.223.71.181:3004/dom
本文主要记录一下上面这个前端监控系统的实现 [ 框架-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()
用于发送这个请求 所以我们改写这个方法
而监听响应 可以有 onreadystatechange
和 addEventListener
两种方式
所以我们两种都要兼顾
-
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后 我们可以用 getDerivedStateFromError
和 componentDidCatch
来捕获错误
他们的函数签名如下 注意 getDerivedStateFromError
是静态方法 这意味着你无法使用
this.setState
来更新state 但是它可以返回一个obj 用于直接更新state
static getDerivedStateFromError(error) {
// 返回值会直接影响state
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// do something
}
注意 这两者不必一起使用
@nanshu/monitor
最后说回 如何搭建一个这样的平台
我的项目结构如下
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);
},
};
如果想看更全的代码 可以戳文章开头的链接哦