欢迎光临
我们一直在努力

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 }
);

// …
};

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

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

    维度

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

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

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

  • 新数据回来后更新 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 的对比

    维度

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

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

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

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

  • 就这两个状态,用 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 的对比

    维度

    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 工具函数已经帮你处理了 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 — 项目在线演示地址

  • 赞(0)
    未经允许不得转载:171主机测评 » Next.js 全栈实战:拆解 RealWorld 项目——React SSR 入门
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址