服务端数据访问
服务端数据访问 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
本课把数据库访问放进 Next.js 的正确位置:服务端。读完你能直接在服务端组件里查询 SQLite,并解释为什么这段代码绝不能出现在客户端组件里。
问题场景
课程列表页要显示「课程总数」和「已学完数量」。数据在本地 SQLite 里。第一反应可能是写一个 /api/courses 接口,然后在客户端组件里 useEffect + fetch 拉取。能用,但绕了远路:浏览器先拿到空壳页面,再发一次请求,等待期间还要处理加载态。而服务端组件本来就在服务器上执行——数据库就在本机,直接查完把结果渲染进 HTML 发给浏览器就行。
核心解释
App Router 里组件默认就是服务端组件:它们只在 Node.js 进程里执行,产出 HTML。这意味着三件事:
- 可以直接 import 数据库客户端。
node:sqlite是 Node 内置模块,浏览器里根本没有它,只有服务端组件能安全使用。 - 秘密天然隔离。数据库文件路径、API 密钥这些只在服务端存在的值,不会进入浏览器拿到的 JS 包——前提是你别把它们显式传给客户端组件。
- 没有额外的网络往返。服务端组件查库是进程内调用,比客户端 fetch 快一个数量级。
边界规则一句话:从服务端组件往客户端组件传的 props 必须可序列化且无秘密。传「课程列表数组」没问题,传「数据库连接对象」不行——客户端组件根本不该知道数据库存在。
// app/courses/page.tsx —— 服务端组件(默认)
import { getDb } from "@/lib/db/client";
export const dynamic = "force-dynamic";
export default function CoursesPage() {
const db = getDb();
const rows = db
.prepare("SELECT slug, title, track FROM course ORDER BY order_index")
.all();
return (
<ul>
{rows.map((r) => (
<li key={r.slug}>{r.title}</li>
))}
</ul>
);
}
注意 export const dynamic = "force-dynamic":个人进度类数据每次打开都应该是最新的,这个声明告诉框架不要在构建期把页面预渲染成静态 HTML(否则你看到的永远是构建那一刻的数据)。
示例拆解
把「查询」收进一个数据访问模块,页面只管展示:
// lib/queries/courses.ts —— 只允许在服务端 import
import { getDb } from "@/lib/db/client";
export interface CourseRow {
slug: string;
title: string;
track: string;
}
export function listCourses(): CourseRow[] {
return getDb()
.prepare("SELECT slug, title, track FROM course ORDER BY order_index")
.all() as CourseRow[];
}
export function countCompleted(): number {
const row = getDb()
.prepare("SELECT COUNT(*) AS n FROM lesson_progress WHERE status = 'completed'")
.get() as { n: number };
return row.n;
}
// app/page.tsx
import { listCourses, countCompleted } from "@/lib/queries/courses";
export const dynamic = "force-dynamic";
export default function Dashboard() {
const courses = listCourses();
return <p>共 {courses.length} 门课,已完成 {countCompleted()} 节。</p>;
}
关键点:
- 数据访问函数返回普通对象(可序列化),所以页面的其余部分可以是客户端组件并拿到这些数据。
- SQL 用
prepare+ 参数占位符,永远不要字符串拼接 SQL。 - 这类模块顶部写一行注释提醒「服务端专用」,防止被误 import 进客户端组件——真误用了,构建时 Node 模块解析会直接报错,反而是好事。
动手任务
本课实验区是一段可在浏览器里模拟「服务端查库」的等价演示(真实 SQLite 请在你本机的工程里跟着做):
- 在你的工程里建
lib/queries/courses.ts,实现listCourses()。 - 新建一个页面调用它,
npm run dev打开确认课程标题渲染出来。 - 用
npm run build构建,观察输出:这个页面应被标记为动态(ƒ),而不是静态(○)。
常见错误
- 把
"use client"加到了查库的页面上:客户端组件运行在浏览器,node:sqlite直接报模块不存在。查库的部分要么留在服务端组件,要么改走 Route Handler。 - 在服务端组件里把数据库对象当 props 传下去:编译报错「函数/对象不可序列化」。传数据快照,不传连接。
- 忘记 force-dynamic:数据页面被构建成静态,重启后进度永远不变,还以为数据库没写入。
- 在模块顶层打开数据库连接却从不复用:每次 import 都新建连接。本项目
getDb()用全局单例解决了这个问题,照着用即可。
检查与复盘
完成下方检查题。自查清单:
- 能说出为什么数据库代码只能出现在服务端组件 / Route Handler
- 知道 force-dynamic 解决什么问题、什么时候需要
- 你的数据查询都收在 lib/queries 里,页面不直接写 SQL
参考资料
- Next.js 文档:Server and Client Components —— 边界划分的官方说明
- Node.js 文档:node:sqlite —— 本项目使用的内置 SQLite 模块
读完这一课了?
标记完成后,工作台会安排接下来的学习。