Vue组件的API类型生成:从源代码中自动提取类型信息

好的,我们开始。

Vue 组件 API 类型生成:从源代码中自动提取类型信息

今天我们来探讨一个非常实用的主题:如何从 Vue 组件的源代码中自动提取 API 类型信息,并生成相应的类型定义。这对于大型 Vue 项目的维护、协作和提升开发效率至关重要。

1. 为什么需要自动生成 API 类型?

在大型 Vue 项目中,组件的数量往往非常庞大。手动维护每个组件的 API 类型定义是一项繁琐且容易出错的任务。如果没有准确的类型信息,会导致以下问题:

  • 代码提示不准确: IDE 无法提供准确的属性、事件和方法的代码提示,降低开发效率。
  • 类型错误难以发现: 在运行时才能发现类型错误,增加调试成本。
  • 文档维护困难: 手动编写和维护文档与实际代码可能不一致,导致误导。
  • 重构风险高: 修改组件 API 时,缺乏类型检查容易引入错误。

自动生成 API 类型可以有效解决这些问题,提高代码质量和开发效率。

2. 从哪些地方提取类型信息?

Vue 组件的 API 类型信息主要来源于以下几个部分:

  • Props: 通过 props 选项定义的属性。可以从中提取属性名、类型、默认值、是否必需等信息。
  • Emits: 通过 emits 选项定义的事件。可以提取事件名和参数类型。
  • Methods: 组件实例上的方法。可以提取方法名、参数类型和返回值类型。
  • Computed Properties: 计算属性。可以提取属性名和返回值类型。
  • Slots: 插槽。虽然难以直接提取参数类型,但可以提取插槽名。
  • Expose: 通过 expose 选项暴露的属性和方法。

3. 提取类型信息的工具和技术

有多种工具和技术可以用于提取 Vue 组件的 API 类型信息:

  • TypeScript 编译器 API: TypeScript 编译器提供了强大的 API,可以解析 TypeScript 代码,并提取类型信息。这是最精确和可靠的方法,但也需要一定的 TypeScript 知识。
  • AST (Abstract Syntax Tree) 解析器: 可以使用 AST 解析器(如 esprimaacornbabel-parser)解析 JavaScript 代码,然后遍历 AST 提取相关信息。这种方法比较灵活,但需要处理复杂的语法结构。
  • 正则表达式: 对于简单的组件,可以使用正则表达式提取类型信息。这种方法简单快捷,但容易出错,不适合复杂的组件。
  • Vue CLI 插件: 可以编写 Vue CLI 插件,在构建过程中自动提取类型信息并生成类型定义文件。

4. 使用 TypeScript 编译器 API 提取类型信息

我们重点介绍使用 TypeScript 编译器 API 提取类型信息的方法,因为它最准确和可靠。

4.1 搭建环境

首先,需要安装 TypeScript 和相关的类型定义:

npm install typescript --save-dev
npm install @types/node --save-dev

4.2 编写提取类型信息的脚本

创建一个 TypeScript 文件(例如 extract-types.ts),编写以下代码:

import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';

interface ComponentInfo {
  props: { [name: string]: { type: string; required: boolean; defaultValue: any } };
  emits: string[];
  methods: { [name: string]: { parameters: { name: string; type: string }[]; returnType: string } };
  computed: { [name: string]: string };
  slots: string[];
  expose: string[];
}

function extractComponentInfo(fileName: string): ComponentInfo {
  const program = ts.createProgram([fileName], {
    target: ts.ScriptTarget.ESNext,
    module: ts.ModuleKind.ESNext,
    esModuleInterop: true,
    jsx: ts.JsxEmit.Preserve
  });

  const sourceFile = program.getSourceFile(fileName);
  if (!sourceFile) {
    throw new Error(`Source file not found: ${fileName}`);
  }

  const typeChecker = program.getTypeChecker();

  const componentInfo: ComponentInfo = {
    props: {},
    emits: [],
    methods: {},
    computed: {},
    slots: [],
    expose: []
  };

  function visit(node: ts.Node) {
    if (ts.isObjectLiteralExpression(node)) {
      // 提取 props
      const propsProperty = node.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText() === 'props');
      if (propsProperty && ts.isPropertyAssignment(propsProperty) && ts.isObjectLiteralExpression(propsProperty.initializer)) {
        const propsInitializer = propsProperty.initializer;
        propsInitializer.properties.forEach(prop => {
          if (ts.isPropertyAssignment(prop) || ts.isMethodDeclaration(prop)) {
            const propName = prop.name.getText();
            let propType = 'any';
            let required = false;
            let defaultValue: any = undefined;

            if (ts.isPropertyAssignment(prop) && prop.initializer) {
              if(ts.isObjectLiteralExpression(prop.initializer)){
                //处理对象形式的props定义
                const typeProperty = prop.initializer.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText() === 'type')
                if(typeProperty && ts.isPropertyAssignment(typeProperty) && typeProperty.initializer){
                  propType = typeChecker.typeToString(typeChecker.getTypeAtLocation(typeProperty.initializer));
                }

                const requiredProperty = prop.initializer.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText() === 'required')
                if(requiredProperty && ts.isPropertyAssignment(requiredProperty) && requiredProperty.initializer){
                  required = requiredProperty.initializer.kind === ts.SyntaxKind.TrueKeyword;
                }

                const defaultProperty = prop.initializer.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText() === 'default')
                if(defaultProperty && ts.isPropertyAssignment(defaultProperty) && defaultProperty.initializer){
                  defaultValue = evaluate(defaultProperty.initializer, typeChecker);
                }

              } else {
                //简写形式的props定义
                propType = typeChecker.typeToString(typeChecker.getTypeAtLocation(prop.initializer));
              }

            }

            componentInfo.props[propName] = { type: propType, required, defaultValue };
          }
        });
      }

      // 提取 emits
      const emitsProperty = node.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText() === 'emits');
      if (emitsProperty && ts.isPropertyAssignment(emitsProperty) && ts.isArrayLiteralExpression(emitsProperty.initializer)) {
        const emitsInitializer = emitsProperty.initializer;
        emitsInitializer.elements.forEach(emit => {
          if (ts.isStringLiteral(emit)) {
            componentInfo.emits.push(emit.text);
          }
        });
      }

      // 提取 methods
      const methodsProperty = node.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText() === 'methods');
      if (methodsProperty && ts.isPropertyAssignment(methodsProperty) && ts.isObjectLiteralExpression(methodsProperty.initializer)) {
        const methodsInitializer = methodsProperty.initializer;
        methodsInitializer.properties.forEach(method => {
          if (ts.isMethodDeclaration(method)) {
            const methodName = method.name.getText();
            const parameters: { name: string; type: string }[] = [];
            method.parameters.forEach(param => {
              parameters.push({ name: param.name.getText(), type: typeChecker.typeToString(typeChecker.getTypeAtLocation(param)) });
            });
            const returnType = typeChecker.typeToString(typeChecker.getTypeAtLocation(method.type || method));
            componentInfo.methods[methodName] = { parameters, returnType };
          }
        });
      }

      // 提取 computed
      const computedProperty = node.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText() === 'computed');
      if (computedProperty && ts.isPropertyAssignment(computedProperty) && ts.isObjectLiteralExpression(computedProperty.initializer)) {
        const computedInitializer = computedProperty.initializer;
        computedInitializer.properties.forEach(computed => {
          if (ts.isPropertyAssignment(computed) || ts.isMethodDeclaration(computed)) {
            const computedName = computed.name.getText();
            let returnTypeNode: ts.TypeNode | undefined;

            if (ts.isPropertyAssignment(computed) && computed.initializer) {
                // 处理简写形式的计算属性
                if (ts.isArrowFunction(computed.initializer) || ts.isFunctionExpression(computed.initializer)) {
                    returnTypeNode = computed.initializer.type;
                }
            } else if (ts.isMethodDeclaration(computed)) {
                returnTypeNode = computed.type;
            }

            const returnType = returnTypeNode ? typeChecker.typeToString(typeChecker.getTypeAtLocation(returnTypeNode)) : 'any';
            componentInfo.computed[computedName] = returnType;
          }
        });
      }

      //提取 expose
      const exposeProperty = node.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText() === 'expose');
      if (exposeProperty && ts.isPropertyAssignment(exposeProperty) && ts.isArrayLiteralExpression(exposeProperty.initializer)) {
        const exposeInitializer = exposeProperty.initializer;
        exposeInitializer.elements.forEach(element => {
          if (ts.isStringLiteral(element)) {
            componentInfo.expose.push(element.text);
          }
        });
      }
    }

    ts.forEachChild(node, visit);
  }

  visit(sourceFile);
  return componentInfo;
}

// 简单地尝试评估一个表达式,这对于获取字符串或数字的默认值很有用。
function evaluate(node: ts.Expression, typeChecker: ts.TypeChecker): any {
    try {
        if (ts.isStringLiteral(node)) {
            return node.text;
        } else if (ts.isNumericLiteral(node)) {
            return Number(node.text);
        } else if (node.kind === ts.SyntaxKind.TrueKeyword) {
            return true;
        } else if (node.kind === ts.SyntaxKind.FalseKeyword) {
            return false;
        } else if (node.kind === ts.SyntaxKind.NullKeyword) {
            return null;
        } else if (ts.isIdentifier(node)) {
          //尝试解析变量
          const symbol = typeChecker.getSymbolAtLocation(node);
          if(symbol && symbol.valueDeclaration && ts.isVariableDeclaration(symbol.valueDeclaration) && symbol.valueDeclaration.initializer){
            return evaluate(symbol.valueDeclaration.initializer,typeChecker);
          }

        }
        // 可以添加更多类型的字面量支持
    } catch (e) {
        console.warn("无法评估表达式:", node.getText());
    }
    return undefined;
}

function generateTypes(componentInfo: ComponentInfo, componentName:string): string {
  let types = `
  declare module '${componentName}' {
    import { DefineComponent } from 'vue';

    interface Props {
      ${Object.entries(componentInfo.props)
        .map(([name, prop]) => `${name}${prop.required ? '' : '?'}: ${prop.type};`)
        .join('n      ')}
    }

    interface Emits {
      ${componentInfo.emits.map(emit => `${emit}: (...args: any[]) => void;`).join('n      ')}
    }

    const ${componentName}: DefineComponent<Props, {}, {}, {}, Emits>;
    export default ${componentName};
  }
  `;

  return types;
}

// 获取组件文件名作为参数
const componentFileName = process.argv[2];
const componentName = path.basename(componentFileName, path.extname(componentFileName));

if (!componentFileName) {
  console.error('请提供组件文件名作为参数');
  process.exit(1);
}

try {
  const componentInfo = extractComponentInfo(componentFileName);
  const types = generateTypes(componentInfo, componentName);

  // 将类型定义写入文件
  const typesFileName = componentFileName.replace(/.vue|.ts|.js$/, '.d.ts');
  fs.writeFileSync(typesFileName, types);

  console.log(`类型定义已生成: ${typesFileName}`);

} catch (error) {
  console.error('提取类型信息失败:', error);
  process.exit(1);
}

4.3 示例 Vue 组件

创建一个示例 Vue 组件文件(例如 MyComponent.vue):

<template>
  <div>
    <h1>{{ title }}</h1>
    <button @click="handleClick">Click me</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

const defaultMessage = "Hello";

export default defineComponent({
  name: 'MyComponent',
  props: {
    title: {
      type: String,
      default: defaultMessage,
    },
    count: {
      type: Number,
      required: true,
    },
    isVisible: Boolean,
    message: String
  },
  emits: ['click', 'update'],
  data() {
    return {
      internalCount: 0,
    };
  },
  computed: {
    doubleCount(): number {
      return this.count * 2;
    },
  },
  methods: {
    handleClick() {
      this.internalCount++;
      this.$emit('click', this.internalCount);
    },
    updateMessage(newMessage: string) {
      this.$emit('update', newMessage);
    },
  },
  expose: ['handleClick', 'updateMessage'],
});
</script>

4.4 运行脚本

在命令行中运行以下命令:

ts-node extract-types.ts MyComponent.vue

这将在与 MyComponent.vue 文件相同的目录下生成一个 MyComponent.d.ts 文件,其中包含自动生成的类型定义。

4.5 生成的类型定义文件 (MyComponent.d.ts)

declare module 'MyComponent' {
    import { DefineComponent } from 'vue';

    interface Props {
      title?: String;
      count: Number;
      isVisible?: Boolean;
      message?: String;
    }

    interface Emits {
      click: (...args: any[]) => void;
      update: (...args: any[]) => void;
    }

    const MyComponent: DefineComponent<Props, {}, {}, {}, Emits>;
    export default MyComponent;
  }

5. 代码解释

  • extractComponentInfo(fileName: string) 函数:

    • 使用 ts.createProgram() 创建一个 TypeScript 程序。
    • 使用 program.getSourceFile() 获取源文件。
    • 使用 program.getTypeChecker() 获取类型检查器。
    • 定义 ComponentInfo 接口,用于存储提取的类型信息。
    • 使用 visit(node: ts.Node) 函数递归遍历 AST。
    • visit() 函数中,根据节点类型提取 propsemitsmethodscomputed 的类型信息。
    • 返回 ComponentInfo 对象。
  • generateTypes(componentInfo: ComponentInfo) 函数:

    • 接收 ComponentInfo 对象作为参数。
    • 根据 ComponentInfo 中的信息生成 TypeScript 类型定义字符串。
    • 返回类型定义字符串。

6. 改进和扩展

  • 支持更多组件选项: 可以扩展脚本以支持提取 datawatchprovideinject 等选项的类型信息。
  • 处理复杂的类型: 可以改进脚本以处理更复杂的类型,例如联合类型、交叉类型和泛型类型。
  • 支持 Vue 3 的 Composition API: 可以修改脚本以支持 Vue 3 的 Composition API,例如 setup() 函数和 ref()reactive() 等函数。
  • 生成 JSDoc 注释: 可以生成 JSDoc 注释,以便 IDE 提供更详细的代码提示。
  • 集成到 Vue CLI 插件中: 可以将脚本集成到 Vue CLI 插件中,以便在构建过程中自动生成类型定义。
  • 处理单文件组件的script setup语法糖: 需要解析<script setup>标签内的语法,这需要更复杂的AST处理逻辑。

7. 其他方法:使用 AST 解析器

除了 TypeScript 编译器 API,还可以使用 AST 解析器(如 esprimaacornbabel-parser)解析 JavaScript 代码。这种方法比较灵活,但需要处理复杂的语法结构。

以下是使用 babel-parser 解析 Vue 组件并提取 props 的示例代码:

import * as parser from "@babel/parser";
import traverse from "@babel/traverse";
import * as fs from 'fs';

const code = fs.readFileSync('MyComponent.vue', 'utf-8');

// 使用正则表达式提取 <script> 标签内的代码
const scriptContent = code.match(/<script.*?>([sS]*)</script>/)?.[1] || '';

const ast = parser.parse(scriptContent, {
  sourceType: "module",
  plugins: ["typescript", "decorators-legacy"],
});

traverse(ast, {
  ObjectProperty(path) {
    if (path.node.key.name === "props") {
      const propsNode = path.node.value;

      if (propsNode.type === "ObjectExpression") {
        propsNode.properties.forEach((prop) => {
          if (prop.type === "ObjectProperty") {
            const propName = prop.key.name;
            console.log("Prop Name:", propName);

            // 进一步解析 prop 的类型和默认值
            // ...
          }
        });
      }
    }
  },
});

这种方法需要更多的代码来处理 AST 节点,但是可以更灵活地提取信息。

8. 表格对比不同方法

方法 优点 缺点 适用场景
TypeScript 编译器 API 最准确、可靠,可以处理复杂的类型 需要一定的 TypeScript 知识,配置较复杂 大型项目,需要高度准确的类型信息
AST 解析器 灵活,可以处理各种语法结构 需要处理复杂的 AST 节点,代码量大,容易出错 需要高度定制的类型提取逻辑,或者需要支持非 TypeScript 代码
正则表达式 简单快捷 容易出错,不适合复杂的组件 小型项目,组件结构简单
Vue CLI 插件 可以集成到构建流程中,自动化生成类型定义 需要编写插件,配置较复杂 大型项目,需要自动化生成类型定义

9. 总结:类型自动生成,提升开发效率

自动生成 Vue 组件的 API 类型定义可以显著提高开发效率和代码质量。通过选择合适的工具和技术,可以根据项目的具体情况实现类型信息的自动提取和生成。这对于大型 Vue 项目的维护和协作至关重要。

更多IT精英技术系列讲座,到智猿学院

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注