笔者前公司在判断某个插件/三方包是否调用、调用次数、版本等情况时依然是在所有项目中“全局搜索”。这不仅会导致效率低下,还会带来麻烦。
与此相似的情况是:你是否担心 cookie 这样容易被滥用的存储空间的“污染”?你是否在意依赖调用方代码中对有问题的 API 的调用?
我们可以通过在项目中加入 loader/plugin 等方式去统计这些情况。并在公司内部形成一个“可视化平台”!
笔者和同学以此为背景搞了三种形式:在线页面(上传文件/输入url展示可视化数据)、插件(可二次修改,本地自己运行)。本文代码摘自“插件”的方式。
期待开源 & 上线吧!
本文用一个文件、一段比较简单的代码开篇,后面的工具也是围绕这段代码展开。如果多文件、多个依赖的实际场景只需要遍历即可。
(看本文时希望你至少了解了 AST 是个啥东西了)
看这段 TS 代码,它的 API 调用情况就是从依赖 sheer 中引入了名为 cookie 的 API,并且在第 6 行和第 11 行中进行了调用。
// test.ts
import { cookie } from 'sheer';
const dataLen = 3;
let name = 'iceman';
if(cookie){
console.log(name);
}
function getInfos (info: string) {
const result = cookie.get(info);
return result;
}
如果我们想通过程序的方式从代码片段中找出 app 这个 API 被导入后是否有调用,以及调用的次数,代码行分布等信息。首先应该将其转换成 AST 树!
我们用 typescript 自己的编译器(ts-compiler
)来看:
const tsCode = `import { cookie } from 'sheer';
const dataLen = 3;
let name = 'iceman';
if(cookie){
console.log(name);
}
function getInfos (info: string) {
const result = cookie.get(info);
return result;
}`;
// 获取AST
const ast = tsCompiler.createSourceFile('xxx', tsCode, tsCompiler.ScriptTarget.Latest, true);
console.log(ast);
API tsCompiler.createSourceFile
是读取了一个文件里的内容,并将其转换为 AST 结构。上面这段代码的作用是“读取虚构的xxx文件中的代码”,代码内容就是第二个参数tsCode
。
得到 AST 后,我们就要去分析它的节点组成信息了。在这之前,我们先用在线的可视化工具 AST explorer 来看一下他里面都有什么,每一个节点对应什么:
如果你到可视化平台去看一看的话会发现代码片段总共包含 5 条语句,分别为 ImportDeclaration
、VariableStatement
、VariableStatement
、IfStatement
、FunctionDeclaration
,分别对应左边的import
语句、const
、let
、if
和function
语句。然后这 5 个 AST 节点再继续派生出更详细的子节点,共同组成了映射这段 TS 代码的 AST 语法树结构。
从截图中也可以看到,cookie 对应的节点是一个Identifier
。Identifier
节点通常为变量名、属性名、参数名等等一系列声明和引用的名字,既然 Identifier
代表的是各种名字,而我们要寻找的是 cookie 这个 API 在代码中的调用情况,那就可以通过遍历所有 Identifier
类型节点并判断它的名字是否为 cookie,以此来判定 app 这个 API 它有没有被调用。
TypeScript 中有一些非常重要的 CompilerAPI :
- forEachChild,它可以帮助我们实现对 AST 各层级节点的深度遍历
- isIdentifier(node),来判定当前节点是否为 Identifier 类型节点
- getLineAndCharacterOfPosition,方法获取当前遍历节点的代码行信息
const apiMap = {}; // 记录API分析结果
function walk (node) { // AST遍历函数
tsCompiler.forEachChild(node, walk); // 遍历AST节点
const line = ast.getLineAndCharacterOfPosition(node.getStart()).line + 1; // 获取节点所在行
if(tsCompiler.isIdentifier(node) && node.escapedText ==='cookie') { // 判断isIdentifier节点名称是否为app
if(Object.keys(apiMap).includes(node.escapedText)){
apiMap[node.escapedText].callNum++;
apiMap[node.escapedText].callLines.push(line);
}else{
apiMap[node.escapedText] = {}
apiMap[node.escapedText].callNum =1;
apiMap[node.escapedText].callLines = [];
apiMap[node.escapedText].callLines.push(line);
}
}
}
walk(ast);
console.log(apiMap);
现在我们能得到一些东西:
{
'cookie': {
callNum: 3,
callLines: [1,6,11]
}
}
为什么是3呢?因为 import 的那个变量本身并没有被排除在外。我们暂且记下这个“bug”。
还有没有其它问题?我们并没有先对 import 节点进行分析,如果代码中有同名的其它局部变量,那么它们也可以通过 “遍历所有 Identifier 类型节点名称” 这样的判定思路,它只能找到与 cookie 相同名称的 AST 节点而已,这并不能代表这些 cookie 都是从 sheer 导入的。比如:
// test.ts
import { cookie } from 'sheer';
const dataLen = 3;
let name = 'iceman';
function mxc() {
const cookie = 'mxc';
return cookie;
}
if(cookie){
console.log(name);
}
function getInfos (info: string) {
const result = cookie.get(info);
return result;
}
还有,我们上面的代码是用的“当前文件中的代码段”。但实际中肯定要读取真实的文件。我们先来看下ts编译器的几个阶段:
- 解析代码生成AST对象:SourceCode(源码)~~ 扫描器 ~~> Token 流 ~~ 解析器 ~~> AST
- 为AST节点绑定符号:AST ~~ 绑定器 ~~> Symbols
- 语义检查,类型检查:AST + Symbols ~~ 检查器 ~~> 类型验证,语义上下文判断
- 代码生成阶段:AST + 检查器 ~~ 发射器 ~~> JavaScript 代码
其中在本文涉及的场景中并不需要关注最后一个阶段。
那么前三个阶段涉及的最重要的东西就显而易见了:Symbol。
Symbol 正如其名就是一个标志。同一个文件中,两个不同的函数里面定义了名称相同的变量,它们属于不同的 Symbol,如果有两个文件, a.ts
导出的变量 cookie 在 b.ts
里使用,那这个 cookie 在两个文件中对应的是同一个 Symbol。
像上面说的 import 的变量和局部变量之间的区分,就可以用 Symbol 操作!
依然先看可视化工具。这是第一行的cookie 情况:
这是第7行局部变量cookie 的情况:
这是第11行使用 import 的cookie 的情况:
我们可以用它的 pos
、end
值作为 声明节点的“唯一性标识”。
如何获取 AST 节点对应的 Symbol 呢?依然是 Compiler API :createProgram、getSourceFiles 这 2 个 API:
- ts.createProgram,创建 Program 编译上下文,是 TS 代码分析的基础;
- program.getSourceFiles,通过 Program 获取代码文件对应的 SourceFile 对象,也就是 AST。
想获取 Symbol,需要通过 program 获取 Checker 对象,再由 Checker获取 Symbol。这里还有 2 个 API:
- program.getTypeChecker,用于通过 program 获取 Checker 控制器,该控制器用来类型检查、语义检查等;
- typeChecker.getSymbolAtLocation,用于查询 Symbol table,获取指定 AST 节点相关联的 Symbol 信息。
承接上文。文首的一段代码中,我们说
createProgram
API 可以“读取虚构的xxx文件中的代码”。但是区别的地方来了:如果是虚构的文件,虽然也可以使用这里说的其它API,但是生成的ast中最重要的symbol
字段是undefined
!这个笔者暂时还没找到解决方案。
如果可以解决的话,那么就可以作为webpack 的 loader或者 plugin 嵌入项目内使用。
基于上面的 API,我们封装一个名为 parseTs 用于解析指定 TS 文件并返回 ast、checker 控制器( 用于获取 Symbol )的函数。
// 解析ts文件代码,获取ast,checker
function parseTs(fileName: string) {
// 将ts代码转化为AST
const program = tsCompiler.createProgram([fileName], {})
const ast = program.getSourceFile(fileName);
const checker = program.getTypeChecker();
return { ast, checker };
}
处理完 ts 文件拿到想要的内容后,首先要去分析代码:
const { ast, checker } = parseTs("./test.ts")
const importItems = _findImportItems(ast)
上面说的导入方式只是常用导入方式中的一种。他们都属于 ImportDeclaration
类型节点,但是因为导入方式不同,子节点的结构存在很大差异,我们需要区分它们:
import { cookie } from 'sheer'; // named import
import cookie from 'sheer'; // default import
import { request as req } from 'sheer'; // namespaced import
import * as cookie from 'sheer'; // namespaced imort
你可以到可视化平台中看一下从顶到下一直到找到 import 的 name 的路径差异。
大概可以做如下总结:
- Import 语句 AST 对象都有 importClause 属性以及 moduleSpecifier 属性,后者表示目标依赖名;
- importClause 对象如果只有 name 属性,没有 namedBindings 属性,那么可以判定为默认全局导入;
- importClause 对象存在 namedBindings 属性,且类型为 NamespaceImport,则可以判定为全局别名导入;
- importClause 对象存在 namedBindings 属性,并且类型为 NamedImports,并且 elements 属性为数组,并且长度大于 0。遍历 elements 数组的每一个元素,如果该元素的类型为 ImportSpecifier,则可以判定其属于局部导入。至于它是否存在 as 别名,则需要进一步判断其是否存在 propertyName 属性与 name 属性。如果都存在,则说明其属于局部别名导入。如果只有 name 属性,就为常规局部导入。
如何分析 import?
- 遍历 AST ,通过 ts-compiler 的
isImportDeclaration
API 判断各级节点类型,找到所有的 ImportDeclaration 类型节点。 - 通过判断节点的
moduleSpecifier.text
属性是否为分析目标(如:是不是“sheer”) 来过滤掉非目标依赖的 import 节点。 - 根据我们上面的 4 点总结完善判定逻辑。
//...
let importItems = {};
// 处理imports相关map
function dealImports(temp: any){
importItems[temp.name] = {};
importItems[temp.name].origin = temp.origin;
importItems[temp.name].symbolPos = temp.symbolPos;
importItems[temp.name].symbolEnd = temp.symbolEnd;
importItems[temp.name].identifierPos = temp.identifierPos;
importItems[temp.name].identifierEnd = temp.identifierEnd;
}
// 遍历AST寻找import节点
function walk(node: any) {
tsCompiler.forEachChild(node, walk);
const line = ast.getLineAndCharacterOfPosition(node.getStart()).line + baseLine + 1;
// 分析引入情况
if(tsCompiler.isImportDeclaration(node)){
console.log('3',node.moduleSpecifier && node.moduleSpecifier.text)
// 命中target
if(node.moduleSpecifier && node.moduleSpecifier.text && node.moduleSpecifier.text == "sheer"){
// 存在导入项
if(node.importClause){
// default直接引入场景
if(node.importClause.name){
let temp = {
name: node.importClause.name.escapedText,
origin: null,
symbolPos: node.importClause.pos,
symbolEnd: node.importClause.end,
identifierPos: node.importClause.name.pos,
identifierEnd: node.importClause.name.end,
line: line
};
dealImports(temp);
}
if(node.importClause.namedBindings){
// 拓展引入场景,包含as情况
if (tsCompiler.isNamedImports(node.importClause.namedBindings)) {
if(node.importClause.namedBindings.elements && node.importClause.namedBindings.elements.length>0) {
const tempArr = node.importClause.namedBindings.elements;
tempArr.forEach((element: any) => {
if (tsCompiler.isImportSpecifier(element)) {
let temp = {
name: element.name.escapedText,
origin: element.propertyName ? element.propertyName.escapedText : null,
symbolPos: element.pos,
symbolEnd: element.end,
identifierPos: element.name.pos,
identifierEnd: element.name.end,
line: line
};
dealImports(temp);
}
});
}
}
// * 全量导入as场景
if (tsCompiler.isNamespaceImport(node.importClause.namedBindings) && node.importClause.namedBindings.name){
let temp = {
name: node.importClause.namedBindings.name.escapedText,
origin: '*',
symbolPos: node.importClause.namedBindings.pos,
symbolEnd: node.importClause.namedBindings.end,
identifierPos: node.importClause.namedBindings.name.pos,
identifierEnd: node.importClause.namedBindings.name.end,
line: line
};
dealImports(temp);
}
}
}
}
}
}
//...
但上面的代码还是存在一些问题。比如:
- 无法排除 Import 中同名节点的干扰。
- 无法排除局部声明的同名节点的干扰。
- 无法检测 API 属于链式调用还是直接调用。
上面我们说收集了每个 AST 节点都具备的公共属性 pos
、end
、kind
,其中 pos
表示该节点在代码字符串流中索引的起始位置,end
表示该节点在代码字符串流中索引的结束位置,pos
与 end
属性可以用来做节点的唯一性判定:
//...
// 判定当前遍历的节点是否为isIdentifier类型节点,
// 判断从Import导入的API中是否存在与当前遍历节点名称相同的API
if(tsCompiler.isIdentifier(node)
&& node.escapedText
&& ImportItemNames.length>0
&& ImportItemNames.includes(node.escapedText)) {
// 过滤掉不相干的 Identifier 节点后
const matchImportItem = ImportItems[node.escapedText];
if(node.pos !=matchImportItem.identifierPos
&& node.end !=matchImportItem.identifierEnd){
// 排除 Import 语句中同名节点干扰后
const symbol = checker.getSymbolAtLocation(node);
if(symbol && symbol.declarations && symbol.declarations.length>0){//存在声明
const nodeSymbol = symbol.declarations[0];
if(matchImportItem.symbolPos == nodeSymbol.pos
&& matchImportItem.symbolEnd == nodeSymbol.end){
// 语义上下文声明与从Import导入的API一致, 属于导入API声明
if(node.parent){
// 获取基础分析节点信息
const { baseNode, depth, apiName } = _checkPropertyAccess(node);
console.log('1', depth, apiName, line)
// 干一些其他事情
}else{
// Identifier节点如果没有parent属性,说明AST节点语义异常,不存在分析意义
}
}else{
// 同名Identifier干扰节点
}
}
}
}
//...
}