很多项目历史悠久,其中很多 文件或是 export 出去的变量 已经不再使用,非常影响维护迭代。举个例子来说,后端问你:“某某接口统计一下某接口是否还有使用?”你在项目里一搜,好家伙,还有好几处使用呢,结果那些定义或文件是从未被引入的,这就会误导你们去继续维护这个文件或接口,影响迭代效率。
先从删除废弃的 exports 讲起,后文会讲删除废弃文件。
删除 exports,有几个难点:
先给出整体的思路,公司内的小伙伴推荐了 pzavolinsky/ts-unused-exports[2] 这个开源库,并且已经在项目中稳定使用了一段时间,这个库可以搞定上述第一步的诉求,也就是找出 export 出去,但是其他文件未 import 的变量。但下面两步依然很棘手,先给出我的结论:
对分析出的文件调用 ESLint 的 API,no-unused-vars
这个 ESLint rule 天生就可以分析出文件内部某个变量是否使用,但默认情况下它是不支持对 export 出去的变量进行分析的,因为既然你 export 了这个变量,那其实 ESLint 就认为你这个变量会被外部使用。对于这个限制,其实只需要 fork 下来稍微改写即可。
自己编写 rule fixer
删除掉分析出来的无用变量,之后就是格式化,由于 ESLint 删除代码后格式会乱掉,所以手动调用 prettier API 让代码恢复美观即可。
接下来我会对上述每一步详细讲解。
使用测试下来, pzavolinsky/ts-unused-exports[3] 确实可以靠谱的分析出 未使用的 export 变量 ,但是这种分析 import、export
关系的工具,只是局限于此,不会分析 export
出去的这个变量 在代码内部是否有使用到 。
第二步的问题比较复杂,这里最终选用 ESLint
配合自己 fork 改写 no-unused-vars
这个 rule
,并且自己提供规则对应的修复方案 fixer
来实现。
fix
函数,直到不再有新的可修复错误为止。no-unused-vars
默认是不考虑 export
出去的变量的,而经过我对源码的阅读发现,仅仅 修改少量的代码 就可以打破这个限制,让 export
出去的变量也可以被分析,在模块内部是否使用。no-unused-vars
只给出提示,没有提供 自动修复 的方案,需要自己编写,下面详细讲解。当我们在 IDE 中编写代码时,有时会发现保存之后一些 ESLint 飘红的部分被自动修复了,但另一部分却没有反应。这其实是 ESLint 的 rule fixer
的作用。参考官方文档的 Apply Fixer[6] 章节,每个 ESLint Rule 的编写者都可以决定自己的这条规则 是否可以自动修复,以及如何修复。修复不是凭空产生的,需要作者自己对相应的 AST 节点做分析、删除等操作,好在 ESLint 提供了一个 fixer
工具包,里面封装了很多好用的节点操作方法,比如 fixer.remove()
, fixer.replaceText()
。官方的 no-unused-vars
由于稳定性等原因未提供代码的自动修复方案,需要自己对这个 rule
写对应的 fixer[7] 。官方给出的解释在 Add fix/suggestions to `no-unused-vars` rule · Issue #14585 · eslint/eslint[8] 。
把 ESLint Plugin 单独拆分到一个目录中,结构如下:
packages/eslint-plugin-deadvars
├── ast-utils.js
├── eslint-plugin.js
├── eslint-rule-typescript-unused-vars.js
├── eslint-rule-unused-vars.js
├── eslint-rule.js
└── package.json
eslint-plugin.js
: 插件入口,外部引入后才可以使用 rule
eslint-rule-unused-vars.js
: ESLint 官方的 eslint/no-unused-vars
代码,主要的核心代码都在里面。eslint-rule-typescript-unused-vars
: typescript-eslint/no-unused-vars
内部的代码,继承了 eslint/no-unused-vars
,增加了一些 TypeScript AST 节点的分析。eslint-rule.js
:规则入口,引入了 typescript rule
,并且利用 eslint-rule-composer[9] 给这个规则增加了自动修复的逻辑。我们的分析涉及到删除,所以必须有一个严格的限定范围,就是 exports 出去 且被 ts-unused-exports 认定为 外部未使用 的变量。所以考虑增加一个配置 varsPattern
,把 ts-unused-exports 分析出的未使用变量名传入进去,限定在这个名称范围内。主要改动逻辑是在 collectUnusedVariables
这个函数中,这个函数的作用是 收集作用域中没有使用到的变量 ,这里把 exports 且不符合变量名范围 的全部跳过不处理。
else if (
config.varsIgnorePattern &&
config.varsIgnorePattern.test(def.name.name)
) {
// skip ignored variables
continue;
} else if (
+ isExported(variable) &&
+ config.varsPattern &&
+ !config.varsPattern.test(def.name.name)
) {
+ // 符合 varsPattern
+ continue;
}
这样外部就可以这样使用这样的方式来限定分析范围:
rules: {
'@deadvars/no-unused-vars': [
'error',
{ varsPattern: '^foo$|^bar$' },
]
}
接着删除掉原版中 收集未使用变量时 对 isExported
的判断,把 exports 出去但文件内部未使用 的变量也收集起来。由于上一步已经限定了变量名,所以这里只会收集到 ts-unused-exports 分析出来的变量。
if (
!isUsedVariable(variable) &&
- !isExported(variable) &&
!hasRestSpreadSibling(variable)
) {
unusedVars.push(variable);
}
接下来主要就是增加自动修复,这部分的逻辑在 eslint-rule.js
中,简单来说就是对上一步分析出来的各种未使用变量的 AST 节点进行判断和删除。贴一下简化的函数处理代码:
module.exports = ruleComposer.mapReports(rule, (problem, context) => {
problem.fix = fixer => {
const { node } = problem;
const { parent } = node;
// 函数节点
switch (parent.type) {
case 'FunctionExpression':
case 'FunctionDeclaration':
case 'ArrowFunctionExpression':
// 调用 fixer 进行删除
return fixer.remove(parent);
...
...
default:
return null;
}
};
return problem;
});
目前会对以下几种节点类型进行删除:
后续新增节点的删除逻辑,只需要维护这个文件即可。
之前基于 webpack-deadcode-plugin[10] 做了一版无用代码删除,但是在实际使用的过程中,发现一些问题。
首先是 速度太慢 ,这个插件会基于 webpack 编译的结果来分析哪些文件是无用的,每次使用都需要编译一遍项目。
而且前几天加入了 fork-ts-checker-webpack-plugin 进行类型检查之后, 这个删除方案突然失效了 ,检测出来的只有 .less 类型的无用文件,经过和排查后发现是这个插件的锅,它会把 src 目录下的所有 ts 文件 都加入到 webpack 的依赖中,也就是 compilation.fileDependencies
(可以尝试开启这个插件,在开发环境试着手动改一个完全未导入的 ts 文件,一样会触发重新编译)
而 deadcode-plugin 就是依赖 compilation.fileDependencies
这个变量来判断哪些文件未被使用,所有 ts 文件都在这个变量中的话,扫描出来的无用文件自然就只有其他类型了。
这个行为应该是插件的官方有意而为之,考虑如下情况:
// 直接导入一个 TS 类型
import { IProps } from "./type.ts";
// use IProps
在使用旧版的 fork-ts-checker-webpack-plugin 时,如果此时改动了 IProps 造成了类型错误,是不会触发 webpack 的编译报错的。
经过排查,目前官方的行为好像是把 tsconfig 中的 include
里的所有 ts 文件加入到依赖中,方便改动触发编译,而我们项目中的 include
是 ["src/**/*.ts"]
,所以……
具体讨论可以查看这个 Issue:Files that provide only type dependencies for main entry and unused files are not being checked for[11]
首先尝试在 deadcode 模式中手动删除 fork-ts-checker-webpack-plugin,这样可以扫描出无用依赖,但是上文中那样从文件中只导入类型的情况,还是会被认为是无用的文件而误删。
考虑到现实场景中单独建一个 type.ts 文件书写接口或类型的情况比较多,只好先放弃这个方案。
转而一想, pzavolinsky/ts-unused-exports[12] 这个工具既然都能分析出 所有文件的 导入导出变量的依赖关系 ,那分析出未使用的文件应该也是小意思才对。
经过源码调试,大概梳理出了这个工具的原理:
ts.parseJsonConfigFileContent
API 扫描出项目内完整的 ts 文件路径。 {
"path": "src/component/A",
"fullPath": "/Users/admin/works/test/src/component/A.tsx",
{
"path": "src/component/B",
"fullPath": "/Users/admin/works/test/apps/app/src/component/B.tsx",
}
...
{
"path": "src/component/A",
"fullPath": "/Users/admin/works/test/src/component/A.tsx",
"imports": {
"styled-components": ["default"],
"react": ["default"],
"src/components/B": ["TestComponentB"]
},
"exports": ["TestComponentA"]
}
到此思路也就有了,把所有文件中的 imports
信息取一个合集,然后从第一步的文件集合中找出未出现在 imports
里的文件即可。
在第一次检测出无用文件并删除后,很可能会暴露出一些新的无用文件。比如以下这样的例子:
[
{
"path": "a",
"imports": "b"
},
{
"path": "b",
"imports": "c"
},
{
"path": "c"
}
]
文件 a 引入了文件 b,文件 b 引入了文件 c。
第一轮扫描的时候,没有任何文件引入 a,所以会把 a 视作无用文件。
由于 a 引入了 b,所以不会把 b 视作无用的文件,同理 c 也不会视作无用文件。
所以 第一轮删除只会删掉 a 文件 。
只要在每次删除后,把 files 范围缩小,比如第一次删除了 a 以后,files 只留下:
[
{
path: "b",
imports: "c",
},
{
path: "c",
},
];
此时会发现没有文件再引入 b 了,b 也会被加入无用文件的列表,再重复此步骤,即可删除 c 文件。
原项目只考虑到了单个项目和单个 tsconfig 的处理,而如今 monorepo 已经非常流行了,monorepo 中每个项目都有自己的 tsconfig,形成一个自己的 project,而经常有项目 A 里的文件或变量被项目 B 所依赖使用的情况。
而如果单独扫描单个项目内的文件,就会把很多被子项目使用的文件误删掉。
这里的思路也很简单:
--deps
参数,允许传入多个子项目的 tsconfig 路径。imports
部分,找出从别名为 @main
的主项目中引入的依赖(比如 import { Button } from '@main/components'
)imports
合并到主项目的依赖集合中,共同进行接下来的扫描步骤。TypeScript 提供的 API,默认只会扫描 .ts, .tsx
后缀的文件,在开启 allowJS
选项后也会扫描 .js, .jsx
后缀的文件。而项目中很多的 .less, .svg
的文件也都未被使用,但它们都被忽略掉了。
这里我断点跟进 ts.parseJsonConfigFileContent
函数内部,发现有一些比较隐蔽的参数和逻辑,用比较 hack 的方式支持了自定义后缀。
当然,这里还涉及到了一些比较麻烦的改造,比如这个库原本是没有考虑 index.ts, index.less
同时存在这种情况的,通过源码的一些改造最终绕过了这个限制。
目前默认支持了 .less, .sass, .scss
这些类型文件的扫描 ,只要你确保该后缀的引入都是通过 import
语法,那么就可以通过增加的 extraFileExtensions
配置来增加自定义后缀。
import * as ts from "typescript";
const result = ts.parseJsonConfigFileContent(
parseJsonResult.config,
ts.sys,
basePath,
undefined,
undefined,
undefined,
extraFileExtensions?.map((extension) => ({
extension,
isMixedContent: false,
// hack ways to scan all files
scriptKind: ts.ScriptKind.Deferred,
}))
);
ts-prune[13] 是完全基于 TypeScript 服务实现的一个 dead exports 检测方案。
TypeScript 服务提供了一个实用的 API:findAllReferences[14] ,我们平时在 VSCode 里右键点击一个变量,选择 “Find All References” 时,就会调用这个底层 API 找出所有的引用。
ts-morph[15] 这个库封装了包括 findAllReferences
在内的一些底层 API,提供更加简洁易用的调用方式。
ts-prune 就是基于 ts-morph 封装而成。
一段最简化的基于 ts-morph 的检测 dead exports 的代码如下:
// this could be improved... (ex. ignore interfaces/type aliases that describe a parameter type in the same file)
import { Project, TypeGuards, Node } from "ts-morph";
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
for (const file of project.getSourceFiles()) {
file.forEachChild((child) => {
if (TypeGuards.isVariableStatement(child)) {
if (isExported(child)) child.getDeclarations().forEach(checkNode);
} else if (isExported(child)) checkNode(child);
});
}
function isExported(node: Node) {
return TypeGuards.isExportableNode(node) && node.isExported();
}
function checkNode(node: Node) {
if (!TypeGuards.isReferenceFindableNode(node)) return;
const file = node.getSourceFile();
if (
node.findReferencesAsNodes().filter((n) => n.getSourceFile() !== file)
.length === 0
)
console.log(
`[${file.getFilePath()}:${node.getStartLineNumber()}: ${
TypeGuards.hasName(node) ? node.getName() : node.getText()
}`
);
}
findAllReferences
的检测范围包括文件内部,开箱即用。findAllReferences
的调用,在大型项目中速度还是有点慢。findAllReferences
并不识别 Dynamic Import 语法,需要额外处理 import()
形式导入的模块。所以综合评估下来,最后还是选择了 ts-unused-exports + ESLint 的方案。
[1]
Web Infra 团队: https://webinfra.org/bytedance/web-infra
[2]
pzavolinsky/ts-unused-exports: https://github.com/pzavolinsky/ts-unused-exports
[3]
pzavolinsky/ts-unused-exports: https://github.com/pzavolinsky/ts-unused-exports
[4]
作用域分析: https://eslint.org/docs/developer-guide/working-with-rules#contextgetscope
[5]
estree/estree: https://github.com/estree/estree
[6]
Apply Fixer: https://eslint.org/docs/developer-guide/working-with-rules#applying-fixes
[7]
fixer: https://eslint.org/docs/developer-guide/working-with-rules#applying-fixes
[8]
Add fix/suggestions to no-unused-vars
rule · Issue #14585 · eslint/eslint: https://github.com/eslint/eslint/issues/14585
[9]
eslint-rule-composer: https://github.com/not-an-aardvark/eslint-rule-composer
[10]
webpack-deadcode-plugin: https://github.com/MQuy/webpack-deadcode-plugin
[11]
Files that provide only type dependencies for main entry and unused files are not being checked for: https://github.com/TypeStrong/fork-ts-checker-webpack-plugin/issues/502
[12]
pzavolinsky/ts-unused-exports: https://github.com/pzavolinsky/ts-unused-exports
[13]
ts-prune: https://github.com/nadeesha/ts-prune
[14]
findAllReferences: https://github.com/microsoft/TypeScript/blob/main/src/services/findAllReferences.ts
[15]
ts-morph: https://github.com/dsherret/ts-morph
[16]
好不容易说服作者: https://github.com/nadeesha/ts-prune/pull/67
[17]
Add a fix mode that automatically fixes unused exports (revival): https://github.com/nadeesha/ts-prune/pull/104
[18]
现代 Web 开发解决方案、低代码搭建: https://zhuanlan.zhihu.com/p/88616149
[19]
跨端解决方案: https://tzxhy.github.io/2020/02/19/%E5%85%B3%E4%BA%8E%E8%B7%A8%E7%AB%AF%E6%96%B9%E6%A1%88%E7%9A%84%E8%B0%83%E7%A0%94/