跳至主要内容

·阅读时长 12 分钟
Julius Marminge

你是否曾经好奇 tRPC 是如何工作的?也许你想开始为该项目做出贡献,但你害怕内部机制?这篇文章的目的是通过编写一个涵盖 tRPC 工作原理的主要部分的最小客户端,让你熟悉 tRPC 的内部机制。

信息

建议你了解 TypeScript 中的一些核心概念,例如泛型、条件类型、extends 关键字和递归。如果你不熟悉这些概念,我建议你阅读 Matt PocockTypeScript 初学者教程,在继续阅读之前熟悉这些概念。

概述

假设我们有一个简单的 tRPC 路由器,它包含三个看起来像这样的过程

ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});
ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});

我们的客户端的目标是在客户端模拟这个对象结构,以便我们可以像这样调用过程

ts
const post1 = await client.post.byId.query({ id: '123' });
const post2 = await client.post.byTitle.query({ title: 'Hello world' });
const newPost = await client.post.create.mutate({ title: 'Foo' });
ts
const post1 = await client.post.byId.query({ id: '123' });
const post2 = await client.post.byTitle.query({ title: 'Hello world' });
const newPost = await client.post.create.mutate({ title: 'Foo' });

为了做到这一点,tRPC 使用 Proxy 对象和一些 TypeScript 魔法来增强对象结构,在它们上面添加 .query.mutate 方法 - 这意味着我们实际上是在向你撒谎你正在做什么(稍后会详细说明),以便提供出色的开发体验!

从高层次来看,我们要做的是将 post.byId.query() 映射到对服务器的 GET 请求,将 post.create.mutate() 映射到 POST 请求,并且类型应该从后端传播到前端。那么,我们该如何做到这一点呢?

实现一个微型 tRPC 客户端

🧙‍♀️ TypeScript 魔法

让我们从有趣的 TypeScript 魔法开始,它将解锁我们都知道和喜爱的 tRPC 自动完成和类型安全功能。

我们需要使用递归类型,以便我们可以推断任意深度的路由器结构。此外,我们知道我们希望我们的过程 post.byIdpost.create 分别具有 .query.mutate 方法 - 在 tRPC 中,我们称之为装饰过程。在 @trpc/server 中,我们有一些推断帮助程序,它们将使用这些解析的方法推断我们过程的输入和输出类型,我们将使用这些帮助程序来推断这些函数的类型,所以让我们编写一些代码!

让我们考虑一下我们想要实现的目标,以便在路径上提供自动完成以及推断过程的输入和输出类型

  • 如果我们位于路由器上,我们希望能够访问它的子路由器和过程。(我们稍后会讨论这一点)
  • 如果我们位于查询过程中,我们希望能够在它上面调用 .query
  • 如果我们位于变异过程中,我们希望能够在它上面调用 .mutate
  • 如果我们试图访问其他任何内容,我们希望得到一个类型错误,表明该过程在后端不存在。

所以让我们创建一个类型来为我们做到这一点

ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
mutate: Resolver<TProcedure>;
}
: never;
ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
mutate: Resolver<TProcedure>;
}
: never;

我们将使用 tRPC 的一些内置推断帮助程序来推断我们过程的输入和输出类型,以定义 Resolver 类型。

ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 
ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 

让我们在 post.byId 过程中试一试

ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<Post>
ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<Post>

很好,这就是我们预期的 - 我们现在可以在我们的过程中调用 .query 并获得推断的正确输入和输出类型!

最后,我们将创建一个类型,它将递归地遍历路由器并在途中装饰所有过程

ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};
ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};

让我们稍微消化一下这个类型

  1. 我们将 TRPCRouterRecord 作为泛型传递给该类型,它是一个包含 tRPC 路由器上所有过程和子路由器的类型。
  2. 我们迭代记录的键,这些键是过程或路由器名称,并执行以下操作
    • 如果键映射到路由器,我们将递归地对该路由器的过程记录调用该类型,这将装饰该路由器中的所有过程。这将在我们遍历路径时提供自动完成。
    • 如果键映射到过程,我们将使用前面创建的 DecorateProcedure 类型来装饰该过程。
    • 如果键不映射到过程或路由器,我们将分配 never 类型,这就像说“此键不存在”,如果我们试图访问它,将会导致类型错误。

🤯 Proxy 重新映射

现在我们已经设置了所有类型,我们需要实际实现该功能,它将增强客户端上服务器的路由器定义,以便我们可以像普通函数一样调用过程。

我们首先将创建一个用于创建递归代理的帮助程序函数 - createRecursiveProxy

信息

这几乎与生产环境中使用的实现完全相同,只是我们没有处理一些边缘情况。 自己看看

ts
interface ProxyCallbackOptions {
path: string[];
args: unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}
ts
interface ProxyCallbackOptions {
path: string[];
args: unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}

这看起来有点神奇,它做了什么?

  • get 方法处理属性访问,例如 post.byId。键是我们访问的属性名称,因此当我们键入 post 时,我们的 key 将是 post,当我们键入 post.byId 时,我们的 key 将是 byId。递归代理将所有这些键组合成一个最终路径,例如["post", "byId", "query"],我们可以使用它来确定我们要发送请求的 URL。
  • apply 方法在我们调用代理上的函数时被调用,例如 .query(args)args 是我们传递给函数的参数,因此当我们调用 post.byId.query(args) 时,我们的 args 将是我们的输入,我们将根据过程的类型将其作为查询参数或请求主体提供。createRecursiveProxy 接受一个回调函数,我们将使用路径和参数将其映射到 apply

下面是代理在调用 trpc.post.byId.query({ id: 1 }) 时如何工作的可视化表示

proxy

🧩 将所有内容整合在一起

现在我们有了这个帮助程序,并且知道它做了什么,让我们使用它来创建我们的客户端。我们将为 createRecursiveProxy 提供一个回调函数,它将接收路径和参数,并使用 fetch 请求服务器。我们需要在函数中添加一个泛型,它将接受任何 tRPC 路由器类型(AnyTRPCRouter),然后我们将返回类型强制转换为我们之前创建的 DecorateRouterRecord 类型

ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<TRouter['_def']['record']>;
// ^? provide empty array as path to begin with
ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<TRouter['_def']['record']>;
// ^? provide empty array as path to begin with

这里最值得注意的是,我们的路径是 . 分隔的,而不是 /。这使我们能够在服务器上拥有一个单一的 API 处理程序来处理所有请求,而不是为每个过程创建一个。如果你使用的是具有基于文件的路由的框架,例如 Next.js,你可能会认识到通配符 /api/trpc/[trpc].ts 文件,它将匹配所有过程路径。

我们还在 fetch 请求上有一个 TRPCResponse 类型注释。这决定了服务器响应的 JSONRPC 兼容响应格式。你可以阅读更多关于它的信息 这里。TL;DR,我们得到一个 result 或一个 error 对象,我们可以使用它来确定请求是否成功,以及如果出现错误,进行适当的错误处理。

就是这样!这是你在客户端调用 tRPC 过程所需的所有代码,就像它们是本地函数一样。从表面上看,它看起来就像我们只是通过正常的属性访问来调用 publicProcedure.query / mutation 的解析器函数,但实际上我们正在跨越网络边界,这样我们就可以使用服务器端库,例如 Prisma,而不会泄露数据库凭据。

试一试!

现在,创建客户端并为它提供服务器的 URL,你将在调用过程时获得完整的自动完成和类型安全!

ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }
ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }

客户端的完整代码可以在 这里找到,显示使用情况的测试可以在 这里找到。

结论

我希望你喜欢这篇文章,并了解了一些关于 tRPC 如何工作的知识。你可能不应该使用它,而应该使用 @trpc/client,它只比这里展示的内容大几 KB - 它比这里展示的内容具有更大的灵活性

  • 用于中止信号、ssr 等的查询选项...
  • 链接
  • 过程批处理
  • WebSockets / 订阅
  • 良好的错误处理
  • 数据转换器
  • 边缘情况处理,例如当我们没有收到 tRPC 兼容响应时

我们今天也没有涵盖太多服务器端的内容,也许我们会在以后的文章中讨论。如果你有任何问题,请随时在 Twitter 上联系我。

·阅读时长 9 分钟
Sachin Raja

作为库作者,我们的目标是为我们的同行提供最佳的开发体验 (DX)。减少出错时间并提供直观的 API 可以减轻开发人员的心理负担,使他们能够专注于最重要的内容:出色的最终用户体验。


众所周知,TypeScript 是 tRPC 提供出色 DX 的驱动力。TypeScript 的采用是当今提供出色基于 JavaScript 的体验的现代标准 - 但这种类型方面的确定性提高确实有一些权衡。

如今,TypeScript 类型检查器很容易变得缓慢(尽管像 TS 4.9 这样的版本很有希望!)。库几乎总是包含你代码库中最花哨的 TypeScript 咒语,将你的 TS 编译器推向极限。出于这个原因,像我们这样的库作者必须注意我们对这种负担的贡献,并尽最大努力让你的 IDE 尽可能快地工作。

自动执行库性能

当 tRPC 处于 v9 时,我们开始收到开发人员的报告,称他们大型的 tRPC 路由器开始对他们的类型检查器产生不利影响。这对 tRPC 来说是一种新的体验,因为我们在 tRPC 开发的 v9 阶段看到了巨大的采用。随着越来越多的开发人员使用 tRPC 创建越来越大的产品,一些裂缝开始显现。

您的库现在可能并不慢,但随着库的增长和变化,监控性能非常重要。自动化测试可以通过编程方式在每次提交时测试您的库代码,从而减轻您库编写(和应用程序构建!)的巨大负担。

对于 tRPC,我们尽力通过生成测试包含 3,500 个过程和 1,000 个路由器的路由器来确保这一点。但这只测试了我们在 TS 编译器崩溃之前能走多远,而不是类型检查需要多长时间。我们测试了库的所有三个部分(服务器、普通客户端和 React 客户端),因为它们都有不同的代码路径。过去,我们发现了一些仅限于库一部分的回归,并且依赖于我们的测试来告诉我们这些意外行为何时发生。(我们仍然希望做更多来衡量编译时间)

tRPC 不是一个运行时密集型库,因此我们的性能指标集中在类型检查上。因此,我们始终注意

  • 使用 tsc 进行类型检查速度很慢
  • 初始加载时间很长
  • 如果 TypeScript 语言服务器需要很长时间才能响应更改

最后一点是 tRPC 必须最关注的一点。您永远不希望您的开发人员在更改后不得不等待语言服务器更新。这就是 tRPC 必须保持性能的原因,这样您就可以享受出色的 DX。

我在 tRPC 中发现性能机会的方式

在 TypeScript 准确性和编译器性能之间始终存在权衡。这两个都是其他开发人员的重要关注点,因此我们必须非常清楚地了解我们如何编写类型。应用程序是否可能因为某种类型“太宽松”而遇到严重错误?性能提升值得吗?

甚至会有有意义的性能提升吗?好问题。

让我们看看如何在 TypeScript 代码中找到性能改进的机会。我们将回顾我创建PR #2716的过程,从而使 TS 编译时间减少了 59%。


TypeScript 有一个内置的跟踪工具,可以帮助您找到类型中的瓶颈。它并不完美,但它是目前最好的工具。

理想情况下,您应该在真实应用程序上测试您的库,以模拟您的库为真实开发人员执行的操作。对于 tRPC,我创建了一个基本的T3 应用程序,它类似于我们许多用户使用的应用程序。

以下是跟踪 tRPC 的步骤

  1. 将库本地链接到示例应用程序。这样您就可以更改库代码并立即在本地测试更改。

  2. 在示例应用程序中运行此命令

    sh
    tsc --generateTrace ./trace --incremental false
    sh
    tsc --generateTrace ./trace --incremental false
  3. 您将在机器上获得一个 trace/trace.json 文件。您可以在跟踪分析应用程序(我使用Perfetto)或 chrome://tracing 中打开该文件。

这就是事情变得有趣的地方,我们可以开始了解应用程序中类型的性能概况。以下是第一个跟踪的样子:trace bar showing that src/pages/index.ts took 332ms to type-check

较长的条形表示花费更多时间执行该过程。我在此屏幕截图中选择了最上面的绿色条,表明 src/pages/index.ts 是瓶颈。在 Duration 字段下,您会看到它花费了 332 毫秒 - 类型检查花费了大量时间!蓝色的 checkVariableDeclaration 条告诉我们编译器大部分时间都花在一个变量上。单击该条将告诉我们它是哪个:trace info showing the variable's position is 275 pos 字段显示了变量在文件文本中的位置。转到 src/pages/index.ts 中的位置,就会发现罪魁祸首是 utils = trpc.useContext()

但这怎么可能呢?我们只是使用了一个简单的钩子!让我们看看代码

tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;
tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;

好吧,这里没什么可看的。我们只看到一个 useContext 和一个查询失效。表面上看,没有什么应该是 TypeScript 密集型的,这表明问题一定更深层。让我们看看这个变量背后的类型

ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @link https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;
ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @link https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;

好吧,现在我们有一些东西需要解开和学习。让我们先弄清楚这段代码在做什么。

我们有一个递归类型 DecoratedProcedureUtilsRecord,它遍历路由器中的所有过程并使用 React Query 实用程序(如invalidateQueries)“装饰”(向其添加方法)。

在 tRPC v10 中,我们仍然支持旧的 v9 路由器,但 v10 客户端无法调用来自 v9 路由器的过程。因此,对于每个过程,我们检查它是否是一个 v9 过程(extends LegacyV9ProcedureTag),如果是,则将其剥离。对于 TypeScript 来说,这都是很多工作……如果它不是惰性求值的

惰性求值

这里的问题是,TypeScript 正在类型系统中评估所有这些代码,即使它没有立即使用。我们的代码只使用 utils.r49.greeting.invalidate,因此 TypeScript 只需要解开 r49 属性(一个路由器),然后解开 greeting 属性(一个过程),最后解开该过程的 invalidate 函数。该代码中不需要其他类型,并且立即找到所有 tRPC 过程的每个 React Query 实用程序方法的类型会不必要地减慢 TypeScript 的速度。TypeScript 会延迟对对象上的属性的类型求值,直到它们被直接使用,因此理论上我们上面的类型应该获得惰性求值……对吧?

好吧,它不完全是一个对象。实际上有一个类型包装了整个东西:OmitNeverKeys。此类型是一个实用程序,它从对象中删除具有值 never 的键。这是我们剥离 v9 过程的部分,因此这些属性不会出现在 Intellisense 中。

但这会造成巨大的性能问题。我们迫使 TypeScript 现在评估所有类型的值,以检查它们是否为 never

我们如何解决这个问题?让我们更改我们的类型以减少工作量

变得懒惰

我们需要找到一种方法让 v10 API 更优雅地适应旧的 v9 路由器。新的 tRPC 项目不应该受到互操作模式的 TypeScript 性能下降的影响。

想法是重新排列核心类型本身。v9 过程与 v10 过程是不同的实体,因此它们不应该在我们的库代码中共享相同的空间。在 tRPC 服务器端,这意味着我们有一些工作要做,将类型存储在路由器中的不同字段上,而不是单个 record 字段(参见上面的 DecoratedProcedureUtilsRecord)。

我们进行了一些更改,以便 v9 路由器在转换为 v10 路由器时将其过程注入到 legacy 字段中。

旧类型

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;

如果您还记得上面的 DecoratedProcedureUtilsRecord 类型,您会看到我们在这里附加了 LegacyV9ProcedureTag,以在类型级别区分 v9v10 过程,并强制执行 v9 过程不能从 v10 客户端调用。

新类型

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;

现在,我们可以删除 OmitNeverKeys,因为过程是预先排序的,因此路由器的 record 属性类型将包含所有 v10 过程,而其 legacy 属性类型将包含所有 v9 过程。我们不再强制 TypeScript 完全评估巨大的 DecoratedProcedureUtilsRecord 类型。我们还可以删除使用 LegacyV9ProcedureTagv9 过程的过滤。

它有效吗?

我们的新跟踪显示瓶颈已消除:trace bar showing that src/pages/index.ts took 136ms to type-check

一个实质性的改进!类型检查时间从 332 毫秒降至 136 毫秒 🤯!从大局来看,这可能看起来并不多,但这是一个巨大的胜利。200 毫秒一次不多 - 但想想

  • 一个项目中有多少其他 TS 库
  • 今天有多少开发人员在使用 tRPC
  • 他们的类型在一个工作会话中重新评估了多少次

这将是很多 200 毫秒,加起来会是一个非常大的数字。

我们一直在寻找更多机会来改善 TypeScript 开发人员的体验,无论是使用 tRPC 还是在另一个项目中解决的基于 TS 的问题。如果您想谈论 TypeScript,请在Twitter 上与我联系。

感谢Anthony Shew 帮助撰写这篇文章,以及Alex 的审阅!

·阅读时间:4 分钟
Alex / KATT 🐱

tRPC 通过利用 TypeScript 的强大功能来强制执行严格的、全栈的类型绑定,从而提供出色的开发人员体验。没有 API 合同漂移,没有代码生成。

自 2021 年 8 月我们上次发布主要版本以来,tRPC 社区已取得了长足发展

今天,我们发布了 tRPC v10。我们很高兴地分享,v10 已经由许多大型 TypeScript 项目投入生产使用。此正式发布宣布了对更广泛社区的普遍可用性。

对于新项目,您可以使用示例应用程序来了解 tRPC v10。对于已经使用 tRPC v9 的项目,请访问 v10 迁移指南

更改概述

v10 是 tRPC 有史以来最大的版本。这是我们第一次对 tRPC 的结构进行任何根本性更改,我们相信这些更改为快速发展的团队在开发尖端应用程序方面开辟了新的可能性。

改进的开发人员体验

tRPC v10 拥抱您的 IDE。我们希望统一您的类型 - 但我们还在此版本中将您的前端、后端和编辑体验整合在一起。

使用 v10,您可以

  • 使用“转到定义”直接从您的前端使用者跳转到您的后端过程
  • 使用“重命名符号”为整个应用程序中的输入参数或过程赋予新名称
  • 更轻松地推断类型,以便您可以在应用程序中手动使用 tRPC 类型

强大的后端框架

在 v10 版本中,我们重新审视了定义后端过程的语法,为以健康的方式引入所需逻辑提供了更多机会。此版本的 tRPC 包含以下功能:

大幅提升 TypeScript 性能

TypeScript 使开发人员能够完成令人难以置信的事情,但它也可能带来成本。我们用来保持类型严格的许多技术给 TypeScript 编译器带来了沉重的负担。我们听到了社区的反馈,即使用 tRPC v9 的大型应用程序开始因这种编译器压力而导致开发人员 IDE 性能下降。

我们的目标是增强所有规模应用程序的开发人员体验。在 v10 版本中,我们大幅提升了 TypeScript 性能(尤其是在 TS 增量编译方面),让您的编辑器保持流畅。

增量迁移

我们还投入了大量精力,使迁移体验尽可能简单,包括一个 interop() 方法,它允许(几乎)完全向后兼容 v9 路由器。访问迁移指南了解更多信息。

核心团队的Sachin 还制作了一个代码修改器,可以为您完成大部分迁移工作。

不断发展的生态系统

围绕 tRPC 正在形成一套丰富的子库。以下是一些示例:

有关更多插件、示例和适配器,请访问 Awesome tRPC 集合

感谢您!

我和核心团队想让您知道:我们才刚刚开始。我们已经开始尝试使用React 服务器组件 和 Next.js 13。

我还想特别感谢SachinJuliusJamesAhmedChrisTheoAnthony,以及所有帮助发布此版本的贡献者

感谢您使用和支持 tRPC。


·阅读时间:5 分钟
Alex / KATT 🐱

我是 Alex,GitHub 上的昵称是 "KATT",我想向您介绍一个名为tRPC 的库。我还没有发布任何关于它的文章,所以我现在只是写一篇介绍文章来开始(但我们已经在 GitHub 上获得了超过 530 个 🌟)。敬请期待即将发布的文章和视频介绍!如果您想随时了解最新信息或提出问题,可以在 Twitter 上关注我,我的用户名是@alexdotjs

简而言之,tRPC 在您的(节点)服务器和客户端之间提供端到端的类型安全,无需声明类型。您只需在后端返回一个函数中的数据,然后在前端根据端点名称使用该数据。

以下是使用 tRPC 端点和客户端调用时的示例: Alt Text

我为 React 创建了一个库(@trpc/react),它建立在优秀的 react-query 之上,但客户端库(@trpc/client)可以在没有 React 的情况下工作(如果您想构建一个特定的 Svelte/Vue/Angular/[..]库,请与我联系!

不需要代码生成,您可以轻松地将其添加到现有的 Next.js/CRA/Express 项目中。

示例

以下是一个名为 hello 的 tRPC 过程(也称为端点)的示例,它接受一个 string 类型的参数。

tsx
const appRouter = trpc.router().query('hello', {
input: z.string().optional(),
resolve: ({ input }) => {
return {
text: `hello ${input ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;
tsx
const appRouter = trpc.router().query('hello', {
input: z.string().optional(),
resolve: ({ input }) => {
return {
text: `hello ${input ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;

以下是一个使用该数据的类型安全的客户端

tsx
import type { AppRouter } from './server';
async function main() {
const client = createTRPCClient<AppRouter>({
url: `http://localhost:2022`,
});
const result = await client.query('hello', '@alexdotjs');
console.log(result); // --> { text: "hello @alexdotjs" }
}
main();
tsx
import type { AppRouter } from './server';
async function main() {
const client = createTRPCClient<AppRouter>({
url: `http://localhost:2022`,
});
const result = await client.query('hello', '@alexdotjs');
console.log(result); // --> { text: "hello @alexdotjs" }
}
main();

这就是您获得类型安全所需的一切!result 的类型是从后端函数返回的内容推断出来的。输入数据的类型也从验证器的返回值推断出来,因此数据可以直接安全使用 - 实际上,您必须通过验证器传递输入数据(tRPC 与 zod/yup/自定义验证器开箱即用)。

以下是一个 CodeSandbox 链接,您可以在其中使用上面的示例:https://githubbox.com/trpc/trpc/tree/next/examples/standalone-server(查看终端输出而不是预览!)

什么?我从后端导入代码到客户端? - 不,您实际上没有

虽然看起来像是这样,但实际上没有代码从服务器共享到客户端;TypeScript 的 import type "[..]只导入声明以用于类型注释和声明。它始终被完全擦除,因此在运行时没有它的残留。" - 这是 TypeScript 3.8 中添加的功能 - 查看 TypeScript 文档

不需要代码生成,只要您有方法从服务器共享类型到客户端(希望您已经在使用单体仓库),您就可以将它添加到您的应用程序中。

但我们才刚刚开始!

我之前提到过有一个 React 库,在 React 中使用上面的数据,您可以这样做

tsx
const { data } = trpc.useQuery(['hello', '@alexdotjs']);
tsx
const { data } = trpc.useQuery(['hello', '@alexdotjs']);

...您将在客户端获得类型安全的数据。

您可以在现有的遗留项目中添加 tRPC(有针对 Express/Next.js 的适配器),它可以与 CRA 很好地配合使用,也应该可以与 React Native 配合使用。它甚至不依赖于 React,所以如果您想构建一个 Svelte 或 Vue 库,请与我联系。

如何处理数据变动?

变动与查询一样简单,它们在底层实际上是相同的,只是作为语法糖以不同的方式公开,并生成 HTTP POST 请求而不是 GET 请求。

以下是一个使用数据库的更复杂的示例,来自我们在 todomvc.trpc.io 上的 TodoMVC 示例 / https://github.com/trpc/trpc/tree/next/examples/next-prisma-todomvc

tsx
const todoRouter = createRouter().mutation('add', {
input: z.object({
id: z.string().uuid(),
data: z.object({
completed: z.boolean().optional(),
text: z.string().min(1).optional(),
}),
}),
async resolve({ ctx, input }) {
const { id, data } = input;
const todo = await ctx.task.update({
where: { id },
data,
});
return todo;
},
});
tsx
const todoRouter = createRouter().mutation('add', {
input: z.object({
id: z.string().uuid(),
data: z.object({
completed: z.boolean().optional(),
text: z.string().min(1).optional(),
}),
}),
async resolve({ ctx, input }) {
const { id, data } = input;
const todo = await ctx.task.update({
where: { id },
data,
});
return todo;
},
});

React 使用看起来像这样

tsx
const addTask = trpc.useMutation('todos.add');
return (
<>
<input
placeholder="What needs to be done?"
onKeyDown={(e) => {
const text = e.currentTarget.value.trim();
if (e.key === 'Enter' && text) {
addTask.mutate({ text });
e.currentTarget.value = '';
}
}}
/>
</>
)
tsx
const addTask = trpc.useMutation('todos.add');
return (
<>
<input
placeholder="What needs to be done?"
onKeyDown={(e) => {
const text = e.currentTarget.value.trim();
if (e.key === 'Enter' && text) {
addTask.mutate({ text });
e.currentTarget.value = '';
}
}}
/>
</>
)

暂时结束。

总之,正如我所说,我只是想开始。还有很多事情

  • 为传入请求创建上下文以获取特定于用户的数据,这些数据被依赖注入到解析器中 - 链接
  • 路由器的中间件支持 - 链接
  • 合并路由器(您可能不希望将所有后端数据放在一个文件中) - 链接
  • 在 React 世界中,您可以看到最简单的服务器端渲染,使用我们的 @trpc/next 适配器 - 链接
  • 类型安全的错误格式化 - 链接
  • 数据转换器(在网络上传输 Date/Map/Set 对象) - 链接
  • React Query 的帮助程序

如果您想开始,可以在Next.js 入门中找到一些示例。

在 Twitter 上关注我以获取更新!