Next.js 13 + RSC 是个好选择吗?我构建了一个没有客户端 JavaScript 的应用来一探究竟

2025-06-11

Next.js 13 + RSC 是个好选择吗?我构建了一个没有客户端 JavaScript 的应用来一探究竟

Next.js 13 在去年年底左右引发了人们对 React 服务器组件 (RSC) 的第一波关注。随着时间的推移,其他框架,例如RemixRedwoodJS,也开始将 RSC 纳入其未来路线图。然而,React/Next.js 的“将计算迁移到服务器端”这一方向从一开始就备受争议。

借助 RSC 和(仍在试验中的)服务器操作,应该可以构建一个无需任何客户端 JavaScript 代码的全栈应用。它的实际效果如何?我着手通过重建我最喜欢的博客应用来获得第一手经验。没错,这是一个非常简单的应用,但它可以作为一种切实可行的方式来理解这些新模式,至少是部分理解。

要求

如果你读过我之前的一些文章,你可能已经了解这款应用的要求。以下是给新读者的简要回顾:

  1. 基于电子邮件/密码的登录/注册。
  2. 用户可以为自己创建帖子。
  3. 帖子所有者可以更新/发布/取消发布/删除自己的帖子。
  4. 用户不能对不属于自己的帖子进行更改。
  5. 所有登录用户都可以查看已发布的帖子。

该应用程序将使用以下堆栈构建:

  • Next.js 13 个“应用”路由,尽可能使用 RSC
  • NextAuth用于身份验证
  • Prisma + ZenStack用于数据访问和授权
  • SQLite 用于存储

脚手架

我最喜欢的创建新 Next.js 应用程序的方式一直是使用create-t3-app

npm create t3-app@latest
Enter fullscreen mode Exit fullscreen mode

使用以下选项:

  • TypeScript
  • 应用路由器
  • TailwindCSS
  • 棱镜
  • NextAuth

创建的样板代码使用 Discord 提供程序进行身份验证。我已将其更改为使用凭据(使用bcryptjs对密码进行哈希处理):

// src/server/auth.ts

import { compare } from "bcryptjs";

export const authOptions: NextAuthOptions = {
  session: {
    strategy: "jwt",
  },
  callbacks: {
    session({ session, token }) {
      if (session.user) {
        session.user.id = token.sub!;
      }
      return session;
    },
  },
  adapter: PrismaAdapter(db),
  providers: [
    CredentialsProvider({
      credentials: {
        email: { type: "email" },
        password: { type: "password" },
      },
      authorize,
    }),
  ],
};

async function authorize(
  credentials: Record<"email" | "password", string> | undefined,
) {
  if (!credentials?.email) {
    throw new Error('"email" is required in credentials');
  }

  if (!credentials?.password) {
    throw new Error('"password" is required in credentials');
  }

  const maybeUser = await db.user.findFirst({
    where: { email: credentials.email },
    select: { id: true, email: true, password: true },
  });

  if (!maybeUser) {
    return null;
  }

  if (!await compare(credentials.password, maybeUser.password)) {
    return null;
  }

  return { id: maybeUser.id, email: maybeUser.email };
}
Enter fullscreen mode Exit fullscreen mode

实施要求

注册和登录

第一步是实现注册和登录 UI。借助Server Actions功能,构建注册 UI 非常简单。但要使用此功能,我们必须启用实验性标志:

// next.config.mjs

const config = {
  experimental: {
    serverActions: true
  },
};
Enter fullscreen mode Exit fullscreen mode

然后,构建注册 UI 很简单(为了简洁,我删除了所有样式):

// src/app/signup/page.tsx

import { hashSync } from "bcryptjs";
import type { NextPage } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "~/server/db";

const signupSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});

const Signup: NextPage = () => {
  async function signup(formData: FormData) {
    "use server";

    const parsed = signupSchema.parse(Object.fromEntries(formData));

    try {
      const user = await db.user.create({
        data: {
          email: parsed.email,
          password: hashSync(parsed.password),
        },
      });
      console.log("User created:", user);
    } catch (err: any) {
      console.error(err);
      if (err.info?.prisma && err.info?.code === "P2002") {
        return { message: "User already exists" };
      } else {
        return { message: "An unknown error occurred" };
      }
    }

    redirect("/");
  }

  return (
    <div>
      <h1>Sign up</h1>
      <form action={signup}>
        <div>
          <label htmlFor="email">
            Email
          </label>
          <input name="email" type="email" />
        </div>
        <div>
          <label htmlFor="password">
            Password
          </label>
          <input name="password" type="password" />
        </div>
        <input type="submit" value="Create account" />
      </form>
    </div>
  );
};

export default Signup;
Enter fullscreen mode Exit fullscreen mode

一些简短的说明:

  1. use server指令将异步函数标记为服务器操作。您可以从客户端代码(此处为表单操作)调用它,输入和输出数据将自动跨网络边界进行编组。非常简洁,您无需再定义任何用于处理表单的 API。
  2. 由于服务器操作暴露在公共网络中,即使它驻留在组件内部并且仅为该组件提供服务,我们仍然需要验证其输入(此处使用“zod”)。

登录部分由 NextAuth 处理,不涉及服务器组件或操作。由于它与本文的目标无直接关系,所以我现在跳过它,但您可以在文章末尾共享的仓库中找到具体实现。

岗位管理

后期管理部分由以下几部分组成:

  1. 用于加载帖子的服务器组件
  2. 变异的服务器操作
  3. ZenStack用于自动执行访问控制。ZenStack 是一个扩展 Prisma ORM 的工具包,可让您在一个地方建模访问策略和数据模式。

第一步是初始化 ZenStack 的项目:

npx zenstack@latest init
Enter fullscreen mode Exit fullscreen mode

它安装了一些依赖项,并将prisma/schema.prisma文件复制到schema.zmodel。ZModel 文件是 Prisma 模式的超集,包含数据模型和访问策略。以下代码展示了如何在其中建模访问控制:

// schema.zmodel

model Post {
    id Int @id @default(autoincrement())
    name String
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt

    createdBy User @relation(fields: [createdById], references: [id])
    createdById String

    published Boolean @default(false)

    @@index([name])

    // 🔐 author has full access
    @@allow('all', auth() == createdBy)

    // 🔐 logged-in users can view published posts
    @@allow('read', auth() != null && published)
}
Enter fullscreen mode Exit fullscreen mode

运行npx zenstack generate将生成支持 ZenStack 运行时策略检查的代码。
有了这些代码,我们就可以轻松地在主页中构建后期管理功能。同样,样式也简化了。

// src/app/page.tsx

import { enhance } from "@zenstackhq/runtime";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getServerAuthSession } from "~/server/auth";
import { db } from "~/server/db";

// Get an enhanced PrismaClient instance with access policy enforcement
async function getEnhancedDb() {
  const session = await getServerAuthSession();
  return enhance(db, {
    user: session?.user ? { id: session.user.id } : undefined,
  });
}

const createSchema = z.object({ name: z.string() });
async function create(formData: FormData) {
  "use server";

  const session = await getServerAuthSession();
  if (!session) {
    return { error: "not logged in" };
  }

  const parsed = createSchema.parse(Object.fromEntries(formData));
  const db = await getEnhancedDb();
  await db.post.create({
    data: {
      name: parsed.name,
      createdBy: { connect: { id: session?.user.id } },
    },
  });
  revalidatePath("/");
}

const togglePublishedSchema = z.object({ id: z.coerce.number() });
async function togglePublished(formData: FormData) {
  "use server";

  const parsed = togglePublishedSchema.parse(Object.fromEntries(formData));
  const db = await getEnhancedDb();
  const curr = await db.post.findUnique({ where: { id: parsed.id } });
  if (!curr) {
    return { error: "post not found" };
  }
  await db.post.update({
    where: { id: parsed.id },
    data: { published: !curr.published },
  });

  revalidatePath("/");
}

const deleteSchema = z.object({ id: z.coerce.number() });
async function deletePost(formData: FormData) {
  "use server";

  const parsed = deleteSchema.parse(Object.fromEntries(formData));
  const db = await getEnhancedDb();
  await db.post.delete({
    where: { id: parsed.id },
  });
  revalidatePath("/");
}

const Posts = async () => {
  const db = await getEnhancedDb();
  // only posts readable to the current user will be loaded
  const posts = await db.post.findMany({
    include: { createdBy: true },
    orderBy: { createdAt: "desc" },
  });

  return (
    <div>
      <form action={create}>
        <input type="text" name="name" />
        <input type="submit" value="+ Create Post" />
      </form>

      <ul>
        {posts?.map((post) => (
          <li key={post.id}>
            <p>
              {post.name} by {post.createdBy.email}
            </p>
            <div>
              <form action={togglePublished}>
                <input type="hidden" name="id" value={post.id} />
                <input type="submit" value={post.published ? "Unpublish": "Publish"} />
              </form>
              <form action={deletePost}>
                <input type="hidden" name="id" value={post.id} />
                <input type="submit" value="Delete" />
              </form>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

结果应用程序

一些简短的说明:

  1. Posts是一个服务器组件。其代码在服务器端执行,可以直接访问数据库等服务器端资源。
  2. enhanceAPI(来自 ZenStack)创建了一个包装器PrismaClient,它会自动执行我们之前看到的访问策略(针对当前用户的身份)。该db.post.findMany调用不进行任何过滤,仅返回当前用户可读的帖子。同样,如果当前用户无权更改,则更改操作也会被拒绝。
  3. 变异后,您需要调用revalidatePathrevalidateTag使缓存数据无效并触发重新获取。

总结

以下是我回顾如何构建这个玩具应用程序(与使用传统“页面”路线实现的方式相比)所带来的好处和挑战的想法。

收获

  • RSC 允许您更自然地加载数据,而无需像 这样的单独函数getServerSideProps。数据获取代码就位于组件内部。
  • 在组件级别获取数据使得加载逻辑能够更好地与其使用位置共置。
  • 不再需要使用 SWR 或 TanStack Query 等数据获取库。
  • 服务器操作消除了定义变更 API 的需要。客户端代码直接调用服务器端,框架处理所有 RPC 细节。
  • RSC + Server Actions + ZenStack 的组合将样板代码减少到最少。

挑战

  • 即使多次阅读文档后,我仍然需要仔细思考客户端和服务器组件之间的界限以及如何正确组织它们。
  • 指令不太美观,我相信它们会在复杂的应用程序的许多地方散落use clientuse server
  • 开发人员全权负责在必要时重新验证路线。对于这个简单的应用来说,这还好,但对于复杂的应用来说,这可能比较棘手。

包起来

感谢您花时间阅读本文,希望您喜欢。评估 React Server Components 和 Next.js 13 是一个广泛的话题,它们是否是正确的选择仍然存在很大争议。我希望这篇文章能帮助您快速了解它们的使用体验。

您可以在此处找到已完成应用程序的源代码:https://github.com/zenstackhq/sample-blog-nextjs-rsc


ZenStack是我们开源的 TypeScript 工具包,用于更快、更智能、更高效地构建高质量、可扩展的应用。它将数据模型、访问策略和验证规则集中在 Prisma 之上的单一声明式架构中,非常适合 AI 增强型开发。立即开始将ZenStack与您现有的技术栈集成!

鏂囩珷鏉ユ簮锛�https://dev.to/zenstack/is-nextjs-13-rsc-a-good-choice-i-built-an-app-without-client-side-javascript-to-find-out-hik
PREV
React 和 Express 的 NPM 备忘单
NEXT
使用 faker.js 真实地模拟你的 GraphQL 服务器