首页 / 文章 / 在 Next.js App Router 中正确绘制“使用客户端”边界

在 Next.js App Router 中正确绘制“使用客户端”边界

了解“use client”指令究竟标识了什么,为何对容器进行标识会导致包体变大,以及叶组件和子槽如何将服务器端的工作限制在服务器上。

1190 词

打开许多 App Router 项目代码,你会发现几乎每个组件的顶部都有 'use client' 这行代码。这种习惯很快就会养成:每当为下拉菜单添加 onClick 或 useState 时,构建过程就会报错,而该指令能让错误消失。如此重复几十次后,整个组件结构最终都会被打包进浏览器中,形成一种带有额外繁琐流程的单页应用。本指南将解释该指令的真正作用,以及如何正确使用它,从而让服务器端处理留在服务器上,只有真正需要交互的部分才会生成 JavaScript。

该指令实际标记的内容

其名称看似意味着“仅在浏览器中运行”,但并非如此。'use client'用于声明模块边界:即服务器模块结构结束、客户端代码包开始的位置。客户端组件仍会在服务器端预先渲染为HTML,随后在浏览器中进一步初始化。

[ Server Component Tree ] (Executes solely on the server; zero KB client JS)
         │
         ├── Server Component A (Fetches directly from DB)
         │
         ▼
── [ 'use client' Boundary ] ──
         │
         ├── Client Component B (Pre-rendered to HTML on server, hydrated on client)
         │       │
         │       └── Regular Component C (Now forced into the client bundle!)

令团队困扰的问题在于这种传递效应。任何被标记为 'use client' 的文件所导入的模块,无论其自身是否包含该指令,都会成为客户端代码包的一部分。如果某个根级仪表板布局因为包含了用户资料下拉菜单而被标记为客户端模块,那么它的侧边栏、模态框、辅助功能以及它所导入的任何大型库都会一同被传输到浏览器中。如需深入了解为何仅服务器端运行的代码不会占用任何代码包空间,可参阅React Server Components如何实现零代码包渲染。

交互式容器陷阱

最常见的错误是将整个页面都转换为客户端组件,只因为其中有一小部分具有交互功能。下面的示例仅需要用于切换筛选器的状态,但整个控制面板却被标记为客户端代码:

// ❌ BAD: The entire page is pulled into the client bundle
'use client';

import { useState, useEffect } from 'react';
import { db } from '@/lib/db'; // 🚨 Build error or massive security/bundle hazard!

export default function UserDashboard() {
  const [isOpen, setIsOpen] = useState(false);
  const [data, setData] = useState(null);

  useEffect(() => {
    // You've recreated client-side waterfall fetching
    fetch('/api/user-data').then(res => res.json()).then(setData);
  }, []);

  return (
    <div className="p-8">
      <button onClick={() => setIsOpen(!isOpen)}>Toggle Filter</button>
      {isOpen && <div className="dropdown">...</div>}
      <div className="grid">
        {/* Render heavy data tables */}
      </div>
    </div>
  );
}

这会引发两个问题:将数据库客户端导入到客户端模块中要么会导致构建失败,更糟糕的是还可能把仅适用于服务器的代码和配置引入浏览器。此外,数据加载被放在了useEffect中,因此页面最初会显示为空,待数据加载完成后才会渲染内容,这就重新出现了客户端流水线式处理流程,而服务器组件本就是为了消除这种流程而存在的。将server-only包添加到如@/lib/db这样的模块中,会使得第一个错误直接变成明确的构建错误。

将交互功能下放到最底层

状态应存在于最需要它的最小组件中。此处,切换控件成为独立的客户端模块,其显示的内容会作为children参数传入:

// components/FilterToggle.tsx
'use client';

import { useState } from 'react';

export function FilterToggle({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="px-3 py-1.5 bg-neutral-100 rounded text-sm"
      >
        {isOpen ? 'Hide Filters' : 'Show Filters'}
      </button>
      {isOpen && <div className="mt-2">{children}</div>}
    </div>
  );
}

该页面仍为异步服务器组件。它直接查询数据库、渲染静态标记,并仅在发生交互的地方嵌入切换控件:

// app/dashboard/page.tsx (Server Component by default)
import { db } from '@/lib/db';
import { FilterToggle } from '@/components/FilterToggle';
import { AnalyticsChart } from '@/components/AnalyticsChart';

export default async function UserDashboard() {
  // Direct DB access — zero client-side fetch waterfalls
  const metrics = await db.metrics.findMany();

  return (
    <main className="p-8">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-xl font-bold">Performance Analytics</h1>
        <FilterToggle>
          <p className="text-sm text-neutral-500">Filter options...</p>
        </FilterToggle>
      </div>

      <div className="grid grid-cols-3 gap-4">
        {/* AnalyticsChart can also remain an RSC if it doesn't need canvas/DOM APIs */}
        <AnalyticsChart data={metrics} />
      </div>
    </main>
  );
}

虽然该段落位于客户端组件内部,但仍然在服务器端进行渲染并传给FilterToggle。只要AnalyticsChart不依赖canvas这类仅适用于浏览器的API,它就可以继续保持服务器组件的身份;如果需要使用此类API,只有该图表才需要相关指令。

在客户端壳层中组合服务器内容

同样的技术也可应用于布局。假设存在一个类似 FilterToggle 的 CollapsibleShell 客户端组件,用于处理展开与关闭状态。作为服务器组件的布局会生成成本较高的数据源,并将其以插槽的形式传递给该外壳组件:

// app/layout.tsx
import { CollapsibleShell } from '@/components/CollapsibleShell';
import { ExpensiveServerFeed } from '@/components/ExpensiveServerFeed';

export default function RootLayout() {
  return (
    <html lang="en">
      <body>
        <CollapsibleShell>
          {/* ExpensiveServerFeed runs on the server, streams HTML, and ships 0kb JS */}
          <ExpensiveServerFeed />
        </CollapsibleShell>
      </body>
    </html>
  );
}

由于 ExpensiveServerFeed 是在服务器端创建并通过 children 传递的,因此其渲染完全在服务器上完成。React会将渲染后的结果通过 RSC 有效载荷发送到客户端,而该组件的代码永远不会进入客户端打包文件。关键区别在于:导入组件的客户端模块会将其纳入打包文件,而作为属性接收已渲染元素的客户端组件则不会。

添加该指令前的检查清单

  • 该组件是否使用了状态、效应、事件处理程序或浏览器 API?如果没有,就将其留在服务器端。
  • 交互部分能否提取为更小的子组件?
  • 是否可以通过 children 或其他属性而非导入的方式传递服务器渲染的内容?
  • 所有跨边界的属性是否都可序列化,且除了 Server Actions 之外不得包含函数或类实例?
  • 将此文件标记为服务器组件是否会引入诸如 Markdown 解析器或日期处理工具之类的重型库,而这些库本应留在服务器端?

关键要点

  • 服务器组件之所以成为默认选择是有原因的;应将数据获取、解析工具以及静态标记放在服务器端。
  • 应隔离小型交互组件,而非转换其容器。
  • 使用 children 及其他插槽属性将服务器端输出包裹在客户端逻辑中,从而无需将其发送到客户端。
  • 应将 'use client' 视为一种有意的架构边界,而非掩盖构建错误的手段。若运用得当,打包后的文件体积会保持较小,且内容能立即显示。