你是否一直对前端错误监控系统的底层原理充满好奇?
想知道那些"黑科技"是如何拦截报错、上报数据的吗?
与其只做工具的使用者,不如深入底层,探寻其背后的实现机制。
本文将从原理角度切入,手把手带你设计并实现一个轻量级、功能完备的前端错误监控 SDK
学习完本文,你将收获什么?
通过手写这个 SDK,你不仅能获得一个可用的监控工具,更能深入掌握以下核心知识点:
- 浏览器底层原理 :事件冒泡/捕获机制,以及
onerror、unhandledrejection等 API 的工作细节。 - AOP 面向切面编程 :学会如何通过劫持(Hook)原生方法(如
XMLHttpRequest、fetch)来实现无感监控。 - 高可靠数据上报 :掌握
Navigator.sendBeacon的使用场景,确保在页面卸载时也能稳定上报数据。 - 工程化实践:从架构设计到 NPM 发布,体验完整的 SDK 开发全流程。
1. 架构设计
别被"监控系统"这四个字吓到了。拆解下来,核心逻辑就三步:监听 -> 收集 -> 上报。
在开始编码之前,我们先梳理一下 SDK 的整体架构。我们需要监控 JS 运行时错误 、网络请求错误 以及 资源加载错误,并将这些数据统一格式化后上报到服务端。
项目结构
为了保持代码的模块化和可维护性,我们采用以下目录结构:
bash
error-monitor/
├── src/
│ ├── index.ts # 入口文件,暴露初始化方法
│ ├── errorHandler.ts # JS 运行时错误捕获
│ ├── networkMonitor.ts # 网络请求异常监控
│ ├── resourceMonitor.ts # 静态资源加载异常监控
│ ├── sender.ts # 数据上报逻辑
│ └── utils.ts # 工具函数
├── package.json
└── README.md
🚀 浏览项目的完整代码及示例可以点击这里 error-monitor github.com/Teernage/er... ,如果对您有帮助欢迎Star。
2. 核心实现详解
2.1 SDK 初始化入口 (index.ts)
SDK 的入口主要负责接收配置(如上报地址、项目名称)并启动各个监控模块。
typescript
// src/index.ts
import { monitorJavaScriptErrors } from './errorHandler';
import { monitorNetworkErrors } from './networkMonitor';
import { monitorResourceErrors } from './resourceMonitor';
interface ErrorMonitorConfig {
reportUrl: string; // 上报接口地址
projectName: string; // 项目标识
environment: string; // 环境 (dev/prod)
}
export const initErrorMonitor = (config: ErrorMonitorConfig) => {
const { reportUrl, projectName, environment } = config;
// 启动三大监控模块
monitorJavaScriptErrors(reportUrl, projectName, environment);
monitorNetworkErrors(reportUrl, projectName, environment);
monitorResourceErrors(reportUrl, projectName, environment);
};
2.2 全局异常捕获 (errorHandler.ts)
这是错误监控的核心部分。JavaScript 的错误主要分为两类,我们需要分别处理:
- 常规运行时错误 :代码逻辑错误(如
undefined is not a function)。这部分由老牌的window.onerror事件负责,它能提供详细的行号、列号和堆栈信息。- Promise 异常 :随着
async/await的普及,未被catch的 Promise 错误越来越常见。这部分错误 不会 触发onerror,需要通过监听unhandledrejection事件来捕获。双管齐下,才能确保代码逻辑错误不被遗漏。
typescript
// src/errorHandler.ts
import { sendErrorData } from './sender';
export const monitorJavaScriptErrors = (
reportUrl: string,
projectName: string,
environment: string
) => {
// 1. 捕获 JS 运行时错误
const originalOnError = window.onerror;
window.onerror = (message, source, lineno, colno, error) => {
const errorInfo = {
type: 'JavaScript Error',
message,
source,
lineno,
colno,
stack: error ? error.stack : null,
projectName,
environment,
timestamp: new Date().toISOString(),
};
sendErrorData(errorInfo, reportUrl);
// 关键点:如果原来有 onerror 处理函数,继续执行它,避免覆盖用户逻辑
// 这样做是为了不破坏宿主环境(例如用户自己写的或其他 SDK)已有的错误处理逻辑
if (originalOnError) {
return originalOnError(message, source, lineno, colno, error);
}
};
// 2. 捕获未处理的 Promise Rejection
const originalOnUnhandledRejection = window.onunhandledrejection;
window.onunhandledrejection = (event) => {
const errorInfo = {
type: 'Unhandled Promise Rejection',
message: event.reason?.message || event.reason,
stack: event.reason?.stack,
projectName,
environment,
timestamp: new Date().toISOString(),
};
sendErrorData(errorInfo, reportUrl);
// 关键点:执行原有的 Promise 错误处理逻辑
// 这样做是为了不破坏宿主环境(例如用户自己写的或其他 SDK)已有的错误处理逻辑
if (originalOnUnhandledRejection) {
return originalOnUnhandledRejection.call(window, event);
}
};
};
2.3 网络请求监控 (networkMonitor.ts)
接口监控是监控的难点,因为浏览器并没有 提供一个全局的
onNetworkError事件。怎么办?
我们需要使用 AOP(面向切面编程) 的思想,重写浏览器原生的
XMLHttpRequest和fetch方法。简单来说,就是把原生的方法"包"一层:在请求发出前/响应返回后,插入我们的监控代码,然后再执行原有的逻辑。这样业务代码完全无感知,而我们却能拿到所有的请求细节。
typescript
// src/networkMonitor.ts
export const monitorNetworkErrors = (
reportUrl: string,
projectName: string,
environment: string
) => {
// 1. 劫持 XMLHttpRequest
const originalXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (
method: string,
url: string | URL,
...args: any[]
) {
// 关键点:排除上报接口自身的请求,防止死循环
const urlStr = typeof url === 'string' ? url : String(url);
if (urlStr.includes(reportUrl)) {
return originalXhrOpen.apply(this, [method, url, ...args] as any);
}
// 监听 error 事件
this.addEventListener('error', () => {
sendErrorData(
{
type: 'Network Error',
message: `Request Failed: ${method} ${url}`,
projectName,
environment,
},
reportUrl
);
});
return originalXhrOpen.apply(this, [method, url, ...args] as any);
};
// 2. 劫持 Fetch
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
// 关键点:排除上报接口自身的请求,防止死循环
const urlStr = (input instanceof Request) ? input.url : String(input);
if (urlStr.includes(reportUrl)) {
return originalFetch(input, init);
}
try {
const response = await originalFetch(input, init);
if (!response.ok) {
sendErrorData(
{
type: 'Fetch Error',
message: `HTTP ${response.status}: ${response.statusText}`,
url: input instanceof Request ? input.url : input,
projectName,
environment,
},
reportUrl
);
}
return response;
} catch (error) {
// 网络故障等无法发出请求的情况
sendErrorData(
{
type: 'Fetch Error',
message: `Fetch Failed: ${input}`,
projectName,
environment,
},
reportUrl
);
throw error;
}
};
};
2.4 资源加载监控 (resourceMonitor.ts)
使用 window.onerror 检测不到资源的错误,因为 资源加载失败(如 img/script src 404)产生的
error事件是不会冒泡的。而
window.onerror依靠事件冒泡来捕获错误,所以它对资源错误无能为力。但是
window.addEventListener('error', handler, true)在 捕获阶段 可以将资源加载的错误"拦截"下来。所以资源加载监控这里,我们使用
window.addEventListener('error', () => {}, true)来进行监控。
typescript
// src/resourceMonitor.ts
export const monitorResourceErrors = (
reportUrl: string,
projectName: string,
environment: string
) => {
// 注意:useCapture 设置为 true,在捕获阶段处理
window.addEventListener(
'error',
(event) => {
const target = event.target as HTMLElement;
// 过滤掉 window 自身的 error,只处理资源元素的 error
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) {
sendErrorData(
{
type: 'Resource Load Error',
message: `Failed to load ${target.tagName}: ${
target.getAttribute('src') || target.getAttribute('href')
}`,
projectName,
environment,
},
reportUrl
);
}
},
true // 捕获阶段
);
};
2.5 数据上报 (sender.ts)
收集到错误数据后,如何发给后端?这看似简单,实则暗藏玄机。
痛点:页面卸载时的"遗言"发不出去
用户遇到 Bug 的第一反应往往是关闭页面。如果我们使用普通的
fetch或XHR上报:
- 异步请求可能会被取消:页面关闭时,浏览器通常会 cancel 掉所有未完成的请求。
- 同步请求会阻塞跳转:虽然能强行发出去,但会卡住页面切换,严重影响体验。
救星:Navigator.sendBeacon
sendBeacon是专门为此场景设计的 API。它有三大优势:
- 可靠:即使页面卸载,浏览器也会在后台保证数据发送成功。
- 异步:完全不阻塞页面关闭或跳转。
- 高效:传输少量数据时性能极佳。
因此,我们的上报策略是:优先
sendBeacon,不支持则降级为fetch。
typescript
// src/sender.ts
export const sendErrorData = (errorData: Record<string, any>, url: string) => {
// 补充浏览器信息(UserAgent 等)
const dataToSend = {
...errorData,
userAgent: navigator.userAgent,
// 还可以添加更多环境信息,如屏幕分辨率、当前 URL 等
};
// 优先使用 sendBeacon (异步,不阻塞,页面卸载时仍有效)
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(dataToSend)], {
type: 'application/json',
});
navigator.sendBeacon(url, blob);
} else {
// 降级使用 fetch
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSend),
}).catch(console.error);
}
};
💡 知识扩展:经典的 1x1 GIF 打点
你可能听说过用
new Image().src = 'http://api.com/report?data=...'这种方式上报。这在统计 PV/UV 时非常流行,因为它兼容性极好且天然跨域。但在错误监控 场景下,通常不推荐 作为主力方案。 核心原因正是数据量:
- URL 长度限制:GIF 打点本质是 GET 请求,数据都挂在 URL 上。浏览器对 URL 长度有限制(通常 2KB~8KB)。
- 堆栈过长:一个完整的报错堆栈(Stack Trace)动辄几千字符,很容易就被浏览器截断,导致我们看不到关键的报错信息。
所以,对于体积较大 的错误数据,走 POST 通道的
sendBeacon或fetch是更稳妥的选择。
2.6 进阶优化:采样与缓冲,别把服务器搞崩了
如果线上出现大规模故障,成千上万的用户同时上报错误,可能会瞬间把监控服务器打挂(DDoS 既视感)。
这时候我们需要引入两个机制:
-
采样 (Sampling):
- 大白话:不要每个错误都报。比如只允许 20% 的运气不好的用户上报,剩下的忽略。这样既能发现问题,又能节省 80% 的流量。
- 实现 :
if (Math.random() > 0.2) return;
-
缓冲 (Buffering):
- 大白话:不要出一条错就发一个请求,太浪费资源。先把错误攒在数组里,凑够 10 条或者每隔 5 秒统一发一车。
- 注意:记得在页面卸载(关闭)时,把车上剩下的货强制发出去,别丢了。
3. 工程化构建配置
既然是 SDK,最好的分发方式当然是发布到 NPM。这样其他项目只需要一行命令就能接入你的前端错误监控系统。
这里我们选择 Rollup对代码进行打包,因为它比 Webpack 更适合打包库(Library),生成的代码更简洁。
经过构建和测试服务的搭建,我们最终的项目全貌是这样的:
bash
error-monitor/
├── dist/ # 打包产物
├── src/ # 源码目录
│ ├── index.ts # 入口文件
│ ├── errorHandler.ts # JS 错误捕获
│ ├── networkMonitor.ts# 网络请求监控
│ ├── resourceMonitor.ts# 资源加载监控
│ ├── sender.ts # 上报逻辑
│ └── utils.ts # 工具函数
├── test/ # 测试靶场
│ ├── server.js # 本地测试服务
│ └── index.html # 错误触发页面
├── package.json # 项目配置
├── rollup.config.js # Rollup 打包配置
├── tsconfig.json # TypeScript 配置
└── README.md
3.1 package 配置 (package.json)
package.json 不仅仅是依赖管理,它还定义了你的包如何被外部使用。配置不当会导致用户引入报错或无法获得代码提示。
json
{
"name": "error-monitor-sdk",
"version": "1.0.0",
"description": "A lightweight front-end error monitoring SDK",
"main": "dist/index.cjs.js", // CommonJS 入口
"module": "dist/index.esm.js", // ESM 入口
"browser": "dist/index.umd.js", // UMD 入口
"type": "module",
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w"
},
"keywords": ["error-monitor", "frontend", "sdk"],
"license": "MIT",
"files": ["dist"], // 发布时仅包含 dist 目录
"devDependencies": {
"rollup": "^4.9.0",
"@rollup/plugin-typescript": "^11.1.0",
"@rollup/plugin-terser": "^0.4.0", // 用于压缩代码
"typescript": "^5.3.0",
"tslib": "^2.6.0"
}
}
💡 关键字段解读:
name: 包的"身份证号"。在 NPM 全球范围内必须唯一,发布前记得先去搜一下有没有重名。- 入口文件"三剑客" (决定了别人怎么引用你的包):
main: CommonJS 入口。给 Node.js 环境或老旧构建工具(如 Webpack 4)使用的。module: ESM 入口。给现代构建工具(Vite, Webpack 5)使用的。支持 Tree Shaking(摇树优化),能减小体积。browser: UMD 入口 。给浏览器直接通过<script>标签引入使用的(如 CDN)。
files: 发布白名单 。指定npm publish时只上传哪些文件(这里我们只传编译后的dist目录)。源码、测试代码等不需要发上去,以减小包体积。
3.2 TypeScript 配置 (tsconfig.json)
我们需要配置 TypeScript 如何编译代码,并生成类型声明文件(.d.ts),这对使用 TS 的用户非常友好。
json
{
"compilerOptions": {
"target": "es5", // 编译成 ES5,兼容旧浏览器
"module": "esnext", // 保留 ES 模块语法,交给 Rollup 处理
"declaration": true, // 生成 .d.ts 类型文件 (关键!)
"declarationDir": "./dist", // 类型文件输出目录
"strict": true, // 开启严格模式,代码更健壮
"moduleResolution": "node" // 按 Node 方式解析模块
},
"include": ["src/**/*"] // 编译 src 下的所有文件
}
3.3 Rollup 打包配置 (rollup.config.js)
为了兼容各种使用场景,我们配置 Rollup 输出三种格式:
- ESM (
.esm.js): 给现代构建工具(Vite, Webpack)使用,支持 Tree Shaking。 - CJS (
.cjs.js): 给 Node.js 或旧版工具使用。 - UMD (
.umd.js) : 可以直接在浏览器通过<script>标签引入,会挂载全局变量。
javascript
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
export default {
input: 'src/index.ts', // 入口文件
output: [
{
file: 'dist/index.cjs.js',
format: 'cjs',
sourcemap: true,
},
{
file: 'dist/index.esm.js',
format: 'es',
sourcemap: true,
},
{
file: 'dist/index.umd.js',
format: 'umd',
name: 'ErrorMonitor', // <script> 引入时的全局变量名',
sourcemap: true,
plugins: [terser()], // UMD 格式进行压缩体积
},
],
plugins: [
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: 'dist',
}),
],
};
4. 发布到 NPM (保姆级教程)
4.1 准备工作
- 注册账号 :去 npmjs.com 注册一个账号(记得验证邮箱,否则无法发布)。
- 检查包名 :在 NPM 搜一下你的
package.json里的name,确保没有被占用。如果不幸重名,改个独特的名字,比如error-monitor-sdk-vip。
4.2 终端操作三步走
打开终端(Terminal),在项目根目录下操作:
第一步:登录 NPM
bash
npm login
- 输入命令后按回车,浏览器会弹出登录页面。
- 或者在终端根据提示输入用户名、密码和邮箱验证码。
- 登录成功后会显示
Logged in as <your-username>. - 注意:如果你之前切换过淘宝源,发布时必须切回官方源:
npm config set registry https://registry.npmjs.org/
第二步:打包代码
确保 dist 目录是最新的,不要发布空代码。
bash
npm run build
第三步:正式发布
bash
npm publish --access public
--access public参数用于确保发布的包是公开的(特别是当包名带@前缀时)。- 看到
+ error-monitor-sdk@1.0.0字样,恭喜你,发布成功!

现在,全世界的开发者都可以通过 npm install error-monitor-sdk 来使用你的作品了!
5. 如何使用
SDK 发布后,支持多种引入方式,适配各种开发场景。
方式 1:NPM + ES Modules (推荐)
适用于现代前端项目(Vite, Webpack, Rollup 等)。
bash
# 请将 error-monitor-sdk 替换为你实际发布的包名
npm install error-monitor-sdk
在你的业务代码入口(如 main.ts 或 app.js)引入并初始化:
typescript
// 请将 error-monitor-sdk 替换为你实际发布的包名
import { initErrorMonitor } from 'error-monitor-sdk';
initErrorMonitor({
reportUrl: 'http://localhost:3000/error-report',
projectName: 'MyAwesomeProject',
environment: 'production',
});
方式 2:NPM + CommonJS
适用于 Node.js 环境或旧版打包工具。
bash
# 请将 error-monitor-sdk 替换为你实际发布的包名
npm install error-monitor-sdk
javascript
// 请将 error-monitor-sdk 替换为你实际发布的包名
const { initErrorMonitor } = require('error-monitor-sdk');
initErrorMonitor({
reportUrl: 'http://localhost:3000/error-report',
projectName: 'MyAwesomeProject',
environment: 'production',
});
方式 3:CDN 直接引入
适用于不使用构建工具的传统项目或简单的 HTML 页面。
html
<!-- 请将 error-monitor-sdk 替换为你实际发布的包名,x.x.x 替换为具体版本号 -->
<script src="https://unpkg.com/error-monitor-sdk@x.x.x/dist/index.umd.js"></script>
<script>
// UMD 版本会将 SDK 挂载到 window.ErrorMonitor
ErrorMonitor.initErrorMonitor({
reportUrl: 'http://localhost:3000/error-report',
projectName: 'MyAwesomeProject',
environment: 'production',
});
</script>
6. 总结与展望
到这里,我们这个"麻雀虽小,五脏俱全"的错误监控 SDK 就算是跑起来了。
回头看看,几百行代码没白写,实打实搞定了三件事:
- 啥都能抓:JS 报错、Promise 挂了、接口 500、图片 404,一个都跑不掉,统统收入囊中。
- 死活都能报 :用了
Navigator.sendBeacon,哪怕用户秒关页面,最后那条"遗言"也能顽强地发给服务器。 - 拿来就能用:打包好了三种格式,还送了个"靶场"页面,点点按钮就能看效果,主打一个省心。
不过说实话,这离真正的"企业级"监控还有点距离。
想在生产环境(特别是高流量业务)扛大旗,还得把下面这些坑填了:
- 别盲猜 Bug :线上代码都是压缩的,得搞定 Sourcemap 还原 ,不然对着
a.b is not a function只有哭的份。 - 页面白了没 :有时候没报错但页面一片白,这种"假死"得靠 白屏检测 来发现。
- 到底快不快 :光不报错不够,还得看 性能指标 (FCP/LCP),监控页面加载速度。
- 用户干了啥 :复现 Bug 全靠猜?不行,得把用户出事前的点击、路由跳转全记下来,来个 行为回溯(案发现场还原)。
- 别把服务器搞崩 :报错太多得限流、去重,引入 采样率,不然监控服务先挂了就尴尬了。
贪多嚼不烂,这次我们先聚焦在最核心的"错误监控"闭环。
至于上面那些进阶玩法,我们下篇文章接着聊,带你一步步把这个系统打磨得更完美。
造轮子不是为了重复造,而是为了亲手拆开看看里面的齿轮是怎么转的,这才是学习的本质。
希望这篇文章能是你打造专属监控系统的起点。Happy Coding!