自定义 TSLint 规则实践

TSLint Rule Develop Best Practice

TSLint 是一个非常好用的开源 TypeScript 代码风格检查器,它能够在可读性、可维护性、代码正确性等方面为开发者提供帮助。TSLint 被广泛用于各种前端构建工具和编辑器中。

在编写代码时,编译器会根据 TSLint 抛出高亮提示,在代码编译时,编译工具可以运行 TSLint 然后及时抛出错误阻断编译的继续,防止不符合规范的代码进入生产环境。 TSLint本身拥有非常丰富的规则库,见 TSLint core rules

背景

我目前正在做一个中文前端项目的国际化,原代码中存在大量的中文字符串,为了能够统一管理和翻译界面文字,同时防止新增代码中出现中文字符串,我希望能够有一个 TSLint 规则帮我检查代码中的中文字符,但是已有的规则并不支持。

TSLint 自称拥有很强的定制能力——『千万不要相信官方宣传』——在我阅读了它的文档之后终于懂了这句话。我没有在 google 上找到关于自定义 TSLint 规则的 best practice,看来只能自己根据文档看源码了。好在 TSLint 的源码使用 TypeScript 编写,在类型声明的帮助下我很快弄懂了自己需要什么并完成了开发 。

TSLint 工程拥有完整的规则开发框架。本文将从规则的实现、如何进行代码检查、如何测试规则的正确性等三方面介绍如何借用 TSLint 工程开发你自己的规则。希望读者在阅读完本文后可以轻松地定制自己的规则,避免陷入我所陷入过得迷茫境地。

本文假定读者使用过 TSLint,至少已经了解 tslint.json 的用法

规则定义

TSLint 使用 TypeScript 编写,规则用 Rule 表示。每个 Rule 都继承自 Lint.Rule.AbstractRuleAbstractRule 最核心的方法是 apply 。在 apply 方法中返回 Lint.RuleFailure 类型的检查结果,外部插件结合这些结果便可以做可视化/命令行错误提示。一个 Rule 的实现一般如下:

export class Rule extends Lint.Rules.AbstractRule {
    /* 对外参数配置 */
    public static metadata: Lint.IRuleMetadata = {/*...*/};

    /* 该规则的错误提示 */
    public static FAILURE_MESSAGE = `...`;

    /* 代码检查 */
    public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
        /* 实现代码检查 */
        return this.applyWithFunction(sourceFile, walk);
        /* 少数的规则用了下面这个方法 */
        // return this.applyWithWalker(sourceFile, walk);
    }
}

其实 TSLint 的奥义在 this.applyWithWalkerthis.applyWithFunction ,这两个函数返回了代码中的错误。这两个方法的入参都有 walker 对象,只是在实现上有些微区别,大部分的规则都选用 this.applyWithFunction (我猜测选择哪一种只是个人喜好) 。让我们看一下他们的定义与实现。

applyWithWalker

export abstract class AbstractRule implements IRule {

    /* ... */

    public applyWithWalker(walker: IWalker): RuleFailure[] {
        walker.walk(walker.getSourceFile());
        return walker.getFailures();
    }

    /* ... */
}

applyWithFunction

export abstract class AbstractRule implements IRule {

    /* ... */

    protected applyWithFunction(sourceFile: ts.SourceFile, walkFn: (ctx: WalkContext<void>) => void): RuleFailure[];
    protected applyWithFunction<T>(sourceFile: ts.SourceFile, walkFn: (ctx: WalkContext<T>) => void, options: NoInfer<T>): RuleFailure[];
    protected applyWithFunction<T, U>(
        sourceFile: ts.SourceFile,
        walkFn: (ctx: WalkContext<T>, programOrChecker: U) => void,
        options: NoInfer<T>,
        checker: NoInfer<U>,
    ): RuleFailure[];
    protected applyWithFunction<T, U>(
        sourceFile: ts.SourceFile,
        walkFn: (ctx: WalkContext<T | void>, programOrChecker?: U) => void,
        options?: T,
        programOrChecker?: U,
    ): RuleFailure[] {
        const ctx = new WalkContext(sourceFile, this.ruleName, options);
        walkFn(ctx, programOrChecker);
        return ctx.failures;
    }

    /* ... */
}

乍一看后者比前者复杂很多,但如果你仔细看,其实他们的实现不超过三行。这两个函数的区别在对 walker 的定义上。

applyWithWalker 接受一个参数,是类型为 IWalker 的对象。在运行时,首先通过 IWalker.walk 方法向 walker 传入被检查文件的抽象语法树根节点 SourceFile 类型对象,最后通过调用 walker.getFailures 方法获得类型为 RuleFailure[] 的错误信息并返回。

interface IWalker {
    getSourceFile(): ts.SourceFile;
    walk(sourceFile: ts.SourceFile): void;
    getFailures(): RuleFailure[];
}

applyWithFunction 接受2到4个参数,其中第一个参数是被检查文件的抽象语法树根节点 SourceFile 类型对象,第二个参数是 (ctx: WalkContext<T | void>, programOrChecker?: U) => void 类型的 walk 函数。在运行时,首先生成包含文件信息和规则信息的 WalkContext 类型的 ctx 变量,接着 ctx 传入 walk 函数。walk 函数遇到语法错误时会将错误存入类型为 RuleFailure[]ctx.failures 对象中。最终将 ctx.failures 返回。

代码检查

规则的作用就是在规定时候抛出错误,帮助我们更好地编写程序。上文中讲了一个规则的实现是怎样的,但是读者们是不是仍然对规则的运作原理一头雾水?其实大家只需要弄明白两个问题:

1. 我该怎么读取被检查的代码?
2. 我该如何抛出错误?

AST(抽象语法树)

我该怎么读取被检查的代码?这个也曾困扰过我,因为我不知道 TSLint 是如何和代码文件『发生关系』的。

TSLint 每次的检查以文件为单位,还记得上文中拗口的 _被检查文件的抽象语法树根节点 _ SourceFile 类型 么?它就是规则和被检查文件的桥梁,它包含了被检查文件的所有信息。AST(抽象语法树)是 代码文件 的结构化表示,有了它我们就可以对原文件做语法检查或代码分析。TSLint 用的是 TypeScript AST,语法树的每个节点对应原文件的一小段文字,并包含了一些额外信息。TSLint 中的语法树节点有以下属性:

interface Node extends TextRange {
    kind: SyntaxKind;
    flags: NodeFlags;
    decorators?: NodeArray<Decorator>;
    modifiers?: ModifiersArray;
    parent?: Node;
}

interface TextRange {
    pos: number;
    end: number;
}

Node 上还有一些有用的方法,比如 getFullTextgetText 可以获得节点的字符串(我没有看明白这两者的区别,如果有人知道请告诉我)。

interface Node {
    getSourceFile(): SourceFile;
    getChildCount(sourceFile?: SourceFile): number;
    getChildAt(index: number, sourceFile?: SourceFile): Node;
    getChildren(sourceFile?: SourceFile): Node[];
    getStart(sourceFile?: SourceFile, includeJsDocComment?: boolean): number;
    getFullStart(): number;
    getEnd(): number;
    getWidth(sourceFile?: SourceFile): number;
    getFullWidth(): number;
    getLeadingTriviaWidth(sourceFile?: SourceFile): number;
    getFullText(sourceFile?: SourceFile): string;
    getText(sourceFile?: SourceFile): string;
    getFirstToken(sourceFile?: SourceFile): Node;
    getLastToken(sourceFile?: SourceFile): Node;
    forEachChild<T>(cbNode: (node: Node) => T | undefined, cbNodeArray?: (nodes: NodeArray<Node>) => T | undefined): T | undefined;
}

Rule 用到的 walker 正是通过从这棵语法树 遍历 代码文件的。

function walk(ctx: Lint.WalkContext<void>) {
    return ts.forEachChild(ctx.sourceFile, function cb(node: ts.Node): void {
        /* some code */
        return ts.forEachChild(node, cb); // 继续遍历
        // return; // 中断对当前节点子树的遍历
    });
}

上面是一种简单的 walker 实现。使用 ts.forEachChild 方法遍历 Node 的子节点。

如果你想要更了解 TypeScript 代码的 AST 结构,强烈推荐这个网站: TypeScript AST Viewer ,在左侧输入 TypeScript 代码段,右侧实时预览语法树。

抛出 TSLint 错误

当我们知道如何遍历原文件,那么最后一个问题就是『我们该如何找出错误并抛出错误』了。

代码检查的规则由需求决定。检查代码时依靠文件的 AST,其实就是对单个 Node 或多个 Node 之间的关系进行分析。 Node 包含很多属性和方法,下面以 Node.kind 为例说明。

Node.kind

Node.kind 是一个表示这个节点语义类型的整数,枚举类 ts.SyntaxKind 有完整的对应关系。一个节点可能有0个或以上子节点,子节点也有 kind 属性,所以不同 SyntaxKind 之间可能存在包含关系。一般这种关系是单向的。

enum SyntaxKind {
    Unknown = 0,
    EndOfFileToken = 1, // 文档结尾
    SingleLineCommentTrivia = 2, // 单行注释
    MultiLineCommentTrivia = 3, // 多行注释

    /* more... */
}

我在实现中文字符检查时,只使用了 Node.kind 属性和 Node.getFullText() 方法。首先找到所有可能出现中文字符的 SyntaxKind 类型,选出所有符合条件的节点 Node ,再对节点包含的文字做正则匹配即可。

最后,我只需要将有问题的节点抛给外部就可以了。如何抛出错误?如果你使用的是 applyWithWalker 你需要把错误 push 到 walker.failures。一般的做法是通过继承辅助类 Lint.AbstractWalker 实现 walker,然后在出错的地方调用 Lint.AbstractWalkeraddFailure 方法。

class CurlyWalker extends Lint.AbstractWalker<Options> {
    public walk(sourceFile: ts.SourceFile) {
        const cb = (node: ts.Node): void => {
            if (this.check(node)) {
                return ts.forEachChild(node, cb);
            }
        };
        return ts.forEachChild(sourceFile, cb);
    }

    private checkStatement(node: ts.Node) {
        if (/* ... */) {
            this.addFailure(node.start, node.end, 'fail message', fix);
            return false;
        }
        return true;
    }
}

如果你使用的是 applyWithFunction 你只需要在 Walker 方法中调用:

function walk(ctx: Lint.WalkContext<void>) {
    return ts.forEachChild(ctx.sourceFile, function cb(node: ts.Node): void {
        if (/* ... */) {
            ctx.addFailureAtNode(node, 'fail message');
            return;
        }
        return ts.forEachChild(node, cb);
    });
}

规则元数据

可能你没有注意到 Rule 类的实现上有一个 public 的静态属性叫做 metadatametadata (元数据)规定了规则和外部交互的信息。以下为 no-null-keyword 规则的元数据 :

{
     ruleName: "no-null-keyword", // 规则名,烤串命名法
     description: "Disallows use of the `null` keyword literal.", // 简单的单行说明
     rationale: Lint.Utils.dedent`
     Instead of having the dual concepts of \`null\` and\`undefined\` in a codebase,
     this rule ensures that only \`undefined\` is used.`, // 规则的详细解释
     optionsDescription: "Not configurable.", // 可配置参数说明
     options: null, // 可配置参数形式
     optionExamples: [true], // 参数范例
     type: "functionality", // 规则类型
     typescriptOnly: false, // 是否适用于 *.js
     hasFix: true, // 带修复方式
}

规则名称

name 非常重要,它是这个规则对外的名称,使用者靠这个字符串辨别不同规则。关于 name 有一点要说的是,TSLint 规定每个规则的入口文件文件名为该规则名的驼峰式命名法加上 Rule 尾缀。比如 no-null-keyword 的文件名就叫 noNullKeywordRule.ts,位于 TSLint 工程的 src/ruls/ 路径下。

规则类型

规则元数据有一个 type 字段,它表示这个规则的分类。所有的 TSLint 规则分为四大类:

  1. TypeScript-specific:仅对 Typescript 特性的提示。如:no-empty-interface
  2. Functionality:适用于 js 的,在编码或特容易出错代码上做的提示。如:no-null-key
  3. Maintainability:使工程维护更加简单的规则。如:trailing-comma
  4. Style:代码样式规则。如:align

正确性测试

你写完了一条规则,却无处测试?TSLint 自带测试规则测试框架。它的测试以每条规则为单元,一条规则可以有一个或多个测试文件。需要在 TSLint 工程根目录运行以下脚本。

$ yarn compile

这条命令把用 TypeScript 编写的规则文件编译成 js 版本,供 node 环境调用。

$ yarn test:rules

这条命令会对所有规则进行测试。

小tip:在开发时改动 ruleTestRunner.js 脚本中的 testDirectories 遍历逻辑,使其只执行你的规则。

对了,最后还要说一下如何编写测试用例,此处可以借鉴 官方文档 。测试用例位于 test/rules/ 路径下,每个规则对应的测试用例都放在以该规则名命名的文件夹下。最简单的测试用例包含两个文件: tslint.jsontest.ts.lint 。前者用来配置该规则,后者用来编写一些可能遇到的出错环境,并手写错误提示效果。运行 test:rules 脚本,当规则生成的错误提示和你手写的错误提示完全匹配时,就通过了测试。为了保证正确性,要尽可能地写出全面的测试用例哦!~

结语

本文参考阅读 TSLint 官方文档和开源代码。经过本次实践,我想对 TypeScript 的研发表达由衷的感谢,有了你代码阅读不再艰难。我的源码在 这里 可以找到。

Email me or Tweet me if you have anything to say ;)

可能出现中文的 SyntaxKind

  • StringLiteral = 9
  • JsxText = 1
  • NoSubstitutionTemplateLiteral = 13
  • TemplateHead = 14
  • TemplateMiddle = 15
  • TemplateTail = 16
  • TaggedTemplateExpression = 183
  • TemplateExpression = 196
知识共享许可协议
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。