入职2个月,我写了一个VSCode插件解决团队遗留的any问题

2,771 阅读4分钟

背景

团队项目用的是React Ts,接口定义使用Yapi

但是项目中很多旧代码为了省事,都是写成 any,导致在使用的时候没有类型提示,甚至在迭代的时候还发现了不少因为传参导致的bug。

举个例子

表格分页接口定义的参数是 pageSizeoffset ,但是代码里传的却是 sizeoffset ,导致每次都是全量拉数据,然而因为测试环境数据量少,完全没测出来。

在这种项目背景下,大致过了一个月,结合自己试用期的目标(我也不想搞啊......),想通过一个工具来快速解决这类问题。

目标

把代码中接口的 any 替换成 Yapi 上定义的类型,减少因为传参导致的bug数量。

交互流程

image.png

设计

鉴于当前项目中接口数量庞大(eslint扫出来包含any的接口有768个),手动逐一审查并替换类型显得既不现实又效率低下。

显然需要一种更加高效且可靠的方法来解决。

因为组内基本上都是使用 VSCode 开发,因此最终决定开发一个 VSCode 插件来实现类型的替换。

考虑到直接扫描整个项目进行替换风险较大,因此最终是 按文件维度,针对当前打开的文件执行替换

整个插件分为3个命令:

  • 单个接口替换
  • 整个文件所有接口替换
  • 新增接口

image.png

整体设计

插件按功能划分为6个模块:

image.png

环境检测

Easy Yapi需要和Yapi服务器交互,需要用户提供Yapi Project相关的信息,因此需要填写配置文件(由使用者提供)。

插件执行命令时会对配置文件内的信息进行检测。

缓存接口列表

从性能上考虑,一次批量替换后,会缓存当前Yapi项目所有接口的定义到cache文件中,下次替换不会重新请求。

接口捕获

不管是单个接口替换还是整个文件接口替换都需要先捕获接口,这里是通过将代码转成AST来实现。

image.png

image.png

类型生成

将接口定义转化成TS类型,通过循环+递归拼接字符串生成类型。

为什么不直接使用Yapi自带的ts类型?

  1. 命名问题,Yapi自带的ts类型命名过于简单粗暴,就是直接把接口路径拼接起来
  2. 有的字段因为粗心带了空格,最后还需要手动修改一遍类型 image.png
  3. 实际项目中有一层请求中间件,可能最终需要的类型只有data那一层,而Yapi定义的是整个类型

代码插入

  1. 将生成的类型插入文件中
    // 检查文件是否存在
  if (fs.existsSync(targetFilePath)) {
    const currentContent = fs.readFileSync(targetFilePath);
    if (!currentContent.includes(typeName)) { // 判断类型是否已存在
      try {
        fs.appendFileSync(targetFilePath, content); // 追加内容
        editor.document.save(); // 调用vscode api保存文件
        return true;
      } catch (err: any) {
        ......
        return false;
      }
    } else {
      ......
      return false;
    }
  } else {   // 文件不存在,创建并写入类型
    try {
      fs.writeFileSync(targetFilePath, content);
      editor.document.save();
      return true;
    } catch (err: any) {
      ......
    }
  }
  1. 替换原有函数字符串
   const nextFnStr = functionText
      .replace(/(\w+:\s*)(any)/, (_, $1) => {
        if (query.apiReq) {
          return `${$1}${query.typeName}`;
        }
        // 没参数
        else {
          return "";
        }
      })
      .replace(/Promise<([a-zA-Z0-9_]+<any>|any)>/, (_, $1) => {
        if (res?.apiRes) {
          return `Promise<${res?.typeName}>`;
        }
        return `Promise<void>`;
      })
      .replace(/,\s*\{\s*params\s*\}/, (_) => {
        // 对于没有参数的case, 应该删除参数
        if (!query.apiReq) {
          return "";
        }
        return _;
      });
  1. 调用vscode api替换函数字符串
    const startPosition = new vscode.Position(functionStartLine - 1, 0); // 减1因为VS Code的行数是从0开始的
    const endPosition = new vscode.Position(
      functionEndLine - 1,
      document.lineAt(functionEndLine - 1).text.length
    ); 
    const textRange = new vscode.Range(startPosition, endPosition);

    const editApplied = await editor.edit((editBuilder) => {
      editBuilder.replace(textRange, nextFnStr);
    });

    ......
   
  1. 引入类型, 插入import语句
    const document = editor.document;
    const fullText = document.getText(); // 调用vscode api 拿到当前文件字符串
    
    // 匹配单引号或双引号,并确保结束引号与开始引号相匹配
    const importRegex =
      /(import\s+(type\s+)?\{\s*[^}]*)(}\s+from\s+(['"])\.\/types(\.ts)?['"]);?/g;

    let matchIndex = fullText.search(importRegex); // 使用search得到全局匹配的起始索引

    if (matchIndex !== -1) {
      // 已经有类型语句
      let matchText = fullText.match(importRegex)?.[0]; // 获取完整的匹配文本

      // 去重,如果 import { a, b } from './types'中已经有typeNames中的类型,则不需要重复引入
      // existingTypes = ['a', 'b']
      const existingTypes = (
        /\{\s*([^}]+)\s*\}/g.exec(matchText)?.[1] as string
      )
        .split(",")
        .map((v) => v.trim());

      const uniqueTypeNames = typeNames.filter(
        (v) => !existingTypes.includes(v)
      );

      // 将生成的类型插入原有的import type语句中
      // 例如: import { a } from './types'
      // 生成了类型 b c 则变成 import { a, b, c } from './types'
      let updatedImport = matchText?.replace(
        importRegex,
        (_, group1, group2, group3) => {
          // group1 对应 $1,即 import 语句到第一个 "}" 之前的所有内容
          // group3 对应 $3,即 "}" 到语句末尾的部分
          return `${
            (group1.trim() as string).endsWith(",") ? group1 : `${group1}, `
          }${uniqueTypeNames.join(", ")} ${group3}`;
        }
      );

      // 计算确切的起始和结束位置
      let startPos = document.positionAt(matchIndex);
      let endPos = document.positionAt(matchIndex + matchText.length);
      let range = new vscode.Range(startPos, endPos);

      // 替换
      await editor.edit((editBuilder) => {
        editBuilder.replace(range, updatedImport as string);
      });
    } else {
      // 直接插入import type
      await editor.edit((editBuilder) => {
        editBuilder.insert(
          new vscode.Position(0, 0),
          `import type { ${typeNames.join(",")} } from './types';\n`
        );
      });
    }

    // importStr导入语句需要进行判断再导入
    // 例如:import request from '@service/request';
    if (importStr && requestName) {
      const importStatementRegex = new RegExp(
        `import\\s+(?:\\{\\s*${requestName}\\s*\\}|${requestName})\\s+from\\s+['"]([^'"]+)['"];?`
      );

      const match = importStatementRegex.exec(editor.document.getText());
     
      // 当前文件没有这个语句,插入
      if (!match) {
        await editor.edit((editBuilder) => {
          editBuilder.insert(new vscode.Position(0, 0), `${importStr};\n`);
        });
      }
    }

效果

kmstd-ub9vg.gif

替换后,会在当前文件同级目录下生成对应类型,然后import到当前文件。

总结

开发这个插件自己学到了不少东西,团队也用上了,有同学给了插件使用的反馈。

最后,试用期过了。

不过,新公司ppt文化是真的很重!!!