又有半年没写文章了,趁着最近不那么忙先来水一篇。恰巧最近给typescript-eslint实现的支持decorator metadata的PR合掉了,那就拿这个写写吧。标题因为不知道怎么取,就拿最终实现的目标写上去了。

背景

先说说做这件事的背景吧。我们公司一小伙苦于项目里的循环依赖,写了个脚本来检测,但是检测归检测并没有给出好的解决方案。我看了下如果要从根本上解决(比如抽离公共依赖之类的方法)解决那肯定少不了一番重构,可是老代码谁也不敢轻举妄动啊。

刚好上半年把整个组的typescript升级到3.8以上了,我就想3.8新增的Type-Only Imports或许能解决一部分问题。纵观循环依赖的代码,大概有一半以上确实只依赖了类型,比如下面这种很常见的情况

// Container.ts
import Element from './Element';
export default class Container {
  elements: Element[];
}

// Element.ts
import Container from './Container';
export default class Element {
  context: Container;
}

虽然有从代码上优化的解决方法(比如抽离接口等方法,本文就不说明了),最简单的方法就是改成import type,另外明显后者是可以用工具自动完成的。

consistent-type-imports规则

因为这个优化明显可以通过单文件的代码分析来完成,现成的工具里第一个想到做这个的就是eslint了,于是找了找发现果然有现成的规则[consistent-type-imports]。这个规则可以通过配置将文件里只有类型引用的import变量改为import type。

但是试了下这个规则后发现有个问题,会导致项目里的decorator拿不到metadata,一些依赖类型信息实现的功能就会失效。看了下原因是因为当tsconfig里emitDecoratorMetadata === true时,ts会将带有decorator的类里的成员变量类型、方法参数和返回类型输出到js里。比如下面将成员变量类型输出的情况,会在js里引用该类型,而当前规则无法判断这个情况,将原代码改为import type,这时实际输出的js里就拿不到这个Type了。

// input ts with emitDecoratorMetadata = true
import Type from 'type';
class A {
    @deco
    a?: Type;
}

// output js ignore some helper function
import Type from 'type';
class A {
}
__decorate([
    deco,   
    __metadata("design:type", typeof (_a = typeof Type !== "undefined" && Type) === "function" ? _a : Object)
], A.prototype, "a", void 0);

什么时候会输出decorator metadata

为了修复这个问题首先要看看ts在什么时候会输出decorator metadata
首先能添加到metadata的类型一定只能是个存在对应类的直接类型,任何加工过的类型和type alias都会给你个Object,下面列了一些不能输出或者输出异常的情况。

type Type = T1;
class A {
    // 下面decorator全都省略...
    a: T1 | T2; // 直接输出Object
    b: Pick<T1, 'key'>; // 同上
    c: Type; // 会将 Type 输出,但是type alias会被抹去,所以这里并不能引用到T1
    d: React['Component']; // 直接输出Object,使用 React.Component 可以正常输出
    e: (a: string) => string; // 直接输出Object
}

decorator通常会出现在4个地方:1. 类的定义上;2. 类的成员变量上;3. 类的方法上;4. 类的方法的参数上;这里以这种方式分开讨论。

类的定义上

当类的定义上存在decorator时,构造函数的所有参数类型都会输出metadata。

@deco
class A {
    // Type会被输出到metadata
    constructor(t: Type) {}
}

类的成员变量上

当类的成员变量上存在decorator时,该变量的类型会输出metadata

class A {
    @deco
    field: Type; // Type会被输出到metadata
}

类的方法上

当类的方法上存在decorator时,该方法显式声明的参数类型和返回类型会输出到metadata。另外要注意的是这里指的方法不包括赋值给成员变量的函数。

class A {
    @deco
    foo(input: Type1): Type2 {} // Type1, Type2会被输出到metadata

    @deco
    foo = (input: Type1): Type2 => {}; // 这样子只会被当作普通的成员变量处理
}

这里有一种特例就是 gettersetter,对于相同名称的accessor,只要给其中一个添加decorator就会输出相关类型的metadata,这个类型以setter参数类型优先。比如下面虽然只给getter加了decorator,却只使用了setter的参数类型Type2作为metadata。

// source
class A {
    @deco
    get foo(): Type1 {}
    set foo(v: Type2) {}
}

// output metadata
__decorate([
    deco,
    __metadata("design:type", typeof (_b = typeof Type2 !== "undefined" && Type2) === "function" ? _b : Object),
    __metadata("design:paramtypes", [typeof (_c = typeof Type2 !== "undefined" && Type2) === "function" ? _c : Object])
], A.prototype, "foo", null);

这里 gettersetter 的metadata会按照一定规则合并,一般标识符类型和非复杂的计算值都是支持的。

class A {
    // 相同的普通key、字符串、数字字面量和变量可以合并
    get a() {}
    set ['a'](v: Type) {}

    get [1]() {}
    set [1](v: Type) {}

    // const key = 'k';
    get [key]() {}
    set [key](v: Type) {}

    // 其他类型和表达式不可以合并
    get [true]() {}
    set [true](v: Type) {}

    get ['a' + 'b']() {}
    set ['a' + 'b'](v: Type) {}
}

类的方法的参数上

当类的方法的参数上存在decorator时结果和类方法上有decorator差不多,参数类型和返回类型都会添加到metadata。比如下面两种情况结果是一样的

class A {
    @deco
    foo(input: Type1): Type2 {}

    foo(@deco input: Type1): Type2 {}
}

同样 setter 上的参数decorator也是个特例,结果是啥也不会输出。这时不仅metadata不会输出,连decorator的功能也不会生效。这大概是个TS的BUG。所以下面这种情况需要特殊判断排除掉

class A {
    set foo(@deco input: Type1): Type2 {}
}

如何给typescript-eslint修复

整理完规则后就是看看如何修复了,具体的代码比较多就不详细说明了,就讲讲涉及的一些模块吧。这里可以直接从consistent-type-imports这个规则的代码入手。

在代码里可以看到创建规则是通过给createRule 传入几个用来描述规则的参数和承载主要逻辑的 create 函数来实现,create函数返回的是ast visitor,在这里可以实现当前规则所需要的visitor,最后在Program:exit 通过context.report 报告错误信息和修复函数。

一开始我的想法就是直接在规则代码里通过AST分析判断类型是否会被decorator metadata引用到,拿到最终变量的引用是否全是类型和当前的import类型来判断错误以及修复。后来经过维护者提醒需要将部分判断逻辑实现在scope-manager里,这样对所有规则都是有用的,我也觉得很有道理。

scope manager是eslint就有的一个概念,它会根据AST生成作用域以及内部的所有变量和引用,eslint-typescript则是重写以支持了TS的语法特性,比如可以通过变量上的references拿到所有的引用然后通过Reference上的isValueReferenceisTypeReference判断引用类型,在[consistent-type-import]里就可以通过这两点在访问ImportDeclaration时就能判断是否是正确的import类型。原先的BUG也主要就是scope manager在生成引用类型时没有考虑decorator metadata的影响,那么如何修复就很明显了,在scope manager里生成变量引用时将decorator metadata考虑进来。

scope manager这块也是写各种ast visitor,每次进入新的scope时要 scopeManager.nestForScope(node) 一下,定义了变量时 currentScope.defineIdentifier(...),引用了变量时 currentScope.referenceValuecurrentScope.referenceType。因为这个需求只和类有关,同时为了方便隔离嵌套类的情况,就独立了一个ClassVisitor,专门处理class scope的生成,不过好像因为独立的这个visitor没有漏了一些ast node的处理后面还导致了一些bug。。。

总之虽然目前还有一个小问题,但不妨碍使用,可以升到4.14.2愉快使用了。