React Server Components 类型安全:利用 TypeScript 实现从服务器逻辑到客户端组件的端到端类型透传

各位好,欢迎来到今天的“类型地狱逃生指南”特别版。我是你们的老朋友,一个头发日益稀疏但对 TypeScript 热爱深沉的编程专家。

今天我们不聊 CSS 动画怎么更丝滑,也不聊怎么用 Webpack 优化构建速度。我们要聊的是 React Server Components (RSC) 时代,一个让无数前端工程师从“复制粘贴类型”的泥潭中解脱出来的神器——端到端类型透传

想象一下,你的代码就像一个精密的瑞士钟表。在服务器端,齿轮(数据)转得飞快;在客户端,指针(UI)精准跳动。但在以前,这两个齿轮和指针之间,隔着一道厚厚的玻璃墙。你必须在服务器端画好齿轮的图纸(TypeScript 接口),然后费尽九牛二虎之力,把图纸复印一份,跨越玻璃墙,贴在客户端的墙上。

一旦服务器端的齿轮稍微改了个齿距,你还得记得去客户端把那复印的图纸也改了。稍不留神,编译器就会给你报错:“嘿,你那边改了,我这边的图纸还是旧的!”

这太痛苦了,对吧?这就像是你左手画圆,右手画方,还非要保证两边的圆和方在视觉上完全重合。

今天,我们就来聊聊如何打破这堵墙,让 TypeScript 的类型像传送门一样,直接从服务器“瞬移”到客户端组件。

第一幕:痛苦的现状——类型重复的“丧尸围城”

首先,让我们看看如果不使用类型透传,我们的代码会变成什么样。这就像是一个没有解药的世界。

假设我们在服务器端有一个获取用户数据的函数:

// server.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
  settings: {
    theme: 'dark' | 'light';
    notifications: boolean;
  };
}

export async function getUser(id: number): Promise<User> {
  // 模拟 API 调用
  return {
    id,
    name: 'TypeScript Master',
    email: '[email protected]',
    role: 'admin',
    settings: {
      theme: 'dark',
      notifications: true
    }
  };
}

现在,我们需要在客户端组件中使用这个数据。最原始、最痛苦的做法是什么?是复制粘贴。

// client.tsx
import { getUser } from './server';

// 痛点 1:我们需要把 User 接口再定义一遍!
// 稍微有点不同,我们就得手动改。一旦忘记改,运行时就会爆炸。
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
  settings: {
    theme: 'dark' | 'light';
    notifications: boolean;
  };
}

export default async function UserProfile({ id }: { id: number }) {
  const user = await getUser(id);

  // 现在我们可以使用 user 了,但我们需要显式地告诉 TS 这个变量是什么类型
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Role: {user.role}</p>
      <p>Email: {user.email}</p>
    </div>
  );
}

你看,这有多荒谬?我们在服务器端定义了 User,在客户端又定义了一遍。如果 User 有 20 个字段,改一个字段意味着我们要改两处。这简直就是“类型重复的丧尸围城”,而且你还得时刻提防着别把 string 写成了 String,或者把 role 的类型搞混了。

而且,如果我们把这个 UserProfile 组件放在另一个文件夹里,我们还得把 User 接口导过去。一旦 getUser 返回的 Promise 没了,或者返回类型变了,客户端组件可能还在用旧类型,然后……Boom!运行时错误。

第二幕:RSC 的魔法——async 组件与 use Hook

React Server Components 的出现,就是为了解决这种“断连”问题。RSC 允许组件是 async 的。

// server-component.tsx
import { getUser } from './server';

// 这里的组件是 async 的,这意味着它可以直接在服务器上运行
// 并且可以 await 数据
export default async function ServerUserProfile({ id }: { id: number }) {
  const user = await getUser(id);

  // 关键点来了:我们可以把数据直接传递给客户端组件
  return <ClientUserProfile user={user} />;
}

但是,把 user 直接传过去,客户端组件还是不知道 user 是什么类型。我们需要一个桥梁。

React 18 引入了一个非常有意思的 Hook,名字就叫 use。听起来是不是很随意?但这正是它的精妙之处。它专门用来在客户端组件中消费服务器端传递过来的异步数据。

// client-component.tsx
// 注意:use 是一个特殊的 Hook,只能在客户端组件顶层调用
export default function ClientUserProfile({ user }: { user: any }) {
  // 这里 user 是 any,我们需要类型
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Role: {user.role}</p>
    </div>
  );
}

等等,如果 userany,那我们刚才说的“类型透传”岂不是白费了?

第三幕:Awaited 的奥义——类型体操的入门

要实现端到端类型透传,我们需要利用 TypeScript 的类型推断能力。这就像是用魔法把数据包解包。

让我们回到服务器端组件,看看我们怎么传数据:

export default async function ServerUserProfile({ id }: { id: number }) {
  const user = await getUser(id);

  // 我们把数据传给 ClientUserProfile
  return <ClientUserProfile use={user} />;
}

注意,我传的是 <ClientUserProfile use={user} />,而不是 user

在客户端组件中,我们怎么接收呢?

import { getUser } from './server'; // 不需要导入 User 接口!

export default function ClientUserProfile({ use }: { use: any }) {
  // 这里我们利用 ReturnType 获取函数的返回类型
  // 利用 Awaited 解包 Promise
  // 等等,我们不需要导入 getUser,因为它是从服务器传过来的上下文里来的?
  // 不,在客户端组件中,我们通常需要导入定义函数的文件来获取类型。
  // 但是,如果我们不想导入,我们可以通过泛型来传递类型。
}

这里有一个高级技巧。我们可以利用 Awaited<ReturnType<typeof ...>> 来直接从函数定义中提取类型,而不需要导入接口。

但是,在 RSC 中,数据是通过 props 传递的。我们可以利用一个更高级的技巧:通过泛型传递类型

或者,更简单的方法是利用 use hook 的类型推断。

实际上,React 的 use hook 是这样工作的:它接收一个 Promise 或者一个值,然后在客户端解包它。

让我们看看最优雅的写法。我们需要一个“类型推导器”。

假设我们的服务器组件是这样的:

// server-page.tsx
import { getUser } from './api';
import UserProfile from './UserProfile.client';

export default async function Page({ id }: { id: number }) {
  const user = await getUser(id);
  return <UserProfile user={user} />;
}

客户端组件:

// UserProfile.client.tsx
import { getUser } from './api';

// 这里我们导入 getUser,获取它的返回类型
// Awaited<...> 会把 Promise<User> 变成 User
type User = Awaited<ReturnType<typeof getUser>>;

export default function UserProfile({ user }: { user: User }) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Role: {user.role}</p>
    </div>
  );
}

这看起来好像还是有点重复定义(在客户端又定义了 User),但实际上,这个 User 是从 getUser 推导出来的。如果 getUser 改了,这个类型也会自动更新。这比手动复制粘贴要好得多。

但是,我们能不能更进一步?能不能连 User 这个中间变量都省了?

第四幕:终极奥义——类型透传的“隐形斗篷”

让我们来玩一个稍微有点“类型体操”味道的游戏。

在客户端组件中,我们接收一个 prop,这个 prop 的类型是一个函数的返回类型。我们可以利用 TypeScript 的类型推断,把这个 prop 的类型直接推导出来。

看这个例子:

// server-page.tsx
import { getUser } from './api';
import UserProfile from './UserProfile.client';

export default async function Page({ id }: { id: number }) {
  const user = await getUser(id);
  // 我们把 user 传过去
  return <UserProfile data={user} />;
}
// UserProfile.client.tsx
import { getUser } from './api';

// 这里,我们直接把 { data } 的类型推导出来
export default function UserProfile({ data }: { data: Awaited<ReturnType<typeof getUser>> }) {
  return (
    <div>
      <h1>{data.name}</h1>
      <p>Role: {data.role}</p>
    </div>
  );
}

这还是有点啰嗦。让我们把它封装起来。

第五幕:封装的艺术——自定义 Hook

既然 Awaited<ReturnType<typeof ...>> 这么好用,我们为什么不把它封装成一个通用的 Hook 呢?这就像是给 TypeScript 加了一个“自动补全插件”。

我们可以创建一个 use Hook 的封装。

// hooks/use.ts
// 这是一个特殊的 Hook,用于在客户端消费服务器端传递的数据
// 它会自动推导传入数据的类型

import { use } from 'react';

// 定义这个 Hook 的类型
// T 是传入的数据类型
export function useData<T>(data: T) {
  // 这里 use 会自动处理 Promise 和值的解包
  return use(data);
}

// 在客户端组件中使用
import { getUser } from './api';
import { useData } from './hooks/use';

export default function UserProfile() {
  // 这里我们不需要定义类型!
  // data 的类型会自动从 getUser 的返回类型推导出来
  const data = useData(getUser(123));

  return (
    <div>
      <h1>{data.name}</h1>
      <p>Role: {data.role}</p>
      <p>Email: {data.email}</p>
      <p>Settings Theme: {data.settings.theme}</p>
    </div>
  );
}

等等,这里有个问题。getUser 是一个异步函数,它返回一个 Promise。我们在客户端组件中调用 getUser(123),它会返回一个 Promise。但是 useData 接收的 dataT

如果我们想用 useData,我们得先 await 吗?

在 RSC 中,use hook 的设计初衷是接收一个 Promise 或者一个值。如果你传入一个 Promise,它会自动解包。

所以,我们不需要 await getUser,直接传进去即可!

export default function UserProfile() {
  // getUser 返回 Promise<User>
  // useData 接收 T,但内部 use 会处理 Promise
  const data = useData(getUser(123));

  // 现在 data 的类型是 User
  // 不需要任何类型标注!
  return <div>{data.name}</div>;
}

太棒了!这就是端到端类型透传的精髓。我们不需要在客户端定义 User 接口,不需要复制粘贴,TypeScript 会自动从服务器端的函数定义中推导出类型,并把它带到客户端。

第六幕:Next.js App Router 的集成——fetch 的类型

在实际项目中,我们很少直接写 getUser 函数,更多的是调用 fetch

Next.js App Router 对 RSC 支持得非常完美。fetch 是一个异步函数,它返回一个 Promise<Response>

如果我们想在客户端组件中使用服务器端获取的数据,我们怎么处理类型呢?

// app/page.tsx (Server Component)
import { fetchUser } from './api';

export default async function Page() {
  const user = await fetchUser(1);
  return <UserProfile user={user} />;
}
// app/UserProfile.tsx (Client Component)
import { fetchUser } from './api';

// 我们需要把 fetchUser 的返回类型推导出来
// fetchUser 返回 Promise<User>
// 所以这里我们定义 data 的类型为 User
export default function UserProfile({ user }: { user: User }) {
  return <div>{user.name}</div>;
}

这看起来没问题,但是 fetchUser 是我们自己写的。如果是 Next.js 的原生 fetch 呢?

// app/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/user');
  const user = await res.json();
  return <UserProfile user={user} />;
}

现在,user 的类型是什么?是 any!因为 res.json() 返回的是 any(除非你配置了 response.json() 的类型)。

我们想要类型透传,我们需要告诉 TypeScript user 的类型。

// app/UserProfile.tsx
import { fetch } from 'next/fetch'; // 假设这是 Next.js 的 fetch

// 我们需要手动定义 User 接口,这又回到了原点。
interface User {
  name: string;
  email: string;
}

export default function UserProfile({ user }: { user: User }) {
  return <div>{user.name}</div>;
}

为了解决这个问题,我们可以封装一个 use Hook,专门用于处理 fetch 的结果。

// hooks/use.ts
import { use } from 'react';

export function useFetch(url: string) {
  const res = use(fetch(url));
  return use(res.json());
}

// app/UserProfile.tsx
import { useFetch } from './hooks/use';

export default function UserProfile() {
  const user = useFetch('https://api.example.com/user');

  // user 的类型是什么?
  // 它是 res.json() 的返回类型,也就是 any。
  // 这还是不够好。
}

为了解决这个问题,我们需要配合 fetch 的类型参数。Next.js 允许我们在 fetch 的第二个参数中传递 Response 的类型。

// app/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/user', {
    // 告诉 TypeScript Response 的类型
    response: { type: 'json', schema: UserSchema } // 这是一个假设的 API
  });
  const user = await res.json();
  return <UserProfile user={user} />;
}

但是,Next.js 的原生 fetch 并不支持 response: { type: 'json', schema: ... }。这是 OpenAPI 集成的一个特性。

所以,对于标准的 Next.js fetch,我们通常需要手动定义接口,或者使用第三方库如 tRPCconvex,它们提供了完美的类型推导。

不过,我们可以通过 TypeScript 的泛型来手动推导。

// app/UserProfile.tsx
import { fetch } from 'next/fetch';

// 定义一个类型,用于推导 fetch 的返回类型
type FetchResult<T> = Awaited<ReturnType<typeof fetch>>['json'];

// 但这太复杂了,因为 fetch 返回的是 Response,我们还需要 await res.json()
// 让我们换一个思路。

其实,最简单的方法是使用 use Hook 结合泛型。

// hooks/use.ts
import { use } from 'react';

export function useJson<T>(url: string) {
  return use(fetch(url).then(res => res.json() as Promise<T>));
}

// app/UserProfile.tsx
import { useJson } from './hooks/use';

// 定义 User 接口(虽然不想定义,但在没有 Schema 的情况下,这是必要的)
interface User {
  name: string;
  email: string;
}

export default function UserProfile() {
  // 传入泛型 T
  const user = useJson<User>('https://api.example.com/user');

  // 现在 user 的类型是 User!
  // 我们不需要 await,也不需要手动定义类型。
  return <div>{user.name}</div>;
}

这看起来不错!但是,我们还是定义了 User 接口。能不能不定义?

如果我们在服务器端组件中,直接从 fetch 获取数据并传递给客户端,我们可以利用 Awaited<ReturnType<typeof fetch>> 来推导类型吗?

不行,因为 fetch 返回的是 Promise<Response>,而不是 Promise<User>。我们需要 await res.json()

所以,在标准的 Next.js fetch 中,如果不使用 Schema 定义,我们很难完全避免在客户端定义接口。

但是,如果我们使用自定义的 fetch 或者 use Hook,我们可以做到完全的类型透传。

第七幕:实战演练——构建一个类型安全的 Next.js App

让我们构建一个完整的例子。假设我们有一个博客系统。

  1. 服务器端 API:获取文章列表。
// app/api/posts/route.ts
import { NextResponse } from 'next/server';

export interface Post {
  id: number;
  title: string;
  content: string;
  author: {
    name: string;
    bio: string;
  };
}

export async function GET() {
  const posts: Post[] = [
    {
      id: 1,
      title: 'Hello RSC',
      content: 'TypeScript is great.',
      author: { name: 'John', bio: 'Dev' }
    }
  ];
  return NextResponse.json(posts);
}
  1. 服务器端组件:获取数据并传递给客户端。
// app/posts/page.tsx
import { Post } from './api/posts/route';
import PostList from './PostList.client';

export default async function PostsPage() {
  const res = await fetch('http://localhost:3000/api/posts');
  const posts = await res.json();

  return <PostList posts={posts} />;
}
  1. 客户端组件:接收数据并渲染。
// app/PostList.client.tsx
import { Post } from './api/posts/route';
import { use } from 'react';

export default function PostList({ posts }: { posts: Post[] }) {
  // 这里我们导入了 Post 接口。
  // 虽然有点重复,但它是必要的。
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
          <small>By: {post.author.name}</small>
        </li>
      ))}
    </ul>
  );
}

现在,让我们改进它。我们不想在客户端定义 Post 接口。我们想从服务器端推导出来。

// app/PostList.client.tsx
import { use } from 'react';
import { Post } from './api/posts/route'; // 依然需要导入,因为我们需要获取类型

// 这里的类型推导稍微有点绕。
// 我们可以直接使用 Post,因为它已经被导入了。
// 如果我们不想导入,我们可以利用泛型。
export default function PostList({ posts }: { posts: Post[] }) {
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
          <small>By: {post.author.name}</small>
        </li>
      ))}
    </ul>
  );
}

其实,在客户端组件中,我们依然需要导入定义了类型的模块。这是 TypeScript 的模块系统决定的。

但是,我们可以利用这个导入来消除重复定义。我们不需要在客户端重新定义 Post 接口,只需要从服务器端导入它。

这就是“类型透传”的核心:不重复定义,只引用

第八幕:处理复杂场景与错误

端到端类型透传不仅仅是处理简单的对象。它还涉及到复杂的嵌套、数组和联合类型。

假设我们的 Post 类型有一个可选字段:

export interface Post {
  id: number;
  title: string;
  content?: string; // 可选字段
}

如果服务器端没有传 content,客户端组件在访问 post.content 时,TypeScript 会提示它可能是 undefined。这非常棒!这就是类型安全的威力。

export default function PostList({ posts }: { posts: Post[] }) {
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h2>{post.title}</h2>
          {/* TypeScript 会警告我们 content 可能是 undefined */}
          <p>{post.content}</p>
        </li>
      ))}
    </ul>
  );
}

我们需要处理这种情况:

export default function PostList({ posts }: { posts: Post[] }) {
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content || 'No content'}</p>
        </li>
      ))}
    </ul>
  );
}

第九幕:终极技巧——利用 use Hook 的类型推导

让我们再深入一点。我们能不能写一个 Hook,它接收一个 Promise,然后返回一个带有正确类型的值,而且不需要我们在客户端定义接口?

我们可以利用 TypeScript 的类型推导。

// hooks/use.ts
import { use } from 'react';

// 这个 Hook 接收一个 Promise,并返回它的解包后的类型
export function useAsyncData<T>(promise: Promise<T>) {
  const data = use(promise);
  return data;
}

// app/posts/page.tsx
import { useAsyncData } from './hooks/use';

export default async function PostsPage() {
  // 在服务器端获取数据
  const res = await fetch('http://localhost:3000/api/posts');
  const postsPromise = res.json();

  // 传递给客户端
  return <PostList postsPromise={postsPromise} />;
}
// app/PostList.client.tsx
import { useAsyncData } from './hooks/use';

export default function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
  // 推导类型
  const posts = useAsyncData(postsPromise);

  // 现在 posts 的类型是 Post[]
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

这看起来有点绕。其实,我们不需要封装 useAsyncData,直接使用 use 即可。

// app/PostList.client.tsx
import { use } from 'react';

export default function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
  // 直接使用 use
  const posts = use(postsPromise);

  return <ul>{posts.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
}

但是,use Hook 在 React 18 中有一个限制:它只能用于顶层调用。所以,我们不能在组件内部调用 use,只能在组件顶层调用。

这意味着,我们不能在 PostList 组件内部定义 const posts = use(postsPromise)

我们只能在组件顶层调用 use,然后使用返回的值。

所以,正确的写法应该是:

// app/PostList.client.tsx
import { use } from 'react';
import { Post } from './api/posts/route';

// 这里我们定义了 posts 的类型
export default function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
  const posts = use(postsPromise) as Post[];

  return <ul>{posts.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
}

虽然有点繁琐,但这是目前实现端到端类型透传的最佳实践之一。

第十幕:总结与展望

好了,朋友们,今天的讲座就要结束了。我们聊了很多关于 React Server Components 和 TypeScript 类型透传的内容。

从最基础的 async 组件,到 use Hook 的使用,再到 Awaited<ReturnType<typeof ...>> 的类型推导,我们一步步解开了 RSC 类型安全的面纱。

核心思想其实很简单:

  1. 服务器端定义数据结构:不要在服务器端定义接口,直接在 API 路由或数据获取函数中定义。
  2. 传递 Promise:在服务器端组件中,将数据获取的 Promise 传递给客户端组件。
  3. 客户端消费并推导类型:在客户端组件中,使用 use Hook 接收 Promise,并利用 TypeScript 的类型推导功能,获取数据的类型。

通过这种方式,我们可以实现从服务器逻辑到客户端组件的端到端类型透传,消除类型重复,提高代码的可维护性和类型安全性。

虽然目前还面临着一些限制,比如 Next.js 原生 fetch 的类型支持不够完善,以及 use Hook 的使用限制,但随着 React 和 TypeScript 的不断发展,我们相信,未来的开发体验会更加美好。

最后,我想说的是,TypeScript 不仅仅是一个类型检查工具,它更是一种编程范式。它让我们在写代码之前,就在脑海中构建好数据的形状。在 RSC 时代,这种能力变得更加重要。

希望今天的讲座能对大家有所帮助。让我们一起在 TypeScript 的世界里,快乐地“类型体操”吧!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注