原文:Next.js 13 vs Remix: An In-depth case study。
编者注:这篇文章很长很深入,我在阅读和调整格式上花了近两个小时,收获颇丰,推荐大家阅读。
说到构建 Web 应用程序,React 已经走在了前列,而且其采用率还在持续增长。在使用 React 构建 Web 应用程序的最常见方法中,Next.js 是最受欢迎的选择之一。
去年,Next.js 发布了该框架有史以来最大的更新 —— App Router,自此便备受瞩目。它引入了一种使用嵌套布局的全新路由架构,并与 React Server Components 和 Suspense 紧密集成。
不过,Next.js 并不是第一个实现这种基于布局路由的 React 框架。在 Next.js 公开发布应用路由器的近一年前,另一个名为 Remix 的框架也在其公开版本 v1 中发布了应用路由器。Remix 是由 React 应用程序中最受欢迎的客户端路由器 React Router 的幕后人员构建的。
Remix 背后的理念很简单,它是一个边缘优先的全栈框架,鼓励使用标准的 Web API(如 Request
、Response
、FormData
等)构建网站,其功能包括创建嵌套布局,并行加载数据,为您处理竞赛条件,以及让您的网站在 JavaScript 开始加载之前就能正常运行。他们的教学方法是,当你熟练掌握 Remix 时,你就能更好地掌握网络基础知识。
我非常欣赏 Remix 背后的理念,也对 Next.js 与 React Server Components 的发展方向感到非常兴奋。因此,我想有什么比构建一个完整的全栈应用程序更好的方式来学习这两种技术呢?因此,我创建了我最喜欢的网站之一 X(Twitter 的前身),其中集成了这两个框架的大部分核心功能。这篇博文的重点是我学到的经验教训、一个框架应从另一个框架中采用的方面,以及我在使用这两个框架开发应用程序时的个人经验和观点。
TLDR; Next.js 和 Remix 应用程序都部署在 Vercel 上,您可以分别在以下网址进行测试:https://twitter-rsc.vercel.app/ 和 https://twitter-remix-run.vercel.app/。
除了框架之外,我在这两个应用程序中使用的技术栈是: Tailwind CSS 用于样式 Turborepo 用于管理 monorepo Prisma ORM 用于处理数据库
您还可以在 GitHub 上的这个 monorepo 中找到这两个应用的代码。
我们将在不同的部分对它们进行比较,其中包括布局、数据获取、流、数据突变、无限加载和其他一些功能。
说到布局,我很喜欢这两个框架的相同之处,它们都允许创建共享嵌套布局,并在导航之间保持不变。
如今,我们构建的大多数网络应用都会以这样或那样的方式使用由多个 URL 组合在一起的共享布局。无论是文档中的侧边栏还是分析仪表板中的标签,共享布局无处不在,Twitter Clone 也不例外。事实上,有几个页面出现了一种布局嵌套在另一种布局中的情况。用户配置文件页面的侧边栏几乎贯穿了所有路径,而且还有标签页,每个标签页都有自己的 URL。
如果你用 Next.js 12 或更早版本构建过这样的布局,你就会知道它们有多复杂和凌乱,你必须在组件上创建函数,并将它们封装在 _app.tsx 中的函数中。如果布局需要在服务器上获取一些数据,情况就会变得更加复杂。您必须在共享这些布局的所有页面的 getServerSideProps
中复制布局所需的数据获取逻辑。
但现在,有了 Remix 和 Next.js 13,你就可以依靠框架基于文件系统的路由器为你创建布局。
在 Remix 的新 v2 版本中,您可以使用点分隔符在 URL 中创建斜线 (/
)。例如,名为 app/routes/invoice.new.tsx
的文件将与路由 /invoice/new
匹配,而名为 app/routes/invoice/$id.tsx
的路由将与路由 /invoice/{id}
匹配,其中 id
代表发票 ID。
如果你的发票 URL 有共同的布局,你可以创建一个包含布局的 invoice.tsx
文件。在该文件中,你可以在共享布局的页面上添加 <Outlet />
组件,这样 /invoices/new
和 /invoices/{id}
页面就会共享该布局。
在某些情况下,你可能需要一个共同的布局,但却没有一个共享的 URL 结构。Remix 也有相应的解决方案,只要你创建一个前缀为 _
的路由,该路由就不会包含在 URL 中。这些路由称为无路径路由(Pathless Routes)。
所有这些功能结合在一起,可以让你组成并创建功能强大的嵌套布局,例如 Twitter Clone 应用程序中的用户配置文件布局。
除了几乎所有页面共享的侧边栏外,用户配置文件页面还需要单独的侧边栏。用户配置文件页面还需要一个单独的布局,因为它包含了推文、回复和点赞的标签,而这些本来都是单独的页面,因此需要独立的 URL。这就是 Remix 应用程序的文件结构:
app/
routes/
_base.tsx
_base._index.tsx --> /
_base.$username.tsx
_base.$username._index.tsx --> /{username}
_base.$username.replies.tsx --> /{username}/replies
_base.$username.likes.tsx --> /{username}/likes
_base.status.$id.tsx --> /status/{id}
_auth.tsx
_auth.signin.tsx --> /signin
_auth.signup.tsx --> /signup
这里的 _base.tsx
是主布局,包含大多数页面共享的侧边栏。然后是 _base.$username.tsx
布局,它是基础布局中的嵌套布局,包含个人资料页眉以及推文、回复和点赞的标签。._index.tsx
表示给定布局的 /
URL。
下面是这些路由在应用程序中的用户配置文件页面的工作情况:
您还可以在 GitHub 上查看路由的代码,并在 Remix 文档和这个超棒的可视化文档中了解更多有关路由文件命名规则的信息。
在 Next.js 13 的应用目录中,布局系统也非常相似;主要区别在于你使用目录来表示 URL 和目录内的文件,比如 layout.tsx
用于布局,page.tsx
用于公开访问路由,并在布局中使用 React 的 children
prop 来填充子布局或页面。
事实上,Next.js 13 甚至更进一步,允许您为每个路由段创建单独的文件,用 loading.tsx
定义加载状态,用 error.tsx
定义错误状态。我们将在接下来的章节中详细讨论这些内容。
创建不共用 URL 的布局也与 Remix 非常相似,唯一不同的是,我们创建的不是以 _
开头的文件,而是在括号中加上文件夹名的目录,如 (folderName)
。这些目录在 Next.js 中称为路由组。通过用方括号封装文件夹名称,创建 URL 的动态段,如 [id]
或 [username]
。
这就是 Next.js 13 Twitter 克隆路由的文件结构:
app/
(base)/
[username]/
likes/
page.tsx --> /{username}/likes
replies/
page.tsx --> /{username}/replies
layout.tsx
page.tsx --> /{username}
status/[id]/
page.tsx --> /status/{id}
layout.tsx
page.tsx --> /
(auth)
signin/
page.tsx --> /signin
signup/
page.tsx --> /signup
下图是这些路由如何呈现用户配置文件页面:
你也可以在 GitHub 上查看代码,并在 Next.js 文档中阅读更多关于布局和页面的内容。
现在,如果我们比较一下这两个框架的路由机制,我非常喜欢 Remix 的路由机制,因为它非常直观,你只需看一眼就能知道文件/布局代表了哪条路由。而使用 Next.js,你最终会看到一个由 page.tsx
和 layout.tsx
组成的意大利面,而且你必须查看目录结构,才能知道某个页面将在哪个 URL 上呈现。
不过话虽如此,我也理解 Next.js 为什么要这么做,因为这些目录中不仅有页面和布局,还有其他东西,比如 notFound.tsx
、loading.tsx
、error.tsx
等,它们可以帮助你定义每个路由段的加载/出错状态。还有一个好处是,你可以将组件与路由放在一起。
无论如何,我都喜欢这两个框架在基于文件系统的路由方面选择了几乎相同的方向,而且感觉这是正确的做法。
数据获取是现代网络应用程序的重要组成部分。起初,大多数 React 应用程序都是在客户端渲染的,服务器只是发送一个空的 index.html
文件,并在 <script />
标记中包含相关的 JavaScript 包。这导致在浏览器下载和执行 JavaScript、React 初始化并开始获取数据以呈现组件的过程中,最初的页面是空白的。这会严重影响低功耗设备或网络连接不佳设备的性能。
Next.js 和 Gatsby 简化了在服务器上和/或在 React 应用程序构建时获取数据的过程,允许预渲染初始 HTML,从而主要改变了这一情况。因此,现在用户在网站首次加载时就已经准备好了初始用户界面。尽管用户仍需等待 JavaScript 下载完毕和 React 水合之后才能开始交互。
现在,Next.js 13 和 Remix 都在此基础上更进一步。Next.js 配备了 React Server 组件,而 Remix 则配备了加载器和并行数据获取功能。
在 Remix 中,获取数据的方式是通过加载器,每个路由都可以定义一个加载器函数,在呈现时为路由提供相关数据。加载器只在服务器上运行。
下面是 Remix Twitter 克隆版中加载器的一个示例,该加载器用于 _base.tsx 布局:
export const loader = async ({ request }: LoaderFunctionArgs) => {
const currentLoggedInUser = await getCurrentLoggedInUser(request);
return json({ user: currentLoggedInUser }, { status: 200 });
};
export default export default function RootLayout() {
const { user } = useLoaderData<typeof loader>();
....
}
加载器会获取 Fetch Request
对象作为参数,这样它们就能读取 "headers"、"cookies "等内容,而加载器的返回类型始终是 Fetch Response
。Remix 在 Response 对象之上提供了一些封装器,如 json
、redirect
等,让你可以返回带有相关状态代码的特定类型的响应。然后,您可以使用 useLoaderData
钩子在组件中使用加载器数据。
在 Remix 中,由于您可以在路由段的每个部分(包括布局)中定义加载器,因此它可以并行加载所有路由段的数据,而不是在浏览器上获取数据时以瀑布流的方式获取数据。其 Landing 页面上的这幅图最能说明这一点:
这也是 Remix Twitter 克隆版的网络图:
其次,加载器不仅用于在服务器上渲染页面。由于加载器的响应只是 HTTP 抓取响应,因此 Remix 还可以通过浏览器中的 fetch
调用加载器,进行导航或重新验证。
随着 Next.js 13 中应用目录的引入,Next.js 已经从仅在页面文件中定义 getServerSideProps
/ getStaticProps
的服务器数据获取逻辑转向了 React 服务器组件 (RSC)。
RSC 是一个宽泛的话题,除了在服务器上获取数据外,它们还能解决很多其他问题,值得单独写一篇博客(实际上,我在 React 渲染的未来博客中对此进行了更详细的介绍)。
简而言之,服务器组件是 React 中的一种新范式。它们是只在服务器上渲染的组件,与 React 中传统的服务器端渲染不同,它们从不在客户端水合。服务器组件有很多优点,包括
数据获取和安全性: 由于服务器组件只在服务器上运行,因此您可以在 React 服务器组件中直接包含服务器专用的秘密和 API 调用,而不必担心将它们暴露给客户端。
确定性的捆绑包大小: 以前会影响客户端 JavaScript 捆绑程序大小的依赖项,现在如果只在服务器组件中使用,就永远不会下载到客户端。(一个很好的例子就是 Markdown 解析器,之前它的 JavaScript 是需要下载到水合页面中的。)
以及更多。
对于需要交互的部分,您需要创建客户端组件。与它们的名字不同,客户端组件也是在服务器上渲染的,但它们遵循通常的服务器端渲染管道,必须在客户端下载并执行相关的 JavaScript 来为它们加水。
服务器组件并非灵丹妙药,它有一些局限性,其中包括
由于这些组件只能在服务器上运行,而且从未水合过,因此它们不能包含任何交互式用户界面组件,所以像 useState
、useEffect
、事件处理程序和浏览器专用 API 等都无法使用。相反,您现在应该在组件树中需要交互性的地方使用客户端组件。
您不能在客户端组件中导入和使用服务器组件。由于服务器组件只能在服务器上渲染,因此我们需要在服务器上了解组件树中的所有服务器组件。尽管有一些方法可以将它们交错在一起。
在 app 目录中,默认情况下所有组件都是服务器组件,如果要添加交互性,则需要在组件树中添加客户端组件。客户端组件是通过在文件顶部添加 'use client'
指令创建的。此外,服务器组件和客户端组件绝不能出现在同一个文件中。
下面再以 Remix 中的基本布局为例,说明如何将其作为 Next.js 中的服务器组件:
export default async function BaseLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getCurrentLoggedInUser();
const isLoggedIn = !!user;
return (
...
);
};
由于服务器组件是在服务器上渲染的,因此它们可以返回 Promises。这样,您就可以在组件中等待数据,然后在呈现时使用这些数据。
Next.js 还提供了 headers
、cookies
、redirect
、revalidatePath
等辅助方法,允许你在服务器端访问请求数据并执行服务器操作。这里的 getCurrentLoggedInUser
方法实际上是使用 cookie 从数据库中获取当前登录用户的详细信息。
这是一个真正改变游戏规则的功能,其可能性是无限的,因为现在您不仅可以直接在 React 组件中以声明的方式从数据库中读取数据,还可以在组件树的任何层级中读取数据,而不仅仅是在路由段中读取数据,只要您是在服务器组件中读取数据即可。
在 Next.js 13 中编写应用程序的推荐方式是将客户端组件保留在组件树的叶子上,只在需要交互性、状态或仅浏览器 API 的地方使用。下面是 Next.js Twitter 克隆版中用户配置文件页面的组件分布情况:
Next.js 渲染文档中还有一个表格,可以帮助你决定何时使用客户端组件或服务器组件。
虽然我很欣赏 Remix 使用加载器构建强大 API 的方式,它允许并行获取子路由的数据并轻松进行重验证,我也很喜欢加载器总是返回 Fetch Response 的事实,但 React Server Components 仍然是正确的选择。
除了其他优势(如确定的捆绑包大小),React 服务器组件还提供了良好的开发者体验(DX)。它们允许您以准确获取数据的方式组成组件树,而不是只在路由段中获取数据。
Remix 也承认 RSC 带来的好处,他们也计划在未来集成 React Server Components。
加载器的另一个注意事项是,要在与组件相同的文件中定义加载器。虽然编译器能很好地隔离客户端和服务器端的捆绑包,但仍有可能导致意外暴露服务器端专用的秘密,或将服务器端专用的捆绑包发送到客户端,Remix 也有一份完整的文档说明了在 Route 片段中导入模块时需要注意的问题。是的,app 目录之前的 Next.js 的 getServerSideProps
也存在相同的问题。
最后,服务器组件自身也存在一些问题。默认情况下,如果只是在服务器组件中获取数据,数据将沿着服务器组件树以瀑布流的方式顺序获取。虽然有办法将其并行化,但解决方案远非完美。
有了 React 18,您可以使用 Streaming 和 Suspense,它允许您逐步渲染和增量流式地将 UI 的渲染单元传输到客户端。
通过流,您可以为具有阻塞数据要求的布局部分和路由段显示加载状态。服务器可以先返回依赖部分的加载状态,然后在从服务器获取实际数据后将其替换为加载状态,而不是延迟页面加载,直到服务器上的所有数据都准备就绪。Next.js 流文档中的这幅插图很好地解释了这一点:
Remix 和 Next.js 13 都很好地支持了 Suspense 流。
使用 Remix,您可以简单地使用 defer
包装器,并为您想从加载器中流式传输的项目返回 Promise,而不是解析值。然后,在组件中,您可以使用 Await
组件来处理延迟加载器的 Promise,并将其封装在 Suspense 边界中,以显示加载指示器,直到 Promise resolved。
下面是我在 Twitter 克隆应用中使用该组件的简化版本,在该应用中,我从服务器流式传输了第一页的无限推文:
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const username = params.username as string;
return defer({
tweets: getTweetsByUsername(request, username),
currentLoggedInUser: await getCurrentLoggedInUser(request),
});
};
export default function UserTweets = (
props: SuspendedInfiniteTweetsProps
) => {
const data = useLoaderData<typeof loader>();
console.log(currentLoggedInUser.name)
return (
<Suspense fallback={<Spinner />}>
<Await
resolve={props.tweets}
errorElement={<p>Something went wrong!</p>}
>
{(initialTweets) => (
{/* Render the Tweets */}
)}
</Await>
</Suspense>
);
}
在上面的示例中,请注意 getCurrentLoggedInUser
是被 await 的,因此它不会被流式处理,你可以像使用普通加载器响应一样直接使用它。
如果你想知道流式响应在网络选项卡中的实际效果,请观看人为放慢流式响应的视频:
正如你所看到的,页面一加载,你就会看到一个 Spinner,用于显示延迟的用户初始推文,但它们一加载,就会被服务器本身的实际推文所取代。 在请求时序分解中,我们可以看到第一个字节的时间(绿线表示)发生在一秒钟内,用户会看到页面加载了第一页推文的旋转器。内容下载完成所需的时间(蓝线)约为 2 秒,这是流数据加载到服务器并更新到最终 HTML 输出中的时间。 如果没有流媒体,请求将需要 3 秒钟才能完成,在这段时间内用户将看到一个空白页。
有了 Next.js 13,流式传输就更简单了。正如我们之前在布局部分所讨论的,你可以直接在路由段目录中创建 loading.tsx
,以获得该目录下路由段的即时加载状态。
实际上,Next.js 会将路由段中的页面包裹在 Suspense 边界内,并使用你在 loading.tsx
中指定的 fallback 组件。下图是 Next.js 文档中的最佳示例。
除此之外,如果你想 Suspend 某个非路由段的内容,你仍然可以通过将 Suspense
封装在一个有数据获取需求的异步组件上来实现。
例如,在 Twitter Clone 应用程序中,它的一个很好的用途是在主页上,我想暂停初始推文,但又不想让用户等待标题中的 "创建推文" CTA 因此而被阻止。
export default async function Home() {
const user = await getCurrentLoggedInUser();
return (
<>
{/** Header stuff */}
{user && (
<div className="hidden sm:flex p-4 border-b border-solid border-gray-700">
<Image
src={user.profileImage ?? DEFAULT_PROFILE_IMAGE}
className="rounded-full object-contain max-h-[48px]"
width={48}
height={48}
alt={`${user.username}'s avatar`}
/>
<div className="flex-1 ml-3 mt-2">
<CreateTweetHomePage />
</div>
</div>
)}
<Suspense fallback={<Spinner />}>
<HomeTweets />
</Suspense>
</>
);
}
async function HomeTweets() {
const initialTweets = await getHomeTweets();
return (
/** Render initial infinite Tweets */
);
}
Suspense 流是 React 中的一项出色功能,它通过大幅缩短第一个字节的时间和显示即时加载状态,为用户提供了出色的用户体验,同时还能保持在服务器上获取所有数据。
此外,我非常喜欢 React 服务器组件的直观性,您只需将有阻塞数据获取需求的组件封装在 Suspense 边界中即可。
说到突变,我们可能都习惯于自己处理,向后端服务器发出 API 请求,然后更新本地状态以反映变化,或者甚至使用 React Query 这样的库来帮你处理大部分事情。这两个框架都希望通过将操作作为其核心功能的一部分来改变这种情况。
在 Remix 中,这些都由 action 来处理,而在撰写本博客时,Next.js 也在 13.4 中添加了服务器 action,但它们仍处于 alpha 阶段。
在 Remix 中,突变由 action 处理,它们是 Remix 的核心功能之一。Action 是通过导出一个名为 action
的函数在路由文件中定义的。与 loader 类似,action 也是一个仅用于服务器的函数,你可以从中返回 Fetch Response,但与 loader 不同的是,它可以处理路由的非 GET
请求(POST
、PUT
、PATCH
、DELETE
)。
在 remix 中,与 action 交互的主要方式是通过 HTML 表单。还记得我之前提到过,随着你对网络的掌握越来越好,你对 Remix 的掌握也会越来越好?这一点在 Remix 中体现得淋漓尽致。Remix 鼓励你将应用程序中用户进行操作的每个部分都变成 HTML 表单。没错,就连 "喜欢" 按钮也是一个表单。
每当用户触发表单提交时,它都会调用上下文中最接近的路由上的 action(您可以通过表单的 action
属性指定要发布表单的 URL 来修改)。action 执行后,Remix 会通过浏览器获取请求重新获取该路由的所有 loader,并刷新用户界面,从而确保用户界面始终与数据库保持同步。这就是 Remix 的 "全栈数据流"。
让我们通过 Twitter Clone 的一些示例来看看它是如何工作的。登录页面的代码如下所示
export const action = async ({ request }: ActionFunctionArgs) => {
const form = await request.formData();
const usernameOrEmail = form.get("usernameOrEmail")?.toString() ?? "";
const password = form.get("password")?.toString() ?? "";
const isUsername = !isEmail(usernameOrEmail);
// Find an account
const user = await prisma.user.findFirst({
where: {
[isUsername ? "username" : "email"]: usernameOrEmail,
},
});
const fields = {
usernameOrEmail,
password,
};
if(!user) {
return json({
fields,
fieldErrors: {
usernameOrEmail: `No account found with the given ${
isUsername ? "username" : "email"
}`,
password: null,
},
}, {
status: 400
})
}
const isPasswordCorrect = await comparePassword(password, user.passwordHash);
if(!isPasswordCorrect) {
return json({
fields,
fieldErrors: {
usernameOrEmail: null,
password: "Incorrect password",
},
}, {
status: 400
})
}
return createUserSession(user.id, "/");
}
export default function Signin() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
return (
<>
<h1 className="font-bold text-3xl text-white mb-7">Sign in to Twitter</h1>
<Form method="post">
<div className="flex flex-col gap-4 mb-8">
<FloatingInput
autoFocus
label="Username or Email"
id="usernameOrEmail"
name="usernameOrEmail"
placeholder="john@doe.com"
defaultValue={actionData?.fields?.usernameOrEmail ?? ""}
error={actionData?.fieldErrors?.usernameOrEmail ?? undefined}
aria-invalid={Boolean(actionData?.fieldErrors?.usernameOrEmail)}
aria-errormessage={actionData?.fieldErrors?.usernameOrEmail ?? undefined}
/>
<FloatingInput
required
label="Password"
id="password"
name="password"
placeholder="********"
type="password"
defaultValue={actionData?.fields?.password ?? ""}
error={actionData?.fieldErrors?.password ?? undefined}
aria-invalid={Boolean(actionData?.fieldErrors?.password)}
aria-errormessage={actionData?.fieldErrors?.password ?? undefined}
/>
</div>
<ButtonOrLink type="submit" size="large" stretch disabled={navigation.state === "submitting"}>
Sign In
</ButtonOrLink>
</Form>
</>
);
}
对于需要更改 URL 的表单提交,Remix 提供了一个 Form
组件,它是对原生 HTML form
元素的增强封装。然后,您可以使用 useNavigation
钩子为您提供有关待处理页面导航的信息,以便向用户反馈加载状态。在登录页面中,我们使用它在提交表单时禁用按钮。
与我们在 loader 中看到的 useLoaderData
类似,Remix 也提供了 useActionData
,它充当了服务器和客户端之间的桥梁,为通知用户任何提交错误提供反馈。
此外,请注意我们没有使用任何状态来管理输入。相反,我们依靠浏览器的默认行为来序列化正文中的所有表单字段,并在提交表单时将其 "POST" 到服务器。在 action
中,我们可以通过 Fetch Request 的 formData
方法读取表单数据。
但我们不想每次提交表单时都进行跳转。因此,Remix 还提供了另一种无需导航即可与表单交互的工具,名为 fetcher
。Twitter 克隆版中的其余表单几乎都是 fetcher 表单。让我们以在状态页面上点赞一条推文为例:
export default function TweetStatus() {
const { tweet, user, replies } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const isLoading = fetcher.state !== "idle";
return (
{/** Rest of UI on page ,omitted for brevity **/}
<fetcher.Form method="post">
<input type="hidden" name="tweetId" value={originalTweetId} />
<input
type="hidden"
name="hasLiked"
value={(!tweet.hasLiked).toString()}
/>
<TweetAction
size="normal"
type="like"
active={tweet.hasLiked}
disabled={isLoading}
submit
name="_action"
value="toggle_tweet_like"
/>
</fetcher.Form>
{/** Rest of UI on page ,omitted for brevity **/}
);
}
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const action = formData.get("_action");
const userId = await getUserSession(request);
if (!userId) {
return redirect("/signin", 302);
}
switch (action) {
case "toggle_tweet_like":
{
const tweetId = formData.get("tweetId") as string;
const hasLiked = formData.get("hasLiked") === "true";
await toggleTweetLike({
request,
tweetId,
hasLiked,
});
}
break;
case "toggle_tweet_retweet":
{
/** Handle tweet retweet **/
}
break;
case "reply_to_tweet":
{
/** Handle tweet reply **/
}
break;
}
return json({ success: true });
};
请注意我们是如何在表单中使用 hidden
type 的 input 将 tweetId
和 hasLiked
等相关数据传递给服务器的。我们还将按钮的名称设置为 _action
,将值设置为 toggle_tweet_like
,这样我们就能在服务器上识别触发的操作类型,这在页面上有多个表单时非常有用。
正如我们在全栈数据流中看到的,Remix 会通过浏览器 fetch
自动运行页面上的所有 loader,更新从相关 loader 读取数据的页面上的用户界面。因此,推文点赞数和按钮状态会自动更新。观看视频,了解其工作原理:
我最喜欢的部分是,Remix 会强制你在所有地方使用 HTML 表单,而浏览器默认会将表单输入序列化,并自动将数据发送到服务器,因此用户可以在 JavaScript 加载之前就开始与页面交互。您可以禁用 JavaScript,然后在应用中的几乎任何页面上执行操作来验证这一点。
例如,下面是登录页面的一个示例,即使没有 JavaScript,也会显示表单错误:
还有一个从用户配置文件页面跟踪用户的示例:
在 Next.js 13.4 之前,在服务器上创建和执行操作的唯一方法是创建 API 路由。在 pages/api
下创建的任何文件都会被视为 API 端点,而不是普通页面。
对于需要在服务器上进行一些处理的一次性 API 路由来说,这是一个很好的解决方案,但对于在客户端调用 API 并进行任何重新验证来说,这并不是一个完整的解决方案。
这也是 trpc 等解决方案大受欢迎的原因之一,它们利用 Next.js 的 API 路由系统和 React Query 来处理客户端的 API 请求和突变。
Next.js 13.4 引入了服务器 action,在撰写本文时,它们仍处于试验阶段。
有了服务器 action,你就不需要创建 API 端点。相反,您只需创建异步服务器函数即可直接从组件中调用,并可访问所有 Next.js 服务器专用实用程序,如 cookies
、revalidate
、redirect
等。
Lee Robinson 的这条推文很好地总结了使用服务器 action 可以减少多少代码的编写:
如果你使用的是服务器组件,你可以在组件内定义一个服务器 action,方法是在第一行写上 'use server'
,然后直接将其传递给 form
的 action
prop,或者将其传递给客户端组件。(在服务器组件中使用 action prop 时,表单无需 JavaScript 即可运行)
export default async function Page() {
async function createTodo(formData: FormData) {
'use server'
// This will be executed on the server
}
return <form action={createTodo}>...</form>
// or
return <ClientComponent createTodo={createTodo} />
}
You can also create a separate file with the 'use server'
directive at at top of the file, and all the functions exported from that file can be used as server actions and can be directly imported into client components.
您也可以创建一个单独的文件,并在文件顶部添加 'use server'
指令,这样从该文件导出的所有函数都可用作服务器操作,并可直接导入到客户端组件中。
'use server'
export async function doStuff() {
// This will be executed on the server
}
'use client'
import { doStuff } from './actions';
export default function Button() {
return (
<form action={doStuff}>
<button type="submit">Do stuff</button>
</form>
)
}
在客户端组件上使用 action
属性时,操作将被置于队列中,直到表单水合完成。选择性水合(Selective Hydration) 会优先处理 <form>
,因此会尽快执行。
让我们看看 Next.js Twitter 克隆版中的一些示例。下面是登录页面的代码:
export default function Signin({
searchParams,
}: {
searchParams: Record<string, string>;
}) {
const signin = async (formData: FormData) => {
"use server";
const auth = {
usernameOrEmail: formData.get("usernameOrEmail")?.toString() ?? "",
password: formData.get("password")?.toString() ?? "",
};
const isUsername = !isEmail(auth.usernameOrEmail);
// Find an account
const user = await prisma.user.findFirst({
where: {
[isUsername ? "username" : "email"]: auth.usernameOrEmail,
},
});
if (!user) {
const error = encodeValueAndErrors({
fieldErrors: {
usernameOrEmail: `No account found with the given ${
isUsername ? "username" : "email"
}`,
},
fieldValues: auth,
});
return redirect(`/signin?${error}`);
}
// Compare password
const isPasswordCorrect = await comparePassword(
auth.password,
user.passwordHash
);
if (!isPasswordCorrect) {
const error = encodeValueAndErrors({
fieldErrors: {
password: "Incorrect password",
},
fieldValues: auth,
});
return redirect(`/signin?${error}`);
}
// Set auth cookie
setAuthCookie({
userId: user.id,
});
return redirect("/");
};
const { fieldErrors, fieldValues } = decodeValueAndErrors({
fieldErrors: searchParams.fieldErrors,
fieldValues: searchParams.fieldValues,
});
return (
<>
<h1 className="font-bold text-3xl text-white mb-7">Sign in to Twitter</h1>
<form action={signin}>
<div className="flex flex-col gap-4 mb-8">
<FloatingInput
autoFocus
label="Username or Email"
id="usernameOrEmail"
name="usernameOrEmail"
placeholder="john@doe.com"
defaultValue={fieldValues?.usernameOrEmail}
error={fieldErrors?.usernameOrEmail}
aria-invalid={Boolean(fieldErrors?.usernameOrEmail)}
aria-errormessage={fieldErrors?.usernameOrEmail ?? undefined}
/>
<FloatingInput
required
label="Password"
id="password"
name="password"
placeholder="********"
type="password"
defaultValue={fieldValues?.password}
error={fieldErrors?.password}
aria-invalid={Boolean(fieldErrors?.password)}
aria-errormessage={fieldErrors?.password ?? undefined}
/>
</div>
<SubmitButton>Sign In</SubmitButton>
</form>
</>
);
}
虽然到目前为止,还没有像 Remix 的 useActionData
那样的声明式方法来读取服务器动作的响应,但对于登录页面,我希望有一种无需 JavaScript 就能让用户显示错误的方法,因此我使用了搜索参数来对字段值和错误进行编码和解码。
这里的 SubmitButton
是一个客户端组件,它使用了一个名为 useFormStatus
的实验性钩子,以便在提交表单时显示禁用状态。
"use client";
import { experimental_useFormStatus as useFormStatus } from "react-dom";
import { ButtonOrLink } from "components/ButtonOrLink";
export const SubmitButton = ({ children }: { children?: React.ReactNode; }) => {
const { pending } = useFormStatus();
return (
<ButtonOrLink
type="submit"
size="large"
disabled={pending}
>
{children ?? "Submit"}
</ButtonOrLink>
);
};
在客户端,您还可以使用 startTransition
API 来执行服务器 action,这些动作会进行服务器突变(调用 revalidatePath
、redirect
或 revalidateTag
),并在点击按钮时直接执行服务器动作,例如,请查看 Follow 按钮是如何实现的:
const [isPending, startTransition] = React.useTransition();
<ButtonOrLink
disabled={isPending}
onClick={() => {
startTransition(async () => {
await toggleFollowUser({ userId: profileUserId, isFollowing: true });
});
}}
variant="secondary"
>
Follow
</ButtonOrLink>
与 Remix 类似,您可以直接从服务器操作中重新验证路径,这将导致服务器组件失效,用户界面也会自动反映更新。与 Remix 不同的是,您必须手动调用 revalidatePath
来刷新特定路径的数据。
export const toggleFollowUser = async ({
userId,
isFollowing,
}: {
userId: string;
isFollowing: boolean;
}) => {
/* Updating the value in DB, omitted for brevity */
revalidatePath("/[username]");
};
Here is a demo of how the following state is automatically updated on the profile page with the revalidatePath
when the user clicks on the follow button:
下面演示了当用户点击关注按钮时,如何使用 revalidatePath
在个人资料页面上自动更新关注状态:
平心而论,我非常喜欢 Remix 的操作方法,它通过自动重新获取加载器和更新用户界面来完成全栈数据流,甚至在 JavaScript 加载之前就能让应用程序正常运行,不仅大大改善了用户体验,也大大改善了开发人员的体验。
不过,action 也有一个注意事项,与我们在加载器中看到的一样,即只能在路由段中定义。如果要在多个地方重复使用一个动作,就必须在表单的 action
属性中指定动作的 URL。这可能会随着应用程序的增长而变得混乱,因为您必须根据 action
prop 中提供的值找到执行 action 的文件。举个例子,你可以看看我是如何使用它来创建 tweet 操作的,它被用在两个地方,一个是主页,另一个是 tweet 模态。
Next.js 的服务器 action 解决了上述问题,它允许你创建只需导入就能在应用程序内任意位置调用的函数。不过,目前它们还缺乏 Remix 所拥有的良好表单支持和自动重新验证功能,而且感觉很不稳定,文档也不够完善。我不得不在 Next.js 中进行了几次讨论,才弄明白 API 是如何工作的。
Next.js 最近发布了 v13.5 版,对服务器 action 进行了重大更新。因此,上面使用的一些 API(如使用
startTransition
进行服务器突变)似乎不再有文档记录。此外,他们还为表单添加了更好的支持。我很快就会在应用程序和博客中更新最新的 API。作为参考,你可以在 Next.js 文档的 Web Archive 中找到我在构建 Twitter 克隆版时使用的旧版服务器 action API。
无限滚动的无限加载是一个有趣的问题,因为这两个框架都没有一流的支持,但它却是 Twitter Clone 应用程序非常重要的一部分,因为它几乎出现在每个页面上。
由于需要在客户端处理无限加载,我不得不在客户端通过 useReducer
来管理它们的状态。我将其添加到这两个应用中的经历非常有趣,因此我认为这值得单独写一节。
无限滚动的实现在很大程度上受到了 Kent C Dodds 的《Full Stack Components》一文的启发。正是在这篇文章中,我了解到了资源路由以及它们在 Remix 中的强大功能。
资源路由的概念是,你可以创建一个与普通路由模块类似的路由,但如果你没有从该路由中导出默认组件,你仍然可以通过 GET
和 POST
请求使用该路由中定义的加载器和操作。它们几乎就像是 Next.js 版本的 API 路由。
因此,我为 Remix 创建了一个名为 routes/resource-infinite-tweets.tsx
的新路由,其中有一个名为 InfiniteTweets
的导出。由于这不是默认导出,Remix 不会为该路由渲染任何用户界面。这个具名导出被用于所有具有无限加载推文的组件中。
关于组件的工作原理我就不多说了,你可以在 GitHub 上查看相关代码。简而言之,我使用 IntersectionObserver
API 来检测页面的结束,并触发请求以获取下一页的推文,然后将其添加到 reducer 中。所有其他状态,包括喜欢/转发/回复计数,也都存储在 reducer 中。
让我们以使用该组件的其中一个页面为例:用户推文页面。正如我们在 "流" 一节中所看到的,推文的第一页在服务器上加载,并以流的形式传输到客户端。但对于下一页,我们使用 resource-infinite-tweets.tsx
中定义的 loader,它看起来像这样:
export const loader = async ({ request }: LoaderFunctionArgs) => {
const cursor = getSearchParam(request.url, "cursor") ?? undefined;
const type = getSearchParam(request.url, "type") as InfiniteTweetType;
const username = getSearchParam(request.url, "username");
const tweetId = getSearchParam(request.url, "tweetId");
let tweets: Array<TweetWithMeta> = [];
switch (type) {
case "user_tweets":
tweets = await getTweetsByUsername(request, username as string, cursor);
break;
case "home_timeline":
tweets = await getHomeTweets(request, cursor);
break;
case "tweet_replies":
tweets = await getTweetReplies(request, tweetId as string, cursor);
break;
case "user_replies":
tweets = await getUserReplies(request, username as string, cursor);
break;
case "user_likes":
tweets = await getUserLikes(request, username as string, cursor);
break;
}
return json(
{
tweets,
},
200
);
};
现在,为了触发 loader,我们使用了在数据突变部分看到的 fetcher
。它还有一个名为 submit
的方法,允许我们以编程方式触发对加载器的 GET
请求,从而获取下一批推文。
React.useEffect(() => {
if (isLoading || isLastPage || !isVisible || !shouldFetch) {
return;
}
fetcher.submit(
{
type,
cursor: lastTweetId,
...rest,
},
{
method: "GET",
action: "/resource/infinite-tweets",
}
);
setShouldFetch(false);
}, [
isVisible,
lastTweetId,
isLoading,
isLastPage,
type,
shouldFetch,
rest,
fetcher
]);
当满足特定条件,表明需要获取页面时,就会触发该效果。然后,数据会在 fetcher.data
中提供,并在另一个 effect 中添加到 reducer 中。
React.useEffect(() => {
if (fetcher.data && Array.isArray(fetcher.data.tweets)) {
dispatch({
type: "add_tweets",
newTweets: mapToTweet(fetcher.data.tweets, isLoggedIn),
});
setShouldFetch(true);
}
}, [fetcher.data, isLoggedIn]);
该路由模块也有一个 action,用于处理推文的所有点赞/转发/回复,与我们在数据突变部分看到的 fetcher.Form
代码相同。
Next.js 中的实现也与 Remix 非常相似,主要区别在于 InfiniteTweets
是一个客户端组件,而我们使用服务器 action 来加载下一组页面。
与 Remix 类似,推文的第一页也是从服务器流式传输的。我们在流部分看到的 loading.tsx
文件在这里派上了大用场。我们只需在个人资料页面的所有标签中添加该文件,Next.js 就会处理 Suspense
边界中的页面。
下面是用户推文页面的代码:
export default async function Profile({
params: { username },
}: {
params: { username: string };
}) {
const [tweets, currentLoggedInUser] = await Promise.all([
getTweetsByUsername(username),
getCurrentLoggedInUser(),
]);
const fetchNextUserTweetsPage = async (cursor: string) => {
"use server";
const tweets = await getTweetsByUsername(username, cursor);
return tweets;
};
return (
<>
{/** Tweets */}
<div>
<InfiniteTweets
initialTweets={tweets}
currentLoggedInUser={
currentLoggedInUser
? {
id: currentLoggedInUser.id,
username: currentLoggedInUser.username,
name: currentLoggedInUser.name ?? undefined,
profileImage: currentLoggedInUser.profileImage,
}
: undefined
}
fetchNextPage={fetchNextUserTweetsPage}
isUserProfile
/>
</div>
</>
);
}
请注意我们是如何创建一个名为 fetchNextUserTweetsPage
的服务器 action,并将其传递给 InfiniteTweets
组件的。然后,该组件通过调用通过该 prop 传递的 action 来获取下一页推文。
React.useEffect(() => {
const updateTweets = async () => {
if (isLoading || isLastPage) {
return;
}
setIsLoading(true);
const nextTweets = await fetchNextPage(lastTweetId);
setIsLoading(false);
dispatch({
type: "add_tweets",
newTweetsRemixToTweet(nextTweets, isLoggedIn),
});
};
if (isVisible) {
updateTweets();
}
}, [isVisible, lastTweetId, isLoading, isLastPage, fetchNextPage, isLoggedIn]);
然后,与 Remix 类似,我们将数据添加到 reducer,reducer 将下一组推文呈现在页面上。
无限加载是这两个框架中唯一需要我在客户端管理推文状态的部分。
在 Next.js 13 中,服务器 action 的可组合性在这部分大放异彩。我只需在服务器组件中获取将被流式传输的第一个页面,然后在组件中创建一个服务器 action 来获取下一个页面,并直接传递给客户端组件。
在 Remix 中,虽然获取器和资源路由确实让获取数据变得更容易,但我们还必须为每个路由创建一个单独的加载器,以便为无限推文流式传输第一页。
无论如何,这两个解决方案都不完美,总的来说,我更倾向于使用 React Query 等库提供的 useInfiniteQuery
钩子,该钩子可以帮助您在客户端很好地管理无效和乐观更新,从而实现类似的无限查询。
这两个框架都有很多其他有用的功能,包括:
Remix 和 Next.js 都有一个非常强大的客户端路由器,它们不会重载整个页面并往返服务器获取完整文档,而只是更新 UI,只重新渲染发生变化的路由段。
Next.js 会在后台检测视口中可见的所有 <Link/>
标记中需要 prefetch 的路由。对于动态路由,共享布局会一直向下,直到第一个 loading.tsx
文件被 prefetch 并缓存 30 秒
。这样,一旦用户点击路由,就能立即显示加载状态。
Remix 在 prefetch
属性上更进一步,允许你根据使用情况指定不同的值。我最喜欢的是 intent
,它不仅能获取所有 JavaScript 捆绑程序,还能在 hover 在链接上时通过 <link rel="prefetch">
标记获取下一个路由所需的所有数据。这样,您几乎可以立即呈现下一个页面。
这两个框架都支持在全局和每个路由段内处理预期错误和意外错误。
在 Remix 中,与 loader
和 action
类似,您可以导出 ErrorBoundary
,它将为路由段呈现错误状态。它既能处理服务器或浏览器中可能出现的意外错误,也能处理 404 等预期错误。要捕捉预期错误,可以从 loader 中 throw 一个 Response。例如,请查看用户配置文件页面的 404 状态。
同样,在 Next.js 中,每个路由段都有单独的文件,用于呈现该路由的错误状态。error.tsx
用于专门处理路由段中出现的任何浏览器或服务器错误。它将 ErrorBoundary
包在路由段上,类似于我们在 loading.tsx
中看到的悬挂边界。同样,Next.js 文档中的这张图片也很好地说明了这一点:
为了处理 404,Next.js 为每个路由段准备了一个名为 not-found.tsx
的特定文件。这些都是通过在服务器组件中返回 notFound
util 函数触发的。你可以再次查看 Next.js Twitter 克隆版用户配置文件页面的 not-found.tsx
文件。
对于 Twitter 克隆应用,缓存和静态渲染的使用并不多,因为所有路由段都需要使用用户的 cookie 来获取与用户相关的数据。
Next.js 显著改进了缓存支持,他们拥有不同层次的缓存,不仅可以缓存渲染的路由,还可以缓存边缘上获取请求的响应。您可以在 Next.js 的缓存文档中了解更多详情。
对于 Twitter Clone,我们确实使用了请求备忘录化(request memoization),它将在服务器 React 组件树的多个位置请求相同数据的函数进行备忘录化,同时只执行一次。
Remix 对缓存没有任何想法,而且由于它只使用 HTTP,所以你只需使用 Cache-Control
标头在边缘和浏览器中缓存响应,或者使用 Redis 等其他服务器端缓存解决方案。
如果你已经读到这里,那么我希望你喜欢阅读这篇博客,你对这两个框架中的任何一个都有了一些有趣的启发,这有助于你在构建下一个全栈应用程序时做出更好的决定。
总之,有了这两个框架,使用 React 构建复杂的全栈 Web 应用程序变得前所未有的快速和简单。就我而言,我非常喜欢 Remix 构建的框架,它利用了基本的 Web API,为您提供了一种简单而强大的方式来构建现代 Web 应用程序。同时,Next.js 中的 app 目录让我大开眼界,React Server Components 和 Server Actions 如何让您组成和创建全栈组件,同时向浏览器发送确定的捆绑包大小。对于这两种框架的未来,我感到非常兴奋。