前端代码插桩与覆盖率

lxf2023-12-31 07:10:02

背景

起因是当时公司内部、开源社区前端侧没有上报全量覆盖率数据的功能,我就参与了此需求的开发工作。

在这个项目的开发过程中,我对代码的插桩和覆盖率从大学课本上抽象的概念认知层面到有了一定的实践了解,其中也学习到了很多前端更新更深的知识,但是这个过程其实是比较坎坷的,踩了很多坑,网上关于前端插桩和覆盖率的总结分享少且不全,所以我决定把这方面的研究总结记录一下,分享出来和大家一起学习。

本文篇幅较长,建议慢速阅读效果更佳,欢迎提出问题一起探讨。

前置知识

插桩

什么是代码插桩

在保证被测程序原有逻辑完整性的基础上在程序中插入一些探针(又称为“探测仪”,本质上就是进行信息采集的代码段,可以是赋值语句或采集覆盖信息的函数调用)。(摘自百度百科)

为什么要插桩

在我的大学专业里有一门叫软件测试的专业课,课程中介绍了很多种测试方法,不少测试方法需要在了解代码执行流程的情况下进行,例如“白盒测试”,当我们的代码进行了插桩以及上报覆盖率(下文讲到),测试人员可以通过执行测试 case 得到的结果与覆盖率对比进而判断代码是否已经执行。

什么时候进行插桩

编译时插桩

在代码编译构建过程中对代码进行插桩,这种方式适用于浏览器、小程序等代码编译后运行的环境。

运行时插桩

在代码运行时插桩,这种方式适用于 nodejs 环境的代码。

插桩后的代码是怎样的

不管代码在什么时候插桩,其采用的插桩处理逻辑都是一样的,举一个简单的例子

插桩前

noop.js

function NOOP () {
  const { readInitialCoverage } = require('istanbul-lib-instrument')

  return {
    instrumentSync (code, filename) {
      const extracted = readInitialCoverage(code)
      if (extracted) {
        this.fileCoverage = extracted.coverageData
      } else {
        this.fileCoverage = null
      }
      return code
    },
    lastFileCoverage () {
      return this.fileCoverage
    }
  }
}

module.exports = NOOP

插桩后

function cov_sm299z469() {
  var path =
    "/Users/bytedance/WorkSpace/istanbul/nyc/lib/instrumenters/noop.js";
  var hash = "128ed43041fe832b220f7107a2cbd0a5d680086c";
  var global = new Function("return this")();
  var gcv = "___NYC_SELF_COVERAGE___";
  var coverageData = {
    path: "/Users/bytedance/WorkSpace/istanbul/nyc/lib/instrumenters/noop.js",
    statementMap: {
      0: { start: { line: 4, column: 34 }, end: { line: 4, column: 68 } },
      1: { start: { line: 6, column: 2 }, end: { line: 19, column: 3 } },
      2: { start: { line: 8, column: 24 }, end: { line: 8, column: 49 } },
      3: { start: { line: 9, column: 6 }, end: { line: 13, column: 7 } },
      4: { start: { line: 10, column: 8 }, end: { line: 10, column: 50 } },
      5: { start: { line: 12, column: 8 }, end: { line: 12, column: 32 } },
      6: { start: { line: 14, column: 6 }, end: { line: 14, column: 17 } },
      7: { start: { line: 17, column: 6 }, end: { line: 17, column: 30 } },
      8: { start: { line: 22, column: 0 }, end: { line: 22, column: 21 } },
    },
    fnMap: {
      0: {
        name: "NOOP",
        decl: { start: { line: 3, column: 9 }, end: { line: 3, column: 13 } },
        loc: { start: { line: 3, column: 17 }, end: { line: 20, column: 1 } },
        line: 3,
      },
      1: {
        name: "(anonymous_1)",
        decl: { start: { line: 7, column: 4 }, end: { line: 7, column: 5 } },
        loc: { start: { line: 7, column: 36 }, end: { line: 15, column: 5 } },
        line: 7,
      },
      2: {
        name: "(anonymous_2)",
        decl: { start: { line: 16, column: 4 }, end: { line: 16, column: 5 } },
        loc: { start: { line: 16, column: 24 }, end: { line: 18, column: 5 } },
        line: 16,
      },
    },
    branchMap: {
      0: {
        loc: { start: { line: 9, column: 6 }, end: { line: 13, column: 7 } },
        type: "if",
        locations: [
          { start: { line: 9, column: 6 }, end: { line: 13, column: 7 } },
          { start: { line: 9, column: 6 }, end: { line: 13, column: 7 } },
        ],
        line: 9,
      },
    },
    s: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 },
    f: { 0: 0, 1: 0, 2: 0 },
    b: { 0: [0, 0] },
    _coverageSchema: "1a1c01bbd47fc00a2c39e90264f33305004495a9",
    hash: "128ed43041fe832b220f7107a2cbd0a5d680086c",
  };
  var coverage = global[gcv] || (global[gcv] = {});
  if (!coverage[path] || coverage[path].hash !== hash) {
    coverage[path] = coverageData;
  }
  var actualCoverage = coverage[path];
  {
    // @ts-ignore
    cov_sm299z469 = function () {
      return actualCoverage;
    };
  }
  return actualCoverage;
}
cov_sm299z469();
function NOOP() {
  cov_sm299z469().f[0]++;
  const { readInitialCoverage } =
    (cov_sm299z469().s[0]++, require("istanbul-lib-instrument"));
  cov_sm299z469().s[1]++;
  return {
    instrumentSync(code, filename) {
      cov_sm299z469().f[1]++;
      const extracted = (cov_sm299z469().s[2]++, readInitialCoverage(code));
      cov_sm299z469().s[3]++;
      if (extracted) {
        cov_sm299z469().b[0][0]++;
        cov_sm299z469().s[4]++;
        this.fileCoverage = extracted.coverageData;
      } else {
        cov_sm299z469().b[0][1]++;
        cov_sm299z469().s[5]++;
        this.fileCoverage = null;
      }
      cov_sm299z469().s[6]++;
      return code;
    },
    lastFileCoverage() {
      cov_sm299z469().f[2]++;
      cov_sm299z469().s[7]++;
      return this.fileCoverage;
    },
  };
}
cov_sm299z469().s[8]++;
module.exports = NOOP;

看到这里可以大概了解到插桩本质上是插入了一些“计数器”,在代码执行的时候,被执行到的代码部分也会执行对应的“计数器”,从而得到我们需要收集的覆盖率(覆盖率的收集有增量与全量之分,各对应不同的收集方式,下文详述)。

覆盖率

什么是覆盖率

覆盖率是度量测试完整性的一个手段,是测试有效性的一个度量。通过已执行代码表示,用于可靠性、稳定性以及性能的评测。(摘自百度百科)

对于前端来说,覆盖率主要有四个准则:

  1. 行覆盖率(Line Coverag):是否每一行都执行了
  2. 函数覆盖率(Function Coverage):是否每个函数都调用了
  3. 分支覆盖率(Branch Coverage):是否每个分支都执行了
  4. 语句/指令/声明 覆盖率(Statement Coverage):是否每个语句都执行了

当然覆盖率准则其实还有很多方面,例如路径覆盖率、循环覆盖率等,不过举一反三,当我们理解了代码插桩的原理以及上述四个主要准则的计算方式,对于各种准则的要求自然能够了然于胸,所以这里不对各种准则进行解释了。

为什么要获取覆盖率

  1. 覆盖率是 QA 同学在测试阶段经常打交道的东西,当我们的程序执行时通过获取代码的覆盖率然后上报到测试平台,QA 同学通过查看覆盖率,可以对程序的执行情况有一个比较直观的了解,并且能够提高 QA 同学的测试效率。
  2. 研发同学同样需要关注代码的覆盖率,这是代码质量好坏的一个体现。当然这是一个不绝对的说法,覆盖率的高低实际上是不能代表代码的质量的:
    1. 覆盖率高的代码只能表示代码大部分被测试,但是否可靠并不确定;
    2. 覆盖率高的代码不一定是好代码,但覆盖率低很大程度上代码质量会有问题。

如何获取覆盖率

通过上文中插桩前后的代码对比,插桩后的代码多了一个随机生成的计数函数 cov_xxx(xxx 为一个根据当前插桩文件的路径生成的一个被处理过的哈希值),函数中的逻辑是存储一个对象到全局对象(nodejs 环境中是 global,浏览器环境中是 window)的属性中,这个属性名一般是 __coverage__,我们可以通过 global.__coverage__ 或者 window.__coverage__ 拿到,例如:

前端代码插桩与覆盖率

当然这个属性名也可以自定义,示例中这个属性名则是自定义为 ___NYC_SELF_COVERAGE___(自定义之后可以通过 global.___NYC_SELF_COVERAGE___ 或者 window.___NYC_SELF_COVERAGE___ 拿到),而这个属性对应的值则是键为文件路径、 值为示例中计数函数声明的 coverageData 的多个这样的对象(每个对象包含一个文件的插桩数据)组成的一个对象,而 coverageData 里面的各个属性也就是和覆盖率相关的插桩数据:

var coverageData = {
path: "/Users/bytedance/WorkSpace/istanbul/nyc/lib/instrumenters/noop.js",
statementMap: {
  0: { start: { line: 4, column: 34 }, end: { line: 4, column: 68 } },
  1: { start: { line: 6, column: 2 }, end: { line: 19, column: 3 } },
  2: { start: { line: 8, column: 24 }, end: { line: 8, column: 49 } },
  3: { start: { line: 9, column: 6 }, end: { line: 13, column: 7 } },
  4: { start: { line: 10, column: 8 }, end: { line: 10, column: 50 } },
  5: { start: { line: 12, column: 8 }, end: { line: 12, column: 32 } },
  6: { start: { line: 14, column: 6 }, end: { line: 14, column: 17 } },
  7: { start: { line: 17, column: 6 }, end: { line: 17, column: 30 } },
  8: { start: { line: 22, column: 0 }, end: { line: 22, column: 21 } },
},
fnMap: {
  0: {
    name: "NOOP",
    decl: { start: { line: 3, column: 9 }, end: { line: 3, column: 13 } },
    loc: { start: { line: 3, column: 17 }, end: { line: 20, column: 1 } },
    line: 3,
  },
  1: {
    name: "(anonymous_1)",
    decl: { start: { line: 7, column: 4 }, end: { line: 7, column: 5 } },
    loc: { start: { line: 7, column: 36 }, end: { line: 15, column: 5 } },
    line: 7,
  },
  2: {
    name: "(anonymous_2)",
    decl: { start: { line: 16, column: 4 }, end: { line: 16, column: 5 } },
    loc: { start: { line: 16, column: 24 }, end: { line: 18, column: 5 } },
    line: 16,
  },
},
branchMap: {
  0: {
    loc: { start: { line: 9, column: 6 }, end: { line: 13, column: 7 } },
    type: "if",
    locations: [
      { start: { line: 9, column: 6 }, end: { line: 13, column: 7 } },
      { start: { line: 9, column: 6 }, end: { line: 13, column: 7 } },
    ],
    line: 9,
  },
},
s: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 },
f: { 0: 0, 1: 0, 2: 0 },
b: { 0: [0, 0] },
_coverageSchema: "1a1c01bbd47fc00a2c39e90264f33305004495a9",
hash: "128ed43041fe832b220f7107a2cbd0a5d680086c",
};

这里解释一下这里面各个属性的含义(解释中所指的行和列对应的数值是源代码中的行、列数):

  • path: 代码文件的路径
  • statementMap: 语句覆盖率(全量)
    • 键:语句索引
    • 值:
      • start
        • line:语句执行开始的行
        • column:语句执行开始的列
      • end
        • line:语句执行结束的行
        • column:语句执行结束的列
  • fnMap: 函数覆盖率(全量)
    • 键:函数索引
    • 值:
      • name: 函数名
      • decl
        • start
          • line:函数声明开始的行
          • column:函数声明开始的列
        • end
          • line:函数声明结束的行
          • column:函数声明结束的列
      • loc
        • start
          • line:函数执行(调用)开始的行
          • column:函数执行(调用)开始的列
        • end
          • line:函数执行(调用)结束的行
          • column:函数执行(调用)结束的列
  • branchMap: 分支覆盖率(全量)
    • 键:分支索引
    • 值:
      • loc
        • start
          • line:分支执行开始的行
          • column:分支执行开始的列
        • end
          • line:分支执行结束的行
          • column:分支执行结束的列
      • type: 分支类型,有 if、switch、binary-expr 、cond-expr 等值
      • locations: 一个数组,和 loc 的区别是 locations 存储的是当前分支里的“子分支”的数据,loc存储的是当前分支的数据
        • start
          • line:分支执行开始的行
          • column:分支执行开始的列
        • end
          • line:分支执行结束的行
          • column:分支执行结束的列
      • line:该分支开始所在的行
  • s: 语句执行率(增量)
    • 键:对应的语句索引
    • 值:执行的次数
  • f: 函数执行率(增量)
    • 键:对应的函数索引
    • 值:执行的次数
  • b: 分支执行率(增量)
    • 键:对应的分支索引
    • 值:执行的次数
  • _coverageSchema: 收集覆盖率的这个插件的版本号和名称生成的哈希值
  • hash: 随机生成的一个哈希值

关于增量和全量,我们先看插桩函数中的数据,其实在代码插桩完成时,属性statementMapfnMapbranchMap 分别记录了语句、函数、分支的所有覆盖情况,也就是代表全量覆盖率,而 sfb 里的属性则初始化为 0,为什么是 0 呢? 此时我们再看除了这个计数函数之外在代码里插入的“计数器”:

function NOOP() {
  cov_sm299z469().f[0]++;
  const { readInitialCoverage } =
    (cov_sm299z469().s[0]++, require("istanbul-lib-instrument"));
  cov_sm299z469().s[1]++;
  return {
    instrumentSync(code, filename) {
      cov_sm299z469().f[1]++;
      const extracted = (cov_sm299z469().s[2]++, readInitialCoverage(code));
      cov_sm299z469().s[3]++;
      if (extracted) {
        cov_sm299z469().b[0][0]++;
        cov_sm299z469().s[4]++;
        this.fileCoverage = extracted.coverageData;
      } else {
        cov_sm299z469().b[0][1]++;
        cov_sm299z469().s[5]++;
        this.fileCoverage = null;
      }
      cov_sm299z469().s[6]++;
      return code;
    },
    lastFileCoverage() {
      cov_sm299z469().f[2]++;
      cov_sm299z469().s[7]++;
      return this.fileCoverage;
    },
  };
}
cov_sm299z469().s[8]++;
module.exports = NOOP;

看到这里其实可以了解到,类似 cov_xxx().f[x]++ 这种“计数器”的原理是当代码每一次运行到对应的地方时会调用计数函数,将s、f、b 里的数据相应的增加 1 ,即可以得到增量覆盖率。 也就是说,全量覆盖率代表的是所有覆盖情况,增量覆盖率代表的是代码的执行情况。那么为什么要区分两种覆盖率呢?一个不太准确的描述就是:你可以把全量覆盖率当成分母,增量覆盖率当成分子,这样就好理解多了。

为什么数据中没有行覆盖率

原因

仔细看插桩后的代码,好像并没有记录到行覆盖率?此时你会怎么实现这个数据的收集的呢?假设我们想要在代码运行时直接得到行覆盖率的话,则需要在插桩时对每一行代码都对应的插入 cov_xxx().l[x]++ 这样的计数器,而这样做可能会让插桩后的文件行数比原来的插桩逻辑多一倍以上,导致文件体积大很多,很明显这是不够合理的做法。

实际上行覆盖率需要我们通过语句覆盖率数据来计算。

计算方式

一般来说全量、增量数据的算法是在收集数据的平台里实现的,前端侧负责上报收集到的数据即可,为了捋清楚其中的逻辑,我用 JS 写了增量和全量的算法。

增量计算

增量的计算逻辑是对 s 里值大于 1 对应的语句所在的行数进行计算,计算时需要判断当前语句是否存在子语句,存在的话则需要将子语句所在的行排除在计算范围之内,原因是计算时只能保证当前语句已经执行,子语句不一定执行过。

function calculateExecuteLineCount(coverageData) {
  const { s, statementMap } = coverageData;
  const executeLineMap = {};
  const statementMapArr = Object.keys(statementMap).map((key) => {
    const statement = statementMap[key];
    const { start, end } = statement;
    for (let i = start.line; i <= end.line; i++) {
      if (!executeLineMap.hasOwnProperty(i.toString())) {
        executeLineMap[i] = 0;
      }
    }
    return {
      start: start.line,
      end: end.line,
    };
  });
  Object.keys(s).map((key) => {
    if (s[key] > 0) {
      // 大于 0 表示对应的语句至少被执行一次
      const statement = statementMap[key];
      const { start, end } = statement;
      if (start.line === end.line) {
        if (executeLineMap.hasOwnProperty(start.line.toString())) {
          executeLineMap[start.line] += 1;
        }
        return;
      }
      const childStatement = [];
      statementMapArr.map((item, index) => {
        // 判断语句是否有子语句
        if (
          index.toString() !== key &&
          item.start >= start.line &&
          item.end <= end.line
        ) {
          childStatement.push(item);
        }
      });
      if (childStatement.length) {
        // 存在子语句
        const statementSort = childStatement
          .map((item) => {
            return [item.start, item.end];
          })
          .sort((a, b) => a[0] - b[0]);
        // 求二维数组并集
        const statementUnion = [];
        if (statementSort.length === 1) {
          statementUnion = statementSort;
        } else if (statementSort.length > 1) {
          for (
            let i = 1, start = statementSort[0][0], end = statementSort[0][1];
            i < statementSort.length;
            i++
          ) {
            if (statementSort[i][0] > end) {
              statementUnion.push([start, end]);
              start = statementSort[i][0];
              end = statementSort[i][1];
              if (i === statementSort.length - 1) {
                statementUnion.push([start, end]);
              }
              continue;
            }
            end = statementSort[i][1] > end ? statementSort[i][1] : end;
            if (i === statementSort.length - 1) {
              statementUnion.push([start, end]);
              break;
            }
          }
        }
        const childStatementLine = statementUnion.reduce((pre, cur) => {
          const lineArr = new Array(cur[1] - cur[0] + 1)
            .fill("")
            .map((_, index) => cur[0] + index);
          return pre.concat(lineArr);
        }, []);
        for (let i = start.line; i <= end.line; i++) {
          // 不计算子语句所在的行
          if (
            childStatementLine.indexOf(i) === -1 &&
            executeLineMap.hasOwnProperty(i.toString())
          ) {
            executeLineMap[i] += 1;
          }
        }
      } else {
        // 不存在子语句
        for (let i = start.line; i <= end.line; i++) {
          if (executeLineMap.hasOwnProperty(i.toString())) {
            executeLineMap[i] += 1;
          }
        }
      }
    }
  });
  let executeLineCount = 0;
  Object.keys(executeLineMap).map((key) => {
    if (executeLineMap[key] > 0) {
      executeLineCount += 1;
    }
  });
  return executeLineCount;
}
全量计算

全量的计算逻辑是对 statementMap 里的行的开始 start 和结束 end 转换成一个二维数组,然后求并集,最后再对每个 end - start + 1 的值进行求和得到全量插桩行。 例如 [[1, 8], [6, 10], [12, 15] 求并集得 [[1, 10], [12, 15]], 然后求和得 10 + 4 = 14

// 获取单个文件的插桩行的数目,传入的参数 coverageData 也是上文中写到的那个
function getInstrumentLineCount(coverageData) {
  const { statementMap } = coverageData;
  const statementSort = Object.keys(statementMap)
    .map((key) => {
      return [statementMap[key].start.line, statementMap[key].end.line];
    })
    .sort((a, b) => a[0] - b[0]);
  const statementUnion = [];
  // 求二维数组中数组的并集
  if (statementSort.length === 1) {
    return statementSort[0][1] - statementSort[0][0] + 1;
  } else if (statementSort.length > 1) {
    for (
      let i = 1, start = statementSort[0][0], end = statementSort[0][1];
      i < statementSort.length;
      i++
    ) {
      if (statementSort[i][0] > end) {
        statementUnion.push([start, end]);
        start = statementSort[i][0];
        end = statementSort[i][1];
        if (i === statementSort.length - 1) {
          statementUnion.push([start, end]);
        }
        continue;
      }
      end = statementSort[i][1] > end ? statementSort[i][1] : end;
      if (i === statementSort.length - 1) {
        statementUnion.push([start, end]);
        break;
      }
    }
  }
  if (statementUnion.length) {
    // 求和
    const instrumentLineCount = statementUnion.reduce((pre, cur) => {
      return pre + cur[1] - cur[0] + 1;
    }, 0);
    return instrumentLineCount;
  }
  return 0;
}

如何查看覆盖率

前端侧

我们在前端侧可以通过 nyc 这个工具生成一份比较直观的覆盖率报告:

前端代码插桩与覆盖率

QA侧

规范化覆盖率的展示方式是建立测试数据展示平台,例如我司内部的测试平台:

前端代码插桩与覆盖率

上报覆盖率的方式

上报覆盖率有增量上报和全量上报两种上报类型,且对于不同 平台/环境 有不同的上报方式,下文详述,我们先来理解一下前端到底是怎么实现插桩的,尽量做到知其然且知其所以然。

前端代码插桩

谈起前端代码插桩,GitHub 的一个开源组织 Istanbul Code Coverage,主要维护了 babel-plugin-istanbul、istanbuljs、nyc 这三个与前端代码插桩相关的仓库。

前端代码插桩与覆盖率

目前主要使用 babel-plugin-istanbul 这个插件对前端代码进行插桩,仔细看这个插件的源码可以发现,真正的插桩逻辑并不在这个插件中实现,而是另外一个 monorepo 管理的 istanbuljs,其中的 istanbul-lib-instrument 才是真正实现了插桩的核心功能。而 nyc 则是覆盖率测试报告生成工具,可以用来生成一些以 html 或者其它形式展示的覆盖率报告。

下面讲一下 babel-plugin-istanbul 和 istanbuljs 源码的相关实现与设计思路,我已在代码的注释里解释相关代码逻辑,希望大家能理解其中的原理,共同探讨学习。

babel-plugin-istanbul

这个插件的源码只有一个主要文件:

// index.js
import { declare } from '@babel/helper-plugin-utils'
import { programVisitor } from 'istanbul-lib-instrument'
import schema from '@istanbuljs/schema'

// declare 确保插件运行正常
export default declare((api) => {
  // 这里的 api 指的是 babel 底层的 api,assertVersion 指定 babel 版本
  api.assertVersion(7)

  // 这个函数的功能是判断当前文件是否需要插桩以及是否适合插桩的条件(比如配置文件则不需要插桩)
  const shouldSkip = makeShouldSkip()

  const t = api.types
  return {
    // babel 的访问者模式
    visitor: {
      Program: {
        enter(path) {
          // 对即将进行代码插桩的某个代码文件进行相关配置的初始化
          this.__dv__ = null
          
          // 获取插桩相关的配置,其中包含不需要插桩的文件列表信息
          this.nycConfig = findConfig(this.opts)
          const realPath = getRealpath(this.file.opts.filename)
          
          // 当前文件不需要插桩,返回,不做任何处理。
          if (shouldSkip(realPath, this.nycConfig)) {
            return
          }
          
          // 获取传入的源代码的 SourceMap
          let { inputSourceMap } = this.opts
          
          /**
           * useInlineSourceMaps 表示是否需要使用传入的源码 SourceMap,默认为 true
           * 为 true 时会在插桩后将覆盖率数据映射到源码对应行、分支、函数、语句中
           */
          if (this.opts.useInlineSourceMaps !== false) {
            if (!inputSourceMap && this.file.inputMap) {
              inputSourceMap = this.file.inputMap.sourcemap
            }
          }
          
          const visitorOptions = {}
          
          /** 
           * schema 是 istanbuljs 定义的数据模型,里面设定了默认的字段与类型
           * 以下代码表示对 schema 的 instrumentVisitor 插桩“探针”默认配置进行 forEach
           * 遍历,若在开发者的配置中存在指定同名字段配置,则采用开发者传入的,否则使用默认的
           */
          Object.entries(schema.defaults.instrumentVisitor).forEach(
            ([name, defaultValue]) => {
              if (name in this.nycConfig) {
                visitorOptions[name] = this.nycConfig[name]
              } else {
                visitorOptions[name] = schema.defaults.instrumentVisitor[name]
              }
            }
          )
          
          // 调用封装好的代码探针,传入相关参数,返回插桩后的相关数据
          this.__dv__ = programVisitor(t, realPath, {
            ...visitorOptions,
            inputSourceMap,
          })
          
          // 插桩
          this.__dv__.enter(path)
        },
        exit(path) {
          if (!this.__dv__) {
            return
          }
          const result = this.__dv__.exit(path)
          
          if (this.opts.onCover) {
            // 插桩后的回调
            this.opts.onCover(
              getRealpath(this.file.opts.filename), // 文件路径
              result.fileCoverage // 插桩后得到的插桩数据
            )
          }
        },
      },
    },
  }
})

不难看出,babel-plugin-istanbul 本身提供的是对插桩之前的预处理功能,而其中核心的插桩功能是由 istanbul-lib-instrument 中封装的 programVisitor 提供的。

istanbuljs

istanbuljs 是以 monorepo 方式管理的项目,其中维护了多个负责不同功能的 package,接下来讲一下这些 package 中主要的一些包,没有讲到的是和 nyc 这个工具的配置相关或者生成报告相关功能的包,这里就不展开讲解了。

前端代码插桩与覆盖率

istanbul-lib-coverage

这个 package 有三个封装好的 API,分别是 FileCoverageCoverageMapCoverageSummary,顾名思义:

FileCoverage

提供一个将覆盖率相关信息转化为易于阅读、简化的结构的功能,例如一个空的结构为:

// returns a data object that represents empty coverage
function emptyCoverage(filePath) {
    return {
        path: filePath,
        statementMap: {},
        fnMap: {},
        branchMap: {},
        s: {},
        f: {},
        b: {}
    };
}

CoverageMap

主要复用了 FileCoverage 的功能,可以理解为 FileCoverage 的一个 map,返回一个对象,key 是文件的路径,值是 FileCoverage 返回的值。

CoverageSummary

可以将多次插桩结果进行汇总,返回代码覆盖率的概要(一个对象),其中有 lines、 statements、 branches 、functions 四个属性,每个属性对应的值(对象)都有 total(总数)、 covered(覆盖数)、 skipped(被排除)、 pct(覆盖数 / 总数 * 100 的一个比值) 四个属性,其中这个 class 实现一个主要的 merge 方法:

/**
 * merges a second summary coverage object into this one
 * @param {CoverageSummary} obj - another coverage summary object
 */
merge(obj) {
    const keys = ['lines', 'statements', 'branches', 'functions'];
    keys.forEach(key => {
        this[key].total += obj[key].total;
        this[key].covered += obj[key].covered;
        this[key].skipped += obj[key].skipped;
        this[key].pct = percent(this[key].covered, this[key].total);
    });

    return this;
}

istanbul-lib-hook

一个针对 nodejs 环境实现运行时插桩功能的 package,其中暴露了如下的 API:

  • hookRequire:最常用的 API,hook 了 require 引用的代码
  • hookCreateScript:hook 了 vm.createScript (nodejs 目前的最新稳定版 v16.6.2 已经弃用,改用 new vm.Script)引用的代码
  • unhookCreateScript:unhookCreateScript 的逆过程
  • hookRunInThisContext:hook 了 vm.runInThisContext 引用的代码
  • unhookRunInThisContext:hookRunInThisContext 的逆过程
  • hookRunInContext:hook 了 vm.runInContext 引用的代码
  • unhookRunInContext:hookRunInContext 的逆过程
  • unloadRequireCache:“卸载” require 的缓存

除了 hookRequireunloadRequireCache,其他 API 主要运用了 nodejs 的 vm API 实现了相关功能,本文不过多描述关于 vm 的具体使用,感兴趣的同学可以查看 nodejs 官方文档

而关于 hook 是什么意思,我的理解是 hook 是在代码运行时能够产生对源代码产生一些“副作用”的函数。

看了上面对于各种 API 的简单描述,可以发现 hookRequirehookCreateScripthookRunInThisContext 等都是对某一种被引用的代码进行处理,所以我们理解其中一种就足够了,而其中 hookRequire 是最常用也是实现原理较其他 API 更复杂的 API,接下来我们讲一下 hookRequire 的实现:

hookRequire

参数:

  • matcher:一个函数类型参数,返回布尔值,通过传入的文件绝对路径判断是否需要插桩
  • transformer:一个函数类型参数,调用 transformer 可以将源代码转换成插桩后的代码
  • options:配置项
    • verbose:是否在插桩每一行报错时打印详细信息
    • postLoadHook:一个函数类型参数,在 hookRequire 调用完成后回调移除 hook
/**
 * hooks `require` to return transformed code to the node module loader.
 * Exceptions in the transform result in the original code being used instead.
 * @method hookRequire
 * @static
 * @param matcher {Function(filePath)} a function that is called with the absolute path to the file being
 *  `require`-d. Should return a truthy value when transformations need to be applied to the code, a falsy value otherwise
 * @param transformer {Function(code, filePath)} a function called with the original code and the associated path of the file
 *  from where the code was loaded. Should return the transformed code.
 * @param options {Object} options Optional.
 * @param {Boolean} [options.verbose] write a line to standard error every time the transformer is called
 * @param {Function} [options.postLoadHook] a function that is called with the name of the file being
 *  required. This is called after the require is processed irrespective of whether it was transformed.
 * @returns {Function} a reset function that can be called to remove the hook
 */
function hookRequire(matcher, transformer, options) {
    options = options || {};
    let disable = false;
    const fn = transformFn(matcher, transformer, options.verbose);
    const postLoadHook =
        options.postLoadHook && typeof options.postLoadHook === 'function'
            ? options.postLoadHook
            : null;

    const extensions = options.extensions || ['.js'];

    extensions.forEach(ext => {
        appendTransform((code, filename) => {
            if (disable) {
                return code;
            }
            const ret = fn(code, filename);
            if (postLoadHook) {
                postLoadHook(filename);
            }
            return ret.code;
        }, ext);
    });

    return function() {
        disable = true;
    };
}

transformFn

hookRequire 调用了 transformFn 函数,其实 hookCreateScripthookRunInThisContexthookRunInContext 也用到了这个函数,因此我们来了解一下 transformFn 到底做了什么:

function transformFn(matcher, transformer, verbose) {
    return function(code, options) {
        options = options || {};

        // prior to 2.x, hookRequire returned filename
        // rather than object.
        if (typeof options === 'string') {
            options = { filename: options };
        }

        const shouldHook =
            typeof options.filename === 'string' &&
            matcher(path.resolve(options.filename));
        let transformed;
        let changed = false;

        if (shouldHook) {
            if (verbose) {
                console.error(
                    'Module load hook: transform [' + options.filename + ']'
                );
            }
            try {
                transformed = transformer(code, options);
                changed = true;
            } catch (ex) {
                console.error(
                    'Transformation error for',
                    options.filename,
                    '; return original code'
                );
                console.error(ex.message || String(ex));
                if (verbose) {
                    console.error(ex.stack);
                }
                transformed = code;
            }
        } else {
            transformed = code;
        }
        return { code: transformed, changed };
    };
}

istanbul-lib-instrument

插桩功能的核心工具,暴露了 createInstrumenterprogramVisitorreadInitialCoverage 三个 API。

createIntrumenter

createInstrumenter 里实现了主要的 instrumentSync 方法,接受3个参数:

  • code: 源代码
  • filename: 文件名
  • inputSourceMap: 源代码的 Source Map

这个函数的作用是可以让开发者调用该函数来定制相关插桩工具。

/**
 * instrument the supplied code and track coverage against the supplied
 * filename. It throws if invalid code is passed to it. ES5 and ES6 syntax
 * is supported. To instrument ES6 modules, make sure that you set the
 * `esModules` property to `true` when creating the instrumenter.
 *
 * @param {string} code - the code to instrument
 * @param {string} filename - the filename against which to track coverage.
 * @param {object} [inputSourceMap] - the source map that maps the not instrumented code back to it's original form.
 * Is assigned to the coverage object and therefore, is available in the json output and can be used to remap the
 * coverage to the untranspiled source.
 * @returns {string} the instrumented code.
 */
instrumentSync(code, filename, inputSourceMap) {
    if (typeof code !== 'string') {
        throw new Error('Code must be a string');
    }
    filename = filename || String(new Date().getTime()) + '.js';
    const { opts } = this;
    let output = {};
    const babelOpts = {
        configFile: false,
        babelrc: false,
        ast: true,
        filename: filename || String(new Date().getTime()) + '.js',
        inputSourceMap,
        sourceMaps: opts.produceSourceMap,
        compact: opts.compact,
        comments: opts.preserveComments,
        parserOpts: {
            allowReturnOutsideFunction: opts.autoWrap,
            sourceType: opts.esModules ? 'module' : 'script',
            plugins: opts.parserPlugins
        },
        plugins: [
            [
                ({ types }) => {
                    const ee = programVisitor(types, filename, {
                        coverageVariable: opts.coverageVariable,
                        coverageGlobalScope: opts.coverageGlobalScope,
                        coverageGlobalScopeFunc:
                            opts.coverageGlobalScopeFunc,
                        ignoreClassMethods: opts.ignoreClassMethods,
                        inputSourceMap
                    });

                    return {
                        visitor: {
                            Program: {
                                enter: ee.enter,
                                exit(path) {
                                    output = ee.exit(path);
                                }
                            }
                        }
                    };
                }
            ]
        ]
    };

    const codeMap = transformSync(code, babelOpts);

    if (!output || !output.fileCoverage) {
        const initialCoverage =
            readInitialCoverage(codeMap.ast) ||
            /* istanbul ignore next: paranoid check */ {};
        this.fileCoverage = initialCoverage.coverageData;
        this.sourceMap = inputSourceMap;
        return code;
    }

    this.fileCoverage = output.fileCoverage;
    this.sourceMap = codeMap.map;
    const cb = this.opts.sourceMapUrlCallback;
    if (cb && output.sourceMappingURL) {
        cb(filename, output.sourceMappingURL);
    }

    return codeMap.code;
}

programVisitor

programVisitor 实现了最核心的插桩功能,这部分的源码比较多,这里拆分成部分功能讲解。

babel 与插桩的关系

首先我们都知道 babel 是一个 JavaScript 编译器,可以将 ES5+ 语法的代码转成兼容浏览器的代码,实现插桩这个功能的原理是在 babel 遍历(traverse) AST 时传入一些“副作用”的函数,从而改变经过遍历 AST 后生成的代码内容。

如果想要了解 babel 的工作原理,推荐查看这个手册,本文着重于描述如何实现插桩功能。

babel 的基本工作流程

前端代码插桩与覆盖率

插桩在 AST -> 转换后的 AST 这个过程进行:

前端代码插桩与覆盖率

在遍历 AST 时,@babel/traverse 会用到一个用于 AST 节点的 lodash 式工具 @babel/types,它包含了构造、验证以及变换 AST 节点的方法,所以这时候我们可以得到插桩的思路:当 @babel/traverse 识别到我们插桩需要的语句、分支、函数等,在对应位置插入一个“计数器”的 AST 节点,这样在代码生成时即可得到我们需要的插桩代码。

访问者模式

有了思路之后,该如何插入新节点呢?@babel/traverse 有一个访问者模式的概念(visitor),也就是当“进入”一个节点时,实际上是说我们在访问它们,例如:

const MyVisitor = {
  Identifier() { // 识别到标识符时执行该函数
    enter() {
        console.log("Called!");
    },
    exit() {
        // ...
    }
  }
};

// 你也可以先创建一个访问者对象,并在稍后给它添加方法。
let visitor = {};
visitor.Identifier = function() {...};
visitor.MemberExpression = function() {...};
visitor.FunctionDeclaration = function() {...}

所以在下面的代码中,当我们对这个函数生成的 AST 进行遍历(traverse)时,Identifier() 方法会被调用四次(包括 square 在内,总共有四个 Identifier)

function square(n) {
  return n * n;
}
path.traverse(MyVisitor);
Called!
Called!
Called!
Called!

Identifier() 方法,当识别到标识符时执行该方法,类似这种“识别器”其中存在一个比较固定的结构(上面的代码代码为了容易理解所以简化了代码结构):

enter() {
    // ...
},
exit() {
    // ...
}

当我们向下遍历某棵树的每一个分支时我们最终会走到尽头,于是我们需要往上遍历回去从而获取到下一个节点。 向下遍历这棵树我们 进入(enter) 每个节点,向上遍历回去时我们 退出(exit) 每个节点。所以当创建访问者时实际上有两次机会来访问一个节点:

const MyVisitor = {
  Identifier: {
    enter() {
      console.log("Entered!");
    },
    exit() {
      console.log("Exited!");
    }
  }
};

所以,我们可以借助访问者模式,“注入”我们定制的“识别器”,每个识别器都有插入一个“计数器”的 AST 节点的功能,这样就能实现插桩的功能了。

覆盖率访问者

istanbul-lib-instrument 源码用一个 VisitState 记录每一个独立的覆盖率“访问者”的状态:

// VisitState holds the state of the visitor, provides helper functions
// and is the `this` for the individual coverage visitors.
class VisitState {
    constructor(
        types,
        sourceFilePath,
        inputSourceMap,
        ignoreClassMethods = []
    ) {
        this.varName = genVar(sourceFilePath);
        this.attrs = {};
        this.nextIgnore = null;
        this.cov = new SourceCoverage(sourceFilePath);

        if (typeof inputSourceMap !== 'undefined') {
            this.cov.inputSourceMap(inputSourceMap);
        }
        this.ignoreClassMethods = ignoreClassMethods;
        this.types = types;
        this.sourceMappingURL = null;
    }

    // should we ignore the node? Yes, if specifically ignoring
    // or if the node is generated.
    shouldIgnore(path) {
        return this.nextIgnore || !path.node.loc;
    }

    // extract the ignore comment hint (next|if|else) or null
    hintFor(node) {
        let hint = null;
        if (node.leadingComments) {
            node.leadingComments.forEach(c => {
                const v = (
                    c.value || /* istanbul ignore next: paranoid check */ ''
                ).trim();
                const groups = v.match(COMMENT_RE);
                if (groups) {
                    hint = groups[1];
                }
            });
        }
        return hint;
    }

    // extract a source map URL from comments and keep track of it
    maybeAssignSourceMapURL(node) {
        const extractURL = comments => {
            if (!comments) {
                return;
            }
            comments.forEach(c => {
                const v = (
                    c.value || /* istanbul ignore next: paranoid check */ ''
                ).trim();
                const groups = v.match(SOURCE_MAP_RE);
                if (groups) {
                    this.sourceMappingURL = groups[1];
                }
            });
        };
        extractURL(node.leadingComments);
        extractURL(node.trailingComments);
    }

    // for these expressions the statement counter needs to be hoisted, so
    // function name inference can be preserved
    counterNeedsHoisting(path) {
        return (
            path.isFunctionExpression() ||
            path.isArrowFunctionExpression() ||
            path.isClassExpression()
        );
    }

    // all the generic stuff that needs to be done on enter for every node
    onEnter(path) {
        const n = path.node;

        this.maybeAssignSourceMapURL(n);

        // if already ignoring, nothing more to do
        if (this.nextIgnore !== null) {
            return;
        }
        // check hint to see if ignore should be turned on
        const hint = this.hintFor(n);
        if (hint === 'next') {
            this.nextIgnore = n;
            return;
        }
        // else check custom node attribute set by a prior visitor
        if (this.getAttr(path.node, 'skip-all') !== null) {
            this.nextIgnore = n;
        }

        // else check for ignored class methods
        if (
            path.isFunctionExpression() &&
            this.ignoreClassMethods.some(
                name => path.node.id && name === path.node.id.name
            )
        ) {
            this.nextIgnore = n;
            return;
        }
        if (
            path.isClassMethod() &&
            this.ignoreClassMethods.some(name => name === path.node.key.name)
        ) {
            this.nextIgnore = n;
            return;
        }
    }

    // all the generic stuff on exit of a node,
    // including reseting ignores and custom node attrs
    onExit(path) {
        // restore ignore status, if needed
        if (path.node === this.nextIgnore) {
            this.nextIgnore = null;
        }
        // nuke all attributes for the node
        delete path.node.__cov__;
    }

    // set a node attribute for the supplied node
    setAttr(node, name, value) {
        node.__cov__ = node.__cov__ || {};
        node.__cov__[name] = value;
    }

    // retrieve a node attribute for the supplied node or null
    getAttr(node, name) {
        const c = node.__cov__;
        if (!c) {
            return null;
        }
        return c[name];
    }

    //
    increase(type, id, index) {
        const T = this.types;
        const wrap =
            index !== null
                ? // If `index` present, turn `x` into `x[index]`.
                  x => T.memberExpression(x, T.numericLiteral(index), true)
                : x => x;
        return T.updateExpression(
            '++',
            wrap(
                T.memberExpression(
                    T.memberExpression(
                        T.callExpression(T.identifier(this.varName), []),
                        T.identifier(type)
                    ),
                    T.numericLiteral(id),
                    true
                )
            )
        );
    }

    insertCounter(path, increment) {
        const T = this.types;
        if (path.isBlockStatement()) {
            path.node.body.unshift(T.expressionStatement(increment));
        } else if (path.isStatement()) {
            path.insertBefore(T.expressionStatement(increment));
        } else if (
            this.counterNeedsHoisting(path) &&
            T.isVariableDeclarator(path.parentPath)
        ) {
            // make an attempt to hoist the statement counter, so that
            // function names are maintained.
            const parent = path.parentPath.parentPath;
            if (parent && T.isExportNamedDeclaration(parent.parentPath)) {
                parent.parentPath.insertBefore(
                    T.expressionStatement(increment)
                );
            } else if (
                parent &&
                (T.isProgram(parent.parentPath) ||
                    T.isBlockStatement(parent.parentPath))
            ) {
                parent.insertBefore(T.expressionStatement(increment));
            } else {
                path.replaceWith(T.sequenceExpression([increment, path.node]));
            }
        } /* istanbul ignore else: not expected */ else if (
            path.isExpression()
        ) {
            path.replaceWith(T.sequenceExpression([increment, path.node]));
        } else {
            console.error(
                'Unable to insert counter for node type:',
                path.node.type
            );
        }
    }

    insertStatementCounter(path) {
        /* istanbul ignore if: paranoid check */
        if (!(path.node && path.node.loc)) {
            return;
        }
        const index = this.cov.newStatement(path.node.loc);
        const increment = this.increase('s', index, null);
        this.insertCounter(path, increment);
    }

    insertFunctionCounter(path) {
        const T = this.types;
        /* istanbul ignore if: paranoid check */
        if (!(path.node && path.node.loc)) {
            return;
        }
        const n = path.node;

        let dloc = null;
        // get location for declaration
        switch (n.type) {
            case 'FunctionDeclaration':
            case 'FunctionExpression':
                /* istanbul ignore else: paranoid check */
                if (n.id) {
                    dloc = n.id.loc;
                }
                break;
        }
        if (!dloc) {
            dloc = {
                start: n.loc.start,
                end: { line: n.loc.start.line, column: n.loc.start.column + 1 }
            };
        }

        const name = path.node.id ? path.node.id.name : path.node.name;
        const index = this.cov.newFunction(name, dloc, path.node.body.loc);
        const increment = this.increase('f', index, null);
        const body = path.get('body');
        /* istanbul ignore else: not expected */
        if (body.isBlockStatement()) {
            body.node.body.unshift(T.expressionStatement(increment));
        } else {
            console.error(
                'Unable to process function body node type:',
                path.node.type
            );
        }
    }

    getBranchIncrement(branchName, loc) {
        const index = this.cov.addBranchPath(branchName, loc);
        return this.increase('b', branchName, index);
    }

    insertBranchCounter(path, branchName, loc) {
        const increment = this.getBranchIncrement(
            branchName,
            loc || path.node.loc
        );
        this.insertCounter(path, increment);
    }

    findLeaves(node, accumulator, parent, property) {
        if (!node) {
            return;
        }
        if (node.type === 'LogicalExpression') {
            const hint = this.hintFor(node);
            if (hint !== 'next') {
                this.findLeaves(node.left, accumulator, node, 'left');
                this.findLeaves(node.right, accumulator, node, 'right');
            }
        } else {
            accumulator.push({
                node,
                parent,
                property
            });
        }
    }
}
定制访问者模式参数
// generic function that takes a set of visitor methods and
// returns a visitor object with `enter` and `exit` properties,
// such that:
//
// * standard entry processing is done
// * the supplied visitors are called only when ignore is not in effect
//   This relieves them from worrying about ignore states and generated nodes.
// * standard exit processing is done
//
function entries(...enter) {
    // the enter function
    const wrappedEntry = function(path, node) {
        this.onEnter(path);
        if (this.shouldIgnore(path)) {
            return;
        }
        enter.forEach(e => {
            e.call(this, path, node);
        });
    };
    const exit = function(path, node) {
        this.onExit(path, node);
    };
    return {
        enter: wrappedEntry,
        exit
    };
}

const codeVisitor = {
    ArrowFunctionExpression: entries(convertArrowExpression, coverFunction),
    AssignmentPattern: entries(coverAssignmentPattern),
    BlockStatement: entries(), // ignore processing only
    ExportDefaultDeclaration: entries(), // ignore processing only
    ExportNamedDeclaration: entries(), // ignore processing only
    ClassMethod: entries(coverFunction),
    ClassDeclaration: entries(parenthesizedExpressionProp('superClass')),
    ClassProperty: entries(coverClassPropDeclarator),
    ClassPrivateProperty: entries(coverClassPropDeclarator),
    ObjectMethod: entries(coverFunction),
    ExpressionStatement: entries(coverStatement),
    BreakStatement: entries(coverStatement),
    ContinueStatement: entries(coverStatement),
    DebuggerStatement: entries(coverStatement),
    ReturnStatement: entries(coverStatement),
    ThrowStatement: entries(coverStatement),
    TryStatement: entries(coverStatement),
    VariableDeclaration: entries(), // ignore processing only
    VariableDeclarator: entries(coverVariableDeclarator),
    IfStatement: entries(
        blockProp('consequent'),
        blockProp('alternate'),
        coverStatement,
        coverIfBranches
    ),
    ForStatement: entries(blockProp('body'), coverStatement),
    ForInStatement: entries(blockProp('body'), coverStatement),
    ForOfStatement: entries(blockProp('body'), coverStatement),
    WhileStatement: entries(blockProp('body'), coverStatement),
    DoWhileStatement: entries(blockProp('body'), coverStatement),
    SwitchStatement: entries(createSwitchBranch, coverStatement),
    SwitchCase: entries(coverSwitchCase),
    WithStatement: entries(blockProp('body'), coverStatement),
    FunctionDeclaration: entries(coverFunction),
    FunctionExpression: entries(coverFunction),
    LabeledStatement: entries(coverStatement),
    ConditionalExpression: entries(coverTernary),
    LogicalExpression: entries(coverLogicalExpression)
};
模板函数

还记得前文给出的插桩后的代码例子吗?插桩后的代码会在初始插入一个计数函数,这个函数的由来则是一个“模板”函数,调用 @babel/template,对每一个代码文件插桩时,在代码初始行加入这个“模板”函数,然后根据 programVisitor 传入的配置可以定制函数内部的相关参数,通过函数记录以及返回的数值而得到相关的覆盖率数据:

const coverageTemplate = template(
    `
    function COVERAGE_FUNCTION () {
        var path = PATH;
        var hash = HASH;
        GLOBAL_COVERAGE_TEMPLATE
        var gcv = GLOBAL_COVERAGE_VAR;
        var coverageData = INITIAL;
        var coverage = global[gcv] || (global[gcv] = {});
        if (!coverage[path] || coverage[path].hash !== hash) {
            coverage[path] = coverageData;
        }

        var actualCoverage = coverage[path];
        {
            // @ts-ignore
            COVERAGE_FUNCTION = function () {
                return actualCoverage;
            }
        }

        return actualCoverage;
    }
`,
    { preserveComments: true }
);
主函数声明

首先 programVisitor 接受3个参数:

  • types: babel 的类型参数
  • sourceFilePath: 源文件的路径
  • opts: 相关配置参数,例如可传入 coverageVariable 参数指定覆盖率数据存储到全局属性的名字,默认情况下浏览器环境通过 window.__coverage__ 读取数据,假设 coverageVariable 设置为 __test__coverage__, 则可以通过 window.__test__coverage__ 读取数据。
/**
 * programVisitor is a `babel` adaptor for instrumentation.
 * It returns an object with two methods `enter` and `exit`.
 * These should be assigned to or called from `Program` entry and exit functions
 * in a babel visitor.
 * These functions do not make assumptions about the state set by Babel and thus
 * can be used in a context other than a Babel plugin.
 *
 * The exit function returns an object that currently has the following keys:
 *
 * `fileCoverage` - the file coverage object created for the source file.
 * `sourceMappingURL` - any source mapping URL found when processing the file.
 *
 * @param {Object} types - an instance of babel-types
 * @param {string} sourceFilePath - the path to source file
 * @param {Object} opts - additional options
 * @param {string} [opts.coverageVariable=__coverage__] the global coverage variable name.
 * @param {string} [opts.coverageGlobalScope=this] the global coverage variable scope.
 * @param {boolean} [opts.coverageGlobalScopeFunc=true] use an evaluated function to find coverageGlobalScope.
 * @param {Array} [opts.ignoreClassMethods=[]] names of methods to ignore by default on classes.
 * @param {object} [opts.inputSourceMap=undefined] the input source map, that maps the uninstrumented code back to the
 * original code.
 */
function programVisitor(types, sourceFilePath = 'unknown.js', opts = {}) {
    const T = types;
    opts = {
        ...defaults.instrumentVisitor,
        ...opts
    };
    const visitState = new VisitState(
        types,
        sourceFilePath,
        opts.inputSourceMap,
        opts.ignoreClassMethods
    );
    return {
        enter(path) {
            if (shouldIgnoreFile(path.find(p => p.isProgram()))) {
                return;
            }
            if (alreadyInstrumented(path, visitState)) {
                return;
            }
            path.traverse(codeVisitor, visitState);
        },
        exit(path) {
            if (alreadyInstrumented(path, visitState)) {
                return;
            }
            visitState.cov.freeze();
            const coverageData = visitState.cov.toJSON();
            if (shouldIgnoreFile(path.find(p => p.isProgram()))) {
                return {
                    fileCoverage: coverageData,
                    sourceMappingURL: visitState.sourceMappingURL
                };
            }
            coverageData[MAGIC_KEY] = MAGIC_VALUE;
            const hash = createHash(SHA)
                .update(JSON.stringify(coverageData))
                .digest('hex');
            coverageData.hash = hash;
            const coverageNode = T.valueToNode(coverageData);
            delete coverageData[MAGIC_KEY];
            delete coverageData.hash;
            let gvTemplate;
            if (opts.coverageGlobalScopeFunc) {
                if (path.scope.getBinding('Function')) {
                    gvTemplate = globalTemplateAlteredFunction({
                        GLOBAL_COVERAGE_SCOPE: T.stringLiteral(
                            'return ' + opts.coverageGlobalScope
                        )
                    });
                } else {
                    gvTemplate = globalTemplateFunction({
                        GLOBAL_COVERAGE_SCOPE: T.stringLiteral(
                            'return ' + opts.coverageGlobalScope
                        )
                    });
                }
            } else {
                gvTemplate = globalTemplateVariable({
                    GLOBAL_COVERAGE_SCOPE: opts.coverageGlobalScope
                });
            }
            const cv = coverageTemplate({
                GLOBAL_COVERAGE_VAR: T.stringLiteral(opts.coverageVariable),
                GLOBAL_COVERAGE_TEMPLATE: gvTemplate,
                COVERAGE_FUNCTION: T.identifier(visitState.varName),
                PATH: T.stringLiteral(sourceFilePath),
                INITIAL: coverageNode,
                HASH: T.stringLiteral(hash)
            });
            // explicitly call this.varName to ensure coverage is always initialized
            path.node.body.unshift(
                T.expressionStatement(
                    T.callExpression(T.identifier(visitState.varName), [])
                )
            );
            path.node.body.unshift(cv);
            return {
                fileCoverage: coverageData,
                sourceMappingURL: visitState.sourceMappingURL
            };
        }
    };
}

readInitialCoverage

这个 API 的参数是源代码,功能是获取源代码后通过的 babel 的 parseSync API 解析成抽象语法树(AST)后再通过 traverse API 转换成初始化的插桩代码并返回初始化的插桩数据。

import { parseSync, traverse } from '@babel/core';
import { defaults } from '@istanbuljs/schema';
import { MAGIC_KEY, MAGIC_VALUE } from './constants';

function getAst(code) { // 获取源码的 AST
    if (typeof code === 'object' && typeof code.type === 'string') {
        // Assume code is already a babel ast.
        return code;
    }

    if (typeof code !== 'string') {
        throw new Error('Code must be a string');
    }

    // Parse as leniently as possible
    return parseSync(code, {
        babelrc: false,
        configFile: false,
        parserOpts: {
            allowImportExportEverywhere: true,
            allowReturnOutsideFunction: true,
            allowSuperOutsideMethod: true,
            sourceType: 'script',
            plugins: defaults.instrumenter.parserPlugins
        }
    });
}

export default function readInitialCoverage(code) {
    const ast = getAst(code);

    let covScope;
    traverse(ast, {
        ObjectProperty(path) {
            const { node } = path;
            if (
                !node.computed &&
                path.get('key').isIdentifier() &&
                node.key.name === MAGIC_KEY
            ) {
                const magicValue = path.get('value').evaluate();
                if (!magicValue.confident || magicValue.value !== MAGIC_VALUE) {
                    return;
                }
                covScope =
                    path.scope.getFunctionParent() ||
                    path.scope.getProgramParent();
                path.stop();
            }
        }
    });

    if (!covScope) {
        return null;
    }

    const result = {};

    for (const key of ['path', 'hash', 'gcv', 'coverageData']) {
        const binding = covScope.getOwnBinding(key);
        if (!binding) {
            return null;
        }
        const valuePath = binding.path.get('init');
        const value = valuePath.evaluate();
        if (!value.confident) {
            return null;
        }
        result[key] = value.value;
    }

    delete result.coverageData[MAGIC_KEY];
    delete result.coverageData.hash;

    return result;
}

istanbul-lib-source-maps

暴露了一个 createSourceMapStore API,返回插桩代码源文件的映射。其中实现了 MapStore、SourceMapTransformer 两个主要的 class。

MapStore

以 url 的方式记录被插桩过的代码文件的 source map,记录到的 source map 主要用来表示插桩行对应源代码所在的行。

/**
 * Registers a source map URL with this store. It makes some input sanity checks
 * and silently fails on malformed input.
 * @param transformedFilePath - the file path for which the source map is valid.
 *  This must *exactly* match the path stashed for the coverage object to be
 *  useful.
 * @param sourceMapUrl - the source map URL, **not** a comment
 */
registerURL(transformedFilePath, sourceMapUrl) {
    const d = 'data:';

    if (
        sourceMapUrl.length > d.length &&
        sourceMapUrl.substring(0, d.length) === d
    ) {
        const b64 = 'base64,';
        const pos = sourceMapUrl.indexOf(b64);
        if (pos > 0) {
            this.data[transformedFilePath] = {
                type: 'encoded',
                data: sourceMapUrl.substring(pos + b64.length)
            };
        } else {
            debug(`Unable to interpret source map URL: ${sourceMapUrl}`);
        }

        return;
    }

    const dir = path.dirname(path.resolve(transformedFilePath));
    const file = path.resolve(dir, sourceMapUrl);
    this.data[transformedFilePath] = { type: 'file', data: file };
}

SourceMapTransformer

使用 Source Map 将覆盖率数据对应(映射)到源文件的行、分支、语句、函数:

class SourceMapTransformer {
    constructor(finder, opts = {}) {
        this.finder = finder;
        this.baseDir = opts.baseDir || process.cwd();
    }

    processFile(fc, sourceMap, coverageMapper) {
        let changes = 0;

        Object.entries(fc.statementMap).forEach(([s, loc]) => {
            const hits = fc.s[s];
            const mapping = getMapping(sourceMap, loc, fc.path);

            if (mapping) {
                changes += 1;
                const mappedCoverage = coverageMapper(mapping.source);
                mappedCoverage.addStatement(mapping.loc, hits);
            }
        });

        Object.entries(fc.fnMap).forEach(([f, fnMeta]) => {
            const hits = fc.f[f];
            const mapping = getMapping(sourceMap, fnMeta.decl, fc.path);
            const spanMapping = getMapping(sourceMap, fnMeta.loc, fc.path);

            if (
                mapping &&
                spanMapping &&
                mapping.source === spanMapping.source
            ) {
                changes += 1;
                const mappedCoverage = coverageMapper(mapping.source);
                mappedCoverage.addFunction(
                    fnMeta.name,
                    mapping.loc,
                    spanMapping.loc,
                    hits
                );
            }
        });

        Object.entries(fc.branchMap).forEach(([b, branchMeta]) => {
            const hits = fc.b[b];
            const locs = [];
            const mappedHits = [];
            let source;
            let skip;

            branchMeta.locations.forEach((loc, i) => {
                const mapping = getMapping(sourceMap, loc, fc.path);
                if (mapping) {
                    if (!source) {
                        source = mapping.source;
                    }

                    if (mapping.source !== source) {
                        skip = true;
                    }

                    locs.push(mapping.loc);
                    mappedHits.push(hits[i]);
                }
            });

            if (!skip && locs.length > 0) {
                changes += 1;
                const mappedCoverage = coverageMapper(source);
                mappedCoverage.addBranch(
                    branchMeta.type,
                    locs[0] /* XXX */,
                    locs,
                    mappedHits
                );
            }
        });

        return changes > 0;
    }

    async transform(coverageMap) {
        const uniqueFiles = {};
        const getMappedCoverage = file => {
            const key = getUniqueKey(file);
            if (!uniqueFiles[key]) {
                uniqueFiles[key] = {
                    file,
                    mappedCoverage: new MappedCoverage(file)
                };
            }

            return uniqueFiles[key].mappedCoverage;
        };

        for (const file of coverageMap.files()) {
            const fc = coverageMap.fileCoverageFor(file);
            const sourceMap = await this.finder(file, fc);

            if (sourceMap) {
                const changed = this.processFile(
                    fc,
                    sourceMap,
                    getMappedCoverage
                );
                if (!changed) {
                    debug(`File [${file}] ignored, nothing could be mapped`);
                }
            } else {
                uniqueFiles[getUniqueKey(file)] = {
                    file,
                    mappedCoverage: new MappedCoverage(fc)
                };
            }
        }

        return libCoverage.createCoverageMap(getOutput(uniqueFiles));
    }
}

从 JavaScript 到 AST 再到编译原理

理解了插桩的实现,我们再回过头来回顾一下,从 JavaScript 到 AST 再到编译原理这个“宏观”的角度:

首先 JavaScript 是一门高级语言,出于浏览器兼容性问题,我们需要对ES5+ 的代码进行编译,此时诞生了 babel 这个编译器;

其次,babel 这个编译器使用了业界比较统一的 AST 方案对代码进行解析、遍历、转换和生成处理工作,在转换阶段,支持对 AST 节点进行额外的操作,包括替换、新增、删除等;

最后,回到编译原理,babel 的解析、遍历、转换和生成处理工作一一对应了编译原理中的各个流程,也就是:编译原理(抽象)-> babel(具体实现)

小结

到此,已经讲解了 babel-plugin-istanbul 和 istanbuljs 的源码,其中的插桩逻辑是重头戏,涉及到 babel 的核心工具 @babel/core 以及底层的编译原理,最后将 JavaScript <-> AST <-> 编译原理 这些知识关联了起来,而各种高级语言的词法、语法、语义等万变不离其宗,本质上是一样的,JavaScript <-> AST <-> 编译原理 这个过程中的 JavaScript 可以替换成其他高级语言,因此我们其实理解了 高级语言 <-> AST <-> 编译原理 这个过程。

覆盖率的收集、上报和统计

一般来说,对代码进行插桩仅仅是插桩和记录,而覆盖率的收集则需要开发者自己根据需求进行收集和上报。上文说到,我们可以在类似 global.__coverage__ 或者 window.__coverage__ 拿到覆盖率数据,例如在浏览器可以拿到的覆盖率数据类似这样:

前端代码插桩与覆盖率

然而这仅仅是增量覆盖率,而且这些数据往往需要开发者根据需求进行不同的处理后再进行上报,数据的收集也区分不同环境,比如前端主要的环境有浏览器、Node.js、小程序等,这些不同环境存在不同的限制,需要定制不同的解决方案

JS 覆盖率采集分为三层:采集层 -> 数据层 -> 视图层,其中前端侧需要支持采集层和数据层的功能支持,视图层负责接收数据并处理后进行平台展示。(不同公司的需求不同,下面讲的主要是我司内部的做法)

覆盖率收集(采集层)

浏览器

babel-plugin-istanbul 只支持增量

问题描述

使用 babel-plugin-istanbul 的前端项目在构建之后,在浏览器环境运行的代码只能记录被执行的次数(增量),我们无法通过代码在浏览器执行时再进行全量数据的收集。

解决方案

为了解决全量的问题,也就回到了本文的背景,笔者参与了覆盖率工具项目的开发,整合 babel-plugin-istanbul 和 istanbul-lib-instrument 等插件的功能实现了一个全量覆盖率 babal 插件,这是当时的设计逻辑:

前端代码插桩与覆盖率

其中修改插桩逻辑的主要功能是在对每一个插桩文件插桩后返回的覆盖率数据对进行上文中讲解到的全量计算,然后存到一个指定路径(默认是项目根目录)的 json 文件中,在构建完成时,所有需要插桩的代码均已经插桩完成,这时的 json 文件记录了整个项目的全量数据。

增量数据需要手动收集、处理

问题描述

浏览器环境的覆盖率数据存储到类似 window.__coverage__ 这样的全局对象的属性里,但是里面的数据并不完全是我们需要的,开发者需要自己手动收集并处理,然后再上报。

解决方案

实现一个 babel 插件,统一处理、采集数据。

monorepo 场景下的全量数据问题

问题描述

当前端项目使用的是 monorepo 的管理方式时,会出现多个 package 多次编译上报覆盖率数据的问题,导致无法收集完整的全量数据。

解决方案

数据接收方也就是覆盖率平台服务端设计数据库存取方案处理不同情况的数据。(主要还是得看服务端的设计实现)

NodeJS

需要手动收集、处理

问题描述

和浏览器环境同样的问题。

解决方案

使用 require hook 的方式。

运行时插桩,不支持全量

问题描述

由于 nodejs 环境的代码是运行时的,不能像浏览器环境的项目那样在编译时对所有需要插桩的代码一次性完成插桩,因此无法采用浏览器环境那样的方式收集全量的覆盖率数据。

解决方案

无法获取所有需要用到的文件,也就无法对所有用到的文件进行插桩,因此 nodejs 环境下的全量数据无法记录,此问题暂时无解。

小程序

小程序项目需要注意的是,目前需要使用 babel 且可以配置 babel 的小程序项目才能进行插桩处理,例如 taro 项目是可以的,而微信小程序原生项目由于自带 babel 编译,无法添加 babel 预设,也就无法正常插桩。

插桩导致代码存储空间变大

问题描述

小程序平台对代码体积有相关的限制,比如微信小程序对代码有2MB的大小限制,如果打包后的代码体积超过2MB则无法发布测试,而原有的插桩记录逻辑会导致代码体积增大很多,因此我们需要修改原有的记录逻辑。

解决方案

修改插桩逻辑,减少插桩行数量和代码数量。(行业内相关实践)

覆盖率上报(数据层)

浏览器

增量上报

在浏览器环境下多次 merge 上报,新数据 + 已有覆盖率数据 = 增量覆盖率。

全量上报

对于全量,其实收集、上报这两个是连贯的执行步骤,上文中全量数据的收集已经包含了上报的逻辑,即在代码构建完成时(已经完成全量数据收集)调用全量插件 API 进行上报。

Node.js

增量上报

hookRequire 的方式。

全量上报

由于 nodejs 是运行时的,无法记录全量数据,也就无法上报数据了。

小程序

增量上报

和浏览器环境一样。

全量上报

和浏览器环境一样。

覆盖率统计(视图层)

得到覆盖率数据并上报后一般需要进行统计和展示,目前我司内部的覆盖率统计和展示平台是 Bytest,主要是给测试同学使用。

其中增量的处理逻辑为:

前端代码插桩与覆盖率

前端&QA测试闭环

当覆盖率工具成型,也就形成了前端&QA的测试闭环,能极大提高测试效率。

前端代码插桩与覆盖率

总结

用一张图描述前端如何插桩以及收集上报覆盖率

前端代码插桩与覆盖率

目前依然存在的问题和限制

  1. 对于小程序不同平台的兼容性问题
  2. 无法上报 nodejs 项目的全量数据
  3. etc...

技术总结

我学到了什么

  • 开发插件过程中,对前端工程化有了更深的理解
    • npm 包开发与维护
    • 单元测试
    • SDK 的设计思路
  • 阅读了 webpack 、babel 、node 的源码,学习了其中一些优秀的设计思路
  • 掌握了前端不同环境 编译/运行时 的事件循环
  • 编译原理
  • 因为 node 源码涉及到 C/C++,复习了一下 C,学习了一点 C++
  • 如何写好一篇技术文章(当然现在写的还不够好)

不足

  • 计算机基本功还不够扎实,比如编译原理、操作系统等
  • 对于阅读“抽象的代码”没有足够的耐心和观察力,容易在茫茫源码中迷失自我

自我反思

在研究覆盖率的这个过程中,我反思:明明只是单纯的想要拿到覆盖率数据,居然能在这个点展开了这么广阔的知识面,似乎有些小题大做了?

当然没有,任何事情在它变容易之前都是困难的。

感谢

在研究插桩和覆盖率的过程中,我学习到了很多新知识、积累了宝贵的经验,非常感谢公司的大佬们让我有机会参与项目的开发。

参考资料

  • github.com/istanbuljs
  • www.ruanyifeng.com/blog/2015/0…
  • shenlvmeng.github.io/blog/2020/0…
  • shenlvmeng.github.io/blog/2020/0…
  • shenlvmeng.github.io/blog/2020/0…
  • babeljs.io/docs/en/bab…
  • webpack.docschina.org/plugins/
  • astexplorer.net/
  • zhuanlan.zhihu.com/p/27289600
  • github.com/jamiebuilds…
  • www.ruanyifeng.com/blog/2013/0…
  • /post/697429…
  • github.com/jamiebuilds…
  • github.com/estree/estr…
本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!