N07第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

服务端数据访问

服务端数据访问 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

本课把数据库访问放进 Next.js 的正确位置:服务端。读完你能直接在服务端组件里查询 SQLite,并解释为什么这段代码绝不能出现在客户端组件里。

问题场景

课程列表页要显示「课程总数」和「已学完数量」。数据在本地 SQLite 里。第一反应可能是写一个 /api/courses 接口,然后在客户端组件里 useEffect + fetch 拉取。能用,但绕了远路:浏览器先拿到空壳页面,再发一次请求,等待期间还要处理加载态。而服务端组件本来就在服务器上执行——数据库就在本机,直接查完把结果渲染进 HTML 发给浏览器就行。

核心解释

App Router 里组件默认就是服务端组件:它们只在 Node.js 进程里执行,产出 HTML。这意味着三件事:

  1. 可以直接 import 数据库客户端。node:sqlite 是 Node 内置模块,浏览器里根本没有它,只有服务端组件能安全使用。
  2. 秘密天然隔离。数据库文件路径、API 密钥这些只在服务端存在的值,不会进入浏览器拿到的 JS 包——前提是你别把它们显式传给客户端组件。
  3. 没有额外的网络往返。服务端组件查库是进程内调用,比客户端 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 请在你本机的工程里跟着做):

  1. 在你的工程里建 lib/queries/courses.ts,实现 listCourses()。
  2. 新建一个页面调用它,npm run dev 打开确认课程标题渲染出来。
  3. 用 npm run build 构建,观察输出:这个页面应被标记为动态(ƒ),而不是静态(○)。

常见错误

  • 把 "use client" 加到了查库的页面上:客户端组件运行在浏览器,node:sqlite 直接报模块不存在。查库的部分要么留在服务端组件,要么改走 Route Handler。
  • 在服务端组件里把数据库对象当 props 传下去:编译报错「函数/对象不可序列化」。传数据快照,不传连接。
  • 忘记 force-dynamic:数据页面被构建成静态,重启后进度永远不变,还以为数据库没写入。
  • 在模块顶层打开数据库连接却从不复用:每次 import 都新建连接。本项目 getDb() 用全局单例解决了这个问题,照着用即可。

检查与复盘

完成下方检查题。自查清单:

  • 能说出为什么数据库代码只能出现在服务端组件 / Route Handler
  • 知道 force-dynamic 解决什么问题、什么时候需要
  • 你的数据查询都收在 lib/queries 里,页面不直接写 SQL

参考资料

检查题共 3 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码简单

阅读代码(Next.js 16 服务端组件,lib/db 封装 node:sqlite)。访问 /courses 页面显示什么?

import db from '@/lib/db';

export default async function CoursesPage() {
  const rows = db
    .prepare('SELECT slug, title FROM course ORDER BY no')
    .all();
  return (
    <ul>
      {rows.map((c) => (
        <li key={c.slug}>{c.title}</li>
      ))}
    </ul>
  );
}
Q2读代码中等

阅读代码。开发者把原来查列表的 .all() 改成了 .get(slug),下面的渲染代码没改。访问一个存在的课程页会发生什么?

export default async function CoursePage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const course = db
    .prepare('SELECT * FROM course WHERE slug = ?')
    .get(slug);

  return (
    <ul>
      {course.map((c) => (
        <li key={c.slug}>{c.title}</li>
      ))}
    </ul>
  );
}
Q3单选简单

关于 Next.js App Router 中数据库访问代码的存放位置,正确的说法是?

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...