目前市面上有许多成熟的前端监控系统,但我们没有选择成品,而是自己动手研发。这里面包括多个原因:
* 填补H5日志的空白
* 节约公司费用支出
* 可灵活地根据业务自定义监控
* 回溯时间能更长久
* 反哺运营和产品,从而优化产品质量
* 一次难得的练兵机会
  前端监控地基本目的:了解当前项目实际使用的情况,有哪些异常,在追踪到后,对其进行分析,并提供合适的解决方案。
  其实也可以说是防患于未来,因为根据海恩法则可知:
  每一起严重事故的背后,必然有 29 次轻微事故和 300 起未遂先兆以及 1000 起事故隐患。
  前端监控地终极目标: 1 分钟感知、5 分钟定位、10 分钟恢复。目前是初版,离该目标还比较遥远。
  SDK(采用ES5语法)取名为 [shin.js](https://github.com/pwstrick/shin-admin/blob/main/public/shin.js),其作用就是将数据通过 JavaScript 采集起来,统一发送到后台,采集的方式包括监听或劫持原始方法,获取需要上报的数据,并通过 gif 传递数据。
  整个系统大致的运行流程如下:
:-: ![](https://img.kancloud.cn/19/6b/196bbdb90ddb8e4f33453369c606b128_627x381.jpg =400x)
  2023-01-16 经过 TypeScript 整理重写后,正式将监控系统的脚本开源,命名为 [shin-monitor](https://github.com/pwstrick/shin-monitor)。
## 一、异常捕获
  异常包括运行时错误、Promise错误、框架错误等。
  2022-08-10 错误日志在提交时附带版本号,版本号是在初始化时手动输入的,格式是年月日时分加上名字。
~~~
shin.setParam({
version: '202208091830-strick',
});
~~~
  版本号的作用是为了在发生错误时,能追踪到什么时刻上线的代码。有时候可能因为上了新代码发生了错误,那就得找出新代码的位置。
~~~
/**
* 上报错误
* @param {Object} errorLog 错误日志
*/
function handleError(errorLog) {
// 推送版本号
shin.param.version && (errorLog.version = shin.param.version);
shin.send({
category: ACTION_ERROR,
data: errorLog
});
}
~~~
  有了版本号大致能知道代码提交的时间范围,如果要与 Git 提交操作做精准关联的话,可以将此版本号作为 commit 的备注添加进来。
**1)error事件**
  为 window 注册[error](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/error_event)事件,捕获全局错误,过滤掉与业务无关的错误,例如“Script error.”、JSBridge告警等,还需统一资源载入和运行时错误的数据格式。
~~~
// 定义的错误类型码
var ERROR_RUNTIME = "runtime";
var ERROR_SCRIPT = "script";
var ERROR_STYLE = "style";
var ERROR_IMAGE = "image";
var ERROR_AUDIO = "audio";
var ERROR_VIDEO = "video";
var ERROR_PROMISE = "promise";
var ERROR_VUE = "vue";
var ERROR_REACT = "react";
var LOAD_ERROR_TYPE = {
SCRIPT: ERROR_SCRIPT,
LINK: ERROR_STYLE,
IMG: ERROR_IMAGE,
AUDIO: ERROR_AUDIO,
VIDEO: ERROR_VIDEO
};
/**
* 监控脚本运行时的异常
*/
window.addEventListener(
"error",
function (event) {
var errorTarget = event.target;
// 过滤掉与业务无关的错误
if (event.message === "Script error.") {
return;
}
if (
errorTarget !== window &&
errorTarget.nodeName &&
LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()]
) {
handleError(formatLoadError(errorTarget));
} else {
// 过滤无效错误
event.message && handleError(
formatRuntimerError(
event.message,
event.filename,
event.lineno,
event.colno,
event.error
)
);
}
},
true //捕获
);
/**
* 生成 runtime 错误日志
* @param {String} message 错误信息
* @param {String} filename 出错文件的URL
* @param {Long} lineno 出错代码的行号
* @param {Long} colno 出错代码的列号
* @param {Object} error 错误信息Object
* https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error
*/
function formatRuntimerError(message, filename, lineno, colno, error) {
return {
type: ERROR_RUNTIME,
lineno,
colno,
desc: {
prompt: message + " at " + filename + ":" + lineno + ":" + colno,
url: location.href
}
// stack: error && (error.stack ? error.stack : "no stack") // IE <9, has no error stack
};
}
/**
* 生成 load 错误日志
* 需要加载资源的元素
*/
function formatLoadError(errorTarget) {
return {
type: LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()],
desc: {
url: errorTarget.baseURI,
src: errorTarget.src || errorTarget.href
}
};
}
~~~
  2022-12-14 在 error 事件中,调用 formatRuntimerError() 前,增加 event.message 的判断,用于过滤一些无效的错误,例如 undefined at undefined:undefined:undefined。
  2022-12-16 为 formatRuntimerError() 中的 desc 增加当前出错页面的地址,便于日志检索。
~~  得用[performance.getEntriesByType("resource")](https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType)读取到资源列表(由[PerformanceResourceTiming](https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceResourceTiming)组成),然后循环列表。
  当数据项的decodedBodySize属性为0时,就可判断无法读取这个资源;或者没有该属性,可认为当前资源缓存在浏览器中。
  这种判断的条件不够全,也不够精确,后面就用比较简单粗暴的方式来做判断依据,那就是 duration 大于20秒,就认为请求超时了。
  在日志中会将各个阶段的时间参数都保存,便于后期的校验。~~
  2023-06-27 媒体资源在出现错误时,会在 errorTarget 包含一个错误对象,包括 code 和 message。
  不过 message 我读取时都是空字符串,下面是一段来自于[shin-monitor](https://github.com/pwstrick/shin-monitor)的 TS 代码,修改了 formatLoadError 中的逻辑。
~~~
private formatLoadError(errorTarget: TypeEventTarget): TypeErrorData {
const desc: TypeResourceDesc = {
url: errorTarget.baseURI,
src: errorTarget.src || errorTarget.href
};
/**
* 对于媒体资源 errorTarget 会包含 error 属性,其 code 包含 4 个值
* MEDIA_ERR_ABORTED:表示由于用户取消操作而引发的错误(数值为 1)
* MEDIA_ERR_NETWORK:表示由于网络错误而引发的错误(数值为 2)
* MEDIA_ERR_DECODE:表示由于解码错误而引发的错误(数值为 3)
* MEDIA_ERR_SRC_NOT_SUPPORTED:表示由于不支持媒体资源格式而引发的错误(数值为 4)
*/
if(errorTarget.error) {
const MEDIA_ERR = {
1: '用户取消操作',
2: '网络错误',
3: '解码错误',
4: '不支持的媒体资源格式'
};
const { code } = errorTarget.error;
code && (desc.message = MEDIA_ERR[code]);
}
return {
type: CONSTANT.LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()],
desc
// stack: "no stack"
};
}
~~~
  2022-12-13 在将 error 事件处理程序中的第一个 if 判断条件中的 !event.filename 去掉后,就能监控到脚本、样式和图像等静态资源的异常了。
  所以下面这段代码现在可以全部注释掉了,但是这段代码可以提供一些对异常资源处理的思路,例如根据参数计算出资源是 404 还是超时等问题。
~~~
/**
* 监控资源异常,即无法响应的资源
*/
window.addEventListener(
"load",
function () {
// 罗列资源列表,PerformanceResourceTiming类型
var resources = performance.getEntriesByType("resource");
// 映射initiatorType和错误类型
var hashError = {
script: ERROR_SCRIPT,
link: ERROR_STYLE
// img: ERROR_IMAGE
};
resources &&
resources.forEach(function (value) {
var type = hashError[value.initiatorType];
/**
* 非监控资源、响应时间在20秒内、监控资源是ma.gif或shin.js,则结束当前循环
*/
if (
!type || // 非监控资源
value.duration < 20000 || // 20秒内
value.name.indexOf("ma.gif") >= 0 ||
value.name.indexOf("shin.js") >= 0
) {
return;
}
/**
* 若是CSS文件,则过滤脚本文件
* 但是Vue会通过link元素预请求脚本
*/
// if (type === ERROR_STYLE
// && value.name.indexOf('.js') >= 0) {
// return;
// }
handleError({
type,
desc: handleNumber(value.toJSON())
});
});
},
false
);
~~~
  其实主要是为了监控脚本文本的响应,因为有时候会由于脚本没响应而导致页面空白,直接影响到业务,业务人员也不可能一直盯着页面的,为了避免这种情况,就需要实时监控资源的响应状态。
  在监控到图像的异常后,就发现有两三千个 404 请求,一部分是因为默认给 img 元素的 src 属性赋空字符串导致的。
  另一部分是真的请求不到资源,那么当图像请求不到时,为了能有更好的体验,可以将裂图替换成某一张默认图。
  2022-12-15 在优化后,仍然有三千多个图像错误的记录,于是做了进一步的分析。
  发现这些图像都是用户的头像,虽然在活动页中无法访问,但是在管理后台却能访问。
  经过比对发现,是域名的差异,一个域名可以访问,而另一个不能访问,所以需要将域名替换掉,而路径可以保持不变。
  由于之前头像使用了统一的组件,所以只需在一处做替换即可。
  2023-06-19 对一个常规活动做了一次优化,但是上线后收到大量的 webp 图像错误,并且清一色都来自于 iOS 系统。
  经过搜索了解到 iOS14 以上才支持 webp 格式,因此需要对此类系统做兼容处理。
:-: ![](https://img.kancloud.cn/d5/9e/d59e29c8e7dac2202792b1dd35b726a2_1329x836.png =600x)
  2022-12-30 突然发现 shin.js 脚本内部报的错误,并不会上报到后台,将线上文件通过 Charles 映射到本地,发现报的错误是 Script error。
  Script error 是一种跨域错误,而我的 shin.js 与当前网页是不同域名,因此脚本中的错误就都变成了 Script error。
  因此需要为 shin.js 的请求加上跨域首部:Access-Control-Allow-Origin: \*,并且为 script 增加[crossorigin](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin)属性。
~~~html
<script src="https://www.xxx.com/js/shin.js" crossorigin="anonymous"></script>
~~~
  还有一种办法就是重写 addEventListener,但只能捕获事件回调程序中的错误。在调试的时候,出现了死循环,调查发现和 React 有关,[参考此文](https://ost.51cto.com/posts/33)。
~~~
const originAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, options) {
const wrappedListener = function (...args) {
try {
return listener.apply(this, args);
}
catch (err) {
throw err;
}
}
return originAddEventListener.call(this, type, wrappedListener, options);
}
~~~
**2)unhandledrejection事件**
  为 window 注册[unhandledrejection](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/unhandledrejection_event)事件,捕获未处理的 Promise 错误,当 Promise 被 reject 且没有 reject 处理器时触发。
~~~
window.addEventListener(
"unhandledrejection",
function (event) {
//处理响应数据,只抽取重要信息
var response = event.reason.response || response.status;
//若无响应,则不监控
if (!response) {
return;
}
var desc = response.request.ajax;
desc.status = event.reason.status;
handleError({
type: ERROR_PROMISE,
desc: desc
});
},
true
);
~~~
  Promise 常用于异步通信,例如[axios](https://github.com/axios/axios)库,当响应异常通信时,就能借助该事件将其捕获,得到的结果如下。
~~~
{
"type": "promise",
"desc": {
"response": {
"data": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic",
"status": 504,
"statusText": "Gateway Timeout",
"headers": {
"connection": "keep-alive",
"date": "Wed, 24 Mar 2021 07:53:25 GMT",
"transfer-encoding": "chunked",
"x-powered-by": "Express"
},
"config": {
"transformRequest": {},
"transformResponse": {},
"timeout": 0,
"xsrfCookieName": "XSRF-TOKEN",
"xsrfHeaderName": "X-XSRF-TOKEN",
"maxContentLength": -1,
"headers": {
"Accept": "application/json, text/plain, */*",
},
"method": "get",
"url": "/api/monitor/performance/statistic"
},
"request": {
"ajax": {
"type": "GET",
"url": "/api/monitor/performance/statistic",
"status": 504,
"endBytes": 0,
"interval": "13.15ms",
"network": {
"bandwidth": 0,
"type": "4G"
},
"response": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic"
}
}
},
"status": 504
},
"stack": "Error: Gateway Timeout
at handleError (http://localhost:8000/umi.js:18813:15)"
}
~~~
  这样就能分析出 500、502、504 等响应码所占通信的比例,当高于日常数量时,就得引起注意,查看是否在哪块逻辑出现了问题。
  500 是代码报错,502 是没有找到对应的服务,504 是服务响应过慢导致超时,例如超过 60 秒没有得到服务的响应就会报 504。
  有一点需要注意,上面的结构中包含响应信息,这是需要对[Error](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error)做些额外扩展的,如下所示。
~~~
import fetch from 'axios';
function handleError(errorObj) {
const { response } = errorObj;
if (!response) {
const error = new Error('你的网络有点问题');
error.response = errorObj;
error.status = 504;
throw error;
}
const error = new Error(response.statusText);
error.response = response;
error.status = response.status;
throw error;
}
export default function request(url, options) {
return fetch(url, options)
.catch(handleError)
.then((response) => {
return { data: response.data };
});
}
~~~
  2022-11-22 最近发现前端监控中的 504 数量,要比 Nginx 中的 504 日志数量多。
  经过排查发现是因为有些接口的 response 是空字符串,在 handleError() 函数中,就会给 error.status 赋值 504。
  经查,这些接口中都不会包含 req-id 的记录,也就是说在服务端的日志中,没有留下记录,服务器没有对其进行处理。
  这类接口占比很少(0.001% 左右),怀疑是 Nginx 因为某种原因没有转发到对应的服务中,需要运维配合调查。
  为了能区分是服务器返回的 504,还是因为空字符串定义的 504,对 handleError() 函数做改造。
  将 status 声明成 512,之所以选这个状态码,是因为在它之前的状态码都有明确的含义。
~~~
function handleError(errorObj) {
// ...
if (!response) {
// ...
error.status = 512; // 自定义response为空时的错误状态码
throw error;
}
// ...
}
~~~
  2022-11-08 在 unhandledrejection 事件中,可以增加一些需要过滤的通信异常,例如登录信息超时、埋点请求等,如下所示。
  如果做的通用点,还可以在此处预留一个可配置的钩子函数。
~~~
if(desc.status == 401 || // 过滤管理后台登录信息超时的异常
desc.url.indexOf('reports/logs') >= 0 // 过滤埋点异常
) {
return;
}
~~~
  公司中有一套项目依赖的是 jQuery 库,因此要监控此处的异常通信,需要做点改造。
  好在所有的通信都会请求一个通用函数,那么只要修改此函数的逻辑,就能覆盖到项目中的所有页面。
  搜索了API资料,以及研读了 jQuery 中通信的源码后,得出需要声明一个 xhr() 函数,在函数中初始化 XMLHttpRequest 对象,从而才能监控它的实例。
  并且在 error 方法中需要手动触发 unhandledrejection 事件。
~~~
$.ajax({
url,
method,
data,
success: (res) => {
success(res);
},
xhr: function () {
this.current = new XMLHttpRequest();
return this.current;
},
error: function (res) {
error(res);
Promise.reject({
status: res.status,
response: {
request: {
ajax: this.current.ajax
}
}
}).catch((error) => {
throw error;
});
}
});
~~~
  2023-09-25 在我当前公司中,Nginx 也可以监控 HTTP 的异常请求(例如 5XX 的请求),实践下来比较精确,而网页监控到的错误数量与 Nginx 监控的数量往往不同。
  有时候网页多,有时候网页少,很有可能受外部环境的影响,例如网络波动、浏览器关闭等,导致两边统计的不同。
  其实还有一类响应异常,这类接口的请求是 200,但是由于业务逻辑异常,在返回响应时,会给一个异常的状态码,例如下面是一段正常的 JSON 响应。
~~~
{ code: 0, msg: "", data: {} }
~~~
  当 code 为非 0 时,就认为是一次异常的请求。如果公司对 JSON 响应的格式做了标准化,那就比较容易做区分。
  像我当前公司,没有统一,存在多种格式,例如有些接口中 status 为 1 是正常的,或者 code 为 200 是正常的。
  所以不能简单的来做甄别,为此,将响应状态码特地存储在数据库表的 message_code 字段中,方便自己分析。
**3)框架错误**
  框架是指目前流行的React、Vue等,我只对公司目前使用的这两个框架做了监控。
  React 需要在项目中创建一个[ErrorBoundary](https://react.docschina.org/docs/error-boundaries.html)类,捕获错误。
~~~
import React from 'react';
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
componentDidCatch(error, info) {
this.setState({ hasError: true });
// 将component中的报错发送到后台
shin && shin.reactError(error, info);
}
render() {
if (this.state.hasError) {
return null
// 也可以在出错的component处展示出错信息
// return <h1>出错了!</h1>;
}
return this.props.children;
}
}
~~~
  其中 reactError() 方法在组装错误信息。
~~~
/**
* 处理 React 错误(对外)
*/
shin.reactError = function (err, info) {
handleError({
type: ERROR_REACT,
desc: {
prompt: err.toString(), // 描述
url: location.href
},
stack: info.componentStack
});
};
~~~
  如果要对 Vue 进行错误捕获,那么就得重写[Vue.config.errorHandler()](https://cn.vuejs.org/v2/api/index.html#errorHandler),其参数就是 Vue 对象。
~~~
/**
* Vue.js 错误劫持(对外)
*/
shin.vueError = function (vue) {
var _vueConfigErrorHandler = vue.config.errorHandler;
vue.config.errorHandler = function (err, vm, info) {
handleError({
type: ERROR_VUE,
desc: {
prompt: err.toString(), // 描述
url: location.href
}
stack: err.stack //堆栈
});
// 控制台打印错误
if (
typeof console !== "undefined" &&
typeof console.error !== "undefined"
) {
console.error(err);
}
// 执行原始的错误处理程序
if (typeof _vueConfigErrorHandler === "function") {
_vueConfigErrorHandler.call(err, vm, info);
}
};
};
~~~
  如果 Vue 是被模块化引入的,那么就得在模块的某个位置调用该方法,因为此时 Vue 不会绑定到 window 中,即不是全局变量。
  2022-12-16 为 React 和 Vue 在上报的 desc 中增加当前页面的地址,在日志查询时就能通过 URL 路径来查了。
**4)难点**
  虽然把错误都搜集起来了,但是现代化的前端开发,都会做一次代码合并压缩混淆,也就是说,无法定位错误的真正位置。
  为了能转换成源码,就需要引入自动堆栈映射([SourceMap](https://github.com/mozilla/source-map)),[webpack](https://www.webpackjs.com/configuration/devtool/)默认就带了此功能,只要声明相应地关键字开启即可。
  我选择了 devtool: "hidden-source-map",生成完成的原始代码,并在脚本中隐藏Source Map路径。
~~~
//# sourceMappingURL=index.bundle.js.map
~~~
  在生成映射文件后,就需要让运维配合,编写一个脚本(在发完代码后触发),将这些文件按年月日小时分钟的格式命名(例如 202103041826.js.map),并迁移到指定目录中,用于后期的映射。
  之所以没有到秒是因为没必要,在执行发代码的操作时,发布按钮会被锁定,其他人无法再发。
  映射的逻辑是用 Node.js 实现的,会在后文中详细讲解。注意,必须要有列号,才能完成代码还原。
## 二、行为搜集
  将行为分成:用户行为、浏览器行为、控制台打印行为。监控这些主要是为了在排查错误时,能还原用户当时的各个动作,从而能更好的找出问题出错的原因。
**1)用户行为**
  2022-12-29 观察了下公司网页的布局,里面充斥着 div 元素,没有实现语义化的布局,并且经常拿 div 当按钮使用。
  之前也没约定样式规则,例如按钮样式加 btn- 前缀,所以现在也无法区分哪些 div 是按钮,哪些是容器。
  一般的话,a、button、li 等元素大部分都会绑定交互事件或默认行为,所以它们是有意义。
  而随意的点下背景图、列表等位置,在本处是不需要记录的,因为这里不是用作埋点的功能,可理解为记录关键的影响布局的行为。
  为了避免大量无意义的点击上报,就需要过滤出绑定点击事件的元素,想到一个方法是读取 node.onclick 属性。
  但是发现,并不都能返回值,因此这种判断方式不准确。并且当点击的元素并
  然后试图找到一个方法,可以读取元素绑定的事件名称,网上有个非标准的方法 getEventListeners(),但是有兼容性问题。
  又想到一个方法,那就是记录 HTML 的结构,比对两次是否相同,不同时就记录。
  但是实际操作时,发现很多情况下都会改变 HTML 结构,选中一个文本框、点击表格的一列等,都不是有记录意义的点击。
  现在还没想到比较好的办法,就只能先通过特征慢慢完善需要记录的判断条件了,例如 CSS 类中包含 tabs 字符串的,可认为是一个菜单栏,点击是有意义的。
  在下面的代码中 window.onclick 支持 IE9+,若要支持 IE8 浏览器,可以改成 document.onclick。
~~~
/**
* 全局监听事件
*/
function _eventHandle(eventType, detect) {
return function (e) {
if (!detect(e)) {
return;
}
handleAction(ACTION_EVENT, {
type: eventType,
desc: _removeQuote(e.target.outerHTML) // 去除双引号
});
};
}
/**
* 监听点击事件
* window.onclick 支持 IE9+,若要支持 IE8 浏览器,可以改成 document.onclick
*/
window.addEventListener("click", _eventHandle("click", function (e) {
var node = e.target;
var nodeName = node.nodeName.toLowerCase();
// 若是 body 元素,则不记录
if (nodeName === "body") {
return false;
}
// 白名单
if (
nodeName !== "a" &&
nodeName !== "button" &&
nodeName !== "li" &&
// 先判断是否包含 indexOf 方法,再根据样式特征判断,例如菜单栏样式
(node.className.indexOf && node.className.indexOf('tabs') === -1)) {
return false;
}
return true;
}),
false
);
~~~
  2024-03-01 handleAction() 方法用于将数据整理好后,发送到后台。handleNumber() 会递归的将数字四舍五入小数点后两位,性能参数计算后会有大量小数,在此处可以统一处理。
~~~
private handleAction(type: string, data: any): void {
this.http.send({ category: type, data: this.handleNumber(data) });
}
/**
* 递归的将数字四舍五入小数点后两位
*/
private handleNumber(obj: any): any {
const type = typeof obj;
// 若 obj 是 null,则 typeof null 也是 object
if (type === 'object' && obj !== null) {
for (const key in obj) {
// 读取属性状态
const des = Object.getOwnPropertyDescriptor(obj, key);
// 当key是只读属性时,就不能直接赋值了
if(des && des.writable) {
obj[key] = this.handleNumber(obj[key]);
}
}
}
if (type === 'number') {
return rounded(obj, 2);
}
return obj;
}
~~~
  在为 obj 对象赋值时,需要判断属性是否可写,对于只读属性会报错。
~~~
Cannot assign to read only property 'size' of object '#<Blob>'
~~~
**2)浏览器行为**
  监控异步通信,重写 XMLHttpRequest 对象,并通过[Navigator.connection](https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/connection)读取当前的网络环境,例如4G、3G等。
  其实还想获取当前用户环境的网速,不过还没有较准确的获取方式,因此并没有添加进来。
~~~
var _XMLHttpRequest = window.XMLHttpRequest; // 保存原生的XMLHttpRequest
// 覆盖XMLHttpRequest
window.XMLHttpRequest = function (flags) {
var req = new _XMLHttpRequest(flags); // 调用原生的XMLHttpRequest
monitorXHR(req); // 埋入我们的“间谍”
return req;
};
var monitorXHR = function (req) {
req.ajax = {};
// var _change = req.onreadystatechange;
req.addEventListener(
"readystatechange",
function () {
if (this.readyState == 4) {
// 只上报文本和JSON格式的响应数据
if (
req.responseType &&
(req.responseType != "text" || req.responseType != "json")
) {
return;
}
var end = shin.now(); // 结束时间
req.ajax.status = req.status; // 状态码
if ((req.status >= 200 && req.status < 300) || req.status == 304) {
// 请求成功
req.ajax.endBytes = `${_kb(req.responseText.length * 2)}KB`; // KB
// console.log('响应数据:'+ req.ajax.endBytes); //响应数据大小
} else {
// 请求失败
req.ajax.endBytes = 0;
}
// 为监控的响应头添加 req-id 字段
var reqId = req.getResponseHeader("req-id");
if (reqId) {
req.ajax.header
? (req.ajax.header["req-id"] = reqId)
: (req.ajax.header = { "req-id": reqId });
}
// req.ajax.header
req.ajax.interval = `${_rounded(end - start, 2)}ms`; // 单位毫秒
req.ajax.network = shin.network();
// 只记录6000个字符以内的响应限制,以便让MySQL表中的message字段能成功存储
req.responseText.length <= 6000 &&
(req.ajax.response = req.responseText);
// req.ajax.response = req.responseText;
if (
req.status < 500 && // 只传送500以内的通信
req.ajax.url !== "/api/user" && // 不需要监控后台身份通信
) {
handleAction(ACTION_AJAX, req.ajax);
}
// console.log('ajax响应时间:'+req.ajax.interval);
}
},
false
);
// “间谍”又对open方法埋入了间谍
var _open = req.open;
req.open = function (type, url, async) {
req.ajax.type = type; // 埋点
req.ajax.url = url; // 埋点
return _open.apply(req, arguments);
};
// 设置请求首部
var _setRequestHeader = req.setRequestHeader;
req.setRequestHeader = function (header, value) {
if (header === "Authorization") {
// 监控身份状态
req.ajax.header = {
[header]: value
};
}
return _setRequestHeader.apply(req, arguments);
};
// 发送请求
var _send = req.send;
var start; // 请求开始时间
req.send = function (data) {
start = shin.now(); // 埋点
// var bytes = 0; //发送数据大小
if (data) {
req.ajax.startBytes = `${_kb(JSON.stringify(data).length * 2)}KB`;
req.ajax.data = data; // 传递的参数
}
return _send.apply(req, arguments);
};
};
~~~
  2022-05-10 在正式调用 handleAction() 方法发送监控信息到后台之前,可以过滤一些不需要或不影响业务的接口。
  例如上述代码中的 api/user,第三方的一些日志接口等,这些过滤条件可以暴露给外部作为钩子函数。
  2022-07-29 在响应头添加 req-id 字段,为了能与服务端中的日志关联,详见后面的第 4 小节。
  在所有的日志中,通信占的比例是最高的,大概在 90% 以上。
  2022-06-18 刚开始会将所有搜集到的内容上报到后台服务,但是有些数据会比较大,超出 1M 后,后台就会报 500 的错误,计算下来居然占到了总错误的 94%。
  于是就控制了ajax响应的大小,并且在上报前也会验证内容尺寸,因为有的打印内容也会有几兆,例如打印 base64 格式的图片。
  浏览器的行为还包括跳转,当前非常流行 SPA,所以在记录跳转地址时,只需监听[onpopstate](https://developer.mozilla.org/zh-CN/docs/Web/API/WindowEventHandlers/onpopstate)事件即可,其中上一页地址也会被记录。
~~~
/**
* 在路由中注入自定义逻辑
*/
function injectRouter() {
var href = location.href;
handleAction(ACTION_REDIRECT, {
refer: shin.refer,
current: href
});
shin.refer = href;
}
/**
* 全局监听跳转
* 点击后退、前进按钮或者调用 history.back()、history.forward()、history.go() 方法才会触发 popstate 事件
* 点击 <a href=/xx/yy#anchor>hash</a> 按钮也会触发 popstate 事件
*/
var _onPopState = window.onpopstate;
window.onpopstate = function (args) {
injectRouter();
_onPopState && _onPopState.apply(this, args);
};
~~~
  2023-01-01 注意,popstate 事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮或者调用 history.back()、history.forward()、history.go() 方法。
  现在流行的路由库:[react-router](https://reactrouter.com/en/main)和[vue-router](https://router.vuejs.org/zh/),底层都是通过 history.pushState() 和 history.replaceState() 来实现路由的变化。
  因此,如果要监听这两个方法,就得采用注入的方式,在事件中添加自定义逻辑。
~~~
/**
* 监听 pushState() 和 replaceState() 两个方法
*/
var bindEventListener = function (type) {
var historyEvent = history[type];
return function () {
// 触发 history 的原始事件
var newEvent = historyEvent.apply(this, arguments);
injectRouter();
return newEvent;
};
};
history.pushState = bindEventListener("pushState");
history.replaceState = bindEventListener("replaceState");
~~~
**3)控制台打印行为**
  其实就是重写 console 中的方法,目前只对 log() 做了处理。在实际使用中发现了两个问题。
  第一个是在项目调试阶段,将数据打印在控制台时,显示的文件和行数都是 SDK 的名称和位置,无法得知真正的位置,很是别扭。
  并且在 SDK 的某些位置调用 console.log() 会形成死循环。后面就加了个 isDebug 开关,在调试时就关闭监控,省心。
~~~
function injectConsole(isDebug) {
!isDebug &&
["log"].forEach(function (level) {
var _oldConsole = console[level];
console[level] = function () {
var params = [].slice.call(arguments); // 参数转换成数组
_oldConsole.apply(this, params); // 执行原先的 console 方法
var seen = [];
handleAction(ACTION_PRINT, {
level: level,
// 避免循环引用
desc: JSON.stringify(params, function (key, value) {
if (typeof value === "object" && value !== null) {
if (seen.indexOf(value) >= 0) {
return;
}
seen.push(value);
}
return value;
})
});
};
});
}
~~~
  第二个就是某些要打印的变量包含循环引用,这样在调用[JSON.stringify()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)时就会报错。
  2022-11-22 发现有些打印其实是可以忽略的,例如公司最近引入了一个第三方的 SDK 库。
  而在该库中包含大量的打印代码,这些都应该被忽略掉,忽略后的打印数量从最高的 77W 降低至 3W。
  过滤打印的那段逻辑可以封装成一个钩子函数,暴露给外部。
~~~
function injectConsole(isDebug) {
!isDebug && ["log"].forEach(function (level) {
console[level] = function () {
// ...
var desc = JSON.stringify(params, function (key, value) {
//...
});
// 过滤SDK的打印信息
if (desc && desc.indexOf("SDK") >= 0) {
return;
}
handleAction(ACTION_PRINT, {
type: level,
desc: desc
});
};
});
}
~~~
  2022-11-08 发现当短时间内(例如 1 秒内)有大量的打印(例如几百次),并且将请求发送给后台时,会让电脑 CPU 暴涨,在项目上线后需要注意打印时机和数量。
  2023-06-30 增加对自定义异常 console.error 的监控,在之前的 ["log", "error"] 数组中增加一项。
  2023-07-03 发现如果打印 new Error(),那么字符串序列化的结果是一个空对象({}),看不到真实的错误信息。
  需要对 Error 实例做一次适配(如下所示),[getOwnPropertyNames()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyNames)静态方法会返回一个由自有属性组成的数组。
~~~
function errorToJSON(error) {
const errorObj = {};
Object.getOwnPropertyNames(error).forEach(key => {
errorObj[key] = error[key];
});
return errorObj;
}
~~~
  注意,这么做后有可能会丢失一些错误对象的特定行为和方法,但可以保证得到序列化后的错误信息。
**4)全链路日志查询**
  2022-07-29 新增全链路日志查询的 ID。在前端监控中,会记录通信的请求、响应等信息。
  而这些接口基本都是 Node 服务提供的,它们也会有日志,包括 MySQL语句、埋点、内部接口调用等,为了能将通信日志和服务日志关联,就需要一个标识符。
  我们所有的接口都会由一个统一的 Nginx 网关做转发,在转发时,Nginx 会自动生成一个用于识别通信的字符串标识:X-Request-Id。
  这个标识会作为通信上下文的一个 reqId 属性存在,只要在响应时,给响应头加上这个属性就能实现关联。如下所示,一段 Node.js 的代码,使用的框架是 KOA。
~~~
ctx.set('Access-Control-Expose-Headers', 'req-id');
ctx.set('req-id', ctx.reqId);
~~~
  在 SDK 中,得到响应头后,就能读取 req-id,并记录到监控日志表中,由此就能实现两端日志的关联。
~~~
var reqId = req.getResponseHeader('req-id');
if(reqId) {
req.ajax.header ? (req.ajax.header['req-id'] = reqId) : (req.ajax.header = { 'req-id':reqId });
}
~~~
  2023-11-09 若响应中不存在 req-id,那么在 Chrome 控制台会报错,如下所示。
~~~
Refused to get unsafe header "req-id"
~~~
  这不是 JavaScript 错误,所以代码执行也不会停止,仅仅是一个提示。
  若要消除此错误,需要在调用 getResponseHeader() 方法之前先判断指定的响应头是否存在,如下所示。
~~~
if(req.getAllResponseHeaders().indexOf('req-id') >= 0)
reqId = req.getResponseHeader('req-id');
~~~
## 三、其他
**1)环境信息**
  通过解析请求中的 UA 信息,可以得到操作系统、浏览器名称版本、CPU等信息。
~~~
{
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36",
"browser": {
"name": "Chrome",
"version": "89.0.4389.82",
"major": "89"
},
"engine": {
"name": "Blink",
"version": "89.0.4389.82"
},
"os": {
"name": "Mac OS",
"version": "10.14.6"
},
"device": {},
"cpu": {}
}
~~~
  图省事,就用了一个开源库,叫做[UAParser.js](https://github.com/faisalman/ua-parser-js),在 Node.js 中引用了此库。
**2)上报**
  上报选择了 Gif 的方式,即把参数拼接到一张 Gif 地址后,传送到后台。
~~~
/**
* 组装监控变量
*/
function _paramify(obj) {
obj.token = shin.param.token;
obj.subdir = shin.param.subdir;
obj.identity = getIdentity();
return encodeURIComponent(JSON.stringify(obj));
}
/**
* 推送监控信息
*/
shin.send = function (data) {
var ts = new Date().getTime().toString();
var img = new Image(0, 0);
img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts;
};
~~~
  用这种方式有几个优势:
* 兼容性高,所有的浏览器都支持。
* 不存在跨域问题。
* 不会携带当前域名中的 cookie。
* 不会阻塞页面加载。
* 相比于其他类型的图片格式(BMP、PNG等),能节约更多的网络资源。
  不过这种方式也有一个问题,那就是采用 GET 的请求后,浏览器会限制 URL 的长度,也就是不能携带太多的数据,否则会报 431 错误。
  在之前记录 Ajax 响应数据时就有一个判断,只记录300个字符以内的响应数据,其实就是为了规避此限制而加了这段代码。
  不过在正式使用中发现,由于做了字符判断,因此有时候会缺失查询列表的信息,而这些信息都很关键,对排查起到决定性作用,因为后面就改成了普通的POST提交,这样就不会有数据量的限制了。
  但存储量一下子就暴增,从原先每个月100G增加到半个月250G,保存6个月的话就要存储3T的数据,经济成本上也增加了不少。
  让数据组的同事将比较占内存的通信记录列出,由于不太会用到,因此我单独做了过滤。
**3)错误回放**
  2022-12-21 在搜集到报错时,能够回放错误发生之前的页面动作。之前曾写过一篇《[纯JavaScript实现页面行为的录制](https://www.cnblogs.com/strick/p/12206766.html)》分析了回放原理。
  简单的说,就是将 DOM 映射成指定的 JSON 结构,每次 DOM 发生变化时,记录 DOM 信息(即记录用户行为),回放就是将 JSON 结构反解析成 DOM 元素。
  其中要监控 DOM 的变化,就需要使用[MutationObserver](https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver),它会在 DOM 操作结束后才触发,是一种异步接口,比起同步的[MutationEvent](https://developer.mozilla.org/en-US/docs/Web/API/MutationEvent),性能要更高。
  虽然知道了原理,但是要实现一个比较完善的回放功能,是一件比较复杂的事情。所以本次将会使用一个开源库:[rrweb](https://github.com/rrweb-io/rrweb)。
  原先计划是在发生错误时,存储前面 2 分钟内的行为记录,但是发现容量非常大,可能要好多 M。为了节省空间,暂时先存储 10~20 秒之间的行为,控制在 500KB 以内。
  有两种方式引入该库,一种是在页面源码中加入,另一种是通过 JavaScript 动态添加。如下所示,为了减少内存占用,在页面中只保留 3 段行为记录。
~~~
var recordEventsMatrix = [[]];
function recordPage(isRecord) {
if (!isRecord) {
return;
}
var script = document.createElement("script");
script.src = "//cdn.jsdelivr.net/npm/rrweb@latest/dist/rrweb.min.js";
// 开始监控页面行为
script.onload = function () {
rrweb.record({
emit(event, isCheckout) {
// isCheckout 是一个标识,告诉你重新制作了快照
if (isCheckout) {
// 最多保留 3 段行为记录
var deleteCount = recordEventsMatrix.length - 2;
deleteCount > 0 && recordEventsMatrix.splice(0, deleteCount);
recordEventsMatrix.push([]);
}
var lastEvents = recordEventsMatrix[recordEventsMatrix.length - 1];
lastEvents.push(event);
},
checkoutEveryNms: 10 * 1000 // 每 10 秒重新制作快照
});
};
document.head.append(script);
}
~~~
  为了防止数据库容量增加过快,目前就只有当页面出现白屏时,才上报到后台中。
~~~
/**
* 读取最近 20 秒的行为记录
*/
function getRecentRecord() {
var len = recordEventsMatrix.length;
if (len === 0) return "";
var events;
if (len.length >= 2) {
events = recordEventsMatrix[len - 2].concat(recordEventsMatrix[len - 1]);
} else {
events = recordEventsMatrix[len - 1];
}
return JSON.stringify(events);
}
/**
* 推送监控信息
* 改成POST请求
*/
shin.send = function (data) {
// var ts = new Date().getTime().toString();
// var img = new Image(0, 0);
// img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts;
var m = _paramify(data);
// 大于8000的长度,就不在上报,废弃掉
if (m.length >= 8000) {
return;
}
var body = { m: m };
var record;
// 当前是一条错误日志,并且描述的是奔溃
if (data.category === ACTION_ERROR && data.data.type === ERROR_CRASH) {
// 读取行为记录
record = getRecentRecord();
// 只有当有内容时,才发送行为记录
record.length > 0 && (body.r = record);
}
// 如果修改headers,就会多一次OPTIONS预检请求
fetch(shin.param.src, {
method: "POST",
// headers: {
// 'Content-Type': 'application/json',
// },
body: JSON.stringify(body)
});
};
~~~
  在将此功能上线后,马上就发现了之前一个困扰我的白屏问题(在《[监控页面奔溃](https://www.cnblogs.com/strick/p/14986378.html)》一文中,有具体的监控原理)。就是 body 里明明有内容,但是却上报为白屏。
  查看回放,的确是白屏,于是在控制台中查看页面元素,果然发现了端倪,如下图所示。
:-: ![](https://img.kancloud.cn/1a/c0/1ac05fcc523077018d83aa841b7061cc_2938x1346.png =800x)
  html 元素的字体大小是 0,从而导致页面中涉及到 rem 的计算都为 0,元素的尺寸也就是 0 了。
  经过排查,应该是[flexible.js](https://github.com/beipiaoyu2011/flexible/blob/master/public/js/frame/flexible.debug.js)中的计算问题,因为 getBoundingClientRect() 得到的宽度是 0,从而让 fontSize 也成为了 0。
~~~
function refreshRem() {
var width = document.documentElement.getBoundingClientRect().width;
if (width / dpr > 540) {
width = 540 * dpr;
}
var rem = width / 10;
docEl.style.fontSize = rem + "px";
flexible.rem = win.rem = rem;
}
~~~
  至于为什么是 0,还没有准确答案,可能是在读取时,DOM 元素还不存在。
  解决办法就是当 width 是 0 时,就给个默认值,例如可读取视口宽度的 window.innerWidth。
  2022-12-26 虽然加了 window.innerWidth,但还是会出现白屏的情况。
  那是因为 flexible.js 被放在了 shin.js 的后面请求,这就有可能在调用 document.body.clientHeight 取到的值是 0,马上替换两者的位置。
**4)身份标识**
  每次进入页面都会生成一个唯一的标识,存储在[sessionStorage](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/sessionStorage)中。
  在查询日志时,可通过该标识过滤出此用户的上下文日志,消除与他不相干的日志,也就是展示他的行为轨迹。
~~~
function getIdentity() {
var key = "shin-monitor-identity";
//页面级的缓存而非全站缓存
var identity = sessionStorage.getItem(key);
if (!identity) {
//生成标识
identity = Number(
Math.random().toString().substr(3, 3) + Date.now()
).toString(36);
sessionStorage.setItem(key, identity);
}
return identity;
}
~~~
  2022-11-21 新增可指定的身份信息标识,在实际使用监控的过程中发现,客服在收到用户的问题反馈后,会提供给我们 userId 和遇到问题的时间范围。
  目前我们只能根据时间范围来排查记录,然后通过上面的 identity 来缩小范围,经过上下文的记录分析后,才能大致知道这条记录是否是问题用户的。
  为了能更准确的知道相关记录是否就是问题用户的,又增加了一个身份参数,从外面传进来,或者自动通过 JSBridge 等方式获取。
~~~
var defaults = {
param: {
...
identity: "" //可自定义的身份信息字段
}
};
/**
* 在客户端中埋入可识别的身份信息,例如userId
*/
function injectIdentity(identity) {
// 若不是APP或已经指定身份信息,则返回
if (!isApp() || identity) return;
// 在JSBridge成功调用后的回调函数
window.getMonitorUserSuccess = function (result) {
try {
var json = JSON.parse(result);
shin.param.identity = json.data.userId;
} catch (e) {
// console.error(e.message);
}
};
// 通过JSBridge读取用户信息
var iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = "xxx://xxx.yyy/getInfo?callback=getMonitorUserSuccess";
/**
* 需要加个定时器,因为调用document.body时,DOM还未存在
* Uncaught TypeError: Cannot read property 'appendChild' of null
*/
setTimeout(() => {
document.body && document.body.appendChild(iframe);
}, 500);
}
~~~
  在 getIdentity() 函数中,增加一句合并自定义身份字段的代码。
  2022-12-19 将自定义身份放在默认身份之前,因为这样便于使用 ES 的前缀查询。
~~~
function getIdentity() {
//...
if (!identity) {
// 生成标识
identity = Number(
Math.random().toString().substring(3, 6) + Date.now()
).toString(36);
// 与自定义的身份字段合并,自定义字段在前,便于使用 ES 的前缀查询
shin.param.identity && (identity = shin.param.identity + '-' + identity);
//...
}
return identity;
}
~~~
  上述针对的是客户端中的页面,而在管理后台中,对身份的处理又略有不同,以我当前公司为例。
  由于所有的接口都会在后台校验身份,因此会自带 JWT 加密过的身份信息(Authorization字段),那么只要将此字段也一并保存,需要时将其[解密](https://jwt.io/),就能知道操作人是谁了。
~~~
{
"type": "GET",
"url": "/api/xxx/yyy",
"header": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
"req-id": "52d768494e664885353"
},
"status": 200,
"endBytes": "13.88KB",
"interval": "382.2ms",
"network": {
"bandwidth": 0,
"type": "4G"
}
}
~~~
**5)Canvas 指纹**
  2023-07-10 增加浏览器指纹,用于计算 UV 数据,在上报时会附带此信息。
  首先利用 Canvas 在不同终端中绘制会有细微差别的特点,对生成的图像进行运算,得到一个指纹,如下所示。
~~~
/**
* ASCII字符串转换成十六进制
*/
export function bin2hex(s: string): string {
let o = '';
s += '';
for (let i = 0, l = s.length; i < l; i++) {
const n = s.charCodeAt(i).toString(16);
o += n.length < 2 ? '0' + n : n;
}
return o;
}
/**
* Canvas 指纹
* 注意,同型号的手机,其 Canvas 指纹是相同的
*/
private getFingerprint(): string {
const key = "shin-monitor-fingerprint";
const fingerprint = localStorage.getItem(key);
if (fingerprint) return fingerprint;
// 绘制 Canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const txt = "fingerprint";
ctx.textBaseline = "top";
ctx.font = "16px Arial";
ctx.fillStyle = "#F60";
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = "#069";
ctx.fillText(txt, 2, 15);
ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
ctx.fillText(txt, 4, 17);
var b64 = canvas.toDataURL().replace("data:image/png;base64,", "");
// window.atob 用于解码使用 base64 编码的字符串
const bin = window.atob(b64);
// 必须调用 slice() 否则无法转换
const result = bin2hex(bin.slice(-16, -12));
// 缓存到本地
localStorage.setItem(key, result);
return result;
}
~~~
  为了提升性能,会将生成的字符串存储在 localStorage 中,只进行一次指纹计算。
  经过测试发现,即使将客户端杀掉,再次进入页面,存储的值仍然存在,除非进到应用管理,清空数据。
  注意,同型号的手机,其 Canvas 指纹是相同的,因此在服务端接收到参数后,需要再和 UA 和 IP 进行合并。
  最终 MD5 加密得到一个指纹,当然,这个指纹也会出现重复的概率。
**6)未来展望**
  目前,SDK 的所有逻辑都是写在一个文件中的,未来体积极有可能膨胀,那么到时候会影响加载时间。
  可以将各个监控部分以插件的形式分离,例如 打印一个模块、通信一个模块,想要什么功能就单独组合。
  还可以添加生命周期,在各个阶段增加回调,引入特殊场景的特殊逻辑,保持高扩展性。
*****
> 原文出处:
[博客园-从零开始搞系列](https://www.cnblogs.com/strick/category/1928903.html)
已建立一个微信前端交流群,如要进群,请先加微信号freedom20180706或扫描下面的二维码,请求中需注明“看云加群”,在通过请求后就会把你拉进来。还搜集整理了一套[面试资料](https://github.com/pwstrick/daily),欢迎阅读。
![](https://box.kancloud.cn/2e1f8ecf9512ecdd2fcaae8250e7d48a_430x430.jpg =200x200)
推荐一款前端监控脚本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不仅能监控前端的错误、通信、打印等行为,还能计算各类性能参数,包括 FMP、LCP、FP 等。
- ES6
- 1、let和const
- 2、扩展运算符和剩余参数
- 3、解构
- 4、模板字面量
- 5、对象字面量的扩展
- 6、Symbol
- 7、代码模块化
- 8、数字
- 9、字符串
- 10、正则表达式
- 11、对象
- 12、数组
- 13、类型化数组
- 14、函数
- 15、箭头函数和尾调用优化
- 16、Set
- 17、Map
- 18、迭代器
- 19、生成器
- 20、类
- 21、类的继承
- 22、Promise
- 23、Promise的静态方法和应用
- 24、代理和反射
- HTML
- 1、SVG
- 2、WebRTC基础实践
- 3、WebRTC视频通话
- 4、Web音视频基础
- CSS进阶
- 1、CSS基础拾遗
- 2、伪类和伪元素
- 3、CSS属性拾遗
- 4、浮动形状
- 5、渐变
- 6、滤镜
- 7、合成
- 8、裁剪和遮罩
- 9、网格布局
- 10、CSS方法论
- 11、管理后台响应式改造
- React
- 1、函数式编程
- 2、JSX
- 3、组件
- 4、生命周期
- 5、React和DOM
- 6、事件
- 7、表单
- 8、样式
- 9、组件通信
- 10、高阶组件
- 11、Redux基础
- 12、Redux中间件
- 13、React Router
- 14、测试框架
- 15、React Hooks
- 16、React源码分析
- 利器
- 1、npm
- 2、Babel
- 3、webpack基础
- 4、webpack进阶
- 5、Git
- 6、Fiddler
- 7、自制脚手架
- 8、VSCode插件研发
- 9、WebView中的页面调试方法
- Vue.js
- 1、数据绑定
- 2、指令
- 3、样式和表单
- 4、组件
- 5、组件通信
- 6、内容分发
- 7、渲染函数和JSX
- 8、Vue Router
- 9、Vuex
- TypeScript
- 1、数据类型
- 2、接口
- 3、类
- 4、泛型
- 5、类型兼容性
- 6、高级类型
- 7、命名空间
- 8、装饰器
- Node.js
- 1、Buffer、流和EventEmitter
- 2、文件系统和网络
- 3、命令行工具
- 4、自建前端监控系统
- 5、定时任务的调试
- 6、自制短链系统
- 7、定时任务的进化史
- 8、通用接口
- 9、微前端实践
- 10、接口日志查询
- 11、E2E测试
- 12、BFF
- 13、MySQL归档
- 14、压力测试
- 15、活动规则引擎
- 16、活动配置化
- 17、UmiJS版本升级
- 18、半吊子的可视化搭建系统
- 19、KOA源码分析(上)
- 20、KOA源码分析(下)
- 21、花10分钟入门Node.js
- 22、Node环境升级日志
- 23、Worker threads
- 24、低代码
- 25、Web自动化测试
- 26、接口拦截和页面回放实验
- 27、接口管理
- 28、Cypress自动化测试实践
- 29、基于Electron的开播助手
- Node.js精进
- 1、模块化
- 2、异步编程
- 3、流
- 4、事件触发器
- 5、HTTP
- 6、文件
- 7、日志
- 8、错误处理
- 9、性能监控(上)
- 10、性能监控(下)
- 11、Socket.IO
- 12、ElasticSearch
- 监控系统
- 1、SDK
- 2、存储和分析
- 3、性能监控
- 4、内存泄漏
- 5、小程序
- 6、较长的白屏时间
- 7、页面奔溃
- 8、shin-monitor源码分析
- 前端性能精进
- 1、优化方法论之测量
- 2、优化方法论之分析
- 3、浏览器之图像
- 4、浏览器之呈现
- 5、浏览器之JavaScript
- 6、网络
- 7、构建
- 前端体验优化
- 1、概述
- 2、基建
- 3、后端
- 4、数据
- 5、后台
- Web优化
- 1、CSS优化
- 2、JavaScript优化
- 3、图像和网络
- 4、用户体验和工具
- 5、网站优化
- 6、优化闭环实践
- 数据结构与算法
- 1、链表
- 2、栈、队列、散列表和位运算
- 3、二叉树
- 4、二分查找
- 5、回溯算法
- 6、贪心算法
- 7、分治算法
- 8、动态规划
- 程序员之路
- 大学
- 2011年
- 2012年
- 2013年
- 2014年
- 项目反思
- 前端基础学习分享
- 2015年
- 再一次项目反思
- 然并卵
- PC网站CSS分享
- 2016年
- 制造自己的榫卯
- PrimusUI
- 2017年
- 工匠精神
- 2018年
- 2019年
- 前端学习之路分享
- 2020年
- 2021年
- 2022年
- 2023年
- 日志
- 2020