开发一个 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 的工作原理,并激发你为自己的项目开发类似的工具。


参考资源


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

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

相关推荐
退休倒计时5 小时前
【每日一题】LeetCode 17. 电话号码的字母组合 TypeScript
算法·leetcode·typescript
无情的西瓜皮1 天前
MCP 协议实战:从零搭建一个 MCP 服务器(Node.js 版)
vue.js·人工智能·typescript·node.js
退休倒计时1 天前
【每日一题】LeetCode 78. 子集 TypeScript
算法·leetcode·typescript
学习-学习1 天前
Node.js 现在能直接跑 TypeScript 了?Type Stripping 原理、限制与 tsx / ts-node 取舍
typescript·node.js
yume_sibai2 天前
大屏数据可视化 - 边框红绿呼吸灯实现详解
前端·信息可视化·typescript
孟无岐2 天前
LayaAir IDE 插件开发入门:用 TypeScript 给编辑器加一个自己的面板
typescript·游戏开发·工程化·编辑器扩展·layaair·ide 插件
shakingWaves3 天前
better auth worker on cloudflare
typescript·cloudflare·auth·better-auth
退休倒计时3 天前
【每日一题】LeetCode 437. 路径总和 III TypeScript
算法·leetcode·typescript
柯克七七3 天前
给老项目加了 TypeScript,本来只想自己爽,结果全公司代码审查标准被我抬高了
前端·vue.js·typescript
yume_sibai3 天前
高德地图骑行导航 API 介绍与使用方法
前端·typescript