开发一个 TypeScript 语言服务插件:让 RTK Query 的"跳转到定义"更智能

4 阅读5分钟

前言

在使用 Redux Toolkit Query (RTK Query) 进行开发时,你是否遇到过这样的困扰:

当你想查看某个 API 端点的具体实现时,按下 F12(跳转到定义),IDE 却把你带到了类型定义文件,而不是真正的业务代码。你需要手动搜索endpoint名称,才能在 createApi 中找到对应的定义。

这是一个普遍存在的问题,因为 RTK Query 的 hook 名称(如 useGetUserQuery)是动态生成的,TypeScript 无法建立从 hook 调用到endpoint定义的静态映射关系。

今天,我将介绍如何开发一个 TypeScript Language Service Plugin,来解决这个问题,让开发者能够一键跳转到 RTK Query 的 endpoint 定义。


问题背景

RTK Query 的工作原理

RTK Query 通过 createApi 创建 API 切片:

export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query<User, string>({
      query: (id) => `/users/${id}`,
    }),
    updateUser: builder.mutation<User, Partial<User>>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
    }),
  }),
})

// 自动生成的 hooks
export const { useGetUserQuery, useUpdateUserMutation } = userApi

痛点分析

  1. Hook 名称是动态派生的getUseruseGetUserQuery
  2. TypeScript 只能看到类型:IDE 的"跳转到定义"只能指向类型体操生成的类型定义
  3. 开发体验断裂:开发者需要手动搜索endpoint名称,打断编码流

解决方案:TypeScript Language Service Plugin

什么是 Language Service Plugin?

TypeScript Language Service Plugin 是一种扩展机制,允许我们拦截和自定义 TypeScript 语言服务的各种操作,包括:

  • 跳转到定义 (Go to Definition)
  • 自动补全 (Auto Completion)
  • 悬停提示 (Hover Information)
  • 代码重构 (Code Refactoring)

核心思路

我们的插件需要完成以下工作:

  1. 识别 RTK Query Hook:通过命名规则识别 use{Endpoint}Queryuse{Endpoint}Mutation 等 hook
  2. 解析 AST:找到 hook 所属的 API 实例
  3. 定位 Endpoint:从 API 实例的 endpoints 属性中找到对应的端点定义
  4. 返回定义位置:将跳转目标指向 endpoint 定义处

实现详解

1. 项目结构

rtk-to-endpoints/
├── src/
│   ├── index.ts      # 插件入口
│   └── utils.ts      # 核心逻辑
├── package.json
└── tsconfig.json

2. 插件入口 (index.ts)

import tslib from "typescript/lib/tsserverlibrary";
import { getDefinitionAndBoundSpan } from "./utils.js";

function init(modules: { typescript: typeof tslib }) {
  const ts = modules.typescript;

  function create(info: tslib.server.PluginCreateInfo) {
    const logger = info.project.projectService.logger;
    
    log("✅ Plugin initialized");

    const proxy: tslib.LanguageService = Object.create(info.languageService);

    // 拦截"跳转到定义"请求
    proxy.getDefinitionAndBoundSpan = (
      fileName: string,
      position: number
    ): tslib.DefinitionInfoAndBoundSpan | undefined => {
      const program = info.languageService.getProgram();
      
      // 尝试我们的自定义跳转逻辑
      const definitionInfo = getDefinitionAndBoundSpan(
        fileName, position, ts, program
      );
      
      // 如果匹配到 RTK Query hook,返回自定义结果
      // 否则,回退到默认行为
      return definitionInfo || 
        info.languageService.getDefinitionAndBoundSpan(fileName, position);
    };

    return proxy;
  }

  return { create };
}

export = init;

3. 核心逻辑 (utils.ts)

3.1 识别 Hook 命名模式

RTK Query 生成的 hook 遵循固定的命名规则:

const HOOK_PREFIXES = ["useLazy", "use"] as const;
const HOOK_SUFFIXES = [
  "InfiniteQueryState",
  "InfiniteQuery", 
  "QueryState",
  "Mutation",
  "Query",
] as const;

// 从 hook 名中提取 endpoint 名
export function extractEndpointName(hookName: string) {
  for (const prefix of HOOK_PREFIXES) {
    if (hookName.startsWith(prefix)) {
      const rest = hookName.slice(prefix.length);
      for (const suffix of HOOK_SUFFIXES) {
        if (rest.endsWith(suffix)) {
          const endpointName = rest.slice(0, rest.length - suffix.length);
          if (endpointName) {
            // 首字母小写:GetUser → getUser
            return endpointName[0].toLowerCase() + endpointName.slice(1);
          }
        }
      }
    }
  }
}

3.2 AST 节点查找

使用二分查找在 AST 中快速定位光标所在的节点:

export function getIdentifierNodeAt(
  sourceFile: tslib.SourceFile,
  pos: number,
): tslib.Node | undefined {
  let current: tslib.Node = sourceFile;
  
  while (true) {
    const children = current.getChildren(sourceFile);
    let left = 0;
    let right = children.length - 1;
    let targetChild: tslib.Node | undefined;

    // 二分查找覆盖指定位置的子节点
    while (left <= right) {
      const mid = (left + right) >>> 1;
      const child = children[mid];
      if (pos < child.pos) {
        right = mid - 1;
      } else if (pos >= child.end) {
        left = mid + 1;
      } else {
        targetChild = child;
        break;
      }
    }

    if (!targetChild) break;
    current = targetChild;
  }
  
  return current;
}

3.3 查找 API 实例

支持两种常见的 API 使用模式:

export function findApi(node: tslib.Node, ts: typeof tslib) {
  const parent = node.parent;
  
  // 模式 1:解构赋值
  // const { useGetUsersQuery } = userApi
  if (ts.isBindingElement(parent)) {
    const expressionNode = parent.parent?.parent;
    if (!ts.isVariableDeclaration(expressionNode)) return;
    const apiNode = expressionNode.getChildAt(
      expressionNode.getChildCount() - 1
    );
    if (!apiNode || !ts.isIdentifier(apiNode)) return;
    return apiNode;
    
  // 模式 2:属性访问
  // userApi.useGetProductsQuery()
  } else if (parent && ts.isPropertyAccessExpression(parent)) {
    return parent.getChildAt(parent.getChildCount() - 3);
  }
}

3.4 定位 Endpoint 定义

利用 TypeScript 的类型检查器,从 API 实例的 endpoints 属性中找到目标端点:

export function findEndpoint(
  apiNode: tslib.Node, 
  endpointName: string, 
  checker: tslib.TypeChecker
) {
  // 获取 API 实例的类型
  const apiType = checker.getTypeAtLocation(apiNode);
  
  // 获取 endpoints 属性
  const endpointsSymbol = apiType.getProperty('endpoints');
  if (!endpointsSymbol) return;
  
  // 获取 endpoints 的类型
  const endpointsType = checker.getTypeOfSymbol(endpointsSymbol);
  
  // 查找具体的 endpoint
  const endpointsPropertySymbol = endpointsType.getProperty(endpointName);
  return endpointsPropertySymbol;
}

3.5 组装定义信息

export function getDefinitionAndBoundSpan(
  fileName: string, 
  position: number, 
  ts: typeof tslib, 
  program?: tslib.Program
) {
  const sf = program!.getSourceFile(fileName);
  const checker = program!.getTypeChecker();
  if (!sf || !program || !checker) return;

  // 1. 找到光标处的标识符节点
  const identNode = getIdentifierNodeAt(sf, position);
  if (!identNode || !ts.isIdentifier(identNode)) return;

  // 2. 提取 endpoint 名称
  const endpointName = extractEndpointName(identNode.getText());
  if (!endpointName) return;

  // 3. 找到 API 实例
  const apiNode = findApi(identNode, ts);
  if (!apiNode) return;

  // 4. 查找 endpoint 定义
  const endpointSymbol = findEndpoint(apiNode, endpointName, checker);
  if (!endpointSymbol?.declarations?.length) return;

  // 5. 组装定义信息
  const definitions = endpointSymbol.declarations.map((node): tslib.DefinitionInfo => {
    return {
      fileName: node.getSourceFile().fileName,
      kind: ts.ScriptElementKind.memberFunctionElement,
      name: endpointSymbol.getName(),
      containerKind: ts.ScriptElementKind.classElement,
      containerName: "endpoints",
      textSpan: {
        start: node.getStart(),
        length: node.getWidth(),
      },
    };
  });

  return {
    definitions,
    textSpan: {
      start: identNode.getStart(sf),
      length: identNode.getWidth(sf),
    },
  };
}

使用方式

1. 安装插件

npm install --save-dev rtk-to-endpoints

2. 配置 tsconfig.json

{
  "compilerOptions": {
    "plugins": [
      {
        "name": "rtk-to-endpoints"
      }
    ]
  }
}

3. 配置 VSCode

由于VSCode内置的TypeScript无法读取到项目下的npm包,需要在 VSCode 中设置使用工作区的TypeScript版本:

  1. Ctrl+Shift+P → 输入 "TypeScript: Select TypeScript Version"
  2. 选择 "Use Workspace Version"
  3. 重新加载窗口 (Developer: Reload Window)

效果演示

配置完成后,当你在任何 RTK Query hook 上使用"跳转到定义":

// 点击 useGetUserQuery,直接跳转到 getUser endpoint 定义
const { data } = userApi.useGetUserQuery(userId);

跳转前

  • 指向类型定义文件(无实际业务价值)

跳转后

  • 直接定位到 createApi 中的 getUser endpoint 定义

技术要点总结

1. TypeScript Language Service 架构

┌─────────────────────────────────────────┐
│           VSCode / IDE                  │
└─────────────┬───────────────────────────┘
              │ LSP 协议
┌─────────────▼───────────────────────────┐
│      TypeScript Language Server         │
└─────────────┬───────────────────────────┘
              │
┌─────────────▼───────────────────────────┐
│    TypeScript Language Service          │
│  ┌─────────────────────────────────┐    │
│  │  rtk-to-endpoints Plugin        │    │
│  │  (拦截 getDefinitionAndBoundSpan)│   │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

2. 关键技术点

技术点说明
AST 遍历使用二分查找高效定位节点
类型检查器利用 TypeChecker 解析类型信息
代理模式包装原有 Language Service,保留默认行为
命名解析通过字符串模式匹配识别 hook 类型

扩展思考

这个插件的实现思路可以扩展到其他类似的场景:

  1. Vue Composition API:从 useXxx 跳转到 composable 定义
  2. React Hooks:增强自定义 hook 的跳转体验

结语

TypeScript Language Service Plugin 是一个强大的工具,能够显著提升开发体验。通过理解 TypeScript 的编译器 API 和语言服务架构,我们可以针对特定的框架和库,打造更智能的 IDE 支持。

希望这篇文章能够帮助你理解 Language Service Plugin 的工作原理,并激发你为自己的项目开发类似的工具。


参考资源


如果这篇文章对你有帮助,欢迎点赞、收藏和分享!

有任何问题或建议,欢迎在评论区留言讨论。