← 中文资源

简体中文版本

把 V8 调用栈解析为帧对象

把 V8 栈字符串解析为包含函数名、文件、行列号以及 async/native 标记的结构化帧。

阅读英文原文

功能说明

该示例解析 V8 引擎生成的错误调用栈,把每行 at 记录转换成结构化对象,包含函数名、文件路径、行号、列号以及是否为异步帧或原生帧。结构化后的帧便于做错误聚合、源码映射前的预处理、日志字段提取与崩溃报告展示。

关键代码

parseStackTrace 按行拆分,只处理以 at 开头的行,去掉前缀后交给 parseFrame。parseFrame 先识别 native,再剥离可选的 async 前缀。带括号的帧用懒匹配提取函数名与位置,裸位置行只提取文件与行列,其余形式保留函数名但位置为空。normalizeName 把空字符串与 eval 归一为 null。行列号通过 Number 转换。

使用注意

该解析器针对 V8,包括 Node 与 Chromium;Firefox 与 Safari 的栈格式不同,需要单独规则。异步帧只标记 async 前缀,不代表完整的异步因果链。eval 与匿名函数可能没有可读名称,压缩代码还需要 source map 还原。文件路径可能包含冒号,因此正则使用懒匹配并从右侧捕获行列号。解析结果不应直接展示给终端用户。

验证方式

解析包含 fetchUser、async loadProfile、裸文件位置与 native 的栈,断言函数名、文件、行号、列号、async 与 native 标记全部正确。测试没有 at 行的字符串返回空数组,native 帧的位置字段为 null。再传入非字符串断言 TypeError,并验证 eval 帧名为 null。

代码示例

export interface StackFrame {
  readonly functionName: string | null;
  readonly file: string | null;
  readonly line: number | null;
  readonly column: number | null;
  readonly native: boolean;
  readonly async: boolean;
}

export function parseStackTrace(stack: string): StackFrame[] {
  if (typeof stack !== "string") throw new TypeError("stack must be a string");
  const frames: StackFrame[] = [];
  for (const rawLine of stack.split("\n")) {
    const line = rawLine.trim();
    if (!line.startsWith("at ")) continue;
    const frame = parseFrame(line.slice(3));
    if (frame !== null) frames.push(frame);
  }
  return frames;
}

function parseFrame(value: string): StackFrame | null {
  if (value === "native") {
    return { functionName: null, file: null, line: null, column: null, native: true, async: false };
  }
  let text = value;
  let isAsync = false;
  if (text.startsWith("async ")) {
    isAsync = true;
    text = text.slice(6);
  }
  const locationMatch = /^(.*?) \((.+):(\d+):(\d+)\)$/.exec(text);
  if (locationMatch !== null) {
    return {
      functionName: normalizeName(locationMatch[1] ?? ""),
      file: locationMatch[2] ?? null,
      line: Number(locationMatch[3]),
      column: Number(locationMatch[4]),
      native: false,
      async: isAsync,
    };
  }
  const bareMatch = /^(.+):(\d+):(\d+)$/.exec(text);
  if (bareMatch !== null) {
    return {
      functionName: null,
      file: bareMatch[1] ?? null,
      line: Number(bareMatch[2]),
      column: Number(bareMatch[3]),
      native: false,
      async: isAsync,
    };
  }
  return {
    functionName: normalizeName(text),
    file: null,
    line: null,
    column: null,
    native: false,
    async: isAsync,
  };
}

function normalizeName(value: string): string | null {
  const name = value.trim();
  if (name === "" || name === "eval") return null;
  return name;
}

const stack = [
  "Error: boom",
  "    at fetchUser (/app/src/users.js:42:13)",
  "    at async loadProfile (/app/src/profile.js:8:20)",
  "    at /app/src/index.js:3:1",
  "    at native",
].join("\n");
console.log(parseStackTrace(stack));
console.log(parseStackTrace("Error: empty"));