静态检查能力升级 - 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 一下
以开头那段有问题的代码为例,我们看一下这个规则怎么检查出来隐藏的错误。
- 处理
this.handleClickUnsafe
时,进入MemberExpression
选择器,此时打一个断点,看一下变量的值。
kind: 205
为ts.SyntaxKind.PropertyAccessExpression
- 在进入
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 行的方法定义。
- 这里我们将断点打在
checkMethod
里,这个方法会根据定义处的 symbol ,来决定 MemberExpression 处的使用是否违反规则。
由于 symbol.kind === ts.SyntaxKind.MethodDeclaration
,且 valueDeclaration.params[0]
不是 this
,因此函数返回的 dangerous
为 true ,之后通过 context.report
告诉 ESLint 这里有问题。
Bad Cases
这个规则比较一刀切,碰上一些写法会误报。
- 在 constructor 里
this.method = this.method.bind(this)
。实际没问题但会报出 error,属于 false-positive。这种是比较早的写法了,现在建议使用 class property initializer 。 - 不会将
@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) 级别,有了更多和更准确的静态分析的能力。
参考资料和工具
- Working with Rules - ESLint - Pluggable JavaScript linter ,想看 ESLint 的规则怎么写的细节和一些基础概念的话,可以看看这篇官方文档。
- AST Explorer ,可以看不同 parser 解析出的 AST 结构。这里重点看下 @typescript-eslint/parser 和 typescript
- Writing custom TypeScript Eslint rules with unit tests for Angular project | by Michal Szpak | BigPicture.one | Medium
- TypeScript Compiler Internals - TypeScript Deep Dive