TS:依赖分析助力项目依赖管理
本文字数:9065字
预计阅读时间:23分钟
cookie这样容易被滥用的存储空间的“污染”?你是否在意依赖调用方代码中对有问题的API的调用?loader/plugin等方式去统计这些情况。并在公司内部形成一个“可视化平台”!笔者和同学以此为背景搞了三种形式:在线页面(上传文件/输入 url展示可视化数据)、插件(可二次修改,本地自己运行)。本文代码摘自“插件”的方式。期待开源 & 上线吧!
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;
}
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);
APItsCompiler.createSourceFile是读取了一个文件里的内容,并将其转换为 AST结构。上面这段代码的作用是“读取虚构的xxx文件中的代码”,代码内容就是第二个参数tsCode。AST后,我们就要去分析它的节点组成信息了。在这之前,我们先用在线的可视化工具ASTexplorer来看一下他里面都有什么,每一个节点对应什么:ImportDeclaration、VariableStatement、VariableStatement、IfStatement、FunctionDeclaration,分别对应左边的import语句、const、let、if和function语句。然后这 5 个AST节点再继续派生出更详细的子节点,共同组成了映射这段TS代码的AST语法树结构。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]
}
}
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;
}
解析代码生成 AST对象:SourceCode(源码)~~ 扫描器 ~~>Token流 ~~ 解析器 ~~>AST;为AST节点绑定符号: AST~~ 绑定器 ~~>Symbols;语义检查,类型检查: AST+Symbols~~ 检查器 ~~> 类型验证,语义上下文判断;代码生成阶段: AST+ 检查器 ~~ 发射器 ~~>JavaScript代码。
Symbol。Symbol正如其名就是一个标志。同一个文件中,两个不同的函数里面定义了名称相同的变量,它们属于不同的Symbol,如果有两个文件,a.ts导出的变量cookie在 b.ts里使用,那这个cookie在两个文件中对应的是同一个Symbol。import的变量和局部变量之间的区分,就可以用Symbol操作!cookie情况:cookie的情况:import 的cookie的情况:pos、end值作为 声明节点的“唯一性标识”。AST节点对应的Symbol呢?依然是 CompilerAPI: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信息。
承接上文。文首的一段代码中,我们说 createProgramAPI可以“读取虚构的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的isImportDeclarationAPI判断各级节点类型,找到所有的ImportDeclaration类型节点;通过判断节点的 moduleSpecifier.text属性是否为分析目标(如:是不是“sheer”) 来过滤掉非目标依赖的import节点;根据我们上面的 4 点总结完善判定逻辑。
function _findImportItems(ast: any, baseLine = 0) {
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);
}
}
}
}
}
}
walk(ast);
return importItems;
}
无法排除 Import中同名节点的干扰;无法排除局部声明的同名节点的干扰; 无法检测 API属于链式调用还是直接调用。
AST节点都具备的公共属性pos、end、kind,其中pos表示该节点在代码字符串流中索引的起始位置,end表示该节点在代码字符串流中索引的结束位置,pos与end属性可以用来做节点的唯一性判定:function _dealAST(ImportItems: object, ast: any, checker: any, baseLine = 0) {
const ImportItemNames = Object.keys(ImportItems); // 获取所有导入API信息的名称// 遍历AST
function walk(node: any) {
// console.log(node);
tsCompiler.forEachChild(node, walk);
const line = ast.getLineAndCharacterOfPosition(node.getStart()).line + baseLine + 1;// 判定当前遍历的节点是否为isIdentifier类型节点,
// 判断从Import导入的API中是否存在与当前遍历节点名称相同的API
if(tsCompiler.isIdentifier(node)
&& node.escapedText
&& ImportItemNames.length>0
&& ImportItemNames.includes(node.escapedText)) {
// 过滤掉不相干的 Identifier 节点后
const matchImportItem = ImportItems[node.escapedText];
// console.log(matchImportItem);
if(node.pos !=matchImportItem.identifierPos
&& node.end !=matchImportItem.identifierEnd){
// 排除 Import 语句中同名节点干扰后
const symbol = checker.getSymbolAtLocation(node);
// console.log(symbol);
console.log('1-2', symbol && symbol.declarations && symbol.declarations.length>0)
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干扰节点
}
}
}
}
}walk(ast);
}
_findImportItems函数后调用它就好了:const { ast, checker } = parseTs("./test.ts") // 将TS代码文件解析为 AST
const importItems = _findImportItems(ast)_dealAST(importItems, ast, checker)