上一篇我们讲解了 step3、step4 的实现原理,接下来的 6、7、8 三节文档都会讲述 step5 的实现原理。分成 3 个小节是因为 step5 做的事情可以很清晰地划分为三个小步骤,即针对每一个需要分析的 TS 文件:
API 信息,排除非目标依赖项的干扰。API 在具体代码中是否有调用,过程中还需要排除局部同名变量等一系列干扰。API 调用进行指标判定分析,命中则记录到指定 Map 中。这一小节我们主要讲解如何分析 Import 节点,也就是 step5 的第一个小步骤,对 import 节点进行分析是为了获取代码文件从目标依赖中导入的 API 及声明信息,这是非常重要的一步,试想一下,如果代码文件都没有从目标依赖中导入 API,那这个文件就没有分析的必要了,直接跳过即可。
本篇涉及的完整源码在 lib/analysis.js 中的 116 - 207 行,即 _findImportItems 方法的具体逻辑。
那么如何分析 import 节点呢?第一步肯定是要在 AST 中找到它。我们在《一次搞懂 AST 抽象语法树》一篇学习 AST 的时候,有提到 Import 模块是一种特殊的声明语句,可以通过 isImportDeclaration 这个 API 来判定 AST 节点是否为 import 类型节点,但 import 声明语句存在多种导入方式,如何区分它们呢?
下面几种写法是我们业务代码中经常使用的 import 导入方式,这些语句对应的 AST 节点都是 ImportDeclaration 类型节点,但是因为导入方式不同,子节点的结构存在很大差异,不同导入方式的 API 表述存在差异,分析程序需要区分它们,不然无法获取准确的 API 导入信息。
import { environment } from 'framework'; // named import
import api from 'framework'; // default import
import { request as req } from 'framework'; // aliased named import(局部别名导入)
import * as APP from 'framework'; // namespace import我们结合 TypeScript AST Viewer 观察一下上面几种 Import 语句的 AST 结构:
(ps:可以点击左侧的语句节点,右侧会显示对应节点的详细信息,方便对照理解)
把 4 种类型的 AST 及子节点类型转化为树状图,可以更直观的观察它们的特征:
import { environment } from 'framework'; // named importTypeScript AST Viewer 中的 SourceFile 表示 AST Root 节点,把它命名为 ast,获取节点的相关信息:
// AST节点信息
ast.statements[0].moduleSpecifier.kind // StringLiteral
ast.statements[0].moduleSpecifier.text // framework
ast.statements[0].importClause.namedBindings.kind // NamedImports
ast.statements[0].importClause.namedBindings.elements[0].kind // ImportSpecifier
ast.statements[0].importClause.namedBindings.elements[0].propertyName // undefined
ast.statements[0].importClause.namedBindings.elements[0].name.kind // Identifier
ast.statements[0].importClause.namedBindings.elements[0].name.escapedText // environment通过 AST 各级节点的属性值,我们可以判定上面的 Import 语句表示从 framework 以局部导入方式导入了名为 environment 的 API。
这里大家需要注意一点,AST 对象节点的属性名与其类型名在命名上并非一致,我们在节点树状图中展示的是各个节点的类型名而非属性名。例如,ast.statements[0].importClause.namedBindings 的节点类型是 NamedImports。可以通过 TypeScript AST Viewer 或者 AST explorer 工具来对应 属性名 与 类型名 。
接下来,我们以同样的方式将其它几种导入方式的 AST 节点进行拆解。
import api from 'framework'; // default import// AST节点信息
ast.statements[1].moduleSpecifier.kind // StringLiteral
ast.statements[1].moduleSpecifier.text // framework
ast.statements[1].importClause.name.kind // Identifier
ast.statements[1].importClause.name.escapedText // api import { request as req } from 'framework'; // namespaced import// AST节点信息
ast.statements[2].moduleSpecifier.kind // StringLiteral
ast.statements[2].moduleSpecifier.text // framework
ast.statements[2].importClause.namedBindings.kind // NamedImports
ast.statements[2].importClause.namedBindings.elements[0].kind // ImportSpecifier
ast.statements[2].importClause.namedBindings.elements[0].propertyName.kind // Identifier
ast.statements[2].importClause.namedBindings.elements[0].propertyName.escapedText // request
ast.statements[2].importClause.namedBindings.elements[0].name.kind // Identifier
ast.statements[2].importClause.namedBindings.elements[0].name.escapedText // req import * as APP from 'framework'; // namespace import// AST节点信息
ast.statements[3].moduleSpecifier.kind // StringLiteral
ast.statements[3].moduleSpecifier.text // framework
ast.statements[3].importClause.namedBindings.kind // NamespaceImport
ast.statements[3].importClause.namedBindings.name.kind // Identifier
ast.statements[3].importClause.namedBindings.name.escapedText // APP通过观察这 4 种导入方式的 AST 树状图以及子节点信息,我们可以总结出 4 条判定条件:
importClause 属性以及 moduleSpecifier 属性,后者表示目标依赖名;importClause 对象如果只有 name 属性,没有 namedBindings 属性,那么可以判定为默认全局导入;importClause 对象存在 namedBindings 属性,且类型为 NamespaceImport,则可以判定为全局别名导入;importClause 对象存在 namedBindings 属性,并且类型为 NamedImports,并且 elements 属性为数组,并且长度大于 0。遍历 elements 数组的每一个元素,如果该元素的类型为 ImportSpecifier,则可以判定其属于局部导入。至于它是否存在 as 别名,则需要进一步判断其是否存在 propertyName 属性与 name 属性。如果都存在,则说明其属于局部别名导入。如果只有 name 属性,就为常规局部导入。elements 对象是一个数组,对于多个局部导入的场景上述规则也适用。既然每种导入类型都存在唯一的判定条件,那我们就可以通过程序来区分它们。
第一步:遍历 AST ,通过 isImportDeclaration API 判断各级节点类型,找到所有的 ImportDeclaration 类型节点。
第二步:通过判断节点的 moduleSpecifier.text 属性是否为分析目标(如:framework) 来过滤掉非目标依赖的 import 节点。
第三步:根据我们总结出的 4 条判定条件完善判定逻辑,相关代码如下:
// 分析import导入
_findImportItems(ast, filePath, baseLine = 0) {
// 遍历AST寻找import节点
function walk(node) {
// console.log(node);
tsCompiler.forEachChild(node, walk);
const line = ast.getLineAndCharacterOfPosition(node.getStart()).line + baseLine + 1;
// 分析导入情况
if(tsCompiler.isImportDeclaration(node)){
// 命中target
if(node.moduleSpecifier && node.moduleSpecifier.text && node.moduleSpecifier.text == 'framework'){
// 存在导入项
if(node.importClause){
// default直接导入场景
if(node.importClause.name){
// 记录API相关信息
}
if(node.importClause.namedBindings){
// 局部导入场景,包含as
if (tsCompiler.isNamedImports(node.importClause.namedBindings)) {
if(node.importClause.namedBindings.elements && node.importClause.namedBindings.elements.length>0) {
// console.log(node.importClause.namedBindings.elements);
const tempArr = node.importClause.namedBindings.elements;
tempArr.forEach(element => {
if (tsCompiler.isImportSpecifier(element)) {
// 记录API相关信息
}
});
}
}
// * 全量导入as场景
if (tsCompiler.isNamespaceImport(node.importClause.namedBindings) && node.importClause.namedBindings.name){
// 记录API相关信息
}
}
}
}
}
}
walk(ast);
}区分各种导入方式是为了准确的记录 API 导入信息,有的同学肯定会认为只记录导入 API 的名字不就好了。事实上,从代码分析的角度上看,这是远远不够的。那我们还需要记录哪些信息呢?
Import 节点是一种特殊的声明语句,导入的 API 相当于变量声明,还记得上节课我们反复强调的 Symbol 吧,回到上一篇的示例代码:
// 示例代码
import { app } from 'framework'; // import app 定义 (symbol1)
const dataLen = 3;
let name = 'iceman';
function doWell () {
const app =4; // 局部常量 app 定义 (symbol2)
return app; // 局部常量 app 调用(symbol2)
}
function getInfos (info: string) {
const result = app.get(info); // import app 调用(symbol1)
return result;
}想要证明 13 行中的 app 是否真的来自 import 声明节点导入时,也需要用到 Symbol,所以分析 import 节点不光是为了记录代码文件从目标依赖中导入了 app 这个 API,还需要记录 app 对应的 Symbol 信息,便于后续步骤依据 Symbol 进行语义上下文判断。
除了 Symbol 信息外,我们还需要记录 as 别名的映射关系,举个例子:
import { request as req } from 'framework'; // 存在别名的局部API导入上述 import 语句采用了别名导入方式,虽然我们知道从 framework 导入的 API 名叫 request,但是它在代码中实际是以 req 或 req.xxx 的形式被调用,在判定 API 调用时需要用 req 这个名称去做匹配,所以我们需要记录这层映射关系。
下面是导入 API 需要记录的信息示例:
let temp = {
name: 'req', // 导入后在代码中真实调用使用的 API 名
origin: 'request', // API 别名。null则表示该非别名导入,name就是原本名字
symbolPos: '9', // symbol指向的声明节点在代码字符串中的起始位置
symbolEnd: '22', // symbol指向的声明节点在代码字符串中的结束位置
identifierPos: '20', // API 名字信息节点在代码字符串中的起始位置
identifierEnd: '22', // API 名字信息节点在代码字符串中的结束位置
line: '1' // 导入 API 的import语句所在代码行信息
};name:记录在代码中被调用时所用的 API 名,origin 为 null 时,name 也是 API 本名。
origin:从目标依赖导出的 API 本名,null 表示非别名导入,不需映射。
symbolPos,symbolEnd:API 声明节点在代码字符串中索引的起始 / 结束位置。
这里我们没有记录完整的 Symbol 对象,只记录了 Symbol 指向的声明节点的 pos 和 end 属性值,因为声明节点在代码字符流中的索引位置是唯一且确定的,所以后续步骤在判定代码中的节点是否由 Import 语句中导入的 API 声明时,只需要对比 Symbol 对象指向的声明节点 pos 和 end 属性值与这两个属性值是否一致就可以了。
(<mark>《判定 API 调用》一篇判定 API 调用时会用到</mark>)
identifierPos,identifierEnd: API 名称对应的 Identifier 节点在代码字符串中索引的起始 / 结束位置,我们记录这两个索引位置也是为了后续步骤在分析节点时做唯一性判定。
这 4 个关于节点所在代码字符串中位置的概念,我们会在《判定 API 调用》一篇中具体用到时再展开来讲解。
在搞清楚要收集哪些信息后,我们完善一下 _findImportItems 函数中收集节点信息的逻辑。这里需要注意的是,不同导入方式在收集这些信息时的获取方式也不同,这就是我们一定要区分它们的原因。
// 分析import导入
_findImportItems(ast, filePath, baseLine = 0) {
let importItems = {};
let that = this; // this表示codeAnalysis实例
// 记录导入的API及相关信息
function dealImports(temp){
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) {
// console.log(node);
tsCompiler.forEachChild(node, walk);
const line = ast.getLineAndCharacterOfPosition(node.getStart()).line + baseLine + 1;
// 分析导入情况
if(tsCompiler.isImportDeclaration(node)){
// 命中target
if(node.moduleSpecifier && node.moduleSpecifier.text && node.moduleSpecifier.text == that._analysisTarget){
// 存在导入项
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) {
// console.log(node.importClause.namedBindings.elements);
const tempArr = node.importClause.namedBindings.elements;
tempArr.forEach(element => {
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);
// console.log(importItems);
return importItems;
}上述代码中提取 ast 相关属性的逻辑建议大家结合 TypeScript AST Viewer 一起理解,我们用 importItems 这个 Map 结构来收集导入的 API 信息,收集的逻辑抽离在 dealImports 子函数中。
可以通过 codeAnalysis 中的 _scanCode 函数来理解 5、6、7、8 这几节文档的关联性,step 3-5 是实现代码分析工具最核心的步骤,前后关联非常紧密。
// 扫描代码文件 & 分析代码
_scanCode() {
......
const entrys = this._scanFiles(); // 扫描所有需要分析的代码文件
// 遍历每个文件,依次(解析AST,分析import,分析API调用)
entrys.forEach(()=>{
......
const { ast, ...} = parseTs(...) // 将TS代码文件解析为 AST
const importItems = this._findImportItems(ast, ...) // 遍历 AST 分析 import 节点
if(Object.keys(importItems).length >0){
this._dealAST(importItems, ast, ...) // 遍历 AST 分析 API 调用
}
......
})
......
}本篇讲解的 _findImportItems 函数会返回 Import 节点分析后收集到的 API 信息,它以入参的形式被 _dealAST 函数使用,是 step 5 后续两个分析步骤的前提。
这一小节我们学习了如何分析 Import 节点,也就是分析范式中 step5 的第一步,需要大家掌握以下知识点:
API 信息,后续的分析依赖这些 API 信息。API 表述上存在差异,分析程序需要区分它们,不然无法获取准确的 API 导入信息。API 调用名,还需要记录 Symbol、as 映射,节点字符流位置等信息。学习完这节内容后,我们可以拿到代码文件从目标依赖中导入的 API 信息,接下来就可以在代码中去分析它是否存在调用,以及如何调用了。