快速搞懂错误监控原理

940 阅读5分钟

在线上中我们经常会遇到一些奇奇怪怪的报错影响页面的执行导致用户体验很差,当我们想复现的时候却复现不出来,如果这个时候我们给项目加入异常的错误监控,我们就可以通过上报的数据及时定位错误并修复。

我们来看看以下几种捕获错误的方法

try-catch

用于捕获同步代码中的错误。

// 普通常规同步错误
try {
  const a = b
} catch (error) {
  console.log('try-catch', error);
}
// 异步错误
try {
  setTimeout(() => {
    const a = c
  }, 0);
} catch (error) {
  console.log('try-catch', error);
}

image.png

可以发现只能捕获代码同步执行错误,异步错误不能捕获到(图中红色的报错是浏览器捕获的)

window.onerror

window.onerror 是最基本的全局错误捕获机制。它可以捕获运行时错误和资源加载错误。

/**
  * @param { string } message 错误信息
  * @param { string } source 发生错误的脚本URL
  * @param { number } lineno 发生错误的行号
  * @param { number } colno 发生错误的列号
  * @param { object } error Error对象
*/
  window.onerror = function(message, source, lineno, colno, error) {
    console.log('错误信息:', message, '\n错误脚本URL', source, '\n错误行号', lineno, '\n错误列号', colno, '\nError对象', error )
  }

我们可以先通过vite脚手架工具(如npm init vite@latest)搭建一个vue的项目,然后运行该项目(本地服务http://127.0.0.1:5173/),在index.html中插入监听错误的代码

<script>
  window.onerror = function(message, source, lineno, colno, error) {
    console.log('错误信息:', message, '\n错误脚本URL', source, '\n错误行号', lineno, '\n错误列号', colno, '\nError对象', error )
  }
</script>

如图

image.png 我们要把这段代码放在所有脚本最先执行的地方,这样才会监听到异常错误。在App.vue文件中输入报错的代码,看看能否捕获到?

// App.vue
<script setup>
undefined.a
</script>

image.png

发现错误可以捕获到,并且报错信息很详细!

类同源限制

现在我们引入一个跨域的js脚本试试,看看是否可以捕获到错误?
直接再跑一个本地服务(执行yarn dev即可),不同端口就符合跨域了。
在public文件下创建一个test.js文件,内容如下

// test.js
undefined.b

在项目中的index.html插入如下代码

// index.html
<script src="http://127.0.0.1:5174/test.js"></script>

image.png

访问第一个本地服务(http://127.0.0.1:5173/),查看log

image.png

为什么捕捉不到详细的错误信息?

因为当加载自不同域的脚本发送语法错误时,为避免信息泄露,浏览器将不会报告具体的错误信息,而代之以简单的“Script error”。

要解决这个,可尝试通过CORS来越过此限制,具体需如下步骤:

  • 目标脚本文件需在响应头中添加Access-Control-Allow-Origin: *属性;
  • 页面引用目标脚本时,需添加crossorigin属性,

现在我们来实操一下

<script src="http://127.0.0.1:4000/test.js" crossorigin></script>

访问第一个本地服务(http://127.0.0.1:5173/),查看log

image.png

成功了!

addEventListener('error')

window.addEventListener('error') 是一个在 JavaScript 中用于监听错误事件的方法。当在全局作用域中发生错误时,会触发这个事件。这可以用于全局错误处理,记录和监控运行时错误。

window.addEventListener('error', function (event) {
    console.log("Error message: " + event.message);
    console.log("Source file: " + event.filename);
    console.log("Line number: " + event.lineno);
    console.log("Column number: " + event.colno);
    console.log("Error object: " + event.error);
    // 可以通过网络请求将错误信息发送到服务器
    event.preventDefault(); // 阻止默认行为
}, true); // 第三个参数 true 确保捕获到捕获阶段的错误

前面两者onerroraddEventListener('error')都无法捕获Promise抛出来的错误

addEventListener('unhandledrejection')

当 Promise被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;

window.addEventListener("unhandledrejection", (event) => {
    console.log(`UNHANDLED PROMISE REJECTION: ${event.reason}`);
    // preventDefault阻止传播,不会在控制台打印
    // event.preventDefault();
});

Promise.reject('我抛出异常了')

image.png

接口错误

接口错误则是通过拦截浏览器内置的 XMLHttpRequest、fetch 对象来实现捕获

XMLHttpRequest

下面就是简单例子

oReq.open("GET", "http://www.example.org/example.txt");
oReq.send();

我们只需要重写opensend方法,在open中可以拦截请求的参数,在send中拦截响应结果

const originalSend = XMLHttpRequest.prototype.send;
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...args) {
  console.log(args, 'args')
  originalOpen.apply(this, args);
};

XMLHttpRequest.prototype.send = function(data) {
  this.addEventListener('loadend', () => {
    const { responseType, response, status } = this;
    console.log('请求响应数据:', responseType, response, status);
  });
  originalSend.call(this, data);
};

其中send方法中的loadend 事件会在请求完成时触发,无论请求是否成功。这种方法可以让你在请求结束时统一处理所有结果,包括成功和失败的情况。

我们写个例子来看看log

// demo1
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.github.com/repos', true);
xhr.send();

// demo2
axios.get('https://api.github.com/repos')

image.png

demo1demo2 的输出结果一致的,因为Axios 在浏览器环境下也是基于 XMLHttpRequest 实现的。 通过输出结果可以看到捕获成功了

fetch

const originalFetch = fetch;
window.fetch = (url, config) => {
  console.log(url, config, 'args')
  return originalFetch(url, config).then(res => {
    res.text().then(data => {
      console.log(data, '全局捕获data')
    })
    return res
  }).catch(function(error) {
    console.log(error, 'error')
  })
}

// demo
fetch('https://api.github.com/repos').then((res) => res.text())
.then()

image.png

发现报错了,是因为fetch返回的Response对象的body只能被读取一次,我们全局的时候读取了一次,就导致在demo那里读取不了,所以我们可以通过res.clone克隆,防止被标记已消费

window.fetch = (url, config) => {
  console.log(url, config, 'args')
  return originalFetch(url, config).then(res => {
    const tempRes = res.clone(); // 新增
    tempRes.text().then(data => {
      console.log(data, '全局捕获data')
    })
    return res
  }).catch(function(error) {
    console.log(error, 'error')
  })
}

image.png

发现捕获成功了,这样接口错误的捕获就大功告成了

插件测试

我们以vue项目为例

写一个异常的监控的插件来自测一下

// errPlugin
class ErrorMonitor {
  static init() {
    // js错误捕获
    window.onerror = function (message, source, lineno, colno, error) {
        console.log("Captured error:", message, source, lineno, colno, error);
        return false;
    };
    // promise捕获
    window.addEventListener('unhandledrejection', function (event) {
        console.log("Captured unhandled rejection:", event.reason);
    });
    // 接口错误捕获
    const originalSend = XMLHttpRequest.prototype.send;
    const originalOpen = XMLHttpRequest.prototype.open;
    let req = {}
    XMLHttpRequest.prototype.open = function (method, url) {
      req = { method, url }
      originalOpen.apply(this, [method, url]);
    };

    XMLHttpRequest.prototype.send = function(data) {
      this.addEventListener('loadend', () => {
        const { responseType, response, status } = this;
        console.log('接口响应捕获:', responseType, response, status);
      });
      originalSend.call(this, data);
    };
  }
}

export default {
    install: (app, option) => {
        ErrorMonitor.init();
    }
};

// main.js
import errPlugin from './errPlugin
'
const app = createApp(App);
app.use(errPlugin).mount('#app')

// App.vue
<script setup>
undefined.aa
</script>

我们执行build的命令打包,然后把打包生成的dist复制到public目录下,并运行项目,这里主要是为了模仿将打包后的项目部署上去。

http://127.0.0.1:5173/这个为运行后的项目路径,访问http://127.0.0.1:5173/#/dist/index.html,看控制台是否有捕获到错误

image.png

捕获成功了

我们再看看promise相关的错误能否捕获,增加异步报错代码,重新执行上述打包和复制操作

// App.vue
<script setup>
Promise.reject('我抛出异常了')
</script>

image.png

也捕获成功了!

我们看看接口错误的捕获

// App.vue
<script setup>
import axios from 'axios'

axios.get('https://api.github.com/repos')
</script>

image.png

接口错误捕获成功了,unhandledrejection也捕获到了,因为axios内部也是通过promise来封装http请求的