为什么 Next.js 要把组件切成两半?从一个 SEO 死穴说起

为什么 Next.js 要把组件切成两半?从一个 SEO 死穴说起

写 Vite + React 的 SPA 上线后,打开百度站长工具一看,收录几乎为零------这不是配置问题,是 SPA 的结构性缺陷。Next.js 给出的解法不是「更好的 SPA」,而是把 React 组件切成两半:一半跑在服务器(换 SEO),一半跑在浏览器(换交互),中间靠 Hydration 水合缝合。

本文用 my-app 项目源码讲清这套设计的取舍逻辑:App Router 文件路由约定、服务端/客户端组件的核心边界、客户端组件在 Next.js 里的「两次执行」、route.ts 怎么写 RESTful 接口。技术栈 Next.js 16.3.0 + React 19.2.8,运行未验证 (未实际 npm run dev)。

一个 SEO 死穴:SPA 的服务器只返回空壳

SPA 部署后,服务器上就三个静态文件:index.htmlmain.js、一些 css。index.html 长这样:

html 复制代码
<body>
  <div id="root"></div>          <!-- 空壳 -->
  <script src="main.js"></script>
</body>

同一个 URL,爬虫和用户看到的完全不一样:

  • 爬虫(百度/Google):拿到空 #root + <script>,不执行 JS,抓不到内容,SEO 为 0
  • 真实用户:浏览器跑完 JS 后看到完整页面

这就是 CSR(客户端渲染) 的本质------内容在客户端才渲染,服务器只给空壳。首屏也慢:下载 JS → 执行 JS → 渲染组件 → 请求数据,串行多步,白屏时间长。

Next.js 的解法不是「更好的 SPA」,而是「服务器先画一遍」

Next.js 是 React 全栈框架,背靠 Vercel。它的核心思路是 SSR + Hydration 混合:服务器先把组件渲染成带内容的 HTML 返回,爬虫和用户都能立刻看到内容;JS 加载后再把静态 HTML「激活」成可交互组件。

工作流程六步:

  1. 浏览器请求 → Next.js 服务器执行对应 page.tsx
  2. 服务器取数据 → 把数据填进组件 → 渲染成 HTML
  3. 服务器返回「带内容的 HTML」
  4. 浏览器直接显示(首屏秒开,爬虫抓得到)
  5. 下载 JS bundle
  6. Hydration 水合:静态 HTML 激活成可交互 React 组件

关键差异(纠正一个常见误区):纯 SPA 服务器返回空 #root;Next.js 即使是 'use client' 组件,服务器也会先渲染一遍静态内容塞进 HTML。所以「Next.js 客户端组件 = 空 HTML」是错的,只有纯 SPA 才空

真正的取舍:把组件切成两半

Next.js 的设计哲学是「默认服务端,按需客户端」。组件只有两类:

服务端组件 客户端组件
标记 默认 文件第一行 'use client'
在哪执行 服务器 浏览器
hooks/事件/window
直接 await 取数据 ❌(用 useEffect)
输出 HTML 字符串 可交互组件

记忆技巧:

  • 要交互(点按钮、输文字、存本地)→ 加 'use client'
  • 纯展示(渲染数据)→ 不加,走服务端组件(自带 SEO)

导入规则是个常见面试题:

  • ✅ 服务端组件可以 import 客户端组件(客户端组件是「边界」,边界里子组件自动都是客户端组件)
  • ❌ 客户端组件不能 import 服务端组件(已经跑浏览器了,没法再跑服务端逻辑)

看首页 app/page.tsx,它是默认服务端组件:

tsx 复制代码
// 服务器端组件 
// 在服务器端 react node 的方式运行
// jsx -> html
export default function Home() {
  return <h1>Hello World</h1>;
}

注释点破了本质:React 组件不是只能在浏览器跑,react(js/node 方式)也能在服务器跑。服务器上没有 DOM,组件函数执行后输出的是 HTML 字符串

客户端组件的「两次执行」:和纯 SPA 的关键差异

这是 Next.js 最容易混的点。app/todos/page.tsx 是客户端组件:

tsx 复制代码
'use client';
import { useState, useEffect } from 'react';

export default function TodosPage() {
  const [todos, setTodos] = useState([]);  // 省略类型注解以聚焦逻辑
  const [text, setText] = useState("");

  const fetchTodos = async () => {
    const res = await fetch("/api/todos");
    const data = await res.json();
    setTodos(data);
  }

  useEffect(() => {
    fetchTodos();
  }, [])

  return (
    <div>
      <h1>待办事项</h1>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={handleAdd}>添加</button>
      <ul>
        {todos.map((item) => <li key={item.id}>{item.content}</li>)}
      </ul>
    </div>
  )
}

客户端组件在 Next.js 里的「两次执行」:

  1. 【服务器端】 静态执行一次:不跑 useEffectuseState 取初始值(todos 是 []),把能渲染的 JSX(<h1>、空 <ul>)拼成 HTML
  2. 【浏览器端】 拿到带内容的 HTML 后,下载 JS,Hydration 激活成交互组件,useEffect 触发 fetch 数据,setTodos 更新列表

对比表(这是面试可以直接背的):

服务器返回 源码能看到内容
纯 SPA(Vite) #root ❌ 什么都没有
Next.js 服务端组件 带内容 HTML ✅ 完整
Next.js 客户端组件 带静态部分 HTML ✅ 和 state 无关的部分在,动态部分用初始值占位

Hydration 水合 就是第 2 步:静态 HTML(看得见但点不动)→ 激活成可交互组件(绑定 onClick)。类比:服务器给你画好的画,JS 加载后注入灵魂。

App Router 的文件路由约定:page / layout / route

Next.js App Router 的约定就一句话:文件即路由

  • 文件夹 = URL 路径段
  • page.tsx = 页面组件
  • layout.tsx = 包裹子路由的布局(自动套用,子路由切换时不重新渲染)
  • route.ts = API 接口(返回 JSON)

my-app 的实际路由表:

URL 文件 类型
/ app/page.tsx 服务端组件
/about app/about/page.tsx 服务端组件
/todos app/todos/page.tsx 客户端组件
/api/todos app/api/todos/route.ts API Route
/dashboard/settings app/dashboard/settings/page.tsx 嵌套路由

根布局 app/layout.tsx 还负责 SEO 元信息和客户端路由:

tsx 复制代码
import type { Metadata } from "next";
import Link from "next/link";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <nav>
          <ul>
            <li><Link href="/about">About</Link></li>
            <li><Link href="/dashboard">Dashboard 后台管理系统</Link></li>
          </ul>
        </nav>
        {children}
      </body>
    </html>
  );
}

两个细节值得记:

  • export const metadata 导出 SEO 元信息,是 Next.js 约定(只能在服务端组件/layout 里导出)
  • <Link> 而不是 <a><a> 整页刷新,<Link> 走客户端路由,局部更新 DOM

API Routes:route.ts 怎么返回 JSON

Next.js 不只是写页面,app/api/<接口路径>/route.ts 可以写后端接口。my-app 的 app/api/todos/route.ts

ts 复制代码
// 模块级数组做内存存储(学习用,重启丢失)
let todos = [
  { id: 1, content: '学习AppRouter', completed: true},
  { id: 2, content: 'next.js 个人官网开发', completed: false},
]

// GET /api/todos
export async function GET() {
  return Response.json(todos);
}

// POST /api/todos
export async function POST(req: Request) {
  const body = await req.json();
  const newItem = {
    id: +Date.now(),
    content: body.content,
    completed: false,
  }
  todos.push(newItem);
  return Response.json(newItem);
}

要点:

  • Response.json() 是 Next.js 封装的返回 JSON 方法(不用 res.send
  • req.json() 解析请求体
  • 方法名必须大写匹配 HTTP 动词(GET/POST/PUT/DELETE
  • 模块级 let todos 做内存存储,重启丢失(生产要接数据库)

前端调用时 GET 和 POST 写法不同:

tsx 复制代码
// GET:默认 method,不需要 headers/body
const res = await fetch("/api/todos");
const data = await res.json();

// POST:需要 method、Content-Type 和 body
await fetch("/api/todos", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ content: text }),
});

收藏:排错表 + 自检清单

排错表

错误现象 原因 处理
服务端组件用 useState 报错 服务端组件不能 hooks 顶部加 'use client'
<a href="/about"> 整页刷新 <a> 是原生跳转 改用 <Link href>
客户端组件 import 服务端组件报错 不能再跑服务端逻辑 拆成 props 传,或重构成客户端组件
API Route 返回不是 JSON 用了 res.send 改用 Response.json()

一个真实的代码 bug(排错案例)

todos/page.tsxhandleAdd 有 bug:

tsx 复制代码
const handleAdd = async () => {
  if (!text.trim()) {
    return;                          // ← 这里 return 了
    await fetch("/api/todos", {     // ← 不会执行!
      method: "POST",
      // ...
    });
  }
}

return 之后的 fetch 不执行。正确写法:把 fetch 移到 if 块外,当 text 非空时才执行。这个 bug 可以直接当面试排错题。

写 Next.js 页面前的 5 个自检问题

  • 这个页面需要 SEO 吗?需要 → 优先服务端组件
  • 有交互(点击/输入/本地存储)吗?有 → 加 'use client'
  • 跳转用的是 <Link> 还是 <a>?必须 <Link>
  • API Route 方法名是不是大写 HTTP 动词?
  • metadata 是不是只在服务端组件/layout 里导出?

写在最后

Next.js 的设计本质是个取舍:默认服务端组件换 SEO 和首屏,只在需要交互的最小范围切到客户端组件。这个取舍背后的判断是------SEO 和交互不能兼得的时代过去了,但要靠「组件边界」来划分职责,而不是把整个应用都丢给浏览器。

记住这一点,比记住任何 API 都重要:客户端组件是边界,不是默认 。下次写 Next.js 项目时,先问自己「这块能不能不交互」,能就用服务端组件,不能才加 'use client'

运行未验证:本文代码基于 my-app 源码静态分析,未实际运行。生产环境请以官方文档为准。


标签:Next.js、React、SSR、前端架构、全栈

相关推荐
Molecular_Chat1 小时前
Biotin SNA 生物素偶联凝集素结合特异性、生物素标记效率定量表征研究
javascript·python
进击的蛋蛋1 小时前
JS对象的遍历
javascript·面试
mONESY1 小时前
React 前端如何不傻等后端接口?
前端·javascript·后端
xiaominlaopodaren1 小时前
three.js地图数学基础(七):地图相机
javascript·gis·three.js
小林ixn1 小时前
深入理解 React 水合(Hydration):从静态 HTML 到可交互页面
react.js·next.js
小林ixn1 小时前
Next.js 全栈实战:从 SPA 到 SSR,SEO 友好到底怎么落地?
react.js·前端框架·next.js
烬羽1 小时前
彻底搞懂 App Router 约定式路由:文件放对位置,路由就自动生成了
react.js·next.js·前端工程化
vx-程序开发2 小时前
django汽车租赁系统---附源码25360
java·javascript·spring boot·python·eclipse·django·php
满栀5852 小时前
vue3动态路由详细效果
前端·javascript·vue.js·typescript·前端框架