本文是「同一 RealWorld 规范,四种技术架构」系列第四篇。上期我们拆了 Nuxt 3 SSR 版,这次轮到 React 生态的 Next.js。如果你是从第一篇 React SPA 一路跟过来的,会发现 SSR 不只是换了个框架,而是换了一种思考方式。全文从 SPA 痛点出发,深入 Pages Router 文件路由、SWR + getInitialProps 数据获取、React Context 状态管理,并附完整的四架构对比全景图。
引言:从 SPA 到 SSR
上期我们聊 Nuxt 3 时,提过 Vue SPA 的一个典型困境:SEO 不友好,首屏白屏。今天换到 React 生态,问题一模一样——React SPA 的痛点,和 Vue SPA 的痛点,本质上是同一个痛点。
React SPA 的三个老问题
第一个,SEO 几乎是零。 搜索引擎爬虫请求页面时,拿到的只是一个空的 <div id="root"> 和一堆 JS 链接。爬虫不会等你的 JS 下载完再执行、再渲染。如果你的网站内容依赖 React 渲染,百度 Google 基本搜不到。
第二个,首屏白屏时间。 用户打开页面 → 下载 HTML → 解析 JS → 下载 JS Bundle → 执行 → 渲染。这个过程在 3G 网络下可能 3-5 秒。用户看到的是一篇空白,然后突然"唰"一下内容全出来了。这种体验在内容型网站(博客、新闻、电商)上尤其致命。
第三个,客户端路由的"闪烁"问题。 SPA 的路由切换虽然快,但每次切换都要重新请求数据,几乎每个页面都有一个 loading spinner。你的用户可能已经习惯了"看到 spinner → 等 → 看到内容"的节奏,但习惯不等于喜欢。
Next.js SSR 做了什么
Next.js 的 SSR(服务端渲染)解决的是"内容可见性"问题:
-
首屏的完整 HTML 在服务端生成,用户直接看到"真内容",不需要等 JS 下载执行
-
搜索引擎直接拿到完整 HTML,SEO 问题迎刃而解
-
文件路由 + 服务端数据预取,开发体验比 SPA 更统一
这里有一个关键认知:SSR 不是"更快",而是"感知上更快"。TTFB(首字节时间)可能比 SPA 更长(因为服务端要渲染),但 FCP(首次内容渲染)会更短——用户看到内容的时间提前了。
和上期 Nuxt 3 的对称关系
这个系列一共四篇:
|
第一篇 |
React SPA(Create React App) |
客户端渲染 |
|
第二篇 |
Vue 3 SPA(Vite) |
客户端渲染 |
|
第三篇 |
Nuxt 3 SSR(上期) |
服务端渲染 |
|
第四篇(本期) |
Next.js SSR |
服务端渲染 |
你会发现,Nuxt 3 是 Vue 生态的 SSR 方案,Next.js 是 React 生态的 SSR 方案。它们解决的问题一样,但实现思路各有特色。
你写 React SPA 时,有没有遇到过 SEO 需求?如果客户要求"文章详情页必须在百度能搜到",你打算怎么处理?SSR 是答案之一,但 Pages Router 和 App Router 你会怎么选?
铺垫完背景,我们看看这个项目到底长什么样——它和上期我们拆的 Nuxt 3 版,有什么本质区别?
项目全景:reck1ess/next-realworld-example-app
什么是 RealWorld
RealWorld 是一套统一的 Medium 克隆规范,由 Thinkster 社区维护。它定义了一个完整的博客平台需要哪些功能,并提供了统一的 API 和设计规范。上期拆 Nuxt 3 版时我们已经熟悉了这个规范,这次是同一规范,不同实现。
技术栈
先看 package.json 里的依赖:
{
"dependencies": {
"axios": "^0.19.2",
"lazysizes": "^5.2.2",
"marked": "^1.1.1",
"next": "^9.5.1",
"react": "16.13.1",
"react-dom": "16.13.1",
"swr": "^0.3.0"
},
"devDependencies": {
"@types/node": "^14.0.27",
"@types/react": "^16.9.44",
"typescript": "^3.9.7"
}
}
几个关键发现:
-
Next.js 9.5.1,用的是 Pages Router(不是 App Router)。这是 2020 年的项目,当时 App Router 还没出生。
-
SWR 0.3.0 做数据获取,这是这个项目最核心的架构特色——"stale-while-revalidate"(先返回缓存,再后台刷新)策略。
-
Axios 封装 API 请求,而不是用浏览器原生的 fetch。
-
样式通过 CDN 引入 Bootstrap 变体(demo.productionready.io/main.css)+ IonIcons 字体图标,在 _document.tsx 中加载。
目录结构
next-realworld-example-app/
├── pages/ # 页面路由(核心)
│ ├── _app.tsx # 全局入口:Context + Layout
│ ├── _document.tsx # 自定义 HTML 文档
│ ├── index.tsx # 首页
│ ├── article/
│ │ └── [pid].tsx # 文章详情(动态路由)
│ ├── editor/
│ │ ├── new.tsx # 新建文章
│ │ └── [pid].tsx # 编辑文章
│ ├── profile/
│ │ └── [pid].tsx # 用户主页
│ └── user/
│ ├── login.tsx # 登录
│ ├── register.tsx# 注册
│ └── settings.tsx# 设置
├── components/ # 可复用 UI 组件
│ ├── common/ # 通用组件(Layout, Navbar, Footer)
│ ├── article/ # 文章相关组件
│ ├── home/ # 首页组件
│ ├── comment/ # 评论组件
│ └── profile/ # 用户相关组件
├── lib/ # 核心逻辑
│ ├── api/ # API 请求封装(模块化 Axios)
│ ├── context/ # React Context 状态管理
│ ├── hooks/ # 自定义 Hooks
│ ├── types/ # TypeScript 类型定义
│ └── utils/ # 工具函数
└── public/ # 静态资源
跑起来
git clone https://github.com/reck1ess/next-realworld-example-app.git
cd next-realworld-example-app
npm install
npm run dev
访问 http://localhost:3000,默认连接 https://conduit.productionready.io/api——这是 RealWorld 的官方 demo API,你不需要自己搭后端就能跑。
注意到 lib/ 目录了吗?Next.js 没有 Nuxt 的 composables/ 自动导入、server/ 服务端目录、middleware/ 路由守卫。React 生态更"自由"——你自己组织目录结构,自己选工具。这种自由度是好是坏?我们边拆边看。
项目跑起来后,我们看看 Next.js 最核心的约定——Pages Router 文件路由。
Pages Router:文件路由
Pages Router 是 Next.js 最经典的约定——pages/ 目录结构就是路由结构。看一眼 pages/ 目录,你就知道整个应用有哪些页面。
路由映射表
|
pages/index.tsx |
/ |
|
pages/user/login.tsx |
/user/login |
|
pages/user/register.tsx |
/user/register |
|
pages/user/settings.tsx |
/user/settings |
|
pages/article/[pid].tsx |
/article/:pid |
|
pages/editor/new.tsx |
/editor/new |
|
pages/editor/[pid].tsx |
/editor/:pid |
|
pages/profile/[pid].tsx |
/profile/:pid |
[pid].tsx 动态路由
方括号 [pid] 表示动态路由参数。来看 pages/article/[pid].tsx 的真实源码:
import { useRouter } from "next/router";
import React from "react";
import useSWR from "swr";
import ArticleAPI from "../../lib/api/article";
import { SERVER_BASE_URL } from "../../lib/utils/constant";
import fetcher from "../../lib/utils/fetcher";
const ArticlePage = (initialArticle) => {
const router = useRouter();
const {
query: { pid },
} = router;
const { data: fetchedArticle } = useSWR(
`${SERVER_BASE_URL}/articles/${encodeURIComponent(String(pid))}`,
fetcher,
{ initialData: initialArticle }
);
// …
};
这里有几个值得注意的点:
router.query.pid 获取路由参数,参数名和文件名一致([pid] → pid)
encodeURIComponent 处理 slug 中的特殊字符
initialData 把服务端预取的数据传给 SWR,这是 SSR 水合的关键
对比 Nuxt 3,动态路由语法几乎一样——Next.js 用 [pid].tsx,Nuxt 3 用 [slug].vue。但 Nuxt 3 的参数获取方式不同:useRoute().params.slug。
_app.tsx 和 _document.tsx 的特殊角色
_app.tsx 是全局应用入口,所有页面都会经过它。来看真实源码:
// pages/_app.tsx
import Head from "next/head";
import React from "react";
import Layout from "components/common/Layout";
import ContextProvider from "lib/context";
import "styles.css";
// 客户端才加载懒加载插件
if (typeof window !== "undefined") {
require("lazysizes/plugins/attrchange/ls.attrchange.js");
require("lazysizes/plugins/respimg/ls.respimg.js");
require("lazysizes");
}
const MyApp = ({ Component, pageProps }) => (
<>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"
/>
</Head>
<ContextProvider>
<Layout>
<Component {…pageProps} />
</Layout>
</ContextProvider>
</>
);
export default MyApp;
_app.tsx 做了三件事:
-
注入全局 Context Provider
-
包裹全局 Layout(Navbar + Footer)
-
处理 SSR 不兼容的客户端库(lazysizes 需要 window 对象)
_document.tsx 自定义 HTML 骨架,配置了完整的 SEO meta 标签(OG、Twitter Card)、PWA manifest、字体图标 CDN:
// pages/_document.tsx(简化)
class MyDocument extends Document {
render() {
return (
<html lang="en">
<Head>
<meta property="og:title" content="Next.js realworld example app" />
<meta property="og:url" content="https://next-realworld.now.sh/" />
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="//demo.productionready.io/main.css" />
<link rel="stylesheet" href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" />
</Head>
<body>
<Main />
<NextScript />
</body>
</html>
);
}
}
对比 Nuxt 3,_app.tsx 相当于 app.vue + layouts/default.vue,_document.tsx 则是 Nuxt 3 的 app.vue 中的 <Head> 部分。
与 Nuxt 3 pages/ 的对比
|
路由语法 |
[pid].tsx |
[slug].vue |
|
全局入口 |
_app.tsx + _document.tsx |
app.vue + layouts/ |
|
布局系统 |
手动在 _app.tsx 中实现 |
layouts/ 自动目录 |
|
中间件 |
无内置(通过 _app.tsx 或 HOC 实现) |
middleware/ 目录 |
|
路由参数 |
router.query.pid |
useRoute().params.slug |
文件路由的本质是"用目录结构替代路由配置"。看一眼 pages/ 目录,就知道整个应用有哪些页面。但文件路由也有边界——复杂的嵌套路由、模态框路由(如 /articles/1?showComments=true)就不太适合。理解文件路由的适用范围,比记住语法更重要。
路由决定了页面结构,那页面数据从哪来?Next.js 提供了 getInitialProps 做服务端预取,再加上 SWR 做客户端数据获取——这是本期最核心的知识点。
数据获取:SWR + getInitialProps
getInitialProps 服务端预取
Pages Router 时代,getInitialProps 是服务端数据预取的标准方式。来看 pages/article/[pid].tsx 的真实用法:
// 在页面组件上定义静态方法
ArticlePage.getInitialProps = async ({ query: { pid } }) => {
const { data } = await ArticleAPI.get(pid);
return data;
};
执行时机:
-
首次页面请求:在服务端执行,渲染出完整 HTML 返回给浏览器
-
客户端导航:在客户端执行,通过 AJAX 获取数据
这意味着同一个函数,在服务端和客户端都会执行——你不需要写两套逻辑。
SWR 客户端数据获取
SWR 是这个项目的核心数据获取模式。看看 components/home/Tags.tsx 的真实写法:
import useSWR from "swr";
import { SERVER_BASE_URL } from "../../lib/utils/constant";
import fetcher from "../../lib/utils/fetcher";
const Tags = () => {
const { data, error } = useSWR(`${SERVER_BASE_URL}/tags`, fetcher);
if (error) return <ErrorMessage message="Cannot load popular tags…" />;
if (!data) return <LoadingSpinner />;
const { tags } = data;
return (
<div className="tag-list">
{tags?.map((tag) => (
// 渲染标签列表
))}
</div>
);
};
SWR 的核心策略——stale-while-revalidate(先返回缓存,再后台刷新):
先检查缓存中有没有数据,有就直接返回
同时在后台发起请求获取新数据
新数据回来后更新 UI
这个模式在 components/article/ArticleList.tsx 中体现得更充分:
const ArticleList = () => {
const page = usePageState();
const { vw } = useViewport();
const router = useRouter();
const { asPath, pathname, query } = router;
const { tag, follow, pid } = query;
// 根据当前路由拼出不同的 API URL
let fetchURL = `${SERVER_BASE_URL}/articles?offset=${page * DEFAULT_LIMIT}`;
switch (true) {
case !!tag:
fetchURL = `${SERVER_BASE_URL}/articles${asPath}&offset=${page * DEFAULT_LIMIT}`;
break;
case pathname.startsWith(`/profile`) && !!favorite:
fetchURL = `${SERVER_BASE_URL}/articles?favorited=${encodeURIComponent(String(pid))}&offset=${page * DEFAULT_LIMIT}`;
break;
// …
}
const { data, error } = useSWR(fetchURL, fetcher);
if (error) return <ErrorMessage message="Cannot load recent articles…" />;
if (!data) return <LoadingSpinner />;
// …
};
这个组件的设计很有意思——一个组件根据 URL 参数判断要请求哪个 API,首页、标签筛选、用户主页、关注流都复用同一个组件。
水合模式:initialData
在 pages/article/[pid].tsx 中,getInitialProps 和 SWR 通过 initialData 配合:
const ArticlePage = (initialArticle) => {
const { data: fetchedArticle } = useSWR(
`${SERVER_BASE_URL}/articles/${encodeURIComponent(String(pid))}`,
fetcher,
{ initialData: initialArticle } // 服务端预取的数据作为初始值
);
const { article }: Article = fetchedArticle || initialArticle;
// 渲染文章内容
};
流程是这样的:
用户首次访问 /article/some-slug → 服务端执行 getInitialProps → 获取文章数据
服务端渲染完整 HTML(含文章内容)→ 返回给浏览器 → 用户直接看到内容
浏览器加载 JS → React 水合(hydrate)→ SWR 接管
客户端导航到另一篇文章 → getInitialProps 在客户端执行 → SWR 管理后续数据
lib/utils/fetcher.ts——API 封装
// lib/utils/fetcher.ts
import axios from "axios";
const updateOptions = () => {
if (typeof window === "undefined") return {}; // 服务端渲染时跳过
if (!window.localStorage.user) return {};
if (Object.keys(window.localStorage.user).length === 0) return {};
const user = JSON.parse(window.localStorage.user);
if (!!user.token) {
return {
headers: {
Authorization: `Token ${user.token}`,
},
};
}
};
export default async function (url) {
const { data } = await axios.get(url, updateOptions());
return data;
}
这里有一个重要的 SSR 兼容处理:typeof window === "undefined" 时返回空对象,因为服务端没有 localStorage。
lib/api/ 模块化封装
lib/api/article.ts 展示了模块化的 API 封装模式:
// lib/api/article.ts
import axios from "axios";
import { SERVER_BASE_URL } from "../utils/constant";
const ArticleAPI = {
all: (page, limit = 10) =>
axios.get(`${SERVER_BASE_URL}/articles?${getQuery(limit, page)}`),
byAuthor: (author, page = 0, limit = 5) =>
axios.get(`${SERVER_BASE_URL}/articles?author=${encodeURIComponent(author)}&${getQuery(limit, page)}`),
get: (slug) => axios.get(`${SERVER_BASE_URL}/articles/${slug}`),
create: async (article, token) => {
const { data, status } = await axios.post(
`${SERVER_BASE_URL}/articles`,
JSON.stringify({ article }),
{
headers: {
"Content-Type": "application/json",
Authorization: `Token ${encodeURIComponent(token)}`,
},
}
);
return { data, status };
},
// …
};
export default ArticleAPI;
注意封装风格:使用对象字面量而非 class 或函数式,每个方法直接调用 axios。这种模式在小型项目中足够清晰,但大型项目可能需要更复杂的封装(如请求拦截器、错误统一处理)。
乐观更新
components/article/ArticlePreview.tsx 展示了 SWR 的乐观更新模式:
const handleClickFavorite = async (slug) => {
// 先更新 UI(乐观更新)
setPreview({
…preview,
favorited: !preview.favorited,
favoritesCount: preview.favorited
? preview.favoritesCount – 1
: preview.favoritesCount + 1,
});
// 再发出请求
try {
if (preview.favorited) {
await axios.delete(`${SERVER_BASE_URL}/articles/${slug}/favorite`, {
headers: { Authorization: `Token ${currentUser?.token}` },
});
} else {
await axios.post(`${SERVER_BASE_URL}/articles/${slug}/favorite`, {}, {
headers: { Authorization: `Token ${currentUser?.token}` },
});
}
} catch (error) {
// 请求失败时回滚
setPreview({
…preview,
favorited: !preview.favorited,
favoritesCount: preview.favorited
? preview.favoritesCount – 1
: preview.favoritesCount + 1,
});
}
};
用户点击"喜欢"按钮时,先更新 UI,再发请求。如果请求失败,回滚到之前的状态。用户感知到的延迟几乎是零。
与 Nuxt 3 useAsyncData 的对比
|
策略 |
stale-while-revalidate |
服务端渲染 + 客户端水合 |
|
缓存 |
全局缓存,自动管理 |
请求去重,键值缓存 |
|
乐观更新 |
原生支持(mutate + trigger) |
需手动实现 |
|
服务端预取 |
getInitialProps 配合 |
内置(组件内直接使用) |
|
自动重试 |
内置(网络恢复时) |
需手动配置 |
|
焦点重新验证 |
内置(浏览器 tab 切换时) |
无 |
SWR 的"先缓存再刷新"策略,在日常使用中你其实每天都在体验——比如微博刷新时,先显示旧的 timeline,后台拉取新数据,然后无缝更新。SWR 把这个模式变成了一个 hook。对比传统的"请求 → loading → 显示"模式,SWR 让用户"永远有内容看"。但想想:什么场景下 SWR 不适合?(提示:强一致性场景,比如支付状态查询。)
数据获取解决了"数据怎么来",但数据来了之后,"状态怎么管"?这个项目选择了最轻量的方案——React Context。
状态管理:React Context
选择 Context 的权衡
这个项目没有使用 Redux、Zustand 等第三方状态管理库,而是选择了 React 内置的 Context API + useReducer。这个决策本身就是一个值得讨论的架构选择。
项目只有两个全局状态需要管理:
分页页码(用户浏览文章列表时记住当前页)
文章总数(用于分页组件渲染)
就这两个状态,用 Redux 确实杀鸡用牛刀了。
Context 分片设计:两个 Context
项目将状态拆分为两个独立的 Context,避免不必要的重渲染。这是 React Context 的优化实践——细粒度 Context 减少消费组件的不必要重渲染。
PageContext(分页页码,通过 sessionStorage 持久化):
// lib/context/PageContext.tsx
import React from "react";
import useSessionStorage from "../hooks/useSessionStorage";
const PageStateContext = React.createContext(undefined);
const PageDispatchContext = React.createContext(undefined);
const PageContextProvider = ({ children }) => {
const [page, setPage] = useSessionStorage("offset", 0);
return (
<PageDispatchContext.Provider value={setPage}>
<PageStateContext.Provider value={page}>
{children}
</PageStateContext.Provider>
</PageDispatchContext.Provider>
);
};
export const usePageState = () => React.useContext(PageStateContext);
export const usePageDispatch = () => React.useContext(PageDispatchContext);
export default PageContextProvider;
PageCountContext(文章总数,内存状态):
// lib/context/PageCountContext.tsx
const PageCountStateContext = React.createContext(undefined);
const PageCountDispatchContext = React.createContext(undefined);
const PageCountContextProvider = ({ children }) => {
const [pageCount, setPageCount] = React.useState(1);
return (
<PageCountDispatchContext.Provider value={setPageCount}>
<PageCountStateContext.Provider value={pageCount}>
{children}
</PageCountStateContext.Provider>
</PageCountDispatchContext.Provider>
);
};
export const usePageCountState = () => React.useContext(PageCountStateContext);
export const usePageCountDispatch = () => React.useContext(PageCountDispatchContext);
注意两个 Context 的设计差异:
-
PageContext 使用 useSessionStorage hook,数据持久化到 sessionStorage(页面刷新不丢失,关闭标签页重置)
-
PageCountContext 使用 React.useState,数据在内存中,页面刷新后重新获取
分片组合
lib/context/index.tsx 将两个 Context 组合在一起:
import React from "react";
import PageContext from "./PageContext";
import PageCountContext from "./PageCountContext";
const ContextProvider = ({ children }) => (
<PageContext>
<PageCountContext>{children}</PageCountContext>
</PageContext>
);
export default ContextProvider;
全局注入
在 _app.tsx 中包裹全局组件:
<ContextProvider>
<Layout>
<Component {…pageProps} />
</Layout>
</ContextProvider>
认证状态管理
认证状态没有使用 Context,而是走了一个更轻量的方案:
JWT token 存储:localStorage 的 user 键中
// lib/utils/storage.ts
const storage = async key => {
const value = localStorage.getItem(key);
return !!value ? JSON.parse(value) : undefined;
};
export default storage;
登录检测:
// lib/utils/checkLogin.ts
const checkLogin = (currentUser) =>
!!currentUser &&
currentUser?.constructor === Object &&
Object.keys(currentUser).length !== 0;
在组件中使用:通过 SWR 的 useSWR("user", storage) 获取用户状态
// components/common/Navbar.tsx
const { data: currentUser } = useSWR("user", storage);
const isLoggedIn = checkLogin(currentUser);
这种做法的好处是:不需要 Context 包裹,任何组件都能直接获取用户状态。SWR 的全局缓存天然充当了"状态中心"。
与 Nuxt 3 的对比
|
状态容器 |
React Context + useReducer |
useCookie 响应式引用 |
|
持久化 |
localStorage / sessionStorage |
Cookie(自动服务端同步) |
|
SSR 兼容 |
需手动处理(服务端无 localStorage) |
天然 SSR 兼容 |
|
认证 token |
localStorage.user → fetcher 注入 |
Cookie → apiFetch 自动携带 |
|
复杂度 |
低(内置 API,无额外依赖) |
低(内置 composable) |
与 Vue 3 Pinia 的对比
|
学习成本 |
低(React 内置) |
低(额外库但 API 简洁) |
|
调试工具 |
无内置(需 React DevTools) |
Pinia DevTools 专用 |
|
模块化 |
手动分片 |
defineStore 天然模块化 |
|
类型安全 |
泛型 + 手动约束 |
内置类型推断 |
|
服务端渲染 |
需手动处理 |
需额外配置 |
看到这里你可能有个疑问:为什么不用 Redux?为什么不用 Zustand?这个项目做出了一个"克制"的选择——Context + useReducer 已经够用了。真实项目中,状态管理工具的选择不是"哪个最流行",而是"哪个刚好够用"。这个项目只有用户认证和分页两个全局状态,Context 完全能 hold 住。如果状态越来越多,什么时候该上 Zustand 或 Redux?这是架构师需要判断的。
拆解完四个核心概念(文件路由、数据获取、API 封装、状态管理),我们做一个全景对比——把 React SPA、Vue 3 SPA、Nuxt 3 SSR、Next.js SSR 四篇放在一起,看看它们在架构层面的差异。
架构对比全景
四篇完整对比
|
渲染方式 |
CSR |
CSR |
SSR |
SSR |
|
路由 |
React Router 配置 |
Vue Router 配置 |
pages/ 文件路由 |
pages/ 文件路由 |
|
状态管理 |
useState/Context/Redux |
Pinia |
useCookie |
React Context |
|
数据获取 |
useEffect + fetch |
onMounted + axios |
useLazyAsyncData + $fetch |
getInitialProps + SWR |
|
组件导入 |
手动 import |
手动 import |
自动导入 |
手动 import |
|
服务端能力 |
无 |
无 |
server/ 目录 (Nitro) |
有 (API Routes,但本项目未用) |
|
部署 |
静态托管 |
静态托管 |
Node 服务 / Nuxt Hub |
Node 服务 / Vercel |
|
SEO |
差 |
差 |
好 |
好 |
|
首屏体验 |
白屏 → JS 加载 → 渲染 |
白屏 → JS 加载 → 渲染 |
HTML 直接显示 → 水合 |
HTML 直接显示 → 水合 |
架构对比图
几个关键差异
路由维度:配置路由(React Router / Vue Router)灵活但分散,你得自己去维护路由配置文件和组件之间的对应关系。文件路由(Next.js / Nuxt 3)约定优于配置,结构即路由——pages/ 下有什么文件,就有什么路由。
状态管理维度:React 生态从 Context 到 Redux 到 Zustand,没有官方标准。Vue 生态 Pinia 是官方推荐,社区共识度高。在 SSR 场景下,Next.js 的 Context 需注意服务端水合问题(typeof window === "undefined" 的判断到处都是),而 Nuxt 3 的 useCookie 天然兼容 SSR。
数据获取维度:SPA 靠 useEffect / onMounted + loading state 手动管理,开发体验是"请求 → 等待 → 显示"。Next.js 的 SWR 是"先显示缓存 → 再刷新 → 无缝更新"。Nuxt 3 的 useLazyAsyncData 是"服务端渲染 → 水合 → 客户端接管"。核心差异在于 SWR 的"stale-while-revalidate"哲学。
部署维度:SPA 扔到 Nginx 或 CDN 上就行,但所有路由需要 fallback 到 index.html。SSR 需要 Node.js 服务。Next.js 部署到 Vercel 是最省心的——vercel 命令一键部署,自动识别 Next.js 框架。
四篇对比下来,你会发现一个规律:SSR 框架解决的是"内容可见性"问题,SPA 框架解决的是"交互体验"问题。内容型网站(博客、电商、新闻)选 SSR,工具型应用(管理后台、编辑器)选 SPA。但更现实的情况是——客户要求"既要 SEO 又要交互丝滑",这时候 SSR + 客户端水合的混合模式才是答案。
理论说完了,我们来做点实际的事情——练习、部署,以及未来的进阶方向。
练习 + 部署 + 进阶
练习:添加文章搜索功能
这是一个串联全文知识点的练习。建议的步骤:
第一步:创建搜索页面 在 pages/ 下创建 search.tsx,路由映射为 /search。
第二步:设计 URL 参数 使用 ?q=keyword 作为搜索关键词参数,通过 useRouter 获取:
import { useRouter } from "next/router";
const SearchPage = () => {
const router = useRouter();
const { q } = router.query; // 搜索关键词
// …
};
第三步:getInitialProps 服务端预取 在页面组件上定义 getInitialProps,在服务端获取初始搜索结果。
第四步:SWR 客户端接管 使用 useSWR 处理后续搜索请求,配合 mutate 实现搜索结果的乐观更新。
提示:RealWorld API 的搜索接口是 GET /api/articles?search=keyword(具体参数请查阅 API 文档)。注意搜索关键词和分页参数的协同——getQuery 工具函数已经帮你处理了 limit 和 offset 参数。
部署:Vercel 一键部署
Next.js 部署到 Vercel 几乎是"一键"的:
注册 Vercel 账号(用 GitHub 登录)
导入 GitHub 仓库(reck1ess/next-realworld-example-app 或你自己的 fork)
配置环境变量:如果需要修改 API 地址,设置 NEXT_PUBLIC_API_URL
自动部署:每次 push 到 main 分支自动触发部署
自定义域名:在 Vercel 项目设置中配置
进阶路径
拆完这个项目后,如果想继续深入 Next.js 生态,建议按这个顺序学习:
App Router(Next.js 13+):学习 app/ 目录、Server Components、Layout 嵌套。这是 Next.js 现在的推荐模式,Pages Router 正在逐步被替代
NextAuth.js:完整的认证解决方案,支持 OAuth(Google、GitHub)、JWT、数据库适配器。比手动处理 localStorage + JWT 更健壮
tRPC:端到端类型安全的 API 层,适合全栈 TypeScript 项目。API 函数在前端直接调用,自动类型推导
Prisma + PostgreSQL:数据库 ORM 集成,和 Next.js 的 API Routes 配合使用,构建完整的全栈应用
Next.js 性能优化:ISR(增量静态生成)、图片优化(next/image)、字体优化(next/font)
下期预告
四篇对比系列到这里就完成了。后续可能的方向:
-
同一项目、四种架构的实战对比总结——从 React SPA 到 Vue 3 SPA 到 Nuxt 3 SSR 到 Next.js SSR,纵向对比同一功能在四种架构下的实现差异
-
全栈框架选型指南——Next.js vs Nuxt 3 vs Remix vs SvelteKit,哪个适合你的项目?
练习的关键不是"写出来",而是"改装"——在理解项目架构的基础上,新增一个功能模块。搜索功能看似简单,但它涉及了本期所有知识点:新路由(Pages Router)、数据获取(getInitialProps + SWR)、API 调用(agent)、状态管理(Context)。一个练习,串联全文。试着找找 RealWorld API 里有没有搜索接口,再想想搜索关键词怎么和分页状态协同——这是真实的工程问题。
结语
回顾全文
从 SPA 的痛点出发,我们拆解了 Next.js SSR 如何解决"内容可见性"问题。通过 reck1ess/next-realworld-example-app 这个真实项目,我们深入了 Pages Router 文件路由、getInitialProps + SWR 数据获取、React Context 状态管理三大核心。
核心落点:架构思维的跃迁
从 React SPA 到 Next.js SSR,不是"学一个框架",是换一种思考方式:
-
SPA 思维:前端只管渲染,后端只管接口,中间用 API 连接
-
SSR 思维:前端要考虑服务端渲染、数据水合、SEO 优化、缓存策略、认证状态在服务端和客户端的同步
这种思维跃迁,是前端工程师从"组件开发"到"全栈架构"的关键一步。
系列收束
四篇文章的完整链路:React SPA → Vue 3 SPA → Nuxt 3 SSR → Next.js SSR。技术是工具,架构思维才是核心。不管是 React 还是 Vue,不管是 SPA 还是 SSR,理解"为什么选这个架构"比"怎么用这个框架"更重要。
欢迎在评论区交流你的想法和问题。
参考资料
Next.js Pages Router 官方文档 — Next.js 框架的核心文档
SWR 官方文档 — SWR 数据获取库的完整文档和使用指南
RealWorld 规范 — 全栈 CRUD 示例规范,涵盖多种框架实现
reck1ess/next-realworld-example-app — 本文分析的源码仓库
Next.js 官方交互式教程 — 从入门到实践的官方教程
SWR 最佳实践 – 性能优化 — SWR 性能调优进阶指南
Thinkster RealWorld 系列教程 — RealWorld 规范的完整教程系列
RealWorld 在线 Demo — 项目在线演示地址



