|

前端应用中函数调用链的跟踪和分析

上篇文章中简要分析了追踪错误的可行性,在以函数作为最小执行单元的前提下,可以通过函数包装器实现异常捕获,除了次方法外,我也分析了借助 Babel 实现全局侵入的可能性,不过我并不是很认可这种方式。本篇文章,我将以函数包装器作为基本操作,加上对原生方法的重写,实现一个相对全面的错误追踪和异常捕获模块。

函数调用链

这里的函数调用链,并不是我们 Web 页面中的 track 信息。如果前端的项目中出现了异常,可能会在控制台抛出如下信息:

errorObj Error: some error message
    at Module../main.js (main.js:370)
    at __webpack_require__ (bootstrap:24)
    at startup:4
    at startup:5

这个错误只能帮助我们获取到代码中抛出异常的位置,例如上面的信息中,main.js 的第 370 行抛出了错误。不过这样的错误只能帮我们定位代码位置,却不能告知我们是什么样的参数导致了错误,所以缺点明显:

  • 只能定位位置,无法定位原因;
  • 如果前端项目是打包部署的,定位错误的位置还需要借助 SourceMap;

我们即将要解决的,是函数调用链的问题。所谓函数调用链,就是从一个入口函数开始,到某个数据输出的整个过程中,哪些函数被调用了,并且每次调用的入参和出参是什么,当某个调用抛出了异常后,我们可以借助入参和出参快速还原现场。

设计目标

基于以上的理解,整理一下我们要实现的功能:

  • 记录单个函数的执行信息,包含入参、出参、执行次数、执行耗时等;
  • 记录函数的调用过程,即调用链;
  • 方便集成,尽量不用改动原项目代码;
  • 方便对错误进行重放;
  • 可以上传错误到服务器;

整体方案

有了上述的设计目标,在加上上篇文章中分析的各种方案,我选择使用函数包装器和重写原生方法相结合的方式来开发,这样做的目的有二:

  • 函数包装器用于拦截函数调用;
  • 重写原生方法用于间接拦截函数调用;

包装器是比较好理解的,就是通过一个包装函数(或者叫修饰函数)对原函数进行包裹,然后返回一个包裹后的新函数,新函数的输入输出与原函数一样,但是内部被增加了追踪器,这样就可以获取到原函数的调用信息。

重写原生方法也比较好理解,主要是为了解决异步函数的追踪问题,例如:

  • 使用 addEventListener 定义的事件回调函数;
  • Promise 对象的异步方法;
  • setTimeoutsetInterval 指令的异步回调方法;
  • Ajax 请求的异步回调方法;

上面列出的几种情况下,回调函数往往是匿名的,并且函数不会被暴露在全局,也不会被定义在对象示例上面,所以无法通过包装器直接包装。不过,借助重写上述情况的原生方法,可以实现间接的函数包装,这样就能追踪到这些异步的方法了。

设计实现

上面分析完毕,现在开始实现。首先,我们需要一个追踪器类,用来给函数增加追踪,下面来介绍追踪器的实现。

单次追踪器

单次追踪器用于记录函数单次调用的信息,定义一个 Trace 类。

function Trace (fn, path = "") {
    if (!utils.isFunction(fn) || fn._trace) {
        return fn;
    }

    let _self = this;

    this.traceId = (Date.now()).toString(16).substring(6) + Math.random().toString(16).substring(6);
    this.traceInfo = null

    this.counter = 0;
    this.path = path;

    this.fnTraced = function () {

        let _traceInfo = {
            id: _self.traceId,
            error: null,
            time: Date.now(),
            input: Array.from(arguments)
        }
        let result,
            start = Date.now();

        try {
            result = fn.apply(this, arguments);
        }
        catch (e) {
            _traceInfo.error = e;
        }
        finally {
            _traceInfo.output = result || null;
            _traceInfo.duration = Date.now() - start;
            _traceInfo.path = _self.path;
            _traceInfo.counter = ++_self.counter;
            _self.traceInfo = _traceInfo;

            // TODO: 加入调用链数组

            if (_traceInfo.error) {
                throw _traceInfo.error;
            }
        }

        return result;
    }
}

/**
 * 执行包装器函数
 * @returns 
 */
Trace.prototype.exec = function () {
    let context = [].shift.call(arguments);
    return this.fnTraced.apply(context, arguments);
}

export default Trace

这个 Trace 的定义很好理解,它的作用就是为传输的函数创建一个单次追踪器,函数被包裹在内部,通过暴露的 exec 方法来触发函数调用,以便我们可以记录调用信息。

另外需要注意,fnTraced 内部对异常做了捕获,但是捕获的目的只是为了记录错误和调用信息,最后这个错误还是被统一抛出,按照单一入口原则,这个错误最后将统一由 window.onerror 来捕获并处理。

包装器

在这两篇文章中,包装器是出现做多的词,上篇文章中我们对包装器也做了实现,这里就不再过多的罗列,这里只列出 activeTrace 方法,在这个方法中,需要对传入的函数进行追踪,会通过 new 一个 Trace 实例来实现。

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

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

    let ret = function (...args) {
        let result = fnTrace.exec(this, ...args);

        if (utils.isFunction(result)) {
            result._name = R;
            result = _self.activeTrace(result, _path);
        }

        if (utils.isObject(result)) {
            result = _self.decoratorObject(result, _path + "." + R);
        }

        return result;
    }

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

    return ret;
}

上面代码的主要部分我做了注释,简单来说,这个函数做了如下的事情:

  • 对传入的函数进行包装,实例化一个已传入参数为对象的 Trace 对象;
  • 将实例化出来的 Trace 对象间接返回到外部;
  • 对原函数的返回值进行判断,以便能继续动态追踪;

与上篇文章的同名方法不同,这个函数中不在对返回的 Promise 增加异常捕获,因为后面我们会重写 Promise 的关键方法。

Promise

Promise 是异步的,在实例化 Promise 对象时,我们很可能会传入一个匿名函数,或者一个没有暴露接口的具名函数,由于我们不可能对每一个项目中的函数都进行包装,所以就只能通过重写的方式来做间接包装。

对于 Promise 而言,需要重写的方法如下:

  • Promise.resolve;
  • Promise.reject;
  • Promise.prototype.then;
  • Promise.prototype.catch;

上面的方法都需要传入回调函数作为参数,所以重写了它们,就可以实现对匿名函数的包装和追踪。

代码如下:

import TraceDecorator from "./decorator"

const prms_then = Promise.prototype.then;
const prms_catch = Promise.prototype.catch;
const prms_resolve = Promise.resolve;
const prms_reject = Promise.reject;

/**
 * 重写 Promise 的 then 方法,以便可以追踪 then 回调函数的错误
 * @param {function} onResolved resolve 回调函数
 * @param {function} onRejected reject 回调函数
 * @returns Promise
 */
Promise.prototype.then = function (onResolved, onRejected) {
    /**
     * 使用包装器对 then 的回调函数进行包装
     * 被包装后的函数被自动添加追踪器
     */
    onResolved = TraceDecorator.decorator(onResolved, `Promise.then[resolved]`);
    onRejected = TraceDecorator.decorator(onRejected, `Promise.then[rejected]`);

    return prms_then.call(this, onResolved, onRejected);
}

/**
 * 重写 Promise 的 resolve 方法,对可能传入的 function 进行追踪
 * @param {any} p 
 * @returns Promise
 */
Promise.resolve = function (p) {
    p = TraceDecorator.decorator(p, `Promise.resolve`);
    return prms_resolve.call(this, p);
}

/**
 * 重写 Promise 的 reject 方法,对可能传入的 function 进行追踪
 * @param {any} p 
 * @returns Promise
 */
Promise.reject = function (p) {
    p = TraceDecorator.decorator(p, `Promise.reject`);
    return prms_reject.call(this, p);
}

/**
 * 重写 Promise 的 catch 方法,对可能传入的 function 进行追踪
 * @param {any} onRejected 异常捕获回调方法
 * @returns Promise
 */
Promise.prototype.catch = function (onRejected) {
    /**
     * 对 catch 传入的异常捕获函数进行追踪
     */
    onRejected = TraceDecorator.decorator(onRejected, `Promise.catch[rejected]`);

    return prms_catch.call(this, onRejected);
}

不过,如果一个 Promise 对象 reject 的异常没有被 catch 所捕获,就会抛出 unhandledrejection 异常,这个异常就会出现在前端的控制台中,如果需要捕获这个异常,就需要实现 window.onunhandledrejection 函数,并在其中处理错误,后续会有介绍。

EventTarget

EventTarget 是一个事件对象,很对原生对象都部署了这个接口,例如 window、document、websocket、XMLHTTPRequest 等等。例如我们需要给一个 DOM 节点添加事件监听,使用 EventTargetaddEventListener 方法可以这样写:

document.getElementById('btn').addEventListener('click', function (e) {
    // do something
});

为了能对自定义的事件回调函数进行错误追踪,需要重写 addEventListener 方法。

import TraceDecorator from "./decorator"

const ETP_addEventListener = EventTarget.prototype.addEventListener;

/**
 * 重写事件定义
 * @param {string} type event type
 * @param {funtion} listener event callback funtion
 * @param {boolean} useCapture Add Event Listener Options
 */
EventTarget.prototype.addEventListener = function (type, listener, useCapture) {
    listener = TraceDecorator.decorator(listener, `${listener.name || 'EventListener'}`);
    ETP_addEventListener.call(this, type, listener, useCapture);
}

通过重写,所有使用 addEventListener 添加的事件回调函数都可以被包装和追踪。

window.onerror

与 window.onerror 同等重要的,还有 window.onunhandledrejection ,前者处理通用异常,后者处理 Promise 异常,通过实现这两个函数,就可以捕获到最终的错误和异常,并按照自定义的方式进行处理。

首先,我们需要对原有的函数进行包装,即如果原应用中已经实现了对上述函数的定义,在不影响原函数功能的前提下,还有对原函数进行包装和追踪,代码如下:

import TraceDecorator from "./decorator"

/**
 * 如果已有对 window.onerror 的定义,则重新包装 onerror 回调函数
 */
if (window.onerror !== null) {
    window.onerror = TraceDecorator.decorator(window.onerror, 'window.onerror');
}

/**
 * 与上同理,处理 onunhandledrejection 事件,以便 Promise 的异常不被遗漏
 */
if (window.onunhandledrejection !== null) {
    window.onunhandledrejection = TraceDecorator.decorator(window.onunhandledrejection, 'window.onunhandledrejection');
}

/**
 * 同理,处理 onmessageerror 事件
 */
if (window.onmessageerror !== null) {
    window.onmessageerror = TraceDecorator(window.onmessageerror, 'window.onmessageerror');
}

然后定义我们自己的错误处理函数。

/**
 * 重新定义 error 监听器,处理如下异常:
 * - 内部函数的错误和异常;
 * - 资源加载异常;
 * - 网络异常;
 * - 无法直接通过包装器追踪的异常;
 */
const W_error = window.onerror;
window.onerror = function (errorMsg, url, lineNumber, columnNumber, errorObj) {
    W_error && W_error.apply(this, arguments);
    // TODO: 处理错误
    return true;
}

/**
 * 定义 unhandledrejection 回调函数,用于处理 Promise reject 没有被 catch 时触发的异常
 * 该操作是对 Promise 异常的补漏
 */
const W_unhandledrejection = window.onunhandledrejection;
window.onunhandledrejection = function (ev) {
    ev = ev || window.event;
    W_unhandledrejection && W_unhandledrejection.apply(this, arguments);
    // TODO: 处理错误
}

这样一来,我们就有了特定的入口来处理前端页面中的错误,并且每一种错误都尽可能的做了详尽的记录。当最终的错误被捕获时,我们可以在回调函数中上报错误信息。

错误处理函数

最后定义一个专门处理错误的对象,当错误发生并被捕获后,通过这个错误触发器来触发对错误的处理。

const ErrorTrigger = {

    /**
     * 出现错误和异常时的回调函数
     * !!!外部必须重写这个方法!!!
     * @param {array} callChain 调用栈信息
     */
    onError: function (callChain = []) {
        throw "please define onError callback function!";
    },

    /**
     * 发送错误信息
     * @param {object} traceinfo 调用信息
     */
    dispatch: function (traceinfo) {
        // TODO: 获取函数调用栈
        let _slef = this;
        setTimeout(function () {
            _self.onError(traceinfo);
        });
    }
}

export {
    ErrorTrigger
}

ErrorTrigger 触发器有两个方法,其中 onError 方法必须又外部去实现,这个接口对外暴露,需要用户去完成对错误的处理,可以选择打印或者上报。

另一个方法 dispatch 是对错误进行处理,可以看到这里的错误处理是通过 SetTimeout 来异步处理的,这是由于错误处理函数可能会比较耗时,使用异步的方式执行可以避免其阻塞主任务。

引申:为什么不重写 setTimeout

JavaScript 原生提供了一些具有异步功能的函数,这些函数会接受一个另一个函数作为参数,这样的原生函数常见的有:

  • setTimeout;
  • setInterval;
  • requestAnimationFrame;

但是我并没有重写他们,主要原因在于,这些函数的回调函数没有确切的输入参数,因为我们的目的是为了记录函数的调用栈以及调用参数,调用参数的关键一点就是入参,而没有入参的函数去记录的话是没有太大意义的。

引申:为什么不重写 Array

没有重写 Array 的一些原生方法,如 sort 、forEach 等等的原因与上述不同,setTimeout 的回调函数没有参数,但是 Array 的 sort 方法是有参数的,依然不去重写的原因,是由于 Array 的原生方法在使用时不会作为一个独立函数存在,我们往往会在另一个函数中去使用他们,所以只需要尽可能保证对调用他们的函数实现追踪即可。

总结

由于篇幅的原因,本篇作为文章的上文先到此为止,本篇文章主要介绍了使用函数包装器和重写原生方法相结合的方式实现函数调用链的追踪和异常分析,本篇是一个构件方案,还未涉及到调用链及其使用,下一篇将接着本篇文件,继续实现调用链的记录以及在前端项目中的使用。

类似文章

发表回复

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