如何将一个 React SPA 项目迁移到 Next.js 服务端渲染

日期:2025年11月24日

价值

  1. "渐进式迁移"策略:学会最大程度保护现有投资,实现技术栈的平稳升级帮助学员使用 Next.js 实现服务端渲染 。
  2. 征服服务端渲染,解决核心业务痛点: 您将系统掌握 Next.js 的核心能力(SSR/SSG/ISR),从根本上解决传统 React SPA 面临的页面加载缓慢、SEO 不友好两大难题,从而提升用户体验和商业转化率。

前言

  1. 本文将聚焦渲染原理,不深入讲解 Next.js 的具体使用细节,而是通过一个真实案例,以战代练的方式带你将 React SPA 应用迁移至 Next.js 框架。
  2. 结合大量实战项目中的踩坑经验,降低上手门槛,并借助第三方生态,助你构建完整的 Next.js 基础能力体系。
  3. 提供一套符合落地要求的 Next.js 构建与部署实用教程。

目标

  1. 对服务端渲染祛魅,深入理解与掌握 SSR、SSG、CSR 等渲染模式的原理、优缺点及适用场景。
  2. 具备平滑迁移与架构设计手段,具备技术落地能力。
  3. 能够将 Next.js 应用顺利部署到云上环境。

目录/结构

  1. 第一个模块主要包括如何最简单的上手 Next.js,包括环境准备、基本的渲染原理、与 SPA 应用的开发差异
  2. 第二个模块用一个实际大型网站迁移路线,说明我们从一个大型网站如何一步一步重构成Next应用,从而实现网页性能优化的过程。
  3. 第三个模块包含了如何进行落地的部署方案

第一章:Next.js 最简单的上手教程

如何最低成本的上手 Next.js 呢?

内心OS:

  • 之前也没有学过相关的知识。是不是很难啊?如果很难的话那算了,我还是回去画我的页面吧/_ \

第一节:前置准备

1、开发环境

基于Next 15 最新api,所以对运行环境有一定要求

  • Node.js 18.18 或更高LTS版本
  • macOS、Windows(含 WSL)或 Linux 系统

2、项目初始化

为了大家更好的上手,我准备了两套可用于生产环境的模版供大家使用

  • 通用版本:codelab.msxf.com/public-repo...

  • 精简版本:codelab.msxf.com/public-repo...

    能力 通用版本 精简版本
    React 版本 19.2.0 18.3.0
    组件库 Ant Design v5 - -
    应用状态管理 Zustand - -
    服务端请求 demo - -
    客户端请求 demo - -
    CSS预处理器:Sass - -
    代码检查 Typescript,Eslint 等 - -
    单元测试 Jest - -
    多环境部署方案 - -
    自定义服务器 Koa - -
    css 样式烘焙 - -
    前端监控 @sentry/react - -
    前端埋点 UBS - -
    微前端方案 qiankun 2.10.16,应用动态更新,微前端通信 模块联邦 2.0

第二节:认识文件系统约定

1、认识组件层级结构

  • layout.js布局文件,默认Server Components
  • template.js 同 layout.js,但是在导航时重新同步执行 useEffect
  • error.js (React 错误边界)
  • loading.js (React Suspense 边界)
  • not-found.js (notFound 函数执行时渲染 UI)
  • page.js 或嵌套的 layout.js(必要的)

2、了解路由嵌套关系

嵌套文件夹定义了路由结构。每个文件夹代表一个路由段,对应 URL 路径中的一个段。只有当路由段中添加了 page.jsroute.js 文件时,该路由才会对外可访问

  • 嵌套路由

    • 最基础的文件路由关系

    规则 说明 实例 用户URL
    folder 路由段 app/folder/page.js /folder
    folder/folder 嵌套路由段 app/folder/folder/page.js /folder/folder
  • 动态路由

    • 无法提前确定确切的路由段名称,并希望根据动态数据创建路由时

    规则 说明 实例 用户URL
    [folder] 动态路由段 app/[slug]/page.js /``shop``/``shop1
    [...folder] 全捕获路由段 app/shop/[...slug]/page.js /shop/a``/shop/a/b``/shop/a/b/c
    [[...folder]] 可选全捕获路由段 通配段和可选通配段的区别在于,可选情况下,不带参数的路由也会被匹配(如上例中的 /shop 以上
    • 例如,博客可以包含以下路由 app/blog/[slug]/page.js,其中 [slug] 是博客文章的动态段
    javascript 复制代码
      export default async function Page({
        params,
      }: {
        params: Promise<{ slug: string }>
      }) {
        const { slug } = await params
        return <div>我的文章: {slug}</div>
      }
  • 路由组与私有文件夹

    • 表示该文件夹仅用于组织目的,通常用来引入公共布局

    规则 说明 实例 用户URL
    (folder) 不影响实际路由的分组 app/(shop)/a/page.js /a
    _folder 将文件夹及其子路由段排除在路由系统外 - -
  • 拦截路由

    • 在后退导航时关闭模态框而非返回上一路由
    • 在前进导航时重新打开模态框
    规则 说明 实例
    (.)folder 拦截同级路由 -
    (..)folder 拦截上一级路由 -
    (..)(..)folder 拦截上两级路由 -
    (...)folder 从根路由拦截 -
    • 例如,当点击信息流中的照片时,你可以在模态框中显示该照片并覆盖在信息流上方。这种情况下,Next.js 会拦截 /photo/123 路由,隐藏 URL 并将其覆盖在 /feed 之上。
  • 并行路由

    规则 说明 实例
    @folder 命名插槽,一个路由命中多个页面 见下方说明

在一个仪表盘应用中,您可以使用并行路由同时或条件性渲染 teamanalytics 两个页面

第三节:页面开发

1、页面概念-RSC渲染机制

Next.js 15 的 App Router 通过服务端组件RSC 渲染机制(服务端组件/客户端组件)模糊了传统 SSR 和 CSR 的严格界限,通过混合渲染模式开发者只需关注"服务端逻辑 "与"客户端逻辑"的划分,而非显式选择渲染模式

第一个需要改变的思维模式是从"页面级"的渲染转变为"组件级"的渲染

假如我们有一个组件如下:

javascript 复制代码
// ServerComponent.js (一个 RSC)
import ClientComponent from './ClientComponent';

async function ServerComponent() {
  // 在服务器上直接进行数据获取
  const data = await fetch('/api/user');
  return (
    <div>
      {/* 服务器渲染这部分 */}
      <h1>我的博客</h1>
      {/* 这里"嵌入"了一个客户端组件 */}
      <ClientComponent initialPosts={data} />
    </div>
  );
}

在这个过程中发生了什么?

  1. 服务器执行 ServerComponent

  2. 服务器从接口/数据库获取 data

  3. 服务器渲染 <h1>我的博客</h1>

  4. 当服务器遇到 <ClientComponent>时,它会为这个客户端组件"留出一个位置",并将 initialPosts作为 prop 序列化。

  5. 最终,服务器发出的流式传输响应包含:

    1. ServerComponent渲染出的 HTML 结构。
    2. 客户端组件位置的标记。
    3. 客户端组件所需的初始数据(序列化的 props)。
    4. 指向客户端组件所需 JavaScript 的链接。
  6. 浏览器收到响应后,会立即显示由服务端渲染好的 HTML 内容(极快的首屏显示)。

  7. 然后,React 会进行 Hydration。但这里的 Hydration 是细粒度的。它只会下载并激活客户端组件部分的 JavaScript,使它们变得可交互。服务端组件的 JavaScript 永远不会发送到客户端。

时间 服务器行为 用户看到什么
0s 开始渲染 空白屏
0.1s 遇到异步操作,立即发送 loading UI 看到静态内容部分看到 ServerComponent"加载中,请稍候..."+ 客户端下载资源、解析资源、渲染内容
2s 数据获取完成,发送实际内容 看到完整的页面内容

思考:当接口需要2s,那是不是所有用户都需要等待 2s loading 之后才能看到页面呢?

2、服务端渲染组件

适用场景:服务端获取数据渲染部分 UI,并将其流式传输到客户端。这些场景更适合服务端渲染

  • 提升首次内容绘制 (FCP),并逐步将内容流式传输到客户端
  • 从数据库或靠近数据源的 API 获取数据
  • 使用 API 密钥、令牌等敏感信息而不暴露给客户端
  • 减少发送到浏览器的 JavaScript 体积

默认情况下,组件都是服务端组件

javascript 复制代码
// 引入客户端组件
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
import { db, posts } from '@/lib/db'

export default async function Page({ params }: { params: { id: string } }) {
  const post = await getPost(params.id)
  const allPosts = await db.select().from(posts)

  return (
    <div>
      <main>
        <h1>{post.title}</h1>
        {/* ... */}
        <LikeButton likes={post.likes} />
        <ul>      
          {allPosts.map((post) => (        
            <li key={post.id}>{post.title}</li>
          ))}    
        </ul>
      </main>
    </div>
  )
}

3、客户端渲染组件

适用场景:当需要交互性 或使用浏览器 API

通过在文件顶部(导入语句之前)添加 "use client" 指令来创建客户端组件。

这时候将你的SPA页面复制到客户端组件中,也能完美运行。

javascript 复制代码
'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>{count} likes</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

注意: 在客户端组件中嵌套服务端渲染的 UI,需要服务端组件作为 prop 传递给客户端组件。不能在客户端组件中直接 import 引入服务端组件

javascript 复制代码
'use client'

export default function Modal({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>
}
javascript 复制代码
import Modal from './ui/modal' // 客户端组件
import Cart from './ui/cart'   // 服务端组件

// 服务端组件
export default function Page() {
  return (
    <Modal>
      <Cart />
    </Modal>
  )
}

思考:为什么客户端组件不能直接 import 服务端组件,反过来服务端组件就可以呢??

第二章:从 CSR 到混合渲染迁移路线

第一节:生态对比关系图

云微前端主项目 Umi 4 到 Next 15

暂时无法在唯科之家2.0文档外展示此内容

第二节:生态迁移细节

1、webpack 迁移到 Turbopack

遇到的问题

  • Q1:没有了svgr,如何支持.svg 文件并将其渲染为 React 组件呢?

    css 复制代码
      // 使用 @svgr/webpack 加载器,该加载器支持导入 .svg 文件并将其渲染为 React 组件
    
      module.exports = {
        turbopack: {
          rules: {
            '*.svg': {
              loaders: ['@svgr/webpack'],
              as: '*.js',
            },
          },
        },
      }
  • Q2:umi 自定义插件迁移问题,通过插件启用Brotli静态压缩

    • next方案不是很完美,默认情况下,当使用 next start 或自定义服务器时,Next.js 会使用 gzip 压缩渲染内容和静态文件。如果想要用Brotli压缩代码,则需要这样做:
    java 复制代码
      module.exports = {  
        compress: false,
      }
    • 如果您正在使用 nginx 并希望改用 brotli 压缩,可以将 compress 选项设为 false 以让 nginx 处理压缩(其实也并不完美,这种方案需要依赖服务器压缩,而不是构建时压缩)。

2、antd 升级

遇到的问题

  • Q1:为什么使用在 Next.js 中直接 <Select.Option /> 、<Typography.Text />会报错,需要从路径引入这些子组件来避免错误

    vbnet 复制代码
      <Select.Option value="1">Option 1</Select.Option>
    • A:服务器组件树序列化问题,当使用 Select.Option这样的点表示法时,实际上是在引用一个对象的属性。Next.js 的 RSC 系统需要能够静态分析组件树,以便正确序列化和在客户端重建组件树。
    • 说人话就是RSC 系统在编译时需要确定:哪些是服务端组件,哪些是客户端组件以及组件之间的边界关系
    • ant.design/docs/react/...
  • Q2:服务端渲染中如何注入样式

    • A:有两种方式在服务端渲染消费组件样式,各有好处

    • 内联:直接将样式内联到所使用的组件上

      • 好处是没有css请求
      • 缺点是如果使用了多个同样的组件,会内联多份相同的样式,造成 HTML 体积增大,影响首屏渲染速度
      • 见:ant.design/docs/react/...
    • 烘焙:类似 antd 4,将项目中使用过的组件样式提取出一份单独的css

      • 好处是打开任意页面时如传统 css 方案一样都会复用同一套 css 文件以命中缓存
      • 缺点是多主题的情况下会烘焙多份样式
      javascript 复制代码
        import React from 'react';
        import fs from 'fs';
        import { extractStyle } from '@ant-design/static-style-extract';
        import AntdConfigProvider from './AntdConfigProvider';
        import { IS_PRODUCTION } from '../src/constants/config';
      
        const outputPath = IS_PRODUCTION ? './public/antd.min.css' : './public/antd.environment.css';
      
        const css = extractStyle((node) => <AntdConfigProvider>{node}</AntdConfigProvider>);
      
        fs.writeFileSync(outputPath, css);

2、状态管理

从 umi 到 Zustand, 由于 Next.js 本身并不提供状态管理工具。我们需要自己选型一款稳定、简单、好用的状态管理工具

看看 React 各种 状态管理工具 Npm 下载趋势

Zustand 的使用本身很简单,定义一个Hooks,然后抛出状态和改变状态的方法即可

javascript 复制代码
 // src/stores/counter-store.ts
import { create } from 'zustand/vanilla'

const useCountStore = create((set) => ({
    count: 0,
    inc: () => set((state) => ({ count: state.count + 1 })),
}))

// src/components/pages/home-page.tsx
import { useCountStore } from '@/providers/counter-store-provider.ts'

export const HomePage = () => {
  const { count, inc} = useCountStore ((state) => state)
}

但这是在SPA中使用,在服务端渲染中对 Zustand 使用不可变数据提出了一些独特的挑战,所以我们需要一点小小的改变

  • 定义一个 Provider 防止组件重复执行初始化数据

    typescript 复制代码
      import { createStore } from 'zustand';
      import { type ReactNode, createContext, useContext, useEffect, useRef } from 'react';
    
      export const createUserStore = () => {
        return createStore<UserStore>()((set) => ({
          userInfo: {},
          refreshUserFn: async (userInfo?: UserState ) => {
            set(() => ({ ...state, userInfo: userInfo }));
          },
        }));
      };
    
      export const UserStoreContext = createContext<UserStoreApi | undefined>(undefined);
    
      /**
      * 创建地域上下文
      * 避免 createUserStore 重复执行
      */
      export const UserStoreProvider = ({ children }: UserStoreProviderProps) => {
        const userStore = createUserStore();
        const storeRef = useRef<UserStoreApi>(null);
        if (!storeRef.current) {
          storeRef.current = userStore;
        }
        return <UserStoreContext.Provider value={userStore}>{children}</UserStoreContext.Provider>;
      };
    
      // 消费或初始化用户数据
      export const useUserStore = <T,>(selector: (store: UserStore) => T): T => {
        const UserContext = useContext(UserStoreContext);
        if (!UserContext) {
          throw new Error(`useUserStore must be used within UserStoreProvider`);
        }
        return useStore(UserContext, selector);
      };
  • 在全局入口挂载并初始化 Provider

    javascript 复制代码
      import { UserStoreProvider } from '@/providers/userStoreProvider';
      export default function Layout({children}) {
        return (
          <UserStoreProvider> {children}</UserStoreProvider >
        )
      }
  • 消费/变更用户数据

    ini 复制代码
       const userInfo = useUserStore((state) => state.userInfo);

3、请求库

  • 服务端 fetch

    • Next.js 扩展了 Web fetch() API,允许服务器上的每个请求设置自己的持久化缓存和重新验证语义。

    • 新增两个api,配合 RSC 渲染实现缓存机制

    • cache :配置请求如何与 Next.js 数据缓存 (Data Cache) 交互

      • auto no cache (默认值):Next.js 会在每次请求时获取资源,且会在next build 时会获取一次
      • no-store: Next.js 会在每次请求时获取资源
      • force-cache: Next.js 会在其数据缓存中查找匹配的请求。如果找到匹配且未过期,将从缓存返回。如果没有匹配或匹配已过期,Next.js 将从远程服务器获取资源并更新缓存。
    • revalidate: 设置资源的缓存生命周期(以秒为单位)。

      • false - 无限期缓存资源。语义上等同于 revalidate: Infinity。HTTP 缓存可能会随时间推移淘汰旧资源。
      • 0 - 阻止资源被缓存。
      • number - (以秒为单位)指定资源的缓存生命周期最多为 n 秒。
  • 客户端 fetch

    • 同 window.fetch

4、css 预处理器

从 less 到 Sass

5、声明路由到文件路由

  • 特点总结:声明式路由和文件路由各有特点,声明路由上手简单,路由结构清晰;文件路由有一定上手门槛,控制颗粒度更细

    声明路由 文件路由 差异化
    重定向 redirct 文件拦截路由 文件拦截路由支持动态拦截,功能更强大
    声明嵌套路由 文件嵌套路由 无差异
    动态路由 文件动态路由 功能无差异。文件路由可以按需添加Loading.js,not-found.js等处理文件,控制颗粒度会更细,声明路由需要根据pathname手动处理公共逻辑
    - 文件并行路由 在设计条件路由、权限路由、标签页、URL模态框的时候比较有用

6、微前端

在 Next.js中实现 @umi/plugin-qiankun 插件功能

  • 原先umi框架中自带了一款非常好用的插件 @umi/plugin-qiankun 这让微前端接入、更新、状态传输变的异常简单,但是在 Next.js 中我们需要自己实现这个插件及相关功能

    • 暂时无法在唯科之家2.0文档外展示此内容

通过服务端server实现qiankun运行时注册,通过 Zustand + qiankun 实现子应用动态更新

第三章:部署方案

第一节:构建与部署

1、基础镜像

  • base/nodejs_nginx_anolios_brotli:v22.14.0_1.21.4.1

此前的基础镜像主要面向SPA应用,承担网络代理与静态资源转发的功能。在引入Next.js服务端渲染方案后,我们还需要部署一套Node服务。按照原有部署流程,需单独申请一个应用来运行Node服务,并额外配置一个Nginx应用,用于处理代理与静态资源相关配置。

为简化部署流程,我们现已重新构建了一款Base镜像,支持在单一实例中同时启动两个进程:Nginx服务负责请求转发和静态资源处理,而Node进程则用于运行Next.js服务。

2、构建

  • standalone

以前使用 Docker 部署时,需要安装包中 dependencies 的所有文件才能运行 next start。从 Next.js 12 开始,可以利用 .next/ 目录中的输出文件追踪功能.nft.json,仅包含必要的文件.next 目录

我们通过开启 standlone 特性,此时 Next.js 可以根据.nft.json自动创建一个 standalone 文件夹,仅复制生产部署所需的文件,包括 node_modules 中的选定文件。还会输出一个最小化的 server.js 文件,可用于替代 next start

优化之前构建时长+镜像制作(包含打包modules时间)共计15分钟。优化之后构建时长和镜像制作约3分钟,当然启用 standalone 也为我们带来一些挑战

  • 默认 next build 部署方式
  • 使用 standalone 构建
  • Q1 :当我们使用 pnpm 构建时,报错 Error: EPERM: operation not permitted, symlink

    • A:windows系统非管理员权限 问题 (相关Discussions),
    • 方法一:可以通过设置,这会扁平化pnpm依赖,需要注意的是幽灵依赖问题
    ini 复制代码
       ## 禁用符号链接来避免此问题,避免非管理员 standalone 构建报错
      symlink=false
      node-linker=hoisted
    • 方法二:本地构建通过环境变量用 output: 'export', // 而不是 'standalone', 生产环境还是使用standalone,需要注意两种构建方式产物差异的问题,这可能会导致本地开发效果跟生产不一致
    • 方法三:换电脑 or 申请管理员权限
  • Q2:standalone 为了极致的性能甚至不会复制 public.next/static 文件夹,他默认我们会将这些文件内容部署到cdn

    • A:当我们没有cdn服务器的时候,需要额外的复制这些文件到 .next文件夹下
    vbnet 复制代码
      cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
  • Q3:如何自定义端口或主机名

    • A:在启动命令中添加
    ini 复制代码
      PORT=3000 HOSTNAME=0.0.0.0

3、部署

  • 关于启动命令,先启动 Next.js 相关 Node 服务器(内部服务器,包含环境变量、端口、主机名称),在启动Nginx 服务(提供外部访问,含动态域名解析)

    bash 复制代码
      HOSTNAME=localhost SERVER_API_ENV=online node ./standalone/server.js & bash /opt/nginx/conf/resolve.sh && /opt/nginx/runnginx.sh

第二节:Nginx 配置说明

1、服务端代理

bash 复制代码
################ 网关配置 #####################
################ 网关配置 #####################
# 网关代理
location ~ ^/(noauth|portal)/ {
  set $proxy_url http://gc-gw-server.mscloud.lo;
  proxy_pass $proxy_url;
}

2、微前端子应用资源代理

通过子应用资源代理,子应用就不用申请公网域名

perl 复制代码
################ 主项目加载子项目静态页面配置 #####################
################ 主项目加载子项目静态页面配置 #####################
# 子应用转发
location ~ /msportal/(\w+)/(.*) {
  # 静态资源根据hash名称缓存
  add_header Cache-Control $cache_control_header;
  proxy_pass http://gc-$1-fe.mscloud.lo/$2$is_args$args;
}

3、Next.js 资源兜底

在配置 SPA Nginx 服务器的时候,通常我们会返回一个兜底的静态页面,防止应用白屏化。同样的,我们也可以在Nginx里面将没有代理到的请求通通转向 Next.js 应用,由应用来处理各种异常情况

bash 复制代码
################ 其他所有请求交给 Next.js 处理 #####################
################ 其他所有请求交给 Next.js 处理 #####################
# 核心Next.js资源
location /_next/static {
  proxy_pass http://localhost:3000;
  expires 30d;
}
# 静态资源缓存设置
location /public {
  proxy_pass http://localhost:3000;
  expires 30d;
}
# 核心路由代理(指向 Node 应用)
location / {
  add_header Cache-Control $cache_control_header;
  proxy_pass http://localhost:3000;
}
相关推荐
1 小时前
使用 svgfmt 优化 SVG 图标
前端·svg·icon
Watermelo6171 小时前
href 和 src 有什么区别,它们对性能有什么影响?
前端·javascript·vue.js·性能优化·html·html5·用户体验
hqk2 小时前
鸿蒙零基础语法入门:开启你的开发之旅
android·前端·harmonyos
AAA阿giao2 小时前
大厂面试之反转字符串:深入解析与实战演练
前端·javascript·数据结构·面试·职场和发展·编程技巧
专业抄代码选手2 小时前
告别“屎山”:用 Husky + Prettier + ESLint 打造前端项目的代码基石
前端
想进字节冲啊冲2 小时前
Vibe Coding 实战指南:从“手写代码”到“意图设计”的前端范式转移
前端·ai编程
离&染2 小时前
宝塔nginx一个域名部署两个前端和两个后端(VUE3)
前端·nginx
朱哈哈O_o3 小时前
前端通用包的作用——md5篇
前端
Lsx_3 小时前
🔍 React 有 useAntdTable,Vue3 怎么办?自封一个 useTable!
前端·javascript·vue.js