ts,依赖分析统计你的代码使用情况

笔者前公司在判断某个插件/三方包是否调用、调用次数、版本等情况时依然是在所有项目中“全局搜索”。这不仅会导致效率低下,还会带来麻烦。
与此相似的情况是:你是否担心 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 来看一下他里面都有什么,每一个节点对应什么:
ts可视化内容
如果你到可视化平台去看一看的话会发现代码片段总共包含 5 条语句,分别为 ImportDeclarationVariableStatementVariableStatementIfStatementFunctionDeclaration,分别对应左边的import语句、constletiffunction语句。然后这 5 个 AST 节点再继续派生出更详细的子节点,共同组成了映射这段 TS 代码的 AST 语法树结构。

从截图中也可以看到,cookie 对应的节点是一个IdentifierIdentifier 节点通常为变量名、属性名、参数名等等一系列声明和引用的名字,既然 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: [1611]
     }
}

为什么是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编译器的几个阶段:

  1. 解析代码生成AST对象:SourceCode(源码)~~ 扫描器 ~~> Token 流 ~~ 解析器 ~~> AST
  2. 为AST节点绑定符号:AST ~~ 绑定器 ~~> Symbols
  3. 语义检查,类型检查:AST + Symbols ~~ 检查器 ~~> 类型验证,语义上下文判断
  4. 代码生成阶段:AST + 检查器 ~~ 发射器 ~~> JavaScript 代码

其中在本文涉及的场景中并不需要关注最后一个阶段。

那么前三个阶段涉及的最重要的东西就显而易见了:Symbol。
Symbol 正如其名就是一个标志。同一个文件中,两个不同的函数里面定义了名称相同的变量,它们属于不同的 Symbol,如果有两个文件, a.ts 导出的变量 cookie 在 b.ts 里使用,那这个 cookie 在两个文件中对应的是同一个 Symbol。

像上面说的 import 的变量和局部变量之间的区分,就可以用 Symbol 操作!

依然先看可视化工具。这是第一行的cookie 情况:
ts-import-symbol
这是第7行局部变量cookie 的情况:
ts-jubu-symbol
这是第11行使用 import 的cookie 的情况:
ts-use-import-symbol
我们可以用它的 posend值作为 声明节点的“唯一性标识”。

如何获取 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?

  1. 遍历 AST ,通过 ts-compiler 的 isImportDeclaration API 判断各级节点类型,找到所有的 ImportDeclaration 类型节点。
  2. 通过判断节点的 moduleSpecifier.text 属性是否为分析目标(如:是不是“sheer”) 来过滤掉非目标依赖的 import 节点。
  3. 根据我们上面的 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);
              }
            }
          }
        }
      }
    }
//...

但上面的代码还是存在一些问题。比如:

  1. 无法排除 Import 中同名节点的干扰。
  2. 无法排除局部声明的同名节点的干扰。
  3. 无法检测 API 属于链式调用还是直接调用。

上面我们说收集了每个 AST 节点都具备的公共属性 posendkind,其中 pos 表示该节点在代码字符串流中索引的起始位置,end 表示该节点在代码字符串流中索引的结束位置,posend 属性可以用来做节点的唯一性判定:

//...
      // 判定当前遍历的节点是否为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干扰节点
                    }
                }
            }
		}
	//...
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

恪愚

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值