ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Next.js 全栈实战:拆解 RealWorld 项目——React SSR 入门

2026/8/15 9:41:42 拓冰建站 浏览量
Next.js 全栈实战:拆解 RealWorld 项目——React SSR 入门

本文是「同一 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 } ); // ... };

这里有几个值得注意的点:

  1. router.query.pid获取路由参数,参数名和文件名一致([pid]pid

  2. encodeURIComponent处理 slug 中的特殊字符

  3. 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/ 的对比

维度

Next.js Pages Router

Nuxt 3

路由语法

[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(先返回缓存,再后台刷新):

  1. 先检查缓存中有没有数据,有就直接返回

  2. 同时在后台发起请求获取新数据

  3. 新数据回来后更新 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; // 渲染文章内容 };

流程是这样的:

  1. 用户首次访问/article/some-slug→ 服务端执行getInitialProps→ 获取文章数据

  2. 服务端渲染完整 HTML(含文章内容)→ 返回给浏览器 → 用户直接看到内容

  3. 浏览器加载 JS → React 水合(hydrate)→ SWR 接管

  4. 客户端导航到另一篇文章 →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 的对比

维度

Next.js (SWR)

Nuxt 3 (useLazyAsyncData)

策略

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。这个决策本身就是一个值得讨论的架构选择。

项目只有两个全局状态需要管理:

  1. 分页页码(用户浏览文章列表时记住当前页)

  2. 文章总数(用于分页组件渲染)

就这两个状态,用 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使用useSessionStoragehook,数据持久化到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,而是走了一个更轻量的方案:

  1. JWT token 存储localStorageuser键中

// lib/utils/storage.ts const storage = async key => { const value = localStorage.getItem(key); return !!value ? JSON.parse(value) : undefined; }; export default storage;
  1. 登录检测

// lib/utils/checkLogin.ts const checkLogin = (currentUser) => !!currentUser && currentUser?.constructor === Object && Object.keys(currentUser).length !== 0;
  1. 在组件中使用:通过 SWR 的useSWR("user", storage)获取用户状态

// components/common/Navbar.tsx const { data: currentUser } = useSWR("user", storage); const isLoggedIn = checkLogin(currentUser);

这种做法的好处是:不需要 Context 包裹,任何组件都能直接获取用户状态。SWR 的全局缓存天然充当了"状态中心"。

与 Nuxt 3 的对比

维度

Next.js (React Context)

Nuxt 3 (useCookie)

状态容器

React Context + useReducer

useCookie 响应式引用

持久化

localStorage / sessionStorage

Cookie(自动服务端同步)

SSR 兼容

需手动处理(服务端无 localStorage)

天然 SSR 兼容

认证 token

localStorage.user → fetcher 注入

Cookie → apiFetch 自动携带

复杂度

低(内置 API,无额外依赖)

低(内置 composable)

与 Vue 3 Pinia 的对比

维度

Next.js (React Context)

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 四篇放在一起,看看它们在架构层面的差异。


架构对比全景

四篇完整对比

维度

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工具函数已经帮你处理了limitoffset参数。

部署:Vercel 一键部署

Next.js 部署到 Vercel 几乎是"一键"的:

  1. 注册 Vercel 账号(用 GitHub 登录)

  2. 导入 GitHub 仓库(reck1ess/next-realworld-example-app或你自己的 fork)

  3. 配置环境变量:如果需要修改 API 地址,设置NEXT_PUBLIC_API_URL

  4. 自动部署:每次 push 到 main 分支自动触发部署

  5. 自定义域名:在 Vercel 项目设置中配置

进阶路径

拆完这个项目后,如果想继续深入 Next.js 生态,建议按这个顺序学习:

  1. App Router(Next.js 13+):学习app/目录、Server Components、Layout 嵌套。这是 Next.js 现在的推荐模式,Pages Router 正在逐步被替代

  2. NextAuth.js:完整的认证解决方案,支持 OAuth(Google、GitHub)、JWT、数据库适配器。比手动处理 localStorage + JWT 更健壮

  3. tRPC:端到端类型安全的 API 层,适合全栈 TypeScript 项目。API 函数在前端直接调用,自动类型推导

  4. Prisma + PostgreSQL:数据库 ORM 集成,和 Next.js 的 API Routes 配合使用,构建完整的全栈应用

  5. 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,理解"为什么选这个架构"比"怎么用这个框架"更重要。

欢迎在评论区交流你的想法和问题。


参考资料

  1. Next.js Pages Router 官方文档 — Next.js 框架的核心文档

  2. SWR 官方文档 — SWR 数据获取库的完整文档和使用指南

  3. RealWorld 规范 — 全栈 CRUD 示例规范,涵盖多种框架实现

  4. reck1ess/next-realworld-example-app — 本文分析的源码仓库

  5. Next.js 官方交互式教程 — 从入门到实践的官方教程

  6. SWR 最佳实践 - 性能优化 — SWR 性能调优进阶指南

  7. Thinkster RealWorld 系列教程 — RealWorld 规范的完整教程系列

  8. RealWorld 在线 Demo — 项目在线演示地址