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

前言

在使用 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 切片:

typescript 复制代码
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. 项目结构

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

2. 插件入口 (index.ts)

typescript 复制代码
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 遵循固定的命名规则:

typescript 复制代码
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 中快速定位光标所在的节点:

typescript 复制代码
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 使用模式:

typescript 复制代码
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 属性中找到目标端点:

typescript 复制代码
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 组装定义信息

typescript 复制代码
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. 安装插件

bash 复制代码
npm install --save-dev rtk-to-endpoints

2. 配置 tsconfig.json

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 上使用"跳转到定义":

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

跳转前

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

跳转后

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

技术要点总结

1. TypeScript Language Service 架构

sql 复制代码
┌─────────────────────────────────────────┐
│           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 的工作原理,并激发你为自己的项目开发类似的工具。


参考资源


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

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

相关推荐
ynchyong1 天前
TS 基础
typescript·ts
西瓜太郎4991 天前
别再手算 Token 成本:用 React 状态机做一个可追溯的模型费用计算器
react.js·typescript
右耳朵猫AI1 天前
Web前端周刊2026W37 | Shopify 转原生、React 编译 Rust 化、Vitest 5.0、Rslib 1.0
前端·javascript·react.js·typescript·node.js
晚安日记wanna2 天前
TS const 类型参数一道面试题的四层追问
前端·面试·typescript
掰头战士2 天前
你说你折这玩意干什么-agent工作的核心,还真得好好考虑怎么折叠上下文
typescript·llm·agent
ynchyong2 天前
VUE 中 不能将类型“R[]”分配给类型“UnwrapRefSimple<R>[]”
typescript·vue·ts·unwraprefsimple
掰头战士2 天前
让散乱的工具调用成为正规军,今天咱们聊聊 Tool System管线的读写锁
typescript·llm·agent
半糖程序员2 天前
从零构建 Agent(5):让事件流同时提供过程和结果
typescript·agent