静态检查能力升级 - ESLint + TypeScript 自定义规则

- hikerpig
#eslint#typescript#工具

一个 BUG 引起的思考

之前修过一个 bug,由于 class 组件上的方法没有 @autobind,也没有使用箭头函数,导致取 this 时候报错。大致示意如下:

import React from 'react';
export class Demo extends React.Component {
  handleClickUnsafe() {
    console.log(this.state) // 没有 bind,这里的 this 是 undefined,有 bug 风险
  }
  render() {
    return (
      <div onClick={this.handleClickUnsafe}></div>
    );
  }
}

有没有办法静态分析出来这种问题呢?

使用 typescript-eslint 中的规则

热心人提供的这个 typescript-eslint rule Enforces unbound methods are called with their expected scope (unbound-method) ,可以检查出使用 class 上的 unbound method 时的潜在问题。

class MyClass {
  public log(): void {
    console.log(this);
  }
}

const instance = new MyClass();

// This logs the global scope (`window`/`global`), not the class instance
const myLog = instance.log;
myLog();

// This log might later be called with an incorrect scope
const { log } = instance;

// arith.double may refer to `this` internally
const arith = {
  double(x: number): number {
    return x * 2;
  },
};
const { double } = arith;

typescript-eslint 背景介绍

依赖类型信息的规则集合

@typescript-eslint/unbound-method 这个规则比较特殊,如果只 extends 了 plugin:@typescript-eslint/recommended 是不会包括进来的。

根据官方文档 Linting with Type Information 描述,plugin:@typescript-eslint/recommended-requiring-type-checking 这个规则集中的规则,会使用 typescript 来解析一遍文件拿到 type 信息,可以做更细致的静态分析。

不过由于 typescript 解析比较慢,对于大型项目来说,真正的 rule 检查逻辑可能没多久,但是 tsc 的速度感人,前置可能需要等很久(用一个有 3000 个 TS 文件的项目测试,TIMINGS=1 eslint 检查一个文件,规则的耗时也就二百来 ms,但主观感觉等了有二三十秒,估计前面都在跑 typescript)。

ESLint 相关的一些背景知识

  • ESLint 项目中使用的 AST 表示是 ESTree(由 ESLint 的作者创建),像是 ts 或是 markdown 这样的其他语言可以通过 plugin 扩展 parser (例如 @typescript-eslint/parser),但是解析出来的 AST 也要是 ESTree 类型,才能在 ESLint 项目中流畅使用。
  • ESLint 的 rule,是通过声明不同的选择器以及在其回调函数中检查 AST 节点来实现功能。选择器(selectors)是访问者模式的一个实现,而且功能还更加强大一点,支持像 VariableDeclarator, AssignmentExpression 这样的后代选择等功能(观感有点像 CSS 里的选择器),更多细节和使用方法可阅读官方文档 Selectors - ESLint - Pluggable JavaScript linter
  • 关于更多自定义 ESLint 规则的细节,可见官方文档 Working with Rules

unbound-method 规则实现浅析

相应源码见这里

import {
  AST_NODE_TYPES,
  TSESTree,
} from '@typescript-eslint/utils';
import * as ts from 'typescript';

// 创建 ESLint 的规则
export default util.createRule({
  name: 'unbound-method',
  create(context, [{ ignoreStatic }]) {
    // ESLint 的架构,支持 plugin 往 context 上挂 parserService
    // 这个 parserServices 是 @typescript-eslint eslint plugin 创建的,
    // 能拿到 ts.Program 等信息。
    const parserServices = util.getParserServices(context);
    // 拿到 ts 的 checker
    const checker = parserServices.program.getTypeChecker();
    const currentSourceFile = parserServices.program.getSourceFile(
      context.getFilename(),
    );

    // 检查和上报,真正的检查逻辑在最底下的 checkMethod 方法里
    function checkMethodAndReport(
      node: TSESTree.Node,
      symbol: ts.Symbol | undefined,
    ): void {
      if (!symbol) {
        return;
      }

      const { dangerous, firstParamIsThis } = checkMethod(symbol, ignoreStatic);
      if (dangerous) {
        context.report({
          messageId:
            firstParamIsThis === false
              ? 'unboundWithoutThisAnnotation'
              : 'unbound',
          node,
        });
      }
    }
    return {
      MemberExpression(node: TSESTree.MemberExpression): void {
        // 这里主要是检查 `object.method` 这样形式的 MemberExpression

        // 从 estree 的 AST Node 到 TS Node 映射表中,拿到 TS Node。
        // 这映射表是 @typescript-eslint eslint plugin 创建的
        const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node);

        checkMethodAndReport(node, checker.getSymbolAtLocation(originalNode));
      },
      'VariableDeclarator, AssignmentExpression'(
        node: TSESTree.VariableDeclarator | TSESTree.AssignmentExpression,
      ): void {
        // 检查 const { method } = object 这样的形式,
        // 不细细展开了,可以去看源码
      },
    };
})

function checkMethod(
  symbol: ts.Symbol,
  ignoreStatic: boolean,
): { dangerous: boolean; firstParamIsThis?: boolean } {
  const { valueDeclaration } = symbol;
  // 通过 symbol.valueDeclaration,拿到这个符号最初的声明
  if (!valueDeclaration) {
    return { dangerous: false };
  }

  switch (valueDeclaration.kind) {
    case ts.SyntaxKind.PropertyDeclaration:
      // 在 `prop = someFunction` 且 someFunction 是一个外部的函数时,dangerous 为 true
      return {
        dangerous:
          (valueDeclaration as ts.PropertyDeclaration).initializer?.kind ===
          ts.SyntaxKind.FunctionExpression,
      };
    case ts.SyntaxKind.MethodDeclaration:
    case ts.SyntaxKind.MethodSignature: {
      // 这里检查 class method 声明
      const decl = valueDeclaration as
        | ts.MethodDeclaration
        | ts.MethodSignature;
      const firstParam = decl.parameters[0];
      const firstParamIsThis =
        firstParam?.name.kind === ts.SyntaxKind.Identifier &&
        firstParam?.name.escapedText === 'this';
      const thisArgIsVoid =
        firstParamIsThis &&
        firstParam?.type?.kind === ts.SyntaxKind.VoidKeyword;

      return {
        dangerous:
          !thisArgIsVoid &&
          !(
            ignoreStatic &&
            tsutils.hasModifier(
              valueDeclaration.modifiers,
              ts.SyntaxKind.StaticKeyword,
            )
          ),
        firstParamIsThis,
      };
    }
  }

  return { dangerous: false };
}

Debug 一下

以开头那段有问题的代码为例,我们看一下这个规则怎么检查出来隐藏的错误。

  1. 处理 this.handleClickUnsafe 时,进入 MemberExpression 选择器,此时打一个断点,看一下变量的值。

member-expressoin-debug-1.jpg.jpg

  • kind: 205ts.SyntaxKind.PropertyAccessExpression
  1. 在进入 checkMethodAndReport 之前,有一个调用 ts.TypeChecker 来找到 this.handleClickUnsafe 成员对应的 Symbol 的调用:checker.getSymbolAtLocation(originalNode)

这里的 Symbol 指的不是 ECMAScript 的那个,而是 TS 编译器内的概念,更多相关知识可以见这篇介绍 TypeScript Compiler Internals - TypeScript Deep Dive

export interface Symbol {
    flags: SymbolFlags;
    escapedName: __String;
    declarations?: Declaration[];
    valueDeclaration?: Declaration;
    members?: SymbolTable;
    exports?: SymbolTable;
    globalExports?: SymbolTable;
}

我们可以简单地认为,使用 checker.getSymbolAtLocation 找到对应的 Symbol,可以拿到该 Symbol 的声明和值声明(valueDeclaration),断点这里的 valueDeclaration,对应与源码 3-5 行的方法定义。

get-symbol-1.jpg

  1. 这里我们将断点打在 checkMethod 里,这个方法会根据定义处的 symbol ,来决定 MemberExpression 处的使用是否违反规则。

由于 symbol.kind === ts.SyntaxKind.MethodDeclaration,且 valueDeclaration.params[0] 不是 this,因此函数返回的 dangerous 为 true ,之后通过 context.report 告诉 ESLint 这里有问题。

Bad Cases

这个规则比较一刀切,碰上一些写法会误报。

  1. 在 constructor 里 this.method = this.method.bind(this)。实际没问题但会报出 error,属于 false-positive。这种是比较早的写法了,现在建议使用 class property initializer 。
  2. 不会将 @autobind 装饰器装饰过的成员排除,依旧是 false-positive 的错误。

改进一下,实现自定义规则

针对上面 @autobind 的情况,我们可以抄一份源码,稍加改进一下,自己写一个规则并发布。

同时也推荐这篇基于 @typescript-eslint 来写规则的文章

使用工具包 @typescript-eslint/utils

这个是 @typescript-eslint 项目中的一个单独的基础 package,详细文档可以看 README。下面我只简单列一下用到的几个。

import { ESLintUtils } from '@typescript-eslint/utils';

export const createEslintRule = ESLintUtils.RuleCreator(
   (ruleName) => ruleName
);

ESLintUtils.RuleCreator 生成的 createEslintRule 方法是一个类型的语法糖,将创建 ESLint 规则的选项自动加上了类型支持。

import {
  AST_NODE_TYPES,
  TSESTree,
  ASTUtils,
} from '@typescript-eslint/utils'
  • TSESTree,由 @typescript-eslint/parser 生成的 ESTree 节点的 type。
  • AST_NODE_TYPES,包含了所有 TSESTree type string 的 enum。
  • ASTUtils,一些对 TSESTree 节点的工具函数,也是好用的 type predicate,例如下面的 isIdentifier 方法。
declare const isIdentifier: (node: TSESTree.Node | null | undefined) => node is TSESTree.Identifier & {
    type: AST_NODE_TYPES.Identifier;
};

新增代码

思路大概就是在原先的基础上,加上一个新的前置检查,如果类或是函数声明被 @autobind 装饰,既可以跳过。

/**
 * 判断 symbol 是否被指定装饰器装饰
 */
function isSymbolDecoratedWith(
  symbol: ts.Symbol,
  decoratorName: string
): boolean {
  const { valueDeclaration } = symbol
  if (!valueDeclaration) return false

  switch (valueDeclaration.kind) {
    case SyntaxKind.ClassDeclaration:
    case SyntaxKind.MethodDeclaration: {
      const hasAutobound = symbol.valueDeclaration.decorators?.some((dec) => {
        const expression = dec.expression
        if (expression.getText() === decoratorName) {
          return true
        }
      })
      return hasAutobound
    }
  }
  return false
}

下面是两处改动的地方。

  MemberExpression(node) {
    // ...
    
    // 增加代码: 如果 class 被 `@autobind` 装饰,既认为不存在问题
    if (isSymbolDecoratedWith(objectSymbol, 'autobind')) {
      return
    }
    // ...
function checkMethod(symbol: ts.Symbol) {
  // ...
  switch (valueDeclaration.kind) {
    case ts.SyntaxKind.MethodDeclaration:
    case ts.SyntaxKind.MethodSignature: {
      // 增加代码: 如果 MethodDeclaration 被 `@autobind` 装饰,既认为不存在问题
      const hasAutobound = isSymbolDecoratedWith(symbol, 'autobind')
      if (hasAutobound) {
        return { dangerous: false }
      }
  // ....
}

给新规则添加单元测试

官方的 Unit Tests - ESLint - Pluggable JavaScript linter 给的例子是使用 mocha 来作为测试工具。不过用 jest 也没什么问题, jest.config.js 如下:

module.exports = {
  testEnvironment: 'node',
  testMatch: ['**/(*.)+(spec|test).[jt]s?(x)'],
  testPathIgnorePatterns: ['/node_modules/', '/dist/'],
  transform: {
    '\\.[jt]sx?$': [
      'esbuild-jest',
      {
        sourcemap: true,
        loaders: {
          '.(spec|test).ts': 'tsx',
        },
      }
    ],
  },
}

ESLintUtils.RuleTester 是对 eslint 原先的 RuleTester 的类型封装。

// file: unbound-method.spec.ts
import { ESLintUtils } from '@typescript-eslint/utils'
import rule from '../unbound-method'

const parserOptions = {
  sourceType: 'module' as const,
  ecmaFeatures: {
    jsx: true,
  },
  project: './tsconfig.eslint.json', // relative to package
}

const ruleTester = new ESLintUtils.RuleTester({
  parserOptions,
  parser: '@typescript-eslint/parser',
})

ruleTester.run('@lark/unbound-method', rule, {
  valid: [
    {
      code: `
      export class Demo extends React.Component {
        state: any

        handleClickSafe1 = () => {
          console.log(this.state) // 箭头函数, 获取 this 没有风险
        }
        @autobind
        handleClickSafe2() {
          console.log(this.state) // autobinded, 获取 this 没有风险
        }
        render() {
          return (
            <div onClick={this.handleClickSafe1}>
              <div onClick={this.handleClickSafe2}></div>
            </div>
          );
        }
      }`,
    },
  ],
  invalid: [
    {
      code: `
    import React from 'react';
    export class Demo extends React.Component {
      handleClickUnsafe() {
        console.log(this.state) // 没有 bind,这里的 this 是 undefined,有 bug 风险
      }
      render() {
        return (
          <div onClick={this.handleClickUnsafe}></div>
        );
      }
    }
    `,
      errors: [{
        messageId: 'unboundWithoutThisAnnotation',
        data: {
          memberName: 'handleClickUnsafe'
        }
      }],
    },
  ],
})

总结

在知道这个规则之前,我仅将 @typescript-eslint 当做是“能解析 ts 文件的 ESLint parser”。

其实这一对组合,让 linter 可以不仅仅只停留在修改代码风格和简单的规则检查上。检查器的能力从 Syntax (AST) 进化到了 Semantic (TS TypeChecker) 级别,有了更多和更准确的静态分析的能力。

参考资料和工具