搜狐技术产品

TS:依赖分析助力项目依赖管理

Image

本文字数: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;
}

如果我们想通过程序的方式从代码片段中找出 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);

APItsCompiler.createSourceFile是读取了一个文件里的内容,并将其转换为 AST结构。上面这段代码的作用是“读取虚构的xxx文件中的代码”,代码内容就是第二个参数tsCode。
得到AST后,我们就要去分析它的节点组成信息了。在这之前,我们先用在线的可视化工具ASTexplorer来看一下他里面都有什么,每一个节点对应什么:

Image

如果你到可视化平台去看一看的话会发现代码片段总共包含 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编译器的几个阶段:
  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情况:

Image

这是第7行局部变量cookie的情况:

Image

这是第11行使用import 的cookie的情况:

Image

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

但上面的代码还是存在一些问题。比如:
  1. 无法排除Import中同名节点的干扰;
  2. 无法排除局部声明的同名节点的干扰;
  3. 无法检测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)