前言
在使用 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
痛点分析
- Hook 名称是动态派生的 :
getUser→useGetUserQuery - TypeScript 只能看到类型:IDE 的"跳转到定义"只能指向类型体操生成的类型定义
- 开发体验断裂 :开发者需要手动搜索
endpoint名称,打断编码流
解决方案:TypeScript Language Service Plugin
什么是 Language Service Plugin?
TypeScript Language Service Plugin 是一种扩展机制,允许我们拦截和自定义 TypeScript 语言服务的各种操作,包括:
- 跳转到定义 (Go to Definition)
- 自动补全 (Auto Completion)
- 悬停提示 (Hover Information)
- 代码重构 (Code Refactoring)
核心思路
我们的插件需要完成以下工作:
- 识别 RTK Query Hook :通过命名规则识别
use{Endpoint}Query、use{Endpoint}Mutation等 hook - 解析 AST:找到 hook 所属的 API 实例
- 定位 Endpoint :从 API 实例的
endpoints属性中找到对应的端点定义 - 返回定义位置:将跳转目标指向 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版本:
Ctrl+Shift+P→ 输入 "TypeScript: Select TypeScript Version"- 选择 "Use Workspace Version"
- 重新加载窗口 (
Developer: Reload Window)
效果演示
配置完成后,当你在任何 RTK Query hook 上使用"跳转到定义":
typescript
// 点击 useGetUserQuery,直接跳转到 getUser endpoint 定义
const { data } = userApi.useGetUserQuery(userId);
跳转前:
- 指向类型定义文件(无实际业务价值)
跳转后:
- 直接定位到
createApi中的getUserendpoint 定义
技术要点总结
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 类型 |
扩展思考
这个插件的实现思路可以扩展到其他类似的场景:
- Vue Composition API :从
useXxx跳转到 composable 定义 - React Hooks:增强自定义 hook 的跳转体验
结语
TypeScript Language Service Plugin 是一个强大的工具,能够显著提升开发体验。通过理解 TypeScript 的编译器 API 和语言服务架构,我们可以针对特定的框架和库,打造更智能的 IDE 支持。
希望这篇文章能够帮助你理解 Language Service Plugin 的工作原理,并激发你为自己的项目开发类似的工具。
参考资源
-
我通过AI工具生成的TypeScript 5.9 API文档,作为参考,不保证其准确性。准确的API应该通过对应版本的TypeScript仓库源码或编译的描述文件中查询
如果这篇文章对你有帮助,欢迎点赞、收藏和分享!
有任何问题或建议,欢迎在评论区留言讨论。