12k
All articles

Waku:脱离 Next.js 使用 React 服务端组件

Waku 是一款轻量级 React 框架,无需 Next.js 即可使用 React Server Components。通过示例应用了解路由、渲染模式与部署。

OpenReplay Team
OpenReplay Team
Waku:脱离 Next.js 使用 React 服务端组件

Waku 是一个基于 Vite 构建的轻量级 React 框架,支持运行 React 19 的服务端组件(Server Components)和服务端操作(Server Actions)。上手 Waku 主要需要掌握两点:基于 src/pages 的文件系统路由,以及每个页面导出的 getConfig,它用于指定静态渲染或动态渲染。

如果你一直在 Next.js App Router 中使用服务端组件,可能很难分清哪些特性属于 React,哪些属于 Next。Waku 保留了 React 的组件模型,同时去掉了外围的大部分框架层。本文将从脚手架搭建到部署,完整构建一个小型应用,帮助你判断是否值得在业余项目中尝试 Waku。Waku 1.0 目前以候选发布版本(Release Candidate)的形式发布,其中 v1.0.0-rc.2 发布于 2026 年 9 月 28 日。请查看 Waku 发布页面,确认稳定版 1.0 是否已经发布。

核心要点

  • Waku 是一个基于 Vite 的极简 React 框架,使用 src/pages 实现文件系统路由。每个布局和页面都可以导出 getConfig,返回 render: 'static' 或 render: 'dynamic'。
  • Waku 中的布局、页面和切片(slices)默认采用静态渲染。只有当页面的 getConfig 返回 render: 'dynamic' 时,才会在每次请求时渲染。
  • 对于 src/pages/releases/[slug].tsx 这类静态动态段路由,必须在 getConfig 中返回一个 staticPaths 数组。
  • Waku 默认部署到 Node.js,同时支持纯静态输出、Vercel、Netlify 和 Cloudflare Workers。Deno Deploy、Bun 和 AWS Lambda 的支持目前标记为实验性。
  • Waku 1.0 目前处于候选发布阶段,最新版本为 v1.0.0-rc.2(2026 年 9 月 28 日),适合能够接受预发布版本频繁变动的小型项目。

如何创建 Waku React 项目?

运行 npm create waku@latest 即可创建 Waku React 项目。之后的日常开发只需用到三个 CLI 命令:waku dev、waku build 和 waku start。

npm create waku@latest
# then, inside the project:
npx waku dev     # local dev server
npx waku build   # production build
npx waku start   # serve the production build

根据 Waku 入门文档,支持的 Node.js 版本为 ^26.0.0、^24.0.0 或 ^22.15.0。在底层实现上,Waku 使用 Vite 官方的 @vitejs/plugin-rsc 来打包服务端组件,因此其配置文件中提供了用于配置插件的 vite 字段。

获取数据的异步页面

在 Waku 中,页面就是 src/pages 下默认导出一个组件的文件。该组件可以是异步服务端组件,直接通过 await 获取数据。如果你熟悉 Next.js 中使用服务端组件获取数据的方式,会发现组件主体的写法完全一致,区别仅在于具名导出的 getConfig。

为了避免依赖外部 API,本示例读取一个本地 JSON 文件。Waku 文档指出,服务端组件可以安全地读取项目根目录下 private 文件夹中的文件。

// private/releases.json
[
  { "slug": "v2-0", "version": "2.0.0", "summary": "New plugin API." },
  { "slug": "v1-9", "version": "1.9.0", "summary": "Bug fixes." }
]
// src/pages/index.tsx
import { readFile } from 'node:fs/promises';

type Release = { slug: string; version: string; summary: string };

export default async function HomePage() {
  const releases: Release[] = JSON.parse(
    await readFile('./private/releases.json', 'utf8'),
  );

  return (
    <>
      <title>Release notes</title>
      <h1>Release notes</h1>
      <ul>
        {releases.map((release) => (
          <li key={release.slug}>
            <a href={`/releases/${release.slug}`}>{release.version}</a>
          </li>
        ))}
      </ul>
    </>
  );
}

export const getConfig = async () => {
  return { render: 'dynamic' } as const;
};

由于 getConfig 返回了 render: 'dynamic',该页面会在每次请求时进行服务端渲染。如果去掉这一行,页面将在构建时预渲染。<title> 标签之所以能生效,是因为 Waku 会将 title、meta 和 link 标签自动提升到文档的 head 中。

使用 ‘use client’ 添加客户端组件

在文件顶部添加 'use client' 后,当该文件被服务端组件导入时,就会形成一个服务端与客户端的边界。从这个边界往下,该文件引入的所有组件都会被水合(hydrate),并在浏览器中运行。这与你在 App Router 中使用的指令和规则完全相同。

// src/components/like-button.tsx
'use client';

import { useState } from 'react';

export const LikeButton = () => {
  const [likes, setLikes] = useState(0);
  return <button onClick={() => setLikes((n) => n + 1)}>👍 {likes}</button>;
};

客户端组件不能导入服务端组件,但可以通过 children 或其他 prop 接收服务端渲染的输出:

// src/components/collapsible.tsx
'use client';

import { useState, type ReactNode } from 'react';

export const Collapsible = ({ children }: { children: ReactNode }) => {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen((o) => !o)}>{open ? 'Hide' : 'Details'}</button>
      {open && children}
    </div>
  );
};
// src/pages/index.tsx (inside the map, with both components imported)
<li key={release.slug}>
  <a href={`/releases/${release.slug}`}>{release.version}</a>
  <LikeButton />
  <Collapsible>
    <p>{release.summary}</p>
  </Collapsible>
</li>

摘要段落在服务端渲染,然后作为 prop 传递给 Collapsible,客户端文件本身从未导入它。关于 React 服务端组件的一个常见误区是 'use server' 的作用。根据 Waku 文档,该指令用于标记服务端操作,而非服务端组件,因此不应放在服务端组件文件的顶部。另外,客户端组件在水合之前同样会先在服务端渲染为 HTML。通过对此类页面进行会话回放(session replay),可以清楚地看到点赞按钮是在首次点击时就能响应,还是要等水合完成后才有反应。

路由:布局、动态段与渲染模式

除非另行指定,Waku 会对布局、页面和切片进行静态渲染。API 处理函数则恰好相反,默认采用动态渲染。渲染模式按文件单独设置,因此静态布局中可以包裹动态页面。

src/
  pages/
    _layout.tsx
    index.tsx
    releases/
      [slug].tsx
  components/
    like-button.tsx
    collapsible.tsx
private/
  releases.json

_layout.tsx 文件作用于其所在路由及其下所有嵌套路由,且其组件必须接收 children prop:

// src/pages/_layout.tsx
import type { ReactNode } from 'react';

export default async function RootLayout({ children }: { children: ReactNode }) {
  return (
    <>
      <header><a href="/">Release notes</a></header>
      <main>{children}</main>
    </>
  );
}

export const getConfig = async () => {
  return { render: 'static' } as const;
};

文件名带方括号的文件即为动态段路由。段的值会作为 prop 传入组件,可以使用 waku/router 中的 PageProps 为其添加类型。根据 Waku 的路由文档,当动态段路由采用静态渲染时,getConfig 必须返回一个 staticPaths 数组,列出需要预渲染的值。由于 getConfig 可以是异步函数,这个列表可以直接从数据中生成:

// src/pages/releases/[slug].tsx
import { readFile } from 'node:fs/promises';
import type { PageProps } from 'waku/router';

type Release = { slug: string; version: string; summary: string };

const loadReleases = async (): Promise<Release[]> =>
  JSON.parse(await readFile('./private/releases.json', 'utf8'));

export default async function ReleasePage({ slug }: PageProps<'/releases/[slug]'>) {
  const release = (await loadReleases()).find((r) => r.slug === slug);
  if (!release) return <p>Release not found.</p>;
  return (
    <>
      <title>{`Release ${release.version}`}</title>
      <h1>{release.version}</h1>
      <p>{release.summary}</p>
    </>
  );
}

export const getConfig = async () => {
  const releases = await loadReleases();
  return { render: 'static', staticPaths: releases.map((r) => r.slug) } as const;
};

Waku 还提供了切片、拦截器(interceptors)和 Hono 中间件等功能,可按需使用,不过本示例应用并未用到。

Waku 可以部署到哪些平台?

Waku 默认部署到 Node.js,同时支持纯静态输出、Vercel、Netlify 和 Cloudflare Workers。Waku 部署文档将 Deno Deploy、Bun 和 AWS Lambda 标记为实验性支持。

  • Node.js: 使用 waku start 运行生产服务器。如需独立部署,可将 dist 文件夹部署到服务器上,然后运行 node dist/serve-node.js。
  • 纯 SSG: 将 dist/public 上传到任意静态托管服务。
  • Vercel: vercel
  • Netlify: 先执行 NETLIFY=1 npm run build,再执行 netlify deploy
  • Cloudflare Workers: 先执行 CLOUDFLARE=1 npm run build,再执行 wrangler deploy
  • Deno Deploy、Bun、AWS Lambda(实验性): 在 src/waku.server.tsx 中导入 waku/adapters/deno、waku/adapters/bun 或 waku/adapters/aws-lambda。

纯静态输出会舍弃所有需要在请求时依赖服务器的功能,包括动态渲染、服务端操作和 API 路由。因此,上文中的动态首页需要改为 render: 'static' 才能以 SSG 方式部署。

什么情况下 Next.js 仍是更好的选择?

如果你需要稳定版本,Next.js 是更稳妥的选择。Waku 1.0 在 v1.0.0-rc.2 时仍处于候选发布阶段,而且 rc.2 的发布说明中包含”breaking: drop deprecated apis”(破坏性变更:移除已弃用的 API),因此版本之间可能会有变动。此外,Next.js 拥有更庞大的生态系统、更多的托管平台集成和更丰富的教程资源,还内置了更多功能,而使用 Waku 时这些功能需要你自行组合实现。Waku 文档指出,选择哪个框架取决于你期望的架构,而非项目规模。Waku 将框架本身保持得非常精简,其余功能依赖生态系统中的第三方库实现;而更重量级的框架则会自行承担更多此类工作。如果你希望由框架替你做这些决策,那么继续使用 Next.js 即可。

总结

Waku 提供了与 App Router 相同的服务端组件模型,但外围的框架层要精简得多:异步页面、相同的 'use client' 边界、基于 children 的组合方式,以及一个按文件显式指定静态或动态渲染的 getConfig 导出。下一步,你可以运行 npm create waku@latest,用 Waku 重写一个小型 Next.js 路由,并在 package.json 中锁定确切的 RC 版本号,以免后续的候选发布版本在不知不觉中改变应用行为。

常见问题

如何在 Waku 中添加 API 路由?

在 src/pages/_api 下添加文件,并为该路由需要处理的每种 HTTP 方法导出一个函数,例如 GET、POST、PUT、PATCH 或 DELETE。路由路径由文件名决定。每个处理函数接收一个标准的 Request 并返回一个标准的 Response。API 处理函数默认采用动态渲染;如果 getConfig 返回 render 'static',则会在构建时预渲染,适用于 RSS 订阅源之类的输出。

Waku 中的环境变量如何使用?

服务端代码可以通过从 'waku' 导入的 getEnv 函数读取环境变量,在 Node.js 环境中也可以使用 process.env。客户端组件只能通过 import.meta.env 读取带有 WAKU_PUBLIC_ 前缀的变量,例如 import.meta.env.WAKU_PUBLIC_HELLO。所有带此前缀的变量都会以明文形式打包进生产环境的 JavaScript 包中,因此切勿为 API 密钥或其他敏感信息添加 WAKU_PUBLIC_ 前缀。

在 Waku 中进行内部导航时,应该使用 a 标签还是 Link 组件?

内部链接请使用从 'waku' 导入的 Link 组件。其 to prop 既可以接收路由字符串,也可以接收包含 to、params、search 和 hash 的结构化对象,导航会通过 Waku 的路由器在客户端完成。普通的 a 标签则会触发浏览器的常规页面跳转。如需进行编程式导航或读取当前路径和查询参数,可在客户端组件中调用从 'waku' 导入的 useRouter Hook。

Waku 的服务端操作默认是安全的吗?

不是。用 'use server' 标记的函数会成为一个可供客户端调用的端点。Waku 文档警告,除非你在函数内部自行编写身份验证和授权检查,否则这些端点不受任何保护。请在每个服务端操作中校验用户身份和权限,并且只为确实需要对外暴露的函数添加该指令,以免意外创建不必要的端点。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.