自定义TSLint Rule之旅

项目中原来有好多_.get(a, `b.c.d`, [])样式的代码,看着很不爽,因为项目用的Typescript,这种语法直接把Typescript的类型约束破坏掉了。但又不能把代码换成:

  (a && a.b && a.b.c && a.b.d) || []

所以一直无技可施,当然了,也不是全无办法,但强推,总会遇到动力,所以想着有没有更优雅的方式解决这个问题。
同事前几天说Typescript3.7开始支持Optional ChainingNullish Coalescing,原来的代码就可以改成:

a?.b?.c?.d ?? []

具体文档请查询Typescript官网。是不是优雅了很多,而且完美的保留了类型约束。但升级有两个问题:

  1. 但项目上原来的_.get怎么办?只能手工把所有代码替换掉,全局搜索后发现总计300多处使用,但为了推一波,没办法。替换的过程中确实发现好多地方直接把类型约束去掉了,类型中无这个字段,_.get可以跳过类型约束直接获取值。
  2. 怎么防止再增加_.get? 团队每天都进行Code Review,但靠人为约束,总归不是个办法。所以思考能不能在tslint里加一个规则,禁掉一部分lodash的上帝函数?再结合husky,在git commit阶段禁止提交。

注意:尽量不自己造轮子,偶尔折腾一下也是可以的,本人不是自己造轮子的激进爱好者

查资料

TSLint是如何工作的?

TSLint 用的是 TypeScript AST,语法树的每个节点对应原文件的一小段文字,并包含了一些额外信息。学习AST(Abstruct Tree)可以查询相关书籍,或者到A handbook for making programming languages学习如何利用AST实现一门编程语言。可以在AST Explorer上查看相关语法生成的Tree。
大概意思是,TSLint会基于整个Tree去检查每个节点上的语法是否符合规范。

官方文档

以下内容摘自官网,略作删减

重要事项

  • Rule 标识遵循kebab-cased。例如:no-lodash-functions
  • Rule 文件名遵循camel-cased。 例如:camelCasedRule.ts
  • Rule 文件名必须以Rule名为后缀。例如:noLodashFunctions.ts
  • Rule 文件必须导出一个名为Rule的类,并且该类继承Lint.Rules.AbstractRule

示例代码

import * as Lint from "tslint";
import * as ts from "typescript";

export class Rule extends Lint.Rules.AbstractRule {
    public static FAILURE_STRING = "import statement forbidden";
    
    public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
        return this.applyWithWalker(new NoImportsWalker(sourceFile, this.getOptions()));
    }
}

// The walker takes care of all the work.
class NoImportsWalker extends Lint.RuleWalker {
    public visitImportDeclaration(node: ts.ImportDeclaration) {
        // create a failure at the current position
        this.addFailure(this.createFailure(node.getStart(), node.getWidth(), Rule.FAILURE_STRING));

        // call the base version of this visitor to actually parse this node
        super.visitImportDeclaration(node);
    }
}

上面是一个禁止Import语法的代码示例,Typescript中的解析器访问每个AST节点的方式是Visitor模式,所以自定义的Walker只需要重载对应的visitor(例如visitImportDeclaration)方法,在里面实现自己的检查语法。

如何生效

其实有两种方式,但官方只给出了直接编译导入的方式,后面我们会介绍另一种方式。

  1. 由于TSLint不支持直接ts类型的文件,所以我们需要先将代码编译为es2015版本:tsc noImportsRule.ts
  2. 在tslint的配置文件中导入生成的文件, 并配置相应规则:
{
    "extends":[],
    "rules": {
        "no-import-rule": true
    },
    //省略其它配置,只要知道在什么层级配置就好
    //./tslint-rules/lib是tsc编译后输出的目录
    "rulesDirectory": ["./tslint-rules/lib"]
} 

如何添加相应的Fix代码

// create a fixer for this failure
const fix = new Lint.Replacement(node.getStart(), node.getWidth(), "");

// create a failure at the current position
this.addFailure(this.createFailure(node.getStart(), node.getWidth(), Rule.FAILURE_STRING, fix));

在addFailure时,添加对应的fix规则

最后提示

  • 核心规则不能被自定义规则覆盖(overwritten)
  • 自定义规则可以通过this.getOptions()获取相关配置
  • 在TSLint 5.7.0版本之后可以不编译.ts文件了。但需要指明如何加载.ts文件。例如使用ts-node
ts-node node_modules/.bin/tslint <your options>
# 或者
node -r ts-node/register node_modules/.bin/tslint <your options>
# 或者
NODE_OPTIONS="-r ts-node/register" tslint <your options>

实战

第一版 仿官方版

Rule文件 noLodashFuctions.ts

将Rule文件放置到tslint-rules/src目录下。因为只是禁用一部分函数,所以规则很好写,大概思路是在调用函数时,判断是不是调用的_.get函数(文件中做了增强,把functionNames做为了参数)。具体的AST Node怎么找到的,可以直接在AST Explorer上调试。

export class Rule extends Lint.Rules.AbstractRule {
  public static metadata: Lint.IRuleMetadata = {
    description: 'Disallows the functions in the lodash library.',
    options: {
      oneOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }]
    },
    optionsDescription: 'the function name(s).',
    requiresTypeInfo: true,
    ruleName: 'no-lodash-functions',
    type: 'maintainability',
    typescriptOnly: false
  };

  public static GET_FAILURE_MESSAGE = (functionName: string) =>
    `The "${functionName}" in the lodash library is not allowed to be used in this project.`;

  public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
    return this.applyWithWalker(new NoLodashFunctionsRuleVisitor(sourceFile, this.getOptions()));
  }
}

// tslint:disable-next-line:max-classes-per-file
class NoLodashFunctionsRuleVisitor extends Lint.RuleWalker {
  private functionNames: Set<string>;
  constructor(sourceFile: ts.SourceFile, options: IOptions) {
    super(sourceFile, options);
    this.functionNames = new Set<string>(options.ruleArguments);
  }
  protected visitCallExpression(node: ts.CallExpression): void {
    if (node.expression.kind === ts.SyntaxKind.PropertyAccessExpression) {
      const propertyAccessExpression = (node as ts.CallExpression).expression as ts.PropertyAccessExpression;
      if (
        this.functionNames.has(propertyAccessExpression.name.text) &&
        (propertyAccessExpression.expression as ts.Identifier).text === '_'
      ) {
        this.addFailureAtNode(propertyAccessExpression.name, Rule.GET_FAILURE_MESSAGE(propertyAccessExpression.name.text));
      }
    }
  }
}

编译

在tslint-rules目录下添加tsconfig.json, 主要是为了将代码编译为es2015的版本:

{
  // 省略n多配置
  "compilerOptions": {
    "target": "es2015",
    "outDir": "./lib"
  },
  "include": [
    "./src/"
  ]
}

在项目的package.json中添加编译命令,主要是为了复用:

"lint-rules-compile": "tsc -p ./tslint-rules"

运行编译命令

yarn run lint-rules-compile

成功编译后,可以看到tslint-rules/lib中已经存在编译后的文件

导入并配置

tslint.json中添加如下配置:

{
  "rules": {
    "no-lodash-functions": [true, "get"]
  },
  "rulesDirectory": ["./tslint-rules/lib"]
}

效果

在webStorm中可以看到:


image

问题

  • 经过多次尝试,发现增加规则后,打开ts(x)等文件速度明显变慢,webstorm反应变慢。
  • 查询源码发现Lint中的RuleWalker已经标记弃用。文档:
/**
 * @deprecated
 * RuleWalker-based rules are slow,
 * so it's generally preferable to use applyWithFunction instead of applyWithWalker.
 * @see https://github.com/palantir/tslint/issues/2522
 */

第二版

解决第一版问题

官网中的Performance Tip中有重要提示Implement your own walking algorithm, 文档如下:

Convenience comes with a price. When using SyntaxWalker or any subclass thereof like RuleWalker you pay the price for the big switch statement in visitNode which then calls the appropriate visitXXX method for every node in the AST, even if you don’t use them.

Use AbstractWalker instead and implement the walk method to fit the needs of your rule. It’s as simple as this:
大意是说,visitXXXX类的方法会访问每一个AST节点,可以用AbstractWalker去定义适合自己的算法,防止遍历所有节点。

认真思考了下该问题,做为一个TSLint新手,可不可以借鉴TSLint核心规则中的一些写法,来优化当前代码呢?例如,no-console-log和现在的算法就很相近,能不能抄一抄?毕竟抄代码是程序员生存的第一技能。

注意:作者不是无脑抄选手,也不建议大家无脑抄。我们要抄的有理有据,并称其为“借鉴”。

翻阅TSLint 核心Rule的源码, 找到noConsoleRule.ts文件,动手实现第二版代码。noConsoleRule的代码请自行查阅。

实现(仿noConsoleRule)

import * as ts from 'typescript';
import * as Lint from 'tslint';
import { isPropertyAccessExpression, isIdentifier } from 'tsutils';

export class Rule extends Lint.Rules.AbstractRule {
  // 省略metadata和GET_FAILURE_MESSAGE

  apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
    return this.applyWithFunction<Set<string>>(sourceFile, walk, new Set<string>(this.getOptions().ruleArguments));
  }
}

function walk(ctx: Lint.WalkContext<Set<string>>) {
  return ts.forEachChild(ctx.sourceFile, function cb(node): void {
    if (
      isPropertyAccessExpression(node) &&
      isIdentifier((node as ts.PropertyAccessExpression).expression) &&
      ((node as ts.PropertyAccessExpression).expression as ts.Identifier).text === '_' &&
      ctx.options.has((node as ts.PropertyAccessExpression).name.text)
    ) {
      ctx.addFailureAtNode(
        (node as ts.PropertyAccessExpression).name,
        Rule.GET_FAILURE_MESSAGE((node as ts.PropertyAccessExpression).name.text)
      );
    }
    return ts.forEachChild(node, cb);
  });
}

延伸阅读

另一种导入模式:发布到repository

其实可以把rule文件单独放到一个工程里,然后编译发布到私有或者公有的npm repository中, 在tslint.json中可以像如下方式导入:

{
  "extends": ["tslint:latest", "tslint-eslint-rules", "tslint-react", "tslint-config-prettier"]
}

开源项目 You-Dont-Need-Lodash-Underscore

在我实现自定义的Lint规则后,我的同事(就前面那个同事,没错,还是他,项目上的好兄弟-强哥)给我推了这个项目You-Dont-Need-Lodash-Underscore, 项目介绍如下:

Lodash and Underscore are great modern JavaScript utility libraries, and they are widely used by Front-end developers. However, when you are targeting modern browsers, you may find out that there are many methods which are already supported natively thanks to ECMAScript5 ES5 and ECMAScript2015 ES6. If you want your project to require fewer dependencies, and you know your target browser clearly, then you may not need Lodash/Underscore.

大意是,Lodash和Underscore是很好的JavaScript库,但是其中的好多方法在ES5/ES6/Typescript中已经实现,本着更少依赖和可读性的原则,我们可能不再需要Lodash/Underscore中的部分函数。
Readme中还列举了一些大牛开发者的声音,感兴趣的可以上去看一下。

思考:Typescript和Lodash

首先说明Typescript和Lodash并不冲突,而且Lodash也有type定义,lodash中确实提供了一些不错的方法,例如chain可以优化一些流式计算速度,例如a.filter().map().filter()
但为什么我们有时候会不提倡lodash?
我们引入Typescript的一个很大原因就是类型约束,但loadsh中一些过于方便的上帝方法会将这层约束破坏掉。在这个层面上我们认为类型约束要比便利性更重要。抉择本来就是一个Balance,如果在Team上对于一些技术的认知能达到一定的共识,那么我们就要遵守。而且Typescript也在向着便利性迈进,比如支持Optional ChainingNullish Coalescing
lodash中的很多函数,ts中本来就支持,我们没必要引入过多的依赖,主要是代码也不太优雅,一屏幕的"_"。有些过于便利的函数,可能本身就是个黑盒,由于我们对其的不了解,在一些特殊情况下,会产生一些莫名其妙的Bug,例如_.isEmpty(2)
在某些情况下引入lodash可能是为了更语义化,但有时候真的更语义化吗?还是说引入了一个黑盒?我们不得不承认在引入“语义化”的同时,增加了“认知成本”。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容