SSR

17 阅读7分钟

Next.js框架入门

Next.js — Web开发领域最高效、便捷的全栈框架

渲染模式

client side rendering:浏览器下载一个最小的 HTML 页面和页面所需的 JavaScript。然后使用 JavaScript 更新 DOM 并渲染页面。当应用首次加载时,用户可能会注意到在看到完整页面之前有轻微的延迟,这是因为在所有 JavaScript 下载、解析和执行之前,页面不会完全渲染。首次加载页面后,导航到同一网站的其他页面通常会更快,因为只需获取必要的数据,JavaScript 可以重新渲染页面的部分内容,而无需完全刷新页面。

server side rendering:在服务器端将网页完整渲染并生成 HTML,再将该完整 HTML 发送到浏览器,客户端接收HTML后,执行JavaScript为DOM附加交互能力。

static site generation:页面的 HTML 将在构建时生成。这意味着在生产环境中,页面 HTML是在执行next build时生成的。这个 HTML 将在每次请求时重复使用。它可以由CDN 缓存。

incremental static regeneration:增量静态再生可以实现:

● 无需重新构建整个网站就能更新静态内容;

● 通过对大多数请求提供预渲染的静态页面来减少服务器负载;

● 确保将正确的 cache-control 标头自动添加到页面;

● 在不增加 next build 时间的情况下处理大量内容页面;

渲染模式渲染位置优势限制
CSR浏览器前端交互更灵活,服务器压力小初始时加载慢,SEO 不佳
SSR服务器首次加载快,SEO 好,社交媒体友好服务器成本高,架构复杂
SSG构建时渲染速度快,成本低,简单内容无法实时更新
ISR构建+更新综合 SSR 与 SSG 优点需要支持的框架和机制(如Next.js)

渲染模式对比

详细渲染模式对比图

CSR的不足

1.  嵌套组件获取远程数据时,造成昂贵的客户端-服务器瀑布;

2.  白屏时间长,JavaScript bundle size过大,这会增加页面的 FCP(首次内容绘制)和 TTI(可交互时间),FCP 和 TTI 被推迟后。这意味着在 FP(首次绘制)和 FCP 之间,用户将看到空白屏幕。

3.  SEO困难,据悉目前百度、Google虽然能够爬取SPA,但效果不如SSR。

Next.js

起步

本教程将基于Next.js v15.x App Router逐步搭建一个包含注册、登录/认证、数据列表、增删改查等功能的服务端渲染Web App,并将其成功部署到vercel。

├── app
|  ├── _components // 私有文件夹,不会渲染成路由
|  ├── dashboard // 一级路由
|  |  ├── (overview) // 分组路由,不会渲染成路由
|  |  | ├── loading.tsx // 路由/dashboard的page加载中的UI
|  |  ├── invoices // 嵌套路由 /dashboard/invoices
|  |  | ├── error // 错误UI — React 错误边界
|  |  | ├── [id]
|  |  | | ├── edit // 动态路由 /dashboard/invoices/[id]/edit
|  |  | | | ├── not-found.tsx // 未找到页面显示的UI
|  |  └── layout.tsx // /dashboard路由的布局
|  ├── favicon.ico // 元数据—网站图标
|  ├── layout.tsx // 根布局
|  ├── lib // 公共方法/字段
|  ├── login
|  |  └── page.tsx // 路由 /login的页面
|  ├── page.tsx // 根路径/的页面
|  ├── seed
|  |  └── route.ts // 初始化数据库
|  └── ui // ui组件库
├── auth.config.ts
├── auth.ts // 身份认证
├── middleware.ts // 请求中间件
├── next.config.ts // Next.js 的配置文件
├── postcss.config.js
├── public // 静态资源 可使用
├── .env // 环境变量
├── .env.local/production/development // 特定环境变量
├── tailwind.config.ts // tailwind配置
└── tsconfig.json // TS配置,各个字段的配置详见https://www.typescriptlang.org/tsconfig/#incremental

项目目录

当前的项目结构策略是将项目文件存储在app内的顶层文件夹中,在特殊文件中定义的组件按特定层次结构渲染,组件在嵌套路由中递归渲染,这意味着路由段的组件将嵌套在其父段的组件中。

更多项目结构参考:Next.js项目结构

配置开发环境

请确保您的系统满足以下要求:Node.js 18.18 或更高版本

npx create-next-app@latest

npx:npx会下载create-next-app@latest放在临时文件中,过一段时间会自动清除,免去了本地全局安装的步骤。

next dev:// 启动开发服务器。
next build:// 为生产构建应用程序。
next start:// 启动生产服务器。
next lint:// 运行 ESLint。

核心概念

Next.js使用基于文件系统的路由,可以使用文件夹和文件来定义路由。路由可以包括layout(布局)和page(页面),路由直接通过Link链接,也可以在客户端组件以编程方式(useRouter)更改路由。

文件系统约定

Next.js项目文件系统约定,预定义了多种文件名对应的作用,下面列出一些开发常用的文件约定:

布局

layout 文件用于在你的 Next.js 应用中定义布局,app/layout.tsx是根布局(根布局必须定义 和 标签),类似app/dashboard/layout.tsx是dashboard路由的布局,接收children和params组成的props

页面

page.tsx文件允许定义对于路由唯一的 UI,接收searchParams和params组成的props,

可以通过从文件中默认导出一个组件来创建页面,和route互斥(一个文件夹下不能同时存在page.tsx和route.ts)。

package.json

项目配置文件,scripts:可运行的脚本;dependencies:项目依赖;更多参数详见:package.json | npm DocsonlyBuiltDependencies告诉pnpm:对于列表中的包,只下载和使用已经编译好的二进制文件,跳过任何源码编译步骤。因为bcrypt和sharp包含c++代码,编译过程中可能出错(缺少编译工具、环境不兼容等原因),通过直接配置onlyBuiltDependencies下载包维护者提供的预编译的二进制文件,安装速度更快,不容易出错

"pnpm": {
    "onlyBuiltDependencies": [
      "bcrypt",
      "sharp"
    ]
  }
路由处理程序

/api/static-page/route.ts 这个路由是对外暴露请求接口,可通过fetch('${origin}/static_page/api')调用,见代码:使用路由API。在App Router可以使用路由处理程序或中间件实现BFF层。

中间件

middleware.ts 文件用于在请求完成之前写入 中间件 并在服务器上运行代码。然后,根据传入的请求,你可以通过重写、重定向、修改请求或响应标头或直接响应来修改响应。

中间件在渲染路由之前执行。它对于实现自定义服务器端逻辑(例如身份验证、日志记录或处理重定向)特别有用。

使用项目根目录中的文件 middleware.ts(或 .js)来定义中间件。

更多文件系统约定说明参考:文件系统约定

样式

● CSS Modules

CSS Modules 通过生成唯一的类名来局部作用域化 CSS。这允许你在不同文件中使用相同的类名而不用担心命名冲突,本教程结合使用了scss和css模块。

● 全局CSS

使用全局 CSS 在整个应用程序中应用样式:创建一个 app/global.css 文件并在根布局(app/layout.tsx)中导入,以将样式应用于应用程序中的每个路由

● Tailwind CSS

包含多个实用工具类的css框架,可以直接在HTML中应用样式,而无需编写自定义的CSS,且高度可定制。

更多方式详见:Getting Started: CSS

服务器/客户端组件

服务器组件:

Next.js中布局(layout.tsx)和页面(page.tsx)默认是服务器组件。

客户端组件:

use client将模块及其传递的依赖项指定在客户端渲染,当创建需要客户端 JavaScript 功能(如状态管理、事件处理、自定义hooks和访问浏览器 API)的交互式用户界面时应该使用它。会被打包到 JavaScript bundle 并发送到浏览器,而服务器组件不会。

详细说明见:服务器和客户端组件

获取数据

服务器组件:

使用fetch API调用接口 或者使用ORM框架,或者调用数据库。见代码:latest-invoices.tsx,这个组件默认是服务端组件,可以通过fetchLatestInvoices

直接调用数据库获取数据。

我们以这个组件为例,将其改造成客户端组件,并添加onClick事件处理程序。见代码:latest-invoices-client.tsx

客户端组件:

● React 的 use

本质还是数据在服务器组件获取,通过props传给作为子组件的客户端,浏览器 Network/XHR 不会暴露http请求;

● 使用像 SWRreact-query这样的社区库(底层也是使用fetch API,封装了缓存、自动刷新、乐观更新、自动重试、全局状态管理等强大功能),定义API Route后,在客户端组件使用库或原生fetch调用接口,浏览器 Network能看到xhr请求。

● LatestInvoicesClient作为客户端组件,默认不能被Suspense,但是启用了 Suspense 的数据源会激活 Suspense 组件,它们包括:

支持 Suspense 的框架如 Relay 和 Next.js。

使用 lazy 懒加载组件代码。客户端组件

使用 use 读取缓存的 Promise 值。客户端组件

更新数据

使用服务器函数,代码见:服务器函数

服务器组件:

可以通过将 "use server" 指令添加到函数主体顶部,在服务器组件中内联服务器函数;

可以直接导入已经定义的服务器函数。

客户端组件:

无法在客户端组件中定义服务器函数。但是,你可以通过从顶部包含 "use server" 指令的文件中导入它们来在客户端组件中调用它们。

指令

use server

指定要在服务器端执行的函数或文件。它可以用在文件顶部,以指示文件中的所有函数都是服务器端的,或者内联在函数顶部以将该函数标记为服务器功能,这是一个 React 特性

渲染策略

动态/静态渲染

静态渲染

使用静态渲染时,HTML 会提前生成 - 无论是在构建时还是通过 revalidation。结果将被缓存并在用户和请求之间共享。

在部分预渲染中,Next.js 会为路由预渲染一个静态 shell。这可能包括布局和任何其他不依赖于请求时间数据的组件。

动态渲染

使用动态渲染时,HTML 会在请求时生成。这允许你根据请求时间数据提供个性化内容

在app/dashboard/(overview)/page.tsx页面toggle对cookie的引用可在devIndicators验证。

● 开发环境本地启动,通过dev提示器查看路由是静态渲染还是动态渲染。

● next build会给出路由是什么渲染模式。

可以在一个路由内结合动态渲染和静态渲染-部分预渲染(partial-prerendering),只能在在canary版本试用。

路由组

路由组(Route Groups) 允许你将文件组织成逻辑上的分组,而不会影响 URL 路径的结构。当你使用括号 () 创建一个新文件夹时,该文件夹的名称不会出现在 URL 路径中 。例如,/dashboard/(overview)/page.tsx 对应的路径是 /dashboard,overview只是开发者用于自己组织代码的文件夹。

身份认证

Next.js推荐使用next-auth,结合,auth.ts 是认证核心,导出 signIn、signOut 等方法,使用 zod 校验登录参数,通过 next-auth 实现邮箱/用户名+密码登录,安全校验用户信息,集中管理认证。逻辑见代码:/auth.ts

调试

1.  新建并配置.vscode/launch.json

2.  Ctrl+Shift+D on Windows/Linux, ⇧+⌘+D on macOS,选择调试模式,按F5,即可开启调试模式。

部署

Next.js部署方案

● 使用Docker

● 使用阿里云的serverless function

● 使用Node.js server方式

Next.js 可部署到任何支持 Node.js的云服务器。运行 npm run build 来构建应用程序,运行 npm run start 来启动 Node.js 服务器。该服务器支持所有 Next.js 功能

● Vercel:官方集成部署方案

1.  根据教程最终搭建完成项目核心功能,pnpm dev启动开发环境,访问 http://localhost:3000,登录成功后,应该能在浏览器看到dashboard页面。

2.  在vercel通过github/gitlab导入项目,部署后能看到项目后台,包括数据库、资源使用情况、应用状态监控、应用指标等。修改代码git push到远程分支(如 main)后,vercel会自动重新部署。

配置数据库

启动pnpm dev后,访问路由 localhost:3000/seed ,会执行建表语句,并使用placeholder-data.ts中的初始化数据给表填充数据。

环境变量

vercel 项目 环境变量

1.  把本项目的.env直接导入到vercel环境变量后台。

2.  nextauth_url:如果使用了next-auth认证库,Vercel部署时还需要加线上域名作为NEXTAUTH_URL 环境变量的值用于认证成功后的跳转。

3.  主域名、本次部署预览域名、发布分支预览域名

vercel后台

性能指标

在app/layout.tsx安装并使用SpeedInsights,后在vercel后台可以看到具体的页面性能指标。

详细部署方案见:Next.js on Vercel

QA

1.  何时使用服务器组件或客户端组件,他们怎么组合使用。

服务器组件是默认选择,适用于:

● 从靠近源的数据库或 API 获取数据。

● 使用 API 密钥、令牌和其他密钥信息,但不要将它们暴露给客户端;

● 减少发送到浏览器的 JavaScript 数量

● 改进 首次内容绘制 (FCP)指标,并将内容逐步流式传输到客户端从靠近源的数据库或 API 获取数据

客户端组件,需要添加 "use client" 指令,适用于:

● 管理状态响应事件。例如onClick, onChange

● 反应式副作用的生命周期。例如useEffect

● 仅限浏览器的 API。例如localStorage、window、Navigator.geolocation 等

● 使用自定义钩子重用逻辑 – React 中文网

组合使用

最佳实践是将客户端组件尽可能推到组件树的叶子节点,服务器组件可以导入客户端组件,但客户端组件不能直接导入服务器组件。如果需要在客户端组件中使用服务器组件,可以通过 children传递,这种架构让你既能享受服务器渲染的性能优势,又能提供丰富的客户端交互体验。详见代码:服务器组件的客户端字组件:nav-links客户端组件内渲染服务器组件:static_page

2.  什么场景需要直接查询数据库?

服务器组件中的数据获取:

● 页面初始渲染需要的数据

● 静态内容生成

● 服务端预处理的复杂查询

API Routes 中的数据操作:

● CRUD 操作

● 复杂的业务逻辑

● 需要身份验证的数据访问

Server Actions 中的数据变更

● 表单提交处理

● 用户交互触发的数据更新

3.  使用ORM(Object Relational Mapping)框架/ODM(Object Document Modeling)库和直接写SQL语句有什么区别?

特性ORMODM原生SQL
目标存储关系型数据库(MySQL、PostgreSQL、OceanBase等),部分 ORM 也支持 MongoDB文档型数据库(MongoDB、CouchDB 等)关系型数据库
抽象层级将数据库表映射为类,将行映射为对象;通过对象方法操作数据将文档集合映射为模型,将文档映射为对象;通过模型方法操作文档无框架抽象层,直接编写和执行原生查询(SQL 或数据库自身的查询语言)
模式定义通常需要定义模型(实体)和字段类型定义 Schema(可选/灵活),并可带验证规则模式由数据库控制,不在代码层面定义
查询语言通过框架 API 或查询构建器生成并执行 SQL通过框架 API(BSON/JSON 风格)生成底层文档查询关系型数据库使用标准 SQL;文档型数据库使用其原生 CRUD 或聚合查询 API
关联处理支持外键、一对多、多对多等,通过 JOIN 自动完成通过嵌套文档或引用实现;无 JOIN,关联查询需手动组合或多次查询手动编写 JOIN、子查询或多次独立查询
事务支持支持MongoDB 自 v4.0 起支持多文档事务,但使用上不如关系库直观关系型数据库使用原生事务(BEGIN/COMMIT/ROLLBACK);文档库事务视驱动支持而定
安全性默认参数化查询,防止注入风险默认参数化或基于驱动的查询,防止注入需手动使用预编译/参数化或转义以防注入
性能调优可执行原生 SQL 或调用底层接口,以缓存、懒加载等方式优化支持原生查询、聚合管道等,但低级优化选项较少完全可控,最易做极致优化
简单示例import { PrismaClient } from '@prisma/client';const prisma = new PrismaClient();async function main() {const users = await prisma.user.findMany({ where: { age: { gt: 18 } } });console.log(users);}main();User.find({ age: { $gt: 18 } }).then(users => console.log(users)) .catch(err => console.error(err));const sql = "SELECT * FROM users WHERE age > 18";db.query(sql, [], (err, rows) => { … });// 文档库 原生查询collection.find({ age: { $gt: 18 } }).toArray();

4.  webpack的define和.env里面的环境变量有什么区别?

.env文件:webpack或Vite等打包工具在Node.js运行时使用dotenv解析.env .[mode]["local"]文件,并将.env中的内容注入到process.env中,客户端是无法访问process.env的。

DefinePlugin:在webpack等打包工具在编译时进行字符串替换,直接嵌入到打包后的代码中,改变打包产物,适用于将需要的变量注入到前端代码中。

tips: Vite的.env通过import.meta.env获取.env中设置的变量,本质上是DefinePlugin的机制,而不是真正的环境变量,Vite内部使用了类似的替换机制。可以在Vite的源码中看到,它会:

1.  读取.env文件

2.  过滤出VITE_前缀的变量

3.  在编译时将import.meta.env.VITE_XXX替换为对应的字面值

troubleshooting

1.  初次登录数据库后台时,可以会遇到Tables报错,并显示Database is not set错误,经验证,在第三方client连接Postgre数据库后,Tables会正常显示数据表。

2.  客户端组件中直接使用不确定的内容如Math.random()、new Date().toLocaleString()等时,会报错水合(Hydration 是 React 将 事件处理程序 附加到 DOM 的过程,以使静态 HTML 具有交互性)失败;

具体原因:客户端组件在服务端渲染时,new Date().toLocaleString() 会生成服务端时间的字符串;而在客户端水合时,new Date().toLocaleString() 会生成客户端时间的字符串。这两个值很可能不一样(时区、时间点、locale等),导致 React 发现 HTML 不一致,报出 hydration error。

解决方案一:用 useEffect 在客户端生成时间,初始渲染时不要输出时间字符串;见代码:app/ui/dashboard/latest-invoices-client.tsx

解决方案二:用 dynamic 并 ssr: false 包裹整个组件,这样组件只在客户端渲染,服务端不会输出 HTML,自然不会有 hydration 问题,ssr: false只能用在客户端组件('use client'),可以再包装一层。

// app/ui/dashboard/LatestInvoicesClientWrapper.tsx
"use client";
import dynamic from "next/dynamic";
 
const LatestInvoicesClient = dynamic(
  () => import("@/app/ui/dashboard/latest-invoices-client"),
  { ssr: false }
);
 
export default function LatestInvoicesClientWrapper() {
  return <LatestInvoicesClient />;
}
// app/dashboard/page.tsx
import LatestInvoicesClientWrapper from "@/app/ui/dashboard/latest-invoices-client-wrapper";

 

参考资料

● Next.js 中文教程

● NextAuth.js

● 流式 HTML