Home

Awesome

大型前端项目要怎么跟踪和分析函数调用链

方案设计

简单做一个函数耗时分析的功能还是比较简单的,写代码也是比较容易的一部分。但如果让一个功能真正发挥它的价值,前期方案的设计和分析也是很重要的。

现状

一般来说,对于大型项目或是新人加入,维护过程(熟悉代码、定位问题、性能优化等)比较痛的有以下问题:

要是遇到代码稍微复杂点,事件比较多、函数调用也特别多的,即使使用断点也能看到眼花,蒸汽眼罩都得多买一些(真的贵啊)。生活本来就比较难了,身为技术人的我们得用技术去提升下自身的生活和工作体验呀。

回到上面说到的现状,函数执行黑盒这块相信大家都比较容易考虑到,而在日常的问题定位中,很多情况下我们需要查用户的问题,但是用户的反馈常常表达上会和我们理解的不一致。那如果能直接还原用户的操作,那岂不是棒棒哒?

目标 那既然现状有了,我们可以根据自己的需要,把目标确定下来。

个人觉得,即使是技术人员,前期的目标和现状分析也是很重要的。我们常常会遇到很多项目进行到一半发现和预期不一致、需要重新返工甚至只能放弃,往往是因为前期做的调研不充分,考虑到的情况还不够多。综上,设计的部分也需要好好去做,至于具体的方式,是手稿、文字、流程图、还是 PPT,可以根据个人喜好去选择。

那么,我先来拆分下自己想要的功能是什么样子的:

首先,基本功能必不可少,主要包括函数的一些执行情况,例如调用链、函数名、类名、入参出参,还有性能分析相关的,包括耗时、函数调用次数的统计等。这些在我们分析和定位问题的时候,能派上不少的用场。

其次,对于这部分功能代码,需要满足易用性,包括易接入、易拓展等。易接入主要考虑不需要改动源代码,这也是代码设计中比较基础的要求了。易拓展则预留给后续想要在现有功能基础上添加新功能的时候,会相对简便。

整体方案设计

方案设计也不算复杂,基本上就是结合目标,然后以自己最熟练的方向作为起点,一点点把完整的功能视图补全。最后,再回顾下前面的现状和目标,分析设计出来的方案是否有脱离实际需要(有时候我们的脑补能力很强大,容易飘离本意)。

说起函数,最简单的就是给每个想要检测的函数包裹一层,除了调用原有的功能以外,新增对函数的一些数据采集,包括上面说到的单个函数执行信息和全局的辅助信息等。

要怎么方便地使用这些信息呢?我们可以通过堆栈的方式存下来,然后对这些信息进行处理来获取调用链、耗时等。通常来说,可以暴露全局变量的接口,来快速打印输出这些信息。

我们来看看设计方案:

这里,将函数重放和上传服务器的优先级降低,先实现核心功能。工作内容的拆分、工作量的预估这些也都是方案设计中比较重要的部分,将大目标拆分成一个个小目标,这样对整体节奏、实现过程的把控会更有力。

方案细节设计 整体方案初步定了,我们需要考虑每个环节的细节方案。以一期的功能为主,流程包括以下:

  1. 监听函数执行。
  2. 采集函数执行情况:(调用链路、入参出参、耗时)。
  3. 暴露全局变量或 API。
  4. 使用全局变量或 API 打印调用链等。

由于这是一个非关键链路的功能,除了怎样的功能更方便使用以外,主要考虑这样的旁路功能不能影响主要功能的性能、不能因为一些异常导致正常功能无法使用。因此我们需要对每个流程进行一些分析和考虑:

至此,大致的方案设计已经完成。

函数调用链的设计和实现

其实对于函数耗时统计的,网上也有一大堆的代码可以搜到,其中基于装饰器实现的也很多。由于我们项目中代码大多数都是基于 Class 设计的,因此装饰器这种不影响源代码的方式更加适合。

单次追踪对象

装饰器的实现其实不难,网上也有很多可以参考的。而我们装饰器里的具体逻辑是怎样的,依赖我们设计的单次追踪对象和调用栈是怎样的。因此,我们可以先设计一下单次追踪对象。

该对象要实现的功能包括:

来,直接看大致的代码设计:

interface IFunctionTraceInfo {
  className?: string; // 类名
  functionName?: string; // 函数名
  inParams?: any[] | null; // 入参
  outParams?: any[] | null; // 出参
  timeConsuming?: number; // 耗时
  originFunction?: Function; // 原函数
}

class FunctionTrace {
  traceId: string; // 标记本次 Trace
  traceInfo: IFunctionTraceInfo;

  constructor(traceInfo: IFunctionTraceInfo) {
    this.traceId = this.getRandomId();
    this.traceInfo = traceInfo;
  }

  // 随机生成一个 ID 来标记
  getRandomId() {
    // 时间戳(9位) + 随机串(10位)
    return Date.now().toString(32) + Math.random().toString(32).substring(2);
  }

  // 更新该函数的一些信息
  update(traceInfo: IFunctionTraceInfo) {}

  // 执行该函数
  exec() {}

  // 打印该函数的一些信息
  print() {
    const { className, functionName, timeConsuming } = this.traceInfo;
    return `${className} -> ${functionName}(${this.traceId}): ${timeConsuming}`;
  }
}

追踪堆栈

除了单次的追踪堆栈,我们还需要根据函数执行的顺序等维护完整的调用链信息,因此我们需要一个堆栈来维护完整的调用链。

那么,这个堆栈的功能也应该包括:

同样的,我们来看看代码:

interface StashFunctionTrace {
  traceLevel?: number;
  trace: FunctionTrace;
}

class FunctionTraceStash {
  level: number; // 当前层级,默认为0
  traceList: StashFunctionTrace[]; // Trace数组

  constructor() {
    this.level = 0;
    this.traceList = [];
  }

  // 开始本次 Trace
  // 添加该 Trace 之后将 level + 1,便于记录当前 Trace 的层次
  start(trace: FunctionTrace) {}

  // 结束本次 Trace
  end() {}

  // 根据 traceId 获取某个 Trace 对象
  getTrace(traceId: string): StashFunctionTrace | null {
    return (
      this.traceList.find(
        (stashTrace) => stashTrace.trace.traceId === traceId
      ) || null
    );
  }

  // 打印 Trace 堆栈信息
  printTraceList(): string {
    const traceStringList: string[] = [];
    this.traceList.forEach((stashTrace) => {
      let prefix = "";
      if (stashTrace.traceLevel && stashTrace.traceLevel > 0) {
        // 根据层次,前置 tab
        prefix = new Array(stashTrace.traceLevel).join("\t");
      }
      traceStringList.push(prefix + stashTrace.trace.print());
    });
    return traceStringList.join("\n");
  }

  // 打印函数调用次数统计
  printTraceCount(className?: string, functionName?: string) {}

  // 重放该堆栈
  replay() {}

  // 清空该堆栈信息
  clear() {}
}

装饰器逻辑

到这里,我们可以确定装饰器需要进行哪些操作:

为了方便使用,我们可以设计基于 Class 的装饰器,以及基于 Class.methods 方法的装饰器,还可以基于单函数的装饰器。

我们还可以通过 AST 分析自动给代码中需要的部分添加上装饰器。至于装饰器具体实现,可参考下文代码细节分析。

执行效果,这里放一下一部分的调用栈打印效果:

<img src="./src/imgs/4.png" />

目前,该功能也发现了一些项目中执行耗时较长、调用次数过多的方法,也在一一定位分析,整体效果还是可以的。

上面这部分感谢被删同学@godbasin作出的总结。

装饰器对类方法性能的监听

在很多时候我们项目越来越大的时候,我们希望去监听局部某些类方法的性能,这个时候我们既不想影响源代码的功能,但又想借助某些方案去窥探类方法内部的运行效能,此时我们就可以考虑使用装饰器对类方法性能进行监听。装饰器相信大家都不陌生了,虽然在 Javasript 里面它仍处于提议阶段,但是我们已经可以 TypeScript 里面运用这个特性,也可以借助 babel 的语法转换在 Javasript 里面使用。

<img src="./src/imgs/2.png" />

那先简单讲讲什么是装饰器吧

装饰器其实是对类、方法、访问符(get 和 set 等)、参数和属性之类的一种装饰,可以针对其添加一些额外的行为,所以一般我们在项目里面常见有四种类型的装饰器:

简单来讲就是在原代码外部包裹另一部分代码,而包裹的代码用于修饰源代码,从而使源代码在不受影响的情况下,拓展出新的功能,这是一种非入侵式的代码注入,是一种良好的代码拓展手段。我们常见的 React 里面经常也会遇到这种思路的代码,比如高阶组件和函数复合,很多第三方库也是用类似的方案来作为一种插件修改源代码,类似的有 Mobx 和 Redux。

如果该装饰器用于修饰拓展一个类,那它就是类装饰器,如果是用于修饰拓展一个函数,那么它就是一个函数装饰器,其他也如此,使用的是 TypeScript 的语法,使用@作为标识符,并放置在被装饰代码之前,由于该语法糖仍处于提议阶段,未来仍有可能改变,具体写法如下:

// 类装饰器
@sealed
class Greeter {
  constructor(message: string) {
    this.greeting = message;
  }

  // 属性装饰器
  @format("Hello, %s")
  greeting: string;

  // 访问器装饰器
  @configurable(false)
  get greeting() {
    return this.greeting;
  }

  // 方法装饰器
  @validate
  // 参数装饰器
  greet(@required name: string) {
    return "Hello " + name + ", " + this.greeting;
  }
}

用装饰器处理下业务代码吧

比如现在我有以下一段业务代码,是一个常用到的工具类:

class RequestApi {
  undo() {}
  redo() {}
  applyCollab() {}
  applyOffline() {}
  // ...
}

现在我们需要增加一个函数执行的耗时,我们可以直接在 undo 方法内部增加这些代码,本质其实是在 undo 方法执行前记录开始时间,执行后记录结束的计算,两者的差值就相当于该函数的运行时间了。

class RequestApi {
  undo(a, b) {
+ // 函数执行前的时间
+ let start = new Date().valueOf();
+   try {
      console.log("undo");
+   } finally {
+     // 函数执行后的时间
+     let end = new Date().valueOf()
+     console.log([{ performance: end - start }]);
+   }
    // ...
  }
}

当然我们还可以查看该类方式在业务上调用的一些具体情况,诸如:入参和出参的情况,方法执行前后的内存变换,方法被调用的次数和方法是否出现未知错误等等。但如果我们直接修改该类方法,那么有可能会破坏该类的原有逻辑和理解,对函数结构造成不可逆的破坏,该函数调用次数也很多,在调用方耦合这部分监听的代码也不友好,后期如果有相似的类方法需要统计耗时,每个函数添加相似片段的代码,低效复用率低和维护成本高,那么怎么办呢,我们就可以在这里使用装饰器代替直接修改类方法,从而在不改变原有代码的固有逻辑和理解情况下,往类方法增加一些监听方法的装饰代码。

// 灵活封装方法装饰器,统计类方法的耗时
+ function logPerformance(target, name, descriptor) {
+   const original = descriptor && descriptor.value;
+   if (typeof original === "function") {
+     // 在 NodeJS 中你可以使用 process.hrtime() 代替 new Date() 实际测试 performance.now() 在浏览器端更精确
+     let start = new Date().valueOf();
+     descriptor.value = function (...args) {
+       try {
+         const result = original.apply(this, args);
+         return result;
+       } catch (e) {
+         throw e;
+       } finally {
+         // @ts-ignore
+         let end = new Date().valueOf();
+         console.log([{ args, performance: end - start }]);
+       }
+     };
+   }
+   return descriptor;
+ }

class RequestApi {
+ @logPerformance
  undo(a, b) {
    console.log("undo");
    // ...
  }
  // ...
}

我们把前面在 undo 方法内部增加的代码封装成logPerformance装饰器,这个装饰器主要逻辑如下,装饰器修饰的不是类本身,而是修饰类的方法,那么它的描述符 descriptor 会记录着这个方法的全部信息,我们可以对它任意的进行扩展和封装,而 descriptor.value 属性是描述符表示方法的默认返回值,这里我们可以直接覆盖一个新的返回值来修改该方法,如果返回值为 undefined 则会忽略,使用之前的 descriptor.value 引用作为方法的描述符,其实我们也可以修改 target 来达到同样的目的。

笔者这里建议在浏览器端使用performance.now()来做为测量,实践中performance.now()更为精确,performance.now()是相对于页面加载和更精确的数量级。用例包括基准测试和其他需要高分辨率时间的情况,如媒体(游戏、音频、视频等)。 需要注意的是,performance.now()仅在较新的浏览器(包括 IE10+)中可用。 Date.now()相对于 Unix epoch(1970-01-01t00:00:00z)并且依赖于系统时钟。在 NodeJS 中你可以使用 process.hrtime() 来代替。

这里其实还可以收集函数执行前后的内存变化,可以使用performance.memory来观察,不过performance.memory还没成为规范,并且实际操作有一定的误差,暂时还是不建议使用。也可以收集函数前后的入参argument和出参,并且在这里还可以加入上报等逻辑,来调查函数的使用频率和错误状态。

上面这段 TypeScript 代码经过编译最终会变成如下 JavaScript 代码:

var __decorate =
  (this && this.__decorate) ||
  function (decorators, target, key, desc) {
    var c = arguments.length,
      r =
        c < 3
          ? target
          : desc === null
          ? (desc = Object.getOwnPropertyDescriptor(target, key))
          : desc,
      d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
      r = Reflect.decorate(decorators, target, key, desc);
    else
      for (var i = decorators.length - 1; i >= 0; i--)
        if ((d = decorators[i]))
          r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
  };
function logPerformance(target, name, descriptor) {
  var original = descriptor && descriptor.value;
  if (typeof original === "function") {
    // 在 NodeJS 中你可以使用 process.hrtime() 代替 performance.now()
    var start_1 = performance && performance.now();
    descriptor.value = function () {
      var args = [];
      for (var _i = 0; _i < arguments.length; _i++) {
        args[_i] = arguments[_i];
      }
      try {
        var result = original.apply(this, args);
        return result;
      } catch (e) {
        throw e;
      } finally {
        var end = performance && performance.now();
        console.log([{ args: args, performance: start_1 - end }]);
      }
    };
  }
  return descriptor;
}
var RequestApi = /** @class */ (function () {
  function RequestApi() {}
  RequestApi.prototype.undo = function (a, b) {
    console.log("undo");
    // ...
  };
  __decorate([logPerformance], RequestApi.prototype, "undo");
  return RequestApi;
})();
new RequestApi().undo();

由于我们使用 TypeScript 解析的装饰器语法糖,从解析后的 JavaScript 代码我们可以分析出装饰器的原理是什么,我们可以看到方法装饰器实现的本质都在__decorate()方法里面,里面主要配合运用了getOwnPropertyDescriptor()方法和Object.defineProperty()方法,而 target 的本质其实是RequestApi.prototype,这里会使用 Object.getOwnPropertyDescriptor(RequestApi.prototype, "undo") 获取 descriptor 描述符信息,然后使用 Object.defineProperty(RequestApi.prototype, "undo", descriptor) 修改 descriptor 并修饰原函数,并使用 Object.defineProperty() 重新设置该类,一般来说我们也可以直接使用直接对对象原型方法来赋值去改变这个方法,但是使用 Object.defineProperty() 的好处是 writable、enumerable 和 configurable 这些值都可以重新修改了,有了上面这几个步骤我们就完成整个装饰器的功能,简化之后大概就是以下代码的思路。

class RequestApi {
  undo() {
    console.log("undo");
    // ...
  }
  // ...
}
const descriptor = Object.getOwnPropertyDescriptor(
  RequestApi.prototype,
  "undo"
);
Object.defineProperty(RequestApi.prototype, "undo", {
  ...descriptor,
  // 修改描述符
  value: () => console.log("undo descriptor"),
});
// 建议使用 Object.defineProperty 更改属性,不建议使用下面这种方法
// RequestApi.prototype.undo = descriptor.value;
new RequestApi().undo();

进一步优化我们的装饰器

由上面的分析之后,我们其实可以使用该原理在 JavaScript 中实现一个装饰器,因为众所周知的原因,我们很多业务还有很多非 TypeScript 的 JavaScript 代码,所以我们可以使用上面的原理做一个兼容性比较好的装饰器去处理各种复杂的业务代码。不仅如此,我们还可以根据业务需求改进我们的方法装饰器,因为代码中一个类有可能有几十个方法,如果我们往每个方法里面绑定一个装饰器,那么代码就会写成以下未改进前的样子,但这种写法在需要装饰比较多的方法之后显得有点低效。

// 未改进前
class RequestApi {
+ @logPerformance
  undo() {}
+ @logPerformance
  redo() {}
+ @logPerformance
  applyCollab() {}
+ @logPerformance
  applyOffline() {}
  // ...
}

// 改进后
=>
+ @logPerformance
class RequestApi {
  undo() {}
  redo() {}
  applyCollab() {}
  applyOffline() {}
  // ...
}

所以某些时候我们不止想装饰类里面的某几个方法,而是想修饰整个类里面的所有方法,那么我们可以考虑一下装饰整个类,扫描整个类里面所有的方法,并修改这些方法修改来装饰,期间我们还可以放入一些方法或者属性的匹配规则,从而有规律的去装饰特定的一些方法,那么我们以下就慢慢进行实现。首先可以先把装饰器放到类上面,使用 Object.getOwnPropertyNames() 方法获取该类所有的方法,并使用 Object.keys 遍历出每一个方法,然后根据上面刚才方法装饰器解析的思路,使用 Object.getOwnPropertyDescriptor() 取出每一个方法的描述符,重新修改和修饰,最后结合 Object.defineProperty() 方法重新整合出新的 RequestApi 类,最后而 RequestApi 类里面所有方法都被成功的装饰了一遍。

class RequestApi {
  undo() {}
  redo() {}
  // ...
}
// 获取类所有的方法
const propertyNames = Object.getOwnPropertyNames(RequestApi.prototype);
// 遍历类所有的方法
Object.keys(propertyNames).forEach((key) => {
  // 获取描述符
  const descriptor = Object.getOwnPropertyDescriptor(
    RequestApi.prototype,
    propertyNames[key]
  );
  Object.defineProperty(RequestApi.prototype, propertyNames[key], {
    ...descriptor,
    value: descriptor.value.bind(this),
  });
});

有了上面的这个思路我们就只需要把它稍微封装一下,就可以封装出这个通用的装饰器,有了这个装饰器我们还可以继续丰富这个装饰器的接口,我们可以使用一个闭包来封装这个装饰器,让装饰器可以带参数来丰富更多的功能,我们可以在上面增加接口开关,控制装饰器的特定功能,比如下面我们可以使用 isTraceDecoratorOpen, isInParamsOpen, isOutParamsOpen 等来分别控制该装饰器是否要记录入参,是否要记录出参,是否使用装饰后的函数还是原函数,后续我们还可以使用 Relfect Metadata ,它强大的反射接口允许我们在运行时检查未知类并找出有关它的所有内容。我们可以使用它找到以下信息,比如:类的名称,类型,构造函数参数的名称和类型等,这里就不单独阐述这方面的知识了,有兴趣的同学可以查看 Relfect Metadata 库的相关文档和信息,甚者我们可以使用一个堆栈去维护装饰器返回的结果,这个堆栈可以提供一个 start 和 end 的方法分别放在函数执行前和执行后,一个完整的堆栈可以分析出局部某一部分的类的执行效率,并通过入参来推导和模拟出一次完整的类方法被调用的过程,从而复现问题和提升类方法的性能。

<img src="src/imgs/3.png" />
+ interface Options {
+     // 是否开启监听
+     isTraceDecoratorOpen?: boolean;
+     // 是否记录函数入参
+     isInParamsOpen?: boolean;
+     // 是否记录函数出参
+     isOutParamsOpen?: boolean;
+ }
+ function logPerformance(options: Options) {
+   return function (Class: any) {
+     // 获取类所有的方法
+     const propertyNames = Object.getOwnPropertyNames(Class.prototype);
+     // 这里可以对原函数进行装饰
+     const decorateFunction = (...inParams) => descriptor.value.apply(this, inParams);
+     // 遍历类所有的方法
+     Object.keys(propertyNames).forEach((key) => {
+       // 获取描述符
+       const descriptor = Object.getOwnPropertyDescriptor(
+         Class.prototype,
+         propertyNames[key]
+       );
+       Object.defineProperty(Class.prototype, propertyNames[key], {
+         ...descriptor,
+         // isTraceDecoratorOpen 开关控制是否要启用经过装饰的原函数
+         value: options.isTraceDecoratorOpen? decorateFunction : descriptor.value.bind(this),
+       });
+     });
+   };
+ }

+ @logPerformance(/*带参数的装饰器*/)
class RequestApi {
  undo() {}
  redo() {}
  applyCollab() {}
  applyOffline() {}
  // ...
}

在项目中,不管你考虑多少种情况,有时我们的代码总还是会出现错误。可能是因为我们的编写的逻辑出错,语法出错,与预期不同的用户输入,或是错误的服务端响应以及其他数千种原因。也有可能有其他疏漏的地方,正常情况下碰到错误,代码可能就自动停下来运行,并在控制台将错误打印出来,此时可以使用 try catch 语句标记要装饰的语句块,并指定一个出现异常时抛出,这是一种更合理的操作,而不是让代码因为错误而停止,这在代码量非常庞大的时候给你一种兜底的方案去监听指定的函数是否有错误异常抛出。

try {
  const timeStart = performance && performance.now();
  // 调用原函数逻辑
  return descriptor && descriptor.value.apply(this, inParams);
} catch (err) {
  // 上报错误到服务器
  console.log(err);
} finally {
  const timeEnd = performance && performance.now();
  const timeConsuming = timeEnd - timeStart;
}

配合 AST 把装饰范围延伸到全局

有时候类装饰器可能覆盖的范围还不够,我们可能想分析出全局所有的类方法的执行效率,那么我们可以考虑和 AST (Abstract Syntax Tree)抽象语法树合作一次,因为手动书写去注入装饰器,在几个文件里面还可以接受,但是往往我要整个模块去注入装饰器,此时手动注入就变得不靠谱,那么我们可以在 webpack 编译的时候通过 loader 这个阶段去分析源代码每一个有类的地方,然后自动帮我们在每一个类里面增加装饰器,AST 主要就是帮我们分析出特定的语法单元,比如类就是这个特定的语法单元,通过确定词法关系,确定对应的表达含义,通过 AST 解析器我们会转化成一个抽象语法树:

class RequestApi {
  undo() {}
  redo() {}
  applyCollab() {}
  applyOffline() {}
  // ...
}

// 经过 AST 分析后会解析成类似下面树状信息 =>
{
  "type": "File",
    'program': {
      "type": "Program",
    "body": [
      {
        "type": "ClassDeclaration",
        "id": {
          "type": "Identifier"
        },
        "body": {
          "type": "ClassBody",
          "body": [
            { "type": "MethodDefinition" },
            { "type": "MethodDefinition" },
            { "type": "MethodDefinition" },
            { "type": "MethodDefinition" },
          ]
        }
      }
    ]
  }
}

我们可以通过 babel 库来使用 AST,事实上 babel 里面的语法糖转化很多都是基于 AST 来实现的,比如箭头函数转化普通函数,let 和 const 等,具体我们需要借助两个库实现 AST 树,分别是 @babel/corebabel-types

// babel 先将 JavaScript 代码转换成 AST 树,然后进行遍历,最后输出 code
const { transform } = require("@babel/core");
const t = require("babel-types");
const allScript = `
  class RequestApi {
      constructor() {
      }
      undo() {
      }
      redo() {
      }
      applyCollab() {
      }
      applyOffline() {
      }
  }
`;

transform(
  allScript,
  {
    plugins: [
      {
        visitor: {
          ClassDeclaration(path) {
            path.insertBefore([t.identifier("@logPerformance")]);
          },
        },
      },
    ],
  },
  (err, result) => {
    if (err) {
      console.log(err);
    } else {
      console.log(result.code);
    }
  }
);

babel 的工作过程经过三个阶段,parse、transform 和 generate,具体来说,如下图所示,在 parse 阶段,使用 @babel/core 库的将源代码转换为 AST,在 transform 阶段,利用各种插件进行代码转换,比如我们常用到的 React 的 JSX 语法转化,在 generator 阶段,再利用代码生成工具,将 AST 转换成代码,业务代码里面其实用到 AST 的场景并不是很多,这里考虑 AST 更多是因为我们的装饰器本质上不影响业务代码,所以我们不需要去关心处理原业务层上的代码,而关心如何匹配相似的规则把对应的装饰器精准的投放到对应的类里面。

<img src="src/imgs/1.png">

根据上面的方案,这里我们需要用到 babel 中的 transform 方法,它可以将 JavaScript 代码转换成 AST ,过程中可以通过使用各种 plugins 对 AST 进行改造,最终生成新的 AST 和 JavaScript 代码,这里的 visitor 就是实现 plugins 最核心,也是最复杂的一部分,它是基于一种访问者模式,根据规则匹配不同的词法,并对 AST 树进行修改,通过修改这颗树,精准的定位到声明语句、赋值语句、运算语句等等,可以实现对原代码的分析、优化、变更等操作,最终重塑出一份新的代码,代码里面可以看到我们用 ClassDeclaration 匹配出代码中所有 class xxx {} 的词法,然后使用 path.insertBefore([t.identifier('@logPerformance')]) 往 class 的头部增加了一段 @logPerformance 的代码,这个 t.identifier() 方法就是 babel-types 库提供给我们的工具,帮助我们在 transform 的时候组装对应的 AST 节点,我们可以使用它来对 AST 树进行增删改查。

修改 webpack 配置让装饰器成功上车

经过我们上边一轮对 AST 操作之后,我们就要去解决,如何把处理后的代码放入业务代码里面运行,因为在 AST 修改其实本质上是不会变动源代码文件的内容,只是源代码在经过 babel 编译处理的时候,夹带装饰器进去而已,那么我们最简单的方法就是更改我们业务中的 webpack 配置。

可以看出以上的每一步处理过程需要有顺序的链式执行,先 ts-loader 再 babel-loader,一份源代码可能需要经历多个 loader 转换才能正常使用,前一个 loader 会把处理好的代码交给下一个 loader 以此类推,所以我们自己定义的,所以我们自己定义的 loader 要放置好位置,一个 loader 的功能也是单一的,只需要完成一种转换,后续如果不只是想在类放置装饰器,想在方法里面放置,建议可以再自定义一个新的 loader 去封装。

[
  {
    loader: "babel-loader",
    options: {
      cacheDirectory: true,
      sourceType: "unambiguous",
      plugins: ["@babel/plugin-proposal-class-properties"],
    },
  },
  +{ loader: path.resolve(__dirname, "../trace/index.js") },
];

这里最适合安放这段 AST 插件的 loader 代码应该是 babel-loader 处理之前这个周期,loader 本质用于对模块的源代码进行转换,这里自定义的 loader 其实相当简单只需要封装一个函数放到 path.resolve(__dirname, '../trace/index.js') 目录下即可,用该函数接受源码,然后经过上面的 AST 几个步骤,再把源码返回到 loader 的这个地方交回给 webpack 处理即可。

大型前端项目的断点调试共享化和复用化实践

背景

随着我们项目越来越大,我们有可能需要维护很多的模块,我们项目大模块有 10 几个,而每个大模块分别有 N 个小模块,每个大模块下的小模块都有主要的负责人在跟进模块问题。

enter image description here

这就会导致一个很大的问题是,模块负责人大部分情况只会关注自己模块的问题,而不甚了解其他负责人手上模块的具体问题。

比如:当我们有用户反馈使用复制粘贴有问题的时候,我们想要快速去定位这个问题,就只能找复制粘贴对应的模块负责人处理,如果复制粘贴模块负责人请假了,那么其他负责人去处理这个问题的时候,解决成本就会非常大,因为其他负责人可能根本对这个模块不熟悉。

又比如:我们新来了几个同学,想让他快速去排查用户反馈的问题的时候,我们只能手把手把我们该模块调试的经验传授他,和所熟知的各个坑点告诉他,或者整理好对应的 iwiki 给他看(一般效率低也没人看!),让他去慢慢定位问题,这样的每个新同学对模块的熟悉,学习和维护的成本就会变得越来越大,项目越大这种情况就会越严重!

所以我们思考了很多,该怎么去解决这些问题,至少要让模块维护成本变低,变得更好去维护和定位问题。

方案

由于上面的问题真的很痛,我们在爬滚中逐渐摸索了一套方案,我们暂且叫它为基于断点调试的共享化和复用化的实践方案吧,这里有个关键词是断点,相比作为每一个开发者都不陌生,在我们前端,模块定位问题的时候,我们少不了去使用断点去断住一些代码运行关键的地方。下面举一个例子:

class CopyPaste {
    // 内部粘贴
    pasteFromInter(){ ...}
    // 外部粘贴
    pasteFromOuter(){ debugger; ...}
    // 外部图文粘贴
    isShapePasteFromOuter(){ ... }
    // 外部图片粘贴
    isImgPasteFromOuter(){ ... }
    // 外部文本粘贴
    isTextFromOuter(){ ... }
}

上面这段代码是当用户反馈一个复制粘贴问题的时候,熟悉该模块的负责人根据用户的反馈,知道用户是外部粘贴出现了问题,由于他对该模块熟悉,他会快速的在浏览器的控制台打断点,或者手动在源代码注入 debugger 关键词去一步一步定位用户的问题,他会先检查内部粘贴 pasteFromOuter 是否触发了,然后检查函数 isShapePasteFromOuter 是否运行成功,出参和入参是否正确,是否代码走歪了,去了 isImgPasteFromOuter

enter image description here

然后在问题排查修复完后,长舒一口气,等遇到下一个问题的时候,再把浏览器或者代码中当前的这些调试的痕迹清理干净,再周而复始的重复上面的一系列动作,我相信大部分的同学每天排查问题甚至做需求都是重复着上面的类似动作,我们是否可以考虑一下把这些珍贵的调试痕迹给保存下来,等自己或者其他同学遇到类似模块问题的时候,我们把这些凝聚着我们血与泪的心路历程再自动复现一次?

代码片段记录 debugger 位置
pasteFromInter2行4列
isShapePasteFromOuter256行89列
isImgPasteFromOuter867行12列

对于大型项目来说,每一个小 Bug 的调试链路的时间成本都是无比巨大的,也是难以复刻和重现的,我们能做的就是当再次遇到相似问题的时候,复用相似的调试经验。有过受伤的痕迹和经历,当问题再次相遇,我们应该会更自信和从容。

所以我们首要任务其实就变成了是保留珍贵的调试链路,也就是保留无数个日夜,那些深扎并刺痛我们内心深处的每个断点。

enter image description here

插件化

在实践的过程中我们尝试过无数的方法,第一个方案就是基于浏览器插件,实现断点留存,基于谷歌浏览器插件开发提供的接口 chrome.debugger,它是 Chrome 远程调试协议的一种消息传输方式。chrome.debugger 可以附加到一个或多个标签页调试 JavaScript。并使用调试对象基于 sendCommand 和 onEvent 来做插件通信。它可以让我们在插件去调试页面,很多插件和工具是基于这个协议来跟浏览器的控制台去做通信,这种方案现只能实现一个远程的调试面板,这个面板类似浏览器本身的调试界面可以加载代码然后记录断点,最后可以把这些断点分享出去。

这种方案体验会比较糟糕,首先插件自己实现的调试面板无法像谷歌浏览器那么好的体验,其次是插件需要开发主动去安装,分享的前提是双方都需要安装好对应的插件,开发和推广成本都比较高,所以个人不是很建议,但是这不代表这个方案走不通,因为这个基于插件还可以有另外一种实现,就是下面的 debug 函数方案。

debug 函数

具体是利用函数断点 debug(functionName)undebug(functionName) 方法,其中 functionName 是要调试的函数。我们可以将 debug() 插入到的代码中(这个方法和 console.log() 语句相似),也可以从 DevTools 控制台中进行调用。 debug() 相当于在第一行函数中设置代码行断点。

enter image description here

一般情况是在控制台中使用,这个方法配合插件会有比较好的体验,因为插件使用 chrome.devtools.inspectedWindow.eval 方法配合浏览器的接口可以把代码注入到控制台中执行,从而实现帮你自动下发断点的功能。

chrome.devtools.inspectedWindow.eval(
  `debug(window.xxxApi);`,
  (value) => {
    callback && callback(value);
  }
);

但是细心的同学发现我使用 debug 函数监听的是一个全局的函数 window.xxxApi,所以这里也总结一下经验,这个方法的缺陷就是如果你在控制台使用,它会在你的上下文寻找该函数,所以它一般只能用于全局的函数打点,如果需要打点的函数不在上下文,还需要手动断点到目标函数的范围,然后使用函数打点来触发,如果是闭包函数那就毫无办法了,但是瑕不掩瑜,这个方法能帮我们快速定位任何的全局函数,就算代码被混淆了,它还是能快读把函数断点给你加上,所以这个方案我建议可以作为一个备选方案,在某些情况下能发挥奇效!

AST 注入

经历过上面的各种坑之后,下面我们简单介绍我们实现的一套方案吧:

我们的方案其实是在之前《函数调用链方案》基础上做的一种改进,既然我们开发可以自己在代码中输入 debugger 关键词去断住任何地方的代码,我们何不把这个工作交给工具?

enter image description here

首先我们可以用使用状态机去告诉工具我们需要分发的打点的位置在哪里,类似我们常用 whistle 的配置表:

Module 'CopyPaste'
    index.ts -f pasteFromInter -s !(()=>{ console.log(window.Worker) })()
    index.ts -f pasteFromOuter -s console.log('success') -check messagecenter1
    index.ts -f isShapePasteFromOuter
End Module

在 webpack 中我们可以在 loader 或者 plugin 这两个过程中去解析这份配置文件,这里你也可以使用第三方库或者正则来解析上面这些状态文本。我是在 loader 中去解析这份状态表的,我在全局目录下或者局部模块内定义一份 .debug.json 来写入上述的状态,然后解析出一份 map 对象出来:

args = argument({
    "--class": String, // 类
    "--function": String, // 函数
    "--code": String, // 函数
    "-c": "--class", // 转义替换
    "-f": "--function",
    "-s": "--code",
  },{ argv: debugConfigValue, }
);

如果不想用状态机的方式去写配置文件的话,其实也可以使用一份 debug.json 文件来描述断点的位置,这种方式更简单,解析 json 文件的成本比状态机的配置文件低不少,json 文件在这里涉及的主要字段分别是需要检测代码的路径,这个方便工具去定位文件,然后是需要检测的类或者函数的名字,这个方便工具去定位代码的位置,还有检测项的名字和需要检测的代码,和一个关键的键值:

{
  "MessageCenter": {
    "function": [
      {
        "path": "src/core/network/message-center/SendMessageCenter.ts",
        "name": "_sendUserChanges",
        "title": "数据层断点测试2",
        "code": "__console.log('数据层断点测试2')",
        "key": "MessageCenter|function|1"
      }
    ]
  }
}

这里键值的涉及可以定义的清晰点,比如 MessageCenter|function|1 指的是对 MessageCenter 模块的文件里面的某一个函数打点,以后还可以继续改进这样写 MessageCenter|class|1:12,意思是 MessageCenter 模块的文件里面某一个类的具体位置打点,如果这个 key 的语义越丰富,后续分发的打点也会更精确,定位问题也会更高效,具体这个可以根据业务场景去定义。

class CopyPaste {
    // 内部粘贴
    pasteFromInter(){
        debugger
        ...
    }
}

当我们有了配置文件,我们就得思考怎么无入侵的在代码里面加入调试和检测代码了,我们首选通过 AST 去注入,它可以帮我们把代码关键部分给梳理成一颗树出来,比如抹掉冒号、括号、分号等,能让我们把精力放在重要的节点上,上面的代码经过解析会得到下面这棵 AST 语法树:

{
  "program": {
    "type": "Program",
    "body": [{
      "type": "ClassDeclaration",
      "id": {{ "type": "Identifier", "identifierName": "CopyPaste" }, "name": "CopyPaste" },
      "body": {
        "type": "ClassBody",
        "body": [{
            "type": "ClassMethod",
            "key": { "type": "Identifier", "name": "pasteFromInter" },
            "body": { "type": "BlockStatement", "body": [{ "type": "DebuggerStatement" }]},
            "leadingComments": [{ "type": "CommentLine", "value": " 内部粘贴" }],
        }]
      }
    }]
  }
}

而具体步骤大概如下:解析 MessageCenter|function|1 这段参数配置的字符串,得到函数名,模块名,位置信息等,然后对代码进行扫描并进行词法和语法分析,并得到 AST 语法树,根据刚才解析得到的函数名,模块名,位置信息来匹配 AST 树节点,在上面进行加入我们的调试和检测代码,最后再输出经过我们加工的代码。

那上面这个原理我们都懂,具体怎么实现呢,我们可以在 webpack 工具使用 plugins 来实现,在 plugins 中我们经常会用到访问者模式,就是说在访问到某一个路径的时候进行匹配,然后在对这个节点进行修改,比如上面这个 pasteFromInter 函数,它是一个 ClassMethod,plugins 就会对代码生成的 AST 树进行访问,访问者可以匹配任何对应的词法特性,我们就可以在这里匹配所有的 ClassMethod 然后根据路径去拿到节点对应的信息,比如函数名,函数参数和函数位置等,拿到这些关键的信息,我们就可以对这个函数节点进行加工,也就是注入我们的调试和检测代码或者直接注入一个 debugger 去打断点。

plugins = {
  // 访问器
  Visitor = {
      'ClassMethod'(path) {
        // 检点
        path.node
      }
  }
}

当然注入检测代码也是需要构造成 ClassMethod 的类似结构,所有我们可以配合 @babel/types 工具去快速注入一段代码,比如最简单的是注入一个 debugger

types.expressionStatement(types.identifier(`debugger`))

这样就会在你匹配的路径的特定位置放入一个 debugger,而你的代码源文件本身其实是没有任何改动的,只是通过 AST 树配合配置文件成功融合了一段代码到指定的位置,当然实际情况会比预想中的复杂,因为有可能下发的位置不是函数中的某个位置,可能是类函数中的某个位置,闭包函数中的某个位置,所以要兼容各种的语法结构,需要在 AST 中匹配这些函数的所有特征才能准确无误的下发代码,还是以函数作为例子,列出部分需要考虑的情况:

需要满足到这两种写法,不然 debugger 会下发错位置。

this.xxx = function() { debugger }
const xxx = function() { debugger }

这个一般情况按下面的方式就能定位到了,但是如果要更精确比如是私有函数等,那就需要写更精确的访问器了。

class xxx { xxx:(){ debugger } }

除了要处理上面函数表达式的写法,不要忘了函数还有声明定义的写法,所以这个也得满上。

function xxx() { debugger }

最后还要考虑下箭头函数的写法

const xxx = () => { debugger }
this.xxx = () => { debugger }
class xxx { xxx = () => { debugger } }

虽然大部分情况匹配函数对项目下发的调试代码能覆盖大部分的场景,但总会有漏网之鱼,比如有的同学想在类定义之前注入检测代码,那就需要继续写对应的访问器去获取路径,然后对该位置去分发对应的检测代码,所以需要对各种语法和对应的访问器类型很熟悉才能顺利实现。

经过上面的改造,我们会在最终代码中会得到新代码(已注入了所有检测代码),但是这样会引发一个新的,当我们运行这份新代码,我们上面所有的检测代码都会跑一遍,这样就会断住很多别的模块负责人不想断住的代码区域,所以实际情况我们需要分发一个带开关的检测代码,当然这个开关的涉及其实可以很简单,如下:

// 基于 AST 在模块中分发的调试开关
if(require('@tencent/vdebugger').call(this, key)){ debugger }
// 或者这样,虽然好看点,但这样 debugger 在闭包里面拿不到上下文
require('@tencent/vdebugger').call(this, key) || (() => { debugger })()
// 注意这种下面类似这种写法是不行的↓
require('@tencent/vdebugger') || debugger

我们可以使用 require('@tencent/vdebugger') 打包一个函数,这个函数可以设计为在全局变量或者 localstorage 等地方读取配置,然后返回一个布尔值,用于判断是否执行该位置的 debugger,这里为了调试方便有几个小细节需要注意,debugger 这个关键词自己要独立一个作用域,所以你不能写成类似这个样子 false || debugger,还有 require('@tencent/vdebugger') 这个函数里面在读取配置之后里面可以包一个 eval 方法来执行检测代码,所以可以用 call 把当前作用域代理过来,更方便去做调试。

当然实际情况可能还要比想象中复杂,举个简单的例子:因为分发的开关有可能会注入到一些被打包到 worker 的代码里面,worker 在大型项目中运用的很多,但是 worker 里面无法读取 document、window 这些对象,虽然可以使用 navigator,location 和 XMLHttpRequest等对象,但无法通过 localstorage 读取配置等手段去控制调试开关了,所以你需要考虑一下是否需要让调试开关分发到 worker 代码中,如果分发了又要怎么去通信对应的开关等问题。

最简单粗暴就是打包 worker 代码的时候进行过滤。

!isWorker && new DebuggerPlugin({
    debugConfig: path.resolve(dirName, '../debug.json'),
}),

当然如果需要分发的开关在 worker 中生效,就需要去实现一个读取开关配置的通信手段,最常见的就是基于 postMessage 的通信手段,让 require('@tencent/vdebugger') 函数,即开关模块接受主线程的配置去向 worker 的运行代码下达是否执行检测代码和启动断点的命令。

myWorker.postMessage(xx);
myWorker.onmessage = () => {
  console.log('Message received from worker');
}

思考

实现了上面的基本功能之后,我们还可以继续优化很多体验,比如我们还可以使用 webpack 的 plugin 来实现本地编译时候的增量更新,这就能做到当我们更改本地配置文件的时候,自动分发断点和调试代码,逻辑也是比较简单的,在 plugin 的 apply 周期使用内置的库 chokidar 去监听配置文件的变更,然后触发编译,重新走 AST 去编译生成带调试代码合断点的代码:

const chokidar = require('chokidar');
this.watcher = chokidar.watch(["../src/**/.debug.json"], {
  usePolling: true,
  ignored: this.options.ignored
});

总结

关于这方面的调试相关文章不多,一路走来跳了不少的坑,感谢团队成员的支持,并让这个方案最终成功落地,也希望有更多志同道合的人加入我们团队,一起去探索和遨游,最后也希望这篇文章能给到你们一些启发吧😁