|

前端项目中的错误追踪与上报的若干方法

错误追踪是前端项目中不可不谈的一个主题,最近巩固学习 JavaScript 的基础内容,也对前端错误追踪有了一些理解,这篇文章简单的整理一下前端项目中错误追踪的若干种方法。

错误和异常

错误和异常是两种相似的概念,可以分割看,也可以结合在一起看。一般来说,错误可以理解为非主观的,异常可理解为主观的,但这样的理解因人而异,本文将错误和异常同等对待 ,同等处理。

从前端应用上来看,大致的错误和异常可以分为两个大类:

  • 文件载入异常;
  • 运行时异常;

文件载入异常又可能有很多的原因导致,比如:

  • 网络异常;
  • 文件路径异常;
  • 文件解析异常;

运行时异常往往表示的是 JavaScript 脚本的异常(CSS 和 HTML 即便解析异常也不会报错),按照 JavaScript 的规范,对错误的定义,又可细分为如下错误:

  • EvalError: eval 错误;
  • RangeError: 范围错误;
  • ReferenceError: 引用错误;
  • TypeError: 类型错误;
  • URIError: URI 错误;
  • SyntaxError: 语法错;

如果对 JavaScript 的运行时异常继续细分,可以分出更多细节,这里不再赘述。

错误追踪和现场恢复

谈到异常捕获,当然是指通过 JavaScript 来捕获。很容易理解,我们希望可以追踪前端项目中出现的错误,并且可以记录前端脚本的调用链,在错误发生的时候,可以快速恢复现场,以便我们可以快速定位错误的位置,所以这样的需求需要满足两个关键点:

  • 错误的追踪;
  • 恢复出错现场;

追踪错误也需要满足几个关键点:

  • 函数名;
  • 入参;
  • 出参;
  • 执行耗时;

而恢复现场则需要:

  • 函数调用链;

也就是说,当错误发生的时候,我们可以知道应用中是通调用了哪些函数,通过输入输出什么样的参数导致了错误和异常,有了这些数据 ,我们就可以快速的恢复出错现场,来实现错误定位。

异常捕获

一般来说,通过 window.onerror 定义可以拿到应用抛出的大部分未被捕获的异常。

window.onerror = function(errorMsg, url, lineNumber, columnNumber, errorObj) {
    console.log("errorMsg", errorMsg);
    console.log("url", url);
    console.log("lineNumber", lineNumber);
    console.log("columnNumber", columnNumber);
    console.log("errorObj", errorObj);
}

但 onerror 只是用来监听错误,并不会捕获错误,所以脚本的代码块会在出错的地方停止运行。

对于前端项目而言,我们大部分的功能都被拆解到一个函数上运行,所以 function 构成了前端应用的最小执行单元,下面介绍几种通过 JavaScript 进行函数错误捕获和追踪的方案,有侵入式的和非侵入式的,都可参考。

函数包裹器

可能最容易想到的就是在函数运行的时候包裹一层,这样可以比较方便的拿到函数的入参、出参和调用链,实现也相对简单。

function trace (fn) {
    let _self = this;
    return function () {
        try {
            // TODO:记录入参
            let res = fn.apply(_self, arguments);
            // TODO:记录出参 + 执行时长
            return res;
        }
        catch (e) {
            // 捕获异常
        }
    }
}

当我们需要调用某个函数时,可以借助 trace 函数来实现:

let sum = trace(function (a, b) {
    return a + b;
});

当被包裹的函数抛出异常时,就可以通过 trace 来捕获。

另一种实现方式是通过修改 Function 的原型对象来实现,效果与定义一个 trace 函数基本类似。

Function.prototype.trace = function () {
    let _self = this;

    return function () {
        try {
            // TODO:记录入参
            let res = _self.apply(this, arguments);
            // TODO:记录出参 + 执行时长
            return res;
        }
        catch (e) {
            // 捕获异常
        }
    }
}

如果一个函数需要被追踪,通过 trace 方法即可:

function sum (a, b) {
    return a + b
}

const mysum = sum.trace();
mysum(1, 2);
// 3

不过使用包裹器的这种方式存在明显的缺点:

  • 书写非常繁琐,需要再每个需要被追踪的函数外增加包裹器;
  • 完全侵入式,需要改动现有代码全部的函数调用部分;
  • 不利于移植;
  • 对实例对象不友好;

上面的方法只是起到了一个简单追踪的目的,但却非常松散,与现有项目无法解耦。

包装器模式

使用函数包裹器的思路,加上使用闭包,组成一个包装器设计模式。我们使用闭包的方式修改上述包裹器的方案。

首先定义一个 TraceStash 类,用于实现包装器和调用链的追踪。

function TraceStash () {
    this.traceList = [];
    // 调用链
    this.callChain = [];

    this.onError = function () { }
}

然后定义一个包装器函数,用于对输入的对象进行包装。因为我们默认了函数是前端应用的最小执行单元,所以我们最终的目的是对函数进行包装。除了函数本身以外,一个对象中可能也包含了方法,方法也是一个函数定义,所以我们需要接受函数和对象两种类型的输入参数,如果输入参数既不是函数也不是对象,就原样返回,不对其进行追踪。所以可以有以下定义:

/**
 * 包装器
 * @description 包装器用于将任意一个输入参数包装成一个支持 trace 的对象
 * @param {any} obj 输入参数
 */
TraceStash.prototype.decorator = function (p, parent = "") {
    if (utils.isFunction(p)) {
        return this.decoratorFunction(p, parent);
    }
    if (utils.isObject(p)) {
        return this.decoratorObject(p, parent);
    }
    return p;
}

由于我们需要追踪调用链,也就是对被包装的函数和对象的关系进行记录,所以这里 parent 参数用来对包装器设置初始名称。

下面需要定义 decoratorObjectdecoratorFunction 两个函数了。

/**
 * 对输入的函数进行包装
 * @param {function} fn 输入函数,被包装函数
 * @returns {function} 包装后的 fn 函数
 */
TraceStash.prototype.decoratorFunction = function (fn, parent = "") {
    fn._name = fn._name || fn.name;
    return this.activeTrace(fn, parent);
}

/**
 * 对输入的对象进行深度包装
 * @param {object} obj 被包裹的对象
 * @param {string}} parentName 父节点名称
 * @returns {object} 被包装后的 obj 对象
 */
TraceStash.prototype.decoratorObject = function (obj, parent = "") {
    for (let key in obj) {
        let rootName = obj.name || parent;
        if (utils.isObject(obj[key])) {
            obj[key] = this.decoratorObject(obj[key], rootName ? `{rootName}.{key}` : key);
            continue;
        }
        if (utils.isFunction(obj[key])) {
            obj[key]._name = key;
            obj[key] = this.decoratorFunction(obj[key], parent);
            continue;
        }
    }
    return obj;
}

上面的代码很好理解,无论是对象还是函数,最终的被包装对象都是函数。在 decoratorFunction 方法中,我们需要通过 activeTrace 来激活对传入函数的追踪,下面定义 activeTrace 方法。

/**
 * 对传入的函数开启追踪
 * @param {fnction} fn 被追踪的函数
 * @returns 
 */
TraceStash.prototype.activeTrace = function (fn, parent = "") {
    if (fn._trace) {
        return fn;
    }

    let _path = parent ? ( fn._name ? (parent + "." + fn._name) : parent) : fn._name;
    let fnTrace = new Trace(fn, _path);

    this.traceList.push(fnTrace);

    let ret = function () {
        try {
            // TODO:记录入参
            let result = fnTrace.exec(this, arguments);
            // TODO:记录出参 + 执行时长
            return result;
        }
        catch (e) {
            // 捕获异常
        }
    }

    ret._name = fn._name;
    ret._path = _path;
    ret._trace = true;

    return ret;
}

可以看到,在 activeTrace 方法中,我们对传入的函数进行了追踪实例化,是通过 new Trace() 来实现的,Trace 对象的设计是为了实现追踪其本身和调用链的解耦,由于篇幅的原因,Trace 对象的定义就不在这里展开了。

使用这个包装器也是比较简单的,这个包装器可以做到对现有代码只进行较小的改动,例如有如下一个对象:

let obj = {
    data: {
        a: 1,
        b: 2
    },
    add: function () {
        return this.data.a + this.data.b;
    },
    obj: function () {
        return {
            count: 0,
            times: function () {
                return ++this.count
            }
        }
    },
    number: {
        random: function (max) {
            if ((max - 0 | 0) !== (max - 0)) {
                throw 'max number must be a integer';
            }
            return Math.ceil(Math.random() * max);
        },
        ary: function () {
            return function () {
                return function () {
                    return Array.from(arguments)
                }
            }
        }
    }
}

如果需要对这个对象添加追踪,则只需要的调用 decorator 进行包装即可,两行代码即可实现:

const trace = new TraceStash();
obj = trace(obj);

另外,如有有一个函数需要被追踪,也可以使用包装器进行包装:

function sum (a, b) {
    if (typeof b === 'undefined') {
        throw 'b is undefined';
    }
    return a + b;
}

// 追踪 sum 函数
const mysum = trace(sum);

虽然相比上一个包裹器方案有了很大的改进,不过这个包装器模式依然存在一些小问题:

  • 半侵入式,仍然需要改动部分现有代码;
  • 对内部函数无法追踪;
  • 对返回值无法追踪;
  • 对 Promise 无法追踪;

侵入式的这个问题暂时是无解的,这样的设计模式避免不了要改写现有的代码,对函数做侵入;内部函数因为外部无法访问,所以无法追踪,这是语言特性,目前也是无解的;Promise 的问题倒是有解决的办法,只是可能不够完美;对返回值的追踪是可以实现的,当一个函数执行后返回一个新的函数或者对象时,可以继续追踪返回的函数或者对象。

下面可以看包装器的改进版。

包装器模式改进

这个改进版主要为了解决两个问题:

  • 增加对函数返回值的追踪;
  • 增加对 Promise 的追踪或者捕获;

追踪函数返回值

追踪内嵌函数还是比较好理解的,看一个例子:

function ary () {
    return function () {
        return function () {
            return Array.from(arguments)
        }
    }
}

如果要追踪上述函数,直接使用包装器进行包装,只能捕获到外层函数,但是这个函数的内部又返回了一个函数,当这个函数出现异常时,我们前面的包装器是捕获不到的,所以也无法记录这个函数的入参和出参信息。

同理,如果一个函数或者对象的内部在运行时返回了另一个函数或者对象,按照现在的方案我们依然是捕获不到的。

所以,为了解决上述问题,我们需要实现动态包装,也就是需要对被包装函数做一个检测,当被包装函数执行后,检测其返回值是否为函数或者对象,如果是函数或者对象,那么就对返回值再做一次包装。

修改 activeTrace 函数如下:

/**
 * 对传入的函数开启追踪
 * @param {fnction} fn 被追踪的函数
 * @returns 
 */
TraceStash.prototype.activeTrace = function (fn, parent = "") {

    ...

    let _self = this;

    let ret = function () {
        try {
            // TODO:记录入参
            let result = fnTrace.exec(this, arguments);
            // TODO:记录出参 + 执行时长

            // 如果返回值是函数,则包装这个函数
            if (utils.isFunction(result)) {
                result._name = R;
                result = _self.activeTrace(result, _path);
            }
            // 如果返回值是一个对象,则包装这个对象
            if (utils.isObject(result)) {
                result = _self.decoratorObject(result, _path + "." + R);
            }

            return result;
        }
        catch (e) {
            // 捕获异常
        }

        return result;
    }

    ...

    return ret;
}

通过以上改进,当一个函数在运行时返回新的函数或者对象时,返回的函数和对象也可以被追踪到。

追踪 Promise 对象

对 Promise 的追踪是有一些难点的,主要问题在于我们无法获取到 Promise 每次 resolve 的那个函数,所以从追踪粒度上来说,可以实现对整个 Promise 的追踪,但是无法精确到对每个 resolve 的回调函数进行追踪。同理,我们也仅可以实现对整个 Promise 进行错误捕获。

对 Promise整体的追踪相对比较好实现,一般来说,一个 Promise 对象也是在函数中返回的,所以我们对函数返回值再做一个分支处理即可。

/**
 * 对传入的函数开启追踪
 * @param {fnction} fn 被追踪的函数
 * @returns 
 */
TraceStash.prototype.activeTrace = function (fn, parent = "") {

    ...

    let _self = this;

    let ret = function () {
        try {
            // TODO:记录入参
            let result = fnTrace.exec(this, arguments);
            // TODO:记录出参 + 执行时长

            ...

            if (utils.isPromise(result)) {
                result.then(function (res) {
                    // TODO:记录 Promise 出参 + 执行时长
                })
                .catch (function (error) {
                    // 捕获异常
                })
            }

            return result;
        }
        catch (e) {
            // 捕获异常
        }

        return result;
    }

    ...

    return ret;
}

但仅仅对 Promise 增加 then 和 catch 是不够的,如果 Promise 的异常不是通过 reject 抛出,我们依然无法捕获到,所以还需要增加 window.onerrror 回调,上文已经有说明,这里不做展开。

到目前为止,虽然还不是足够完美,但大部分自定义的函数和对象产生的错误我们都已经可以追踪。

对 Promise 的追踪是不完美的,例如我们人为加的 catch 捕获不一定会发生。

原生事件方法拦截

如果看到了这里,应该也会发现上述包装器存在的问题,那就是无法处理原生事件,举个例子:

window.addEventListener('load', function () {
    throw "window loaded error"
});

上面的这个 window.onload 回调事件是无法被包装的,所以也就导致了无法追踪,但是前端页面中有许许多多的原生事件,这个庞大的群体如果无法被追踪,那么肯定会增加后续定位错误的难度。

对于这些原生的事件方法,可以拦截他们,并通过重新定义原生方法来实现追踪。

addEventListener 为例,我们需要通过调用这个方法来动态的为 DOM 元素增加事件监听器,通过重写 addEventListener 方法,就可以实现对回调函数的追踪。

重写 EventTarget.prototype.addEventListener 方法:

const ETP_addEventListener = EventTarget.prototype.addEventListener;

EventTarget.prototype.addEventListener = function (type, listener, useCapture) {
    let _self = this;

    let fn = function () {
        try {
            // TODO:记录入参
            let res = listener.apply(this, arguments);
            // TODO:记录出参 + 执行时长
            return res;
        }
        catch (e) {
            // 捕获异常
        }
    }

    fn._name = listener.name;
    fn._trace = true;

    ETP_addEventListener.call(_self, type, fn, useCapture);
}

假设有一个按钮需要添加单击事件,则:

document.getElementById('btn').addEventListener('click', function (e) {
    console.log(e, 'btn clicked');
    // others
})

可以看到并不需要对现有的代码做太大改动,直接拦截原生方法并重写即可。基于这个思路其实可以将 EventTarget 接口的方法全部重写,以便达到拦截的目的。

同样的,我们也可以重新 XMLHttpRequest 的相关接口,实现对 Ajax 的拦截,以便可以追踪异步请求出现的异常。

不过,以上重写原生方法的方式无法拦截这样的事件定义:

document.getElementById('btn').onclick = function (e) {
    throw "error";
}

这种方式定义的事件回调无法被捕获,如果需要获取上述事件的错误,则需要借助 window.onerror 来处理。

ES6 修饰符

通过使用 ES6 的修饰符特性也能实现错误追踪,其原理依然是包装器模式,只不过 ES6 在语言层面上帮我们实现了包装器,而不需要我们在编程式手动包装。

不过修饰器特性在浏览器端的支持不是很好,有较大的兼容性问题,需要 Babel 转码,这里就不展开了。

Babel 全局侵入

借助 Babel 最函数的全局侵入应该是最简单粗暴的方案了。

这个方案的原理也比较好理解:Babel 将源码进行 AST 解析,然后遍历 AST 语法树,对 FunctionDeclaration 节点和 FunctionExpression 节点做注入,加入追踪代码。

这种方案的有如下优点:

  • 全局注入,一步到位;
  • 项目源码无需修改;
  • 可以追踪内部函数;
  • 可以实现对源码的调用栈记录,无需 SourceMap;

但是缺点也是明显的:

  • 转码的结果是对源码的全局嵌入;

  • 目标代码体积增大;

  • 会与现有的代码会产生强耦合;

之前在网络上也有看到基于 Babel 做异常追踪注入的,不过个人感觉这种方法不够友好。这种方式相当于我们在写代码的时候对每一个函数调用都进行了 try catch 包裹,并记录调用参数,本质上还是侵入式的,而且是源码层面的侵入,只是借助了一个工具帮我们实现了自动化而已。

这里也不展开了,具体的实现是借助 AST 抽象语法树做节点遍历和注入来完成的,相当于开发了一个 Babel 的插件。

总结

上面是我最近学习和整理的一些方法,但是每一种方法单独来看都不够完美。最理想的情况是我们可以重写 Function 的构造函数,这种方式虽然可以实现,但却需要我们使用 new 关键字来创建函数,这其实是没有多少帮助的。后续我将基于上文的思路,结合每种方案的优点,尝试开发一个相对完美的错误追踪插件。

类似文章

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注