日期:2025年11月24日
价值
- "渐进式迁移"策略:学会最大程度保护现有投资,实现技术栈的平稳升级帮助学员使用 Next.js 实现服务端渲染 。
- 征服服务端渲染,解决核心业务痛点: 您将系统掌握 Next.js 的核心能力(SSR/SSG/ISR),从根本上解决传统 React SPA 面临的页面加载缓慢、SEO 不友好两大难题,从而提升用户体验和商业转化率。
前言
- 本文将聚焦渲染原理,不深入讲解 Next.js 的具体使用细节,而是通过一个真实案例,以战代练的方式带你将 React SPA 应用迁移至 Next.js 框架。
- 结合大量实战项目中的踩坑经验,降低上手门槛,并借助第三方生态,助你构建完整的 Next.js 基础能力体系。
- 提供一套符合落地要求的 Next.js 构建与部署实用教程。
目标
- 对服务端渲染祛魅,深入理解与掌握 SSR、SSG、CSR 等渲染模式的原理、优缺点及适用场景。
- 具备平滑迁移与架构设计手段,具备技术落地能力。
- 能够将 Next.js 应用顺利部署到云上环境。
目录/结构
- 第一个模块主要包括如何最简单的上手 Next.js,包括环境准备、基本的渲染原理、与 SPA 应用的开发差异
- 第二个模块用一个实际大型网站迁移路线,说明我们从一个大型网站如何一步一步重构成Next应用,从而实现网页性能优化的过程。
- 第三个模块包含了如何进行落地的部署方案
第一章:Next.js 最简单的上手教程
如何最低成本的上手 Next.js 呢?
内心OS:
- 之前也没有学过相关的知识。是不是很难啊?如果很难的话那算了,我还是回去画我的页面吧/_ \
第一节:前置准备
1、开发环境
基于Next 15 最新api,所以对运行环境有一定要求
- Node.js 18.18 或更高LTS版本
- macOS、Windows(含 WSL)或 Linux 系统
2、项目初始化
为了大家更好的上手,我准备了两套可用于生产环境的模版供大家使用
-
精简版本: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 Componentstemplate.js同 layout.js,但是在导航时重新同步执行useEffecterror.js(React 错误边界)loading.js(React Suspense 边界)not-found.js(notFound函数执行时渲染 UI)page.js或嵌套的layout.js(必要的)

2、了解路由嵌套关系
嵌套文件夹定义了路由结构。每个文件夹代表一个路由段,对应 URL 路径中的一个段。只有当路由段中添加了
page.js或route.js文件时,该路由才会对外可访问
-
-
最基础的文件路由关系
规则 说明 实例 用户URL folder 路由段 app/folder/page.js/folderfolder/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]是博客文章的动态段
javascriptexport 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 命名插槽,一个路由命中多个页面 见下方说明
在一个仪表盘应用中,您可以使用并行路由同时或条件性渲染 team 和 analytics 两个页面

第三节:页面开发
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>
);
}
在这个过程中发生了什么?
-
服务器执行
ServerComponent。 -
服务器从接口/数据库获取
data。 -
服务器渲染
<h1>我的博客</h1>。 -
当服务器遇到
<ClientComponent>时,它会为这个客户端组件"留出一个位置",并将initialPosts作为 prop 序列化。 -
最终,服务器发出的流式传输响应包含:
ServerComponent渲染出的 HTML 结构。- 客户端组件位置的标记。
- 客户端组件所需的初始数据(序列化的 props)。
- 指向客户端组件所需 JavaScript 的链接。
-
浏览器收到响应后,会立即显示由服务端渲染好的 HTML 内容(极快的首屏显示)。
-
然后,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压缩代码,则需要这样做:
javamodule.exports = { compress: false, }- 如果您正在使用 nginx 并希望改用
brotli压缩,可以将compress选项设为false以让 nginx 处理压缩(其实也并不完美,这种方案需要依赖服务器压缩,而不是构建时压缩)。
- next方案不是很完美,默认情况下,当使用
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/...
- A:服务器组件树序列化问题,当使用
-
Q2:服务端渲染中如何注入样式
-
A:有两种方式在服务端渲染消费组件样式,各有好处
-
内联:直接将样式内联到所使用的组件上
- 好处是没有css请求
- 缺点是如果使用了多个同样的组件,会内联多份相同的样式,造成 HTML 体积增大,影响首屏渲染速度
- 见:ant.design/docs/react/...
-
烘焙:类似 antd 4,将项目中使用过的组件样式提取出一份单独的css
- 好处是打开任意页面时如传统 css 方案一样都会复用同一套 css 文件以命中缓存
- 缺点是多主题的情况下会烘焙多份样式
javascriptimport 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 本身并不提供状态管理工具。我们需要自己选型一款稳定、简单、好用的状态管理工具
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 防止组件重复执行初始化数据
typescriptimport { 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
javascriptimport { UserStoreProvider } from '@/providers/userStoreProvider'; export default function Layout({children}) { return ( <UserStoreProvider> {children}</UserStoreProvider > ) } -
消费/变更用户数据
iniconst 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文件夹下
vbnetcp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/ - A:当我们没有
-
Q3:如何自定义端口或主机名
- A:在启动命令中添加
iniPORT=3000 HOSTNAME=0.0.0.0
3、部署
-
关于启动命令,先启动 Next.js 相关 Node 服务器(内部服务器,包含环境变量、端口、主机名称),在启动Nginx 服务(提供外部访问,含动态域名解析)
bashHOSTNAME=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;
}