项目中原来有好多_.get(a, `b.c.d`, [])
样式的代码,看着很不爽,因为项目用的Typescript,这种语法直接把Typescript的类型约束破坏掉了。但又不能把代码换成:
(a && a.b && a.b.c && a.b.d) || []
所以一直无技可施,当然了,也不是全无办法,但强推,总会遇到动力,所以想着有没有更优雅的方式解决这个问题。
同事前几天说Typescript3.7开始支持Optional Chaining和Nullish Coalescing,原来的代码就可以改成:
a?.b?.c?.d ?? []
具体文档请查询Typescript官网。是不是优雅了很多,而且完美的保留了类型约束。但升级有两个问题:
- 但项目上原来的
_.get
怎么办?只能手工把所有代码替换掉,全局搜索后发现总计300多处使用,但为了推一波,没办法。替换的过程中确实发现好多地方直接把类型约束去掉了,类型中无这个字段,_.get
可以跳过类型约束直接获取值。 - 怎么防止再增加
_.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
)方法,在里面实现自己的检查语法。
-
Lint.RuleWalker
提供的基础方法可以在syntaxWalker中查询。 - 可以在AST Explorer调试代码。
如何生效
其实有两种方式,但官方只给出了直接编译导入的方式,后面我们会介绍另一种方式。
- 由于TSLint不支持直接ts类型的文件,所以我们需要先将代码编译为es2015版本:
tsc noImportsRule.ts
- 在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中可以看到:
问题
- 经过多次尝试,发现增加规则后,打开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(https://palantir.github.io/tslint/develop/custom-rules/performance-tips.html)
第二版
解决第一版问题
官网中的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 Chaining和Nullish Coalescing。
lodash中的很多函数,ts中本来就支持,我们没必要引入过多的依赖,主要是代码也不太优雅,一屏幕的"_"。有些过于便利的函数,可能本身就是个黑盒,由于我们对其的不了解,在一些特殊情况下,会产生一些莫名其妙的Bug,例如_.isEmpty(2)
。
在某些情况下引入lodash可能是为了更语义化,但有时候真的更语义化吗?还是说引入了一个黑盒?我们不得不承认在引入“语义化”的同时,增加了“认知成本”。