第六章 - 编写 TypeScript 代码检查工具

79 阅读4分钟

第六章 - 编写 TypeScript 代码检查工具

从零开始学习如何编写自定义 TypeScript 代码检查工具

本章将运用之前学到的知识,教你如何创建自定义 TypeScript 代码检查工具!掌握这些内容后,你就能为自己的代码库编写检查工具,不再依赖 ESLint 等第三方工具。

代码检查工具的核心功能是:根据项目配置的规则进行检查,并通过错误或警告的形式反馈问题。高级的检查工具还能自动修复它发现的简单问题。

检查规则

我们的检查工具目前只配置了一个规则:"必须使用 isNaN() 来检查 NaN,禁止直接比较"。在 JavaScript 和 TypeScript 中,NaN 表示"非数字",可能出现在用户输入或数学计算中。我们经常需要检查变量是否为 NaN,如果是就可以执行其他逻辑。

这个工具会找出错误的 NaN 检查写法,比如 if (myNumber == NaN) {},并提示错误。它还会建议改用 isNaN(myNumber),甚至能自动修复。

选择 NaN 作为例子是因为它容易引发困惑。很多开发者不知道直接比较(如 myNumber === NaN)可能产生 bug,因为 NaN 永远不等于自身,这种比较总会返回 false。使用 isNaN(...) 才能得到可靠结果。

说明:我们并非反对使用 ESLint。ESLint 是非常强大的工具,有完善的自定义规则文档。但为了学习目的,我们要从头实现一个简化版。

准备工作

我们将复用第四章的代码结构:

  • index.mjs 编写检查逻辑
  • fun.ts 作为待检查的代码 运行 npm run compile 时,控制台会显示检查出的错误。

fun.ts 中粘贴以下测试代码:

export class Math {
  add(first: number, second: number): number {
    if (first === NaN || second === NaN) {
      return NaN
    }
    return first + second
  }
}
const myNumber = 8
const isMyNumberNaN = myNumber == NaN
const arrowIsNaN = () => myNumber === NaN
function FnIsNaN() {
  function InnerNaN() {
    return myNumber == NaN
  }
  return InnerNaN()
}

这个例子很好,它展示了 NaN 检查可能出现在:

  • 类方法中
  • 变量声明里
  • 箭头函数内
  • 普通函数及其嵌套函数中 同时包含了 ===== 两种比较方式,以及 if 条件语句。这些情况都需要处理。

实现检查工具

打开 index.mjs 文件,从以下基础代码开始:

import ts from "typescript"
const program = ts.createProgram(["fun.ts"], {
  module: ts.ModuleKind.ESNext
})

for (const rootFileName of program.getRootFileNames()) {
  const sourceFile = program.getSourceFile(rootFileName)
  if (sourceFile && !sourceFile.isDeclarationFile) {
    try {
      lintSourceFile(sourceFile)
    } catch (node) {
      const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
      console.error(
        `${sourceFile.fileName} (${line + 1},${character + 1}): 检查错误:禁止直接使用 NaN。请改用 isNaN 方法。`
      )
    }
  }
}

/**
 * @param {ts.SourceFile} sourceFile
 */
function lintSourceFile(sourceFile) {
  // 检查逻辑将放在这里
}

这段代码会遍历项目文件,调用 lintSourceFile 进行检查。目前只检查 fun.ts 文件。

我们跳过了 .d.ts 类型声明文件,因为它们不包含运行时代码。错误处理会捕获异常并显示位置信息。

lintSourceFile 函数开头添加文件日志:

console.log("正在检查文件:", sourceFile.fileName)

然后添加遍历子节点的逻辑:

sourceFile.forEachChild(lintNode)

对每个子节点,我们会调用 lintNode 函数根据节点类型分发处理:

/**
 * @param {ts.Node} node
 */
function lintNode(node) {
  switch (node.kind) {
    case ts.SyntaxKind.VariableStatement:
      return lintVariableStatement(node)
    case ts.SyntaxKind.VariableDeclarationList:
      return lintVariableDeclarationList(node)
    case ts.SyntaxKind.VariableDeclaration:
      return lintVariableDeclaration(node)
    case ts.SyntaxKind.FunctionDeclaration:
      return lintFunctionDeclaration(node)
    case ts.SyntaxKind.ClassDeclaration:
      return lintClassDeclaration(node)
    case ts.SyntaxKind.MethodDeclaration:
      return lintMethodDeclaration(node)
    case ts.SyntaxKind.Block:
      return lintBlock(node)
    case ts.SyntaxKind.Constructor:
      return lintConstructor(node)
    case ts.SyntaxKind.IfStatement:
      return lintIfStatement(node)
    case ts.SyntaxKind.ReturnStatement:
      return lintReturnStatement(node)
    case ts.SyntaxKind.BinaryExpression:
      return lintBinaryExpression(node)
    case ts.SyntaxKind.ArrowFunction:
      return lintArrowFunction(node)
  }
}

接下来实现各个节点类型的处理函数:

/** 处理 if 语句 */
function lintIfStatement(node) {
  lintNode(node.expression)
  lintBlock(node.thenStatement)
  if (node.elseStatement) {
    lintBlock(node.elseStatement)
  }
}

/** 处理 return 语句 */
function lintReturnStatement(node) {
  if (node.expression) {
    lintNode(node.expression)
  }
}

/** 处理变量声明语句 */
function lintVariableStatement(node) {
  lintVariableDeclarationList(node.declarationList)
}

/** 处理变量声明列表 */
function lintVariableDeclarationList(node) {
  node.declarations.forEach(lintVariableDeclaration)
}

/** 处理单个变量声明 */
function lintVariableDeclaration(node) {
  if (node.initializer) {
    lintNode(node.initializer)
  }
}

/** 处理函数声明 */
function lintFunctionDeclaration(node) {
  if (node.body) {
    lintBlock(node.body)
  }
}

/** 处理箭头函数 */
function lintArrowFunction(node) {
  if (node.body) {
    lintNode(node.body)
  }
}

/** 处理类声明 */
function lintClassDeclaration(node) {
  node.forEachChild(lintNode)
}

/** 处理方法声明 */
function lintMethodDeclaration(node) {
  if (node.body) {
    lintBlock(node.body)
  }
}

/** 处理代码块 */
function lintBlock(node) {
  node.forEachChild(lintNode)
}

/** 处理构造函数 */
function lintConstructor(node) {
  if (node.body) {
    lintBlock(node.body)
  }
}

/** 处理二元表达式 - 核心逻辑 */
function lintBinaryExpression(node) {
  throwIfNaN(node.left)
  throwIfNaN(node.right)
  lintNode(node.left)
  lintNode(node.right)
}

/** 检查是否为 NaN 标识符 */
function throwIfNaN(node) {
  if (ts.isIdentifier(node) && node.text === "NaN") {
    throw node
  }
}

核心逻辑在 lintBinaryExpressionthrowIfNaN

  • 遍历所有二元表达式
  • 检查左右节点是否为 NaN 标识符
  • 如果发现违规就抛出异常

运行后会看到错误信息:

fun.ts (3,19): 检查错误:禁止直接使用 NaN。请改用 isNaN 方法。

这表示在第 3 行第 19 列发现了问题。恭喜!你已成功用 TypeScript 编译器 API 实现了第一个代码检查工具!

改进空间

当前实现有几个可以优化的地方:

  1. 应收集所有错误后再报告,而不是遇到第一个错误就停止
  2. 需要覆盖更多可能包含 NaN 检查的场景
  3. 可以增加自动修复功能,将错误写法改为 isNaN()

这些正是 ESLint 等工具的优势所在。它们提供了完整的处理流程,能深度集成到编辑器中。

本章总结

我们完成了:

  1. 制定代码检查规则
  2. 遍历 AST 找出违规代码
  3. 在控制台报告问题

下一章我们将探索如何自动生成代码!