批量修改typescript代码:使用ts-morph实现版本无缝升级

背景

相信很多小伙伴与我一样,可能会碰到这样的问题,公司有统一使用一个通用的工具库或者框架,并且已经迭代了多个大版本,但是大版本之间有破坏性更新,现在为了降低维护成本与使用难度,决定只维护最新的一个大版本,但是有很多项目一直还在使用低版本,没有跟随工具库或者框架迭代升级,这个时候如果强行推动让使用方自己升级,会发现有很大的困难,使用方会找各种理由来推脱不进行升级,那么作为库或者框架的维护者,我们一般会这么做

  • 不升就不升,出了问题不要再来找我
  • 手动帮助使用方升级
  • 提供一个工具,使用方通过工具来完成自动升级

第一种不可取,第二种适用于项目较少的情况,第三种则是适用项目较多的情况,我们公司目前恰好属于第三种,为了降低升级成本,决定通过开发一个cli工具来进行项目自动升级,通过工具来自动增、删、改项目中有变化的代码

工具处理过程中碰到最有挑战的问题如下所示

低版本中方法调用示例

typescript 复制代码
// 这部分是grpc方法,由脚本自动生成
GetAllGrey(
  request: types.backstage.GetAllGreyRequest,
  metadata: MetadataMap,
  options?: { timeout?: number; flags?: number; host?: string; }
): Promise<types.backstage.GetAllGreyResponse>;


// 然后在service中调用改方法
const { data, code } = await this.xxxService.GetAllGrey({id: 1}, {token: 'xxxx'})

高版本中方法调用示例

typescript 复制代码
// 这部分是grpc方法,由脚本自动生成
GetAllGrey(option: {
  request: types.backstage.GetAllGreyRequest,
  metadata: MetadataMap,
  options?: { timeout?: number; flags?: number; host?: string; }
}): Promise<{ response:types.backstage.GetAllGreyResponse, metadata: Metadata }>;

// 然后在service中调用改方法
const { response: { data, code } } = await this.xxxService.GetAllGrey({
  request: {id: 1}, 
  metadata: {token: 'xxxx'}
})

高版本相对于低版本,主要的变化在于传参方式发生了改变 ,及返回值包裹了一层response,至于为什么发生这些变化不做讨论,工具主要要做的就是怎么自动实现这个变化,将低版本的写法转换成高版本的写法

最开始想到的思路是通过replace + 正则的方式来匹配与替换,但是实际上项目内的写法有多种,replace + 正则的方式不够用,随后想到ast操作,由于项目代码都是使用typescript编写,找了一圈之后,发现ts-morph这个工具库,这个工具库在typescript compiler api的基础上做了一层封装,更利于我们操作typescript中的ast,所以决定使用ts-morph来完成我们的目标

在实现具体的功能之前,先一起了解下ts-morph是怎样操作ast的

ts-morph

ts-morph是一个针对 Typescrpit/Javascript的AST处理库,可用于浏览、修改TS/JS的AST。 在 TypeScript 中引入了 TS Compiler API,开发人员可以通过编程的方式访问和操作 TypeScript 抽象语法树(AST)。TS Compiler API 提供了一系列的 API 接口,可以帮助我们分析、修改和生成 TypeScript 代码。不过 TS Compiler API 的使用门槛较高,需要比较深厚的 TypeScript 知识储备。 为了简化这个过程,社区出现了一些基于 TS Compiler API 的工具, ts-morph 就是其中之一,它提供了更加友好的 API 接口,并且可以轻松地访问 AST,完成各种类型的代码操作,例如重构、生成、检查和分析等。

安装依赖

typescript 复制代码
pnpm install --save-dev ts-morph

本文书写时,使用的版本为ts-morph@20.0.0

项目初始化

通过Project初始化一个typescript项目

typescript 复制代码
import { Project } from 'ts-morph';
const project = new Project({
  compilerOptions: {
    target: ScriptTarget.ES3,
  },
});

通过tsConfigFilePath指定tsconfig.json,根据tsconfig.json获取源文件

typescript 复制代码
// 可以通过compilerOptions覆盖tsconfig.json参数
const project = new Project({
11    tsConfigFilePath: '<替换成tsconfig.json文件路径>'
12  })

也可以通过tsConfigFilePath指定tsconfig.json

typescript 复制代码
const project = new Project({
  tsConfigFilePath: "path/to/tsconfig.json",
  skipAddingFilesFromTsConfig: true, // 可以通过这个参数跳过解析tsconfig中指定的源文件
});

// 也可以通过addSourceFilesFromTsConfig方法指定tsconfig.json
project.addSourceFilesFromTsConfig("path/to/tsconfig.json");

还可以通过project.compilerOptions设置ts配置参数

typescript 复制代码
const oldCompilerOptions = project.getCompilerOptions()
  project.compilerOptions.set({
    ...oldCompilerOptions,
    noImplicitReturns: true // 在内存中打开 noImplicitReturns 选项,使得所有 no-implicit-returns 的情况都会有一个TS错误,错误代号为 TS7030
  })

也可以不指定tsconfig.json文件,通过addSourceFilesAtPaths、addSourceFileAtPath方法添加源文件

typescript 复制代码
import { Project } from 'ts-morph'

const project = new Project()

// glob语法添加多个源文件
project.addSourceFilesAtPaths('src/**/*{.js,.jsx}');

// 添加单个文件
project.addSourceFileAtPath("path/to/file.ts")

通过ast的方式创建源文件

typescript 复制代码
const sourceFile = project.createSourceFile("path/to/myStructureFile.ts", {
  statements: [{
    kind: StructureKind.Enum,
    name: "MyEnum",
    members: [{
      name: "member",
    }],
  }, {
    kind: StructureKind.Class,
    name: "MyClass",
    // etc...
  }],
  // etc...
});

通过字符串的方式创建源文件

typescript 复制代码
const fileText = "enum MyEnum {\n}\n";
const sourceFile = project.createSourceFile("path/to/myNewFile.ts", fileText);

获取源文件

当我们完成项目初始化之后,那么就是要进一步操作,比如获取源文件列表,获取类型错误等

typescript 复制代码
// 获取项目内所有文件的编译错误
const diagnostics = project.getPreEmitDiagnostics()

// 获取某个文件的编译错误
const sourceFileDiagnostics = sourceFile.getPreEmitDiagnostics();

// 格式化输出编译错误
console.log(project.formatDiagnosticsWithColorAndContext(diagnostics));

// 获取源文件列表
const files = project.getSourceFiles()

files.forEach(file => {
  // TODO
})

每个编译信息包含的内容

typescript 复制代码
// 获取错误信息
const message = diagnostic.getMessageText();

// 获取错误码
const code = diagnostic.getCode(); // returns: number

// 获取错误文件
const sourceFile = diagnostic.getSourceFile(); // returns: SourceFile | undefined

// 获取文件中的位置、行号、长度
const start = diagnostic.getStart(); // returns: number
const lineNumber = diagnostic.getLineNumber(); // returns: number
const length = diagnostic.getLength(); // returns: number

// 获取信息类别
const category = diagnostic.getCategory(); // returns: DiagnosticCategory

遍历错误信息

typescript 复制代码
for (const item of diagnostics) {
    const file = item.getSourceFile()
    if (file !== undefined) {
      const start = item.getStart() ?? 0
      const length = item.getLength() ?? 0
      if (start > 0 && length > 0) {
        // 先根据 start 位置定位到具体的Node,再获取Parent得到所属 Function 的Node
        const node = file.getDescendantAtPos(start)?.getParent()
        if (
          (node instanceof FunctionExpression) ||
          (node instanceof MethodDeclaration) ||
          (node instanceof ArrowFunction) ||
          (node instanceof FunctionDeclaration)) {
          toModify.push(node)
        }
      }
    }
  }

操作源文件

接下来怎么操作具体的ast,对内容如何进行增、删、改呢?这里以文件为例 首先获取项目中的某个源文件

typescript 复制代码
// 通过getSourceFile获取指定名称的源文件
const sourceFile = project.getSourceFile(filename);

针对语句进行操作

typescript 复制代码
// 新增语句
const statements = sourceFile.addStatements("console.log(5);\nconsole.log(6);");
// 插入语句
const statements = sourceFile.insertStatements(3, "console.log(5);\nconsole.log(6);");
// 删除语句
sourceFile.removeStatements([1, 3]); // removes statements from index 1 to 3
sourceFile.removeStatement(1); // removes statement at index 1

针对文本进行操作

typescript 复制代码
// 插入文本内容
sourceFile.insertText(0, writer => writer.writeLine("// some comment")); // or provide a string
// 替换文本内容
sourceFile.replaceText([3, 7], "a"); // "// a comment\n"
// 删除文本内容
sourceFile.removeText(sourceFile.getPos(), sourceFile.getEnd());

针对枚举的操作

typescript 复制代码
enum MyEnum {
  myMember,
}
const myVar = MyEnum.myMember;

const myEnum = sourceFile.getEnum("MyEnum")!;

// 重命名
myEnum.rename("NewEnum");

const member = sourceFile.getEnum("MyEnum")!.getMember("myMember")!;
// 删除枚举
member.remove();

针对类的操作

typescript 复制代码
export class MyClass {
  myProp = 5;
}

const classStructure = classDeclaration.getStructure(); // returns: ClassDeclarationStructure

{
    isAbstract: false,
    isExported: true,
    name: "MyClass",
    typeParameters: [],
    constructors: [],
    properties: [{
        name: "myProp",
        initializer: "5",
        type: undefined,
        isReadonly: false,
        isStatic: false
    }],
    methods: []
}

classDeclaration.set({ name: "NewName" });

classDeclaration.set({ properties: [{ name: "newProperty" }] });

// 往源文件中添加类
sourceFile.addClass({ name: "NewClass", ...classDeclaration.getStructure() });

格式化源文件

typescript 复制代码
var myVariable     :      string |    number;
function myFunction(param    : string){
return "";
}


sourceFile.formatText();

sourceFile.formatText({
  placeOpenBraceOnNewLineForFunctions: true,
});

源文件本身的增删改查操作

typescript 复制代码
// 删除源文件
await sourceFile.deleteImmediately();
sourceFile.deleteImmediatelySync();

// 复制源文件
const newSourceFile = sourceFile.copy("newFileName.ts");

const otherSourceFile = sourceFile.copy("other.ts", { overwrite: true });

sourceFile.copyToDirectory("/some/dir");
sourceFile.copyToDirectory(someDirectoryObject);
await sourceFile.copyImmediately("NewFile.ts");
sourceFile.copyImmediatelySync("NewFile2.ts");

// 移动源文件
sourceFile.move("newFileName.ts");
sourceFile.move("other.ts", { overwrite: true });
sourceFile.moveToDirectory("/some/dir");
sourceFile.moveToDirectory(someDirectoryObject);
await sourceFile.moveImmediately("NewFile.ts");
sourceFile.moveImmediatelySync("NewFile2.ts");

// 移除文件,与删除有区别,这个不会真正的删除文件
sourceFile.forget();
project.removeSourceFile(sourceFile); 

操作ast节点

上面包含了对源文件中部分操作,下面是针对源文件中本来就有的Class、Docorators、Enums、Functions、Imports、Params、Variable等操作,到这里我们需要借助辅助工具TypeScript AST Viewer,帮助我们查找节点的信息,如下图所示

选择数的展示模式

forEachChild:只包含真实子节点

getChildren:除了真实子节点,还包括一些token信息

通过getChildren、forEachChild方法操作一级子节点

typescript 复制代码
const allChildren = node.getChildren();

// 遍历当前节点的一级子节点,常用的方法
node.forEachChild(node => {
  console.log(node.getText());
});

const classDec = node.forEachChild(node => {
  if (Node.isClassDeclaration(node))
    return node; 
  return undefined;
});

通过forEachDescendant遍历所有一级子节点

typescript 复制代码
node.forEachDescendant(node => console.log(node.getText()));

// 通过forEachDescendant遍历所有一级子节点
const result = node.forEachDescendant((node, traversal) => {
  switch (node.getKind()) {
    case SyntaxKind.ClassDeclaration:
      // skips traversal of the current node's descendants
      traversal.skip();
      break;
    case SyntaxKind.Parameter:
      // skips traversal of the current node's descendants and its siblings and all their descendants
      traversal.up();
      break;
    case SyntaxKind.FunctionDeclaration:
      // stops traversal completely
      traversal.stop();
      break;
    case SyntaxKind.InterfaceDeclaration:
      // stops traversal completely and returns this value
      return node;
  }

  return undefined;
});

即forEachDescendant会比forEachChild包含更多的子节点,在实际使用中,我们使用forEachChild即可

针对源文件中存在的函数进行增删改操作

typescript 复制代码
// 获取某个文件的所有函数
const functions = sourceFile.getFunctions();

const functions = sourceFile?.getFunctions();

functions?.forEach((node) => {
  // 获取函数名称
  console.log('node', node.getName())

  // 添加参数
  node.addParameter({
      name: 'name'
  })
})

// 获取函数名为Function1的函数
const function1 = sourceFile.getFunction("Function1");

// 获取嵌套的第一个函数
const firstFunctionWithChildFunction = sourceFile.getFunction(f => f.getFunctions().length > 0);


// 添加函数
const functionDeclaration = sourceFile.addFunction({
  name: "FunctionName",
});

// 删除函数
functionDeclaration.remove();

// 设置函数内容
functionDeclaration.setBodyText("const myNumber = 5;");

// 获取函数内容
console.log(functionDeclaration.getBodyText());

针对源文件中存在的变量进行增删改操作

typescript 复制代码
// 获取所有变量声明语句
const variableStatements = sourceFile.getVariableStatements();

variableStatements?.forEach((node) => {
  // 获取声明语句的内容
  console.log(node.getText());// 返回 const name = 'jack';
})

// 获取第一个export的的变量声明语句
const firstExportedVariableStatement = sourceFile.getVariableStatement(s => s.hasExportKeyword());

// 插入变量
const variableStatement = sourceFile.addVariableStatement({
  declarationKind: VariableDeclarationKind.Const, // defaults to "let"
  declarations: [{
    name: "myNumber",
    initializer: "5",
  }, {
    name: "myString",
    type: "string",
    initializer: `'my string'`,
  }],
});

// 删除变量
variableStatement.remove();

// 获取
const variableDeclarations = variableStatement.getDeclarations();
variableDeclarations?.forEach((node) => {
  // 获取声明语句的内容
  console.log(node.getText());// 返回 name = 'jack'; 不包含声明关键词
})

sourceFile?.getVariableDeclarations().forEach((n) => {
  console.log('variableDeclarations', n.getText()) // 返回 name = 'jack';
})

// 查找指定变量
const variableDeclaration = sourceFile.getVariableDeclaration("myVar");

// 获取string类型的变量
const firstStringTypedVariableDeclaration = sourceFile.getVariableDeclaration(v => v.getType().getText() === "string");

// 添加变量
const declaration = variableStatement.addDeclaration({ name: "num", type: "number" });

// 删除变量声明语句
variableDeclaration.remove();

针对源文件中的表达式进行操作

typescript 复制代码
// 获取所有的函数调用表达式
const descendantCallExpressions = sourceFile?.getDescendantsOfKind(SyntaxKind.CallExpression);
  
descendantCallExpressions?.forEach((callExpression) => {
  const value = callExpression.getText()

  // 替换表达式内容
  callExpression.replaceWithText(`${value}.then((res) => res.response || res)`);

  // 获取传入的参数
  const args = callExpression.getArguments();

  args.forEach((node) => {
    // 删除参数
    callExpression.removeArgument(node);
  })

  // 添加参数
  callExpression.addArgument(`{
    request: '{}',
  }`);

  const returnType = callExpression.getReturnType();
})

// 获取某个节点下面的函数调用表达式,获取其它表达式也是类似的方法
const childCallExpressions = node.getChildrenOfKind(SyntaxKind.CallExpression);

针对源文件中的属性表达式进行操作

typescript 复制代码
// 获取到所有的字面量对象表达式
const objectLiteralExpressions = sourceFile?.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)

objectLiteralExpressions?.forEach((objectLiteralExpression) => {
  // 获取字面量表达式的内容
  const content = objectLiteralExpression.getText()

  // 遍历字面量表示的每个属性
  objectLiteralExpression.getProperties().forEach((p) => {
    // console.log('p', p.getText())
  })

  // 根据属性名,获取对象字面量中的对应的属性key与value
  const property = objectLiteralExpression.getProperty("approver_uuid");

  console.log('property', property?.getText());

  // 给字面量对象添加属性
  const propertyAssignment = objectLiteralExpression.addPropertyAssignment({
    name: "propertyAssignment",
    initializer: "5",
  });
  
  // 给字面量对象添加简写属性
  const shorthandPropertyAssignment = objectLiteralExpression.addShorthandPropertyAssignment({
    name: "shorthandPropertyAssignment",
  });
  
  // 给字面量对象添加解构属性
  const spreadAssignment = objectLiteralExpression.addSpreadAssignment({ expression: "spreadAssignment" });
  
  // 给字面量对象添加get、set属性
  const getAccessor = objectLiteralExpression.addGetAccessor({
    name: "someNumber",
    returnType: "number",
    statements: ["return someNumber;"],
  });
  const setAccessor = objectLiteralExpression.addSetAccessor({
    name: "someNumber",
    parameters: [{ name: "value", type: "number" }],
    statements: ["someNumber = value;"],
  });
  
  // 给字面量对象添加方法
  const method = objectLiteralExpression.addMethod({
    name: "method",
    statements: [`return "some string";`],
  });

  // 根据属性名移除属性
  objectLiteralExpression.getPropertyOrThrow("prop1").remove();
})

针对标识符的操作

typescript 复制代码
// 获取某个源文件中的所有标识符
const descendantIdentifiers = sourceFile?.getDescendantsOfKind(SyntaxKind.Identifier);

// 获取某个节点下的子节点中的标识符
const childIdentifiers = node.getChildrenOfKind(SyntaxKind.Identifier);

// 获取某个节点下的所有子节点中的标识符
const descendantIdentifiers = node.getDescendantsOfKind(SyntaxKind.Identifier);

descendantIdentifiers?.forEach((identifier) => {
  // 获取标识符内容
  const text = identifier.getText();

  // 标识符改名
  identifier.rename("someNewName");

  // 获取标识符引用节点
  const references = identifier.findReferences();

  // 获取查找变量的定义
  const definitions = identifier.getDefinitions();

  // 查找变量的定义节点
  const nodes = identifier.getDefinitionNodes();
})

到这里我们已经了解应该怎么去增删改源文件的内容,总结起来就是

  • 首先获取到对应的源文件
  • 其次根据源文件获取源文件内的函数、类、函数调用、对象等
  • 最后就是根据获取到的函数、类进行增删改

项目实践

已经学会了怎么去增删改源文件中内容,下面要做的就是穷举样例、分析样例,最后通过ts-morph修改代码

分析样例

样例1:

typescript 复制代码
const ret = await this.xzxCommonCstService.CommRecommendedCsts({
    proj_id,
    pager: { page, page_size } },
  metadata);

// 传参与返回值修改
const { response: ret } = await this.xzxCommonCstService.CommRecommendedCsts({
  request: {
    pager: { page, page_size } 
  },
  metadata: metadata
});

样例2:

typescript 复制代码
const { comm_cst_item: list = [], pager } = await this.xzxCommonCstService.AssignCommonCsts({
  cst_uuids,
  user_ids,
  operator_id: user_id,
  operator_type: 'admin',
}, metadata, { timeout: API_TIMEOUT });

// 传参与返回值修改
const { response: { comm_cst_item: list = [], pager } } = await this.xzxCommonCstService.AssignCommonCsts({
  request: {
    cst_uuids,
    user_ids,
    operator_id: user_id,
    operator_type: 'admin',
  }, 
  metadata,
  options: { timeout: API_TIMEOUT }
});

样例3:

typescript 复制代码
const result = await Promise.all([
  memberUuIds.length && this.memberService.GetMembersByUUIDs({ member_uuids: memberUuIds, with_team: true }, metadata) as any,
  clueRelatedIds.length && this.saleService.GetSalesByClueRelatedIDs({ clue_related_ids: _.uniq(clueRelatedIds) }, metadata) as any,
  this.yxxExtApiService.getRecommendSourceTexts(metadata.orgcode, recomSourceList),
]).then((res:any) => {
  const memberInfoList = memberUuIds.length ? res[0].members || [] : [];
  const clueRelatedRet = clueRelatedIds.length ? res[1]?.items || [] : [];
  const recomSourceRet = res[2] || [];
  return { xxxx };
});

// 传参修改
const result = await Promise.all([
  memberUuIds.length && this.memberService.GetMembersByUUIDs({
    request: { member_uuids: memberUuIds, with_team: true },
    metadata
  }).then((res) => res.response || res) as any,
  clueRelatedIds.length && this.saleService.GetSalesByClueRelatedIDs({
    request: { clue_related_ids: _.uniq(clueRelatedIds) }, 
    metadata
  }).then((res) => res.response || res) as any,
  this.yxxExtApiService.getRecommendSourceTexts(metadata.orgcode, recomSourceList).then((res) => res.response || res),
]).then((res) => {
  const memberInfoMap:any = {};
  const clueRelatedMap:any = {};
  const recomSourceMap:any = {};
  // 参数取值修改
  const memberInfoList = memberUuIds.length ? res[0].reponse.members || [] : [];
  const clueRelatedRet = clueRelatedIds.length ? res[1]?.reponse.items || [] : [];
  const recomSourceRet = res[2] || [];
  return { list, pager };
});

从样例可以看到,修改主要围绕在

  • 函数调用时传入参数的变化,之前是多个参数,之后是一个对象参数
  • 返回值的取值变化,多了一层response

在修改之前,可以借助ast面板,先查看下对应的节点信息,如下图所示

变量节点如下图所示

代码实现

这里有两个思路

  • 思路1: 通过sourceFIle找到所有的CallExpression,然后过滤出需要修改的CallExpression,在修改传入的参数,与返回值
  • 思路2: 通过ts类型错误,根据错误信息找到对应的CallExpression,然后在修改传入的参数与返回值

这里采用第二种思路

通过 const diagnostics = project.getPreEmitDiagnostics(); 获取项目内所有的类型错误信息,如下所示

typescript 复制代码
src/controller/TeamController.ts:69:151 - error TS2554: Expected 1 arguments, but got 2.

69       await this.unimemberService.UpdateMemberInfo({ member_id: id, name, mobile_tel, id_card_number, job_number, identity_value, empty_job_number }, metadata);
                                                                                                                                                         ~~~~~~~~
src/controller/TeamController.ts:96:9 - error TS2554: Expected 1 arguments, but got 2.

96         metadata,
           ~~~~~~~~
src/controller/TeamController.ts:207:9 - error TS2554: Expected 1 arguments, but got 2.

207         metadata,
            ~~~~~~~~
src/controller/TeamController.ts:210:14 - error TS2339: Property 'member_id' does not exist on type '{ response: CheckTelExistedResponse; metadata: Metadata; }'.

210       if(ret.member_id) {
                 ~~~~~~~~~

             ~~~~~~~~

然后遍历所有的错误节点信息,在根据对应的错误编码TS2554过滤错误信息,找到对应的CallExpression,然后在修改对应的传参与返回值,示例代码如下所示

typescript 复制代码
const codeMap = {
  2554: 'modifyCallFunParamsBy2554',
  2345: 'modifyCallFunParamsBy2345',
} as const;

class AST {
  varActions: Function[];
  paramsActions: Function[];
  constructor() {
    this.varActions = [];
    this.paramsActions = [];
  }

  init({
    diagnostics,
    indent,
    project,
  }: {
    diagnostics: Diagnostic<ts.Diagnostic>[];
    indent: number;
    project?: Project;
  }) {
    this.traverseAst({ diagnostics, indent });
  }

  traverseAst({ diagnostics, indent }: { diagnostics: Diagnostic<ts.Diagnostic>[]; indent: number }) {
    // 每次遍历ast之前先清空数组
    this.varActions.length = 0;
    this.paramsActions.length = 0;

    for (const item of diagnostics) {
      const result = item.getMessageText();
      const lastText = typeof result !== 'string' ? (result as DiagnosticMessageChain).getMessageText() : result;
      const info = {
        code: item.getCode(),
        text: lastText,
        source: item.getSource(),
        start: item.getStart() || 0,
        length: item.getLength() || 0,
        sourceFile: item.getSourceFile(),
      };
      this[codeMap[info.code as 2554]] && this[codeMap[info.code as 2554]](info, indent);
    }

    // 注意变量修改要在前,传参修改要在后,避免await this.memberService.GetMembersInfo({ member_ids: [ret.member_id], with_team: false }, metadata); 这种直接在传参的时候,使用上一个请求的返回值来作为参数的场景
    this.varActions.forEach((act) => act()); // 遍历执行
    this.paramsActions.forEach((act) => act()); // 遍历执行
  }

  modifyCallFunParamsBy2554(info: Info, indent?: number) {
    this.modifyCallFunParams(info, indent);
  }

  modifyCallFunParamsBy2345(info: Info, indent?: number) {
    if (!/request:.*metadata/.test(info.text)) return;
    this.modifyCallFunParams(info, indent);
  }

  modifyCallFunParams(info: Info, indent?: number) {
    // console.log(info, typeof info.text !== 'string' ? info.text.getMessageText() : info.text)
    // 处理error TS2554: Expected 1 arguments, but got 2.
    // 处理error TS2345: Argument of type '{ scene_code: any; type: string; file_name: any; file_type: string; file_content: any; }' is not assignable to parameter of type '{ request: UploadFileRequest; metadata?: MetadataMap | undefined; options?: { timeout?: number | undefined; flags?: number | undefined; host?: string | undefined; type?: string | undefined; } | undefined; }'.Object literal may only specify known properties, and 'scene_code' does not exist in type '{ request: UploadFileRequest; metadata?: MetadataMap | undefined; options?: { timeout?: number | undefined; flags?: number | undefined; host?: string | undefined; type?: string | undefined; } | undefined; }'.
    const currentNode = info.sourceFile!.getDescendantAtPos(info.start);
    const callExpression =
      currentNode?.getParentIfKind(SyntaxKind.CallExpression) ||
      currentNode?.getFirstAncestorByKind(SyntaxKind.CallExpression);
    if (callExpression) {
      // await this.xzxMemberService.SearchUserIDsByName({ name: keyword }, metadata);
      // await this.teamService.GetProjTeamList({ proj_ids }, ctx.state.metadata);
      const args = callExpression.getArguments();

      // const returnStatement = callExpression.getParentIfKind(SyntaxKind.ReturnStatement);
      const parentNode = callExpression.getParent();

      switch (parentNode?.getKind()) {
        case SyntaxKind.AwaitExpression:
          const pp = parentNode?.getParent();
          if (pp?.isKind(SyntaxKind.VariableDeclaration)) {
            const leftVars = pp.getFirstChildByKind(SyntaxKind.Identifier);
            if (leftVars) {
              // const ret = await this.xzxCommonCstService.AssignCommonCsts({})

              const oldText = leftVars?.getText() || '';
              this.varActions.unshift(() => {
                if (!leftVars.wasForgotten()) {
                  // 不能使用rename,使用rename会将 const { pager } = ret; 这里的ret也修改了
                  // realVarNode?.rename(`{response: ${oldText}}`);
                  leftVars?.replaceWithText(`{response: ${oldText}}`);
                }
              });
            } else {
              // const { user_ids = [] } = await this.xzxMemberService.SearchUserIDsByName({ name: keyword }, metadata);
              // const { items } = await this.xzxDownlineService.GetMemberBindDownlineCountList({})
              const objectBindingPattern = pp.getFirstChildByKind(SyntaxKind.ObjectBindingPattern);
              const oldText = objectBindingPattern?.getText();
              this.varActions.unshift(() => {
                objectBindingPattern?.replaceWithText(`{response: ${oldText}}`);
              });
            }
          } else if (pp?.isKind(SyntaxKind.BinaryExpression)) {
            // let result: customTypes.commonResponse
            // result = await this.memberInfo.EditStatus(params)
            this.paramsActions.unshift(() => {
              const newValue = callExpression.getText();
              callExpression.replaceWithText(`${newValue}.then((res) => res.response || res)`);
              callExpression.formatText({
                indentSize: indent,
              });
            });
          }
          break;
        case SyntaxKind.ArrayLiteralExpression:
        case SyntaxKind.PropertyAccessExpression:
          // 不带then场景
          // Promise.all([
          //   bProduct.GetProductList({
          //     ...params,
          //     issue_project_id: authProject,
          // }),

          // 带then场景
          // bProduct.GetProductList({
          //     ...params,
          //     issue_project_id: authProject,
          // }).then((res) => res),
          this.paramsActions.unshift(() => {
            const newValue = callExpression.getText();
            callExpression.replaceWithText(`${newValue}.then((res) => res.response || res)`);
            callExpression.formatText({
              indentSize: indent,
            });
          });
          break;
      }

      this.paramsActions.unshift(() => {
        const values = args.reduce((prev, node, idx) => {
          prev.push(node.getText());
          callExpression.removeArgument(node);
          return prev;
        }, [] as string[]);
        // console.log('values', values);
        callExpression.addArgument(
          (() => {
            if (values.length === 1) {
              return `{
              request: ${values[0] || '{}'},
            }`;
            } else if (values.length === 2) {
              return `{
              request: ${values[0] || '{}'},
              metadata: ${values[1] || '{}'},
            }`;
            }
            return `{
            request: ${values[0] || '{}'},
            metadata: ${values[1] || '{}'},
            options: ${values[2] || '{}'},
          }`;
          })(),
        );

        // 处理controller直接调用rpc服务,且没有处理response的场景,需要解析一次response才行
        // @Get('/')
        // getTag(@QueryParams() params: TagListDto) {
        //   return this.tagService.memberService.GetLabelList(params)
        // }
        // if (parentNode?.isKind(SyntaxKind.ReturnStatement) && /{\srequest:.*metadata.*options/.test(info.text)) {
        if (parentNode?.isKind(SyntaxKind.ReturnStatement)) {
          const newValue = callExpression.getText();
          callExpression.replaceWithText(`${newValue}.then((res) => res.response || res)`);
        }

        callExpression.formatText({
          indentSize: indent,
        });
      });
    }
  }

}

export async function modifyGrpcParams({ tsConfigFilePath, indent }: { tsConfigFilePath: string; indent: number }) {
  const project = new Project({
    tsConfigFilePath,
  });

  // 获取编译错误
  const diagnostics = project.getPreEmitDiagnostics();

  const ast = new AST();
  
  await ast.init({ diagnostics, indent, project });

  await project.save();
}

最终处理结果如下图所示

总结

ts-morph操作ast的步骤主要是

  • 初始化项目
  • 获取源文件
  • 操作源文件
  • 操作ast节点
  • 保存项目or源文件

借助ts-morph完成ts代码的批量转换,帮助我们加速cli工具的发布,截止到现在已经完成10多个项目的无缝升级。

本篇只是描述了其中一个场景,更多的是给大家提供一个思路,希望对各位小伙伴所有帮助

参考链接

ts-morph

相关推荐
如若12316 分钟前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~1 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语1 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport1 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg1 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全
胡西风_foxww1 小时前
【es6复习笔记】rest参数(7)
前端·笔记·es6·参数·rest
m0_748254881 小时前
vue+elementui实现下拉表格多选+搜索+分页+回显+全选2.0
前端·vue.js·elementui
星就前端叭2 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
m0_748234522 小时前
前端Vue3字体优化三部曲(webFont、font-spider、spa-font-spider-webpack-plugin)
前端·webpack·node.js
Web阿成2 小时前
3.学习webpack配置 尝试打包ts文件
前端·学习·webpack·typescript