我们在上一篇《前端性能优化--卡顿心跳检测》一文中介绍过基于requestAnimationFrame的卡顿的检测方案实现,这一篇文章我们将会介绍基于该心跳检测方案,要怎么实现链路追踪,来找到产生卡顿的地方。

# 卡顿监控实现

上一篇我们提到的心跳检测,实现的功能很简单,就是卡顿和心跳事件、开始和停止,那么我们卡顿监控使用的时候也比较简单:

class JankMonitor {
  // 心跳 SDK
  private heartBeatMonitor: HeartbeatMonitor;

  constructor() {
    // 初始化并绑定事件
    this.heartBeatMonitor = new HeartbeatMonitor();
    // PS:此处 addEventListener 为伪代码,可自行实现一个事件转发器
    this.heartBeatMonitor.addEventListener("jank", this.handleJank);
    this.heartBeatMonitor.addEventListener("heartbeat", this.handleHeartBeat);

    // 可以初始化的时候就启动
    this.heartBeatMonitor.start();
  }

  /**
   * 处理卡顿
   */
  private handleJank() {}

  /**
   * 处理心跳
   */
  private handleHeartBeat() {}
}

这时候可以检测到卡顿了,接下来便是在卡顿发生的时候找到问题并上报了。前面《前端性能优化--卡顿的监控和定位》中有大致介绍堆栈的方法,这里我们来介绍下具体要怎么实现吧~

# 堆栈追踪卡顿

同样的,假设我们通过打堆栈的方式来追踪,堆栈信息包括:

interface IJankLog {
  module: string;
  action: string;
  logTime: number;
}

那么,我们的卡顿检测还需要对外提供log打堆栈的能力:

class JankMonitor {
  // 卡顿链路堆栈
  private jankLogStack: IJankLog[] = [];

  log(logPosition: { module: string; action: string }) {
    this.jankLogStack.push({
      ...logPosition,
      logTime: Date.now(),
    });
  }

  private handleHeartBeat() {
    // 心跳的时候,可以将堆栈清空,因为正常心跳发生意味着没有卡顿,此时堆栈内信息可以移除
    this.jankLogStack = [];

    // 清空后,添加心跳信息,方便计算耗时
    this.jankLogStack.push({
      module: "jank",
      action: "heartbeat",
      logTime: Date.now(),
    });
  }

  // ...其他内容省略
}

当卡顿发生时,我们可以根据堆栈计算出卡顿产生的位置:

class JankMonitor {
  private jankLogStack: IJankLog[] = [];

  private handleJank() {
    const jankPosition = this.calculateJankPosition();
    // 拿到卡顿位置后,可以进行上报
    // PS: reportJank 为伪代码,可以根据项目情况自行实现
    reportJank(jankPosition);
    // 打印异常
    console.error("产生了卡顿,位置信息为:", jankPosition);

    // 上报结束后,则需要清空堆栈,继续监听
    this.jankLogStack = [];
  }

  // ...其他内容省略
}

下面我们来详细看一下,要怎么计算出卡顿产生的位置。

# 卡顿位置定位

我们在代码中,使用log方法来打关键链路日志,那么我们拿到的堆栈信息大概会长这样:

jankLogStack = [
  {
    module: "数据模块",
    action: "拉取数据",
    logTime: logTime1,
  },
  {
    module: "数据模块",
    action: "加载数据",
    logTime: logTime2,
  },
  {
    module: "Feature 模块",
    action: "处理数据",
    logTime: logTime3,
  },
  {
    module: "渲染模块",
    action: "渲染数据",
    logTime: logTime4,
  },
];

当卡顿发生的时候,我们可以将堆栈取出来计算最大耗时的位置:

class JankMonitor {
  private jankLogStack: IJankLog[] = [];

  private calculateJankPosition() {
    // 记录产生卡顿的位置
    let jankPosition;
    // 记录最大耗时
    let maxCostTime = 0;

    // 遍历堆栈,计算每一步耗时
    // 第一个信息为心跳信息,可从第二个开始算起
    for (let i = 1; i < this.jankLogStack.length; i++) {
      // 上个位置
      const previousPosition = this.jankLogStack[i - 1];
      // 当前位置
      const currentPosition = this.jankLogStack[i];
      // 链路耗时
      const costTime = currentPosition.logTime - previousPosition.logTime;

      // 可以将链路打出来,方便定位
      console.log(
        `${previousPosition.module}-${previousPosition.action} -> ${currentPosition.module}-${currentPosition.action}, 耗时 ${costTime} ms`
      );

      // 找出最大耗时和最大位置
      if (costTime > maxCostTime) {
        maxCostTime = costTime;
        jankPosition = {
          ...currentPosition,
          costTime,
        };
      }
    }

    return jankPosition;
  }

  // ...其他内容省略
}

这样我们就可以计算出产生卡顿时,代码执行的整个链路(需要使用log记录堆栈),同时可找到耗时最大的位置并进行上报。当然,有时候卡顿产生并不只是一个地方,这里也可以调整为将执行超过一定时间的链路全部进行上报。

现在,我们可以拿到产生卡顿的有效位置,当然前提是需要使用log方法记录关键的链路信息。为了方便,我们可以将其做成一个装饰器来使用。

# @jankTrace 装饰器

该装饰器功能很简单,就是调用JankMonitor.log方法:

/**
 * 装饰器,可用于装饰类中的成员方法和箭头函数
 */
export const JankTrace: MethodDecorator | PropertyDecorator = (
  target,
  propertyKey,
  descriptor
) => {
  const className = target.constructor.name;
  const methodName = propertyKey.toString();
  const isProperty = !descriptor;
  const originalMethod = isProperty
    ? (target as any)[propertyKey]
    : descriptor.value;
  if (typeof originalMethod !== "function") {
    throw new Error("JankTrace decorator can only be applied to methods");
  }

  const newFunction = function (...args: any[]) {
    // 打印卡顿堆栈
    jankMonitor.log({
      moduleValue: className,
      actionValue: methodName,
    });
    const syncResult = originalMethod.apply(this, args);
    return syncResult;
  };

  if (isProperty) {
    (target as any)[propertyKey] = newFunction;
  } else {
    descriptor!.value = newFunction as any;
  }
};

至此,我们可以直接在一些类方法上去添加装饰器,来实现自动跟踪卡顿链路:

class DataLoader {
  @JankLog
  getData() {}

  @JankLog
  loadData() {}
}

# 结束语

本文简单介绍了卡顿检测的一个实现思路,实际上在项目中还有很多其他问题需要考虑,比如需要设置堆栈上限、状态管理等等。

技术方案在项目中落地时,都需要因地制宜做些调整,来更好地适配自己的项目滴~

部分文章中使用了一些网站的截图,如果涉及侵权,请告诉我删一下谢谢~
温馨提示喵