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

数据访问综合实践

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

学习目标

零件都齐了:服务端读、Server Action 写、缓存失效、表单反馈。本课做总装——把「个人知识编辑流程」完整串起来:读出笔记、编辑、保存、缓存更新、撤销、刷新后一切如旧。目标不是新知识,是验证组合之后的行为一致。

问题场景

你在笔记页改了一段话,保存,逛完一圈别的课程再回来——改动没了。或者想撤销,却发现「上一个版本」根本没存过。单看每层都没错:读有、写有、缓存也有——错在流程没串紧。综合实践就是把整条链路当产品来验收。

核心解释

一次可靠的编辑流程 = 读带版本、写带校验、失效带路径、撤销带快照。逐段落地。

读:服务端组件查库,拿到带 revision 的当前版本:

// lib/queries/notes.ts
import { getDb } from "@/lib/db/client";

export interface NoteRow {
  id: string;
  title: string;
  body: string;
  revision: number;
}

export function getNote(id: string): NoteRow | null {
  return (
    (getDb()
      .prepare("SELECT id, title, body, revision FROM note WHERE id = ?")
      .get(id) as NoteRow | undefined) ?? null
  );
}

写:快照旧版本与写入新版本放进同一个事务,撤销有据可依:

// app/notes/actions.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { getDb, nowIso, withTransaction } from "@/lib/db/client";

const EditSchema = z.object({
  id: z.string().min(1),
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(50000),
});

export async function updateNote(formData: FormData) {
  const parsed = EditSchema.safeParse({
    id: formData.get("id"),
    title: formData.get("title"),
    body: formData.get("body"),
  });
  if (!parsed.success) throw new Error("参数不合法");
  const { id, title, body } = parsed.data;

  withTransaction(getDb(), () => {
    const prev = getNote(id);
    if (!prev) throw new Error("笔记不存在");
    // 快照当前版本,撤销才有源头
    getDb()
      .prepare(
        "INSERT INTO note_history (note_id, title, body, revision, saved_at) VALUES (?, ?, ?, ?, ?)"
      )
      .run(prev.id, prev.title, prev.body, prev.revision, nowIso());
    getDb()
      .prepare(
        "UPDATE note SET title = ?, body = ?, revision = revision + 1, updated_at = ? WHERE id = ?"
      )
      .run(title, body, nowIso(), id);
  });

  revalidatePath("/notes");
  revalidatePath(`/notes/${id}`);
}

流程一致性靠动作对齐:

  1. 读:页面调 getNote,服务端渲染。
  2. 写:快照 + 更新在同一个事务里,要么都成、要么都不成。
  3. 缓存更新:列表页与详情页都 revalidate,任何入口看到的都是刚写的版本。
  4. 撤销:从 note_history 取最近快照写回——它本身就是一次 updateNote 式的写,走同样的校验和失效。
  5. 刷新恢复:数据在 SQLite,刷新只是重新读库;写成功则 F5 后必然一致,不一致说明某层在发旧缓存。

示例拆解

本机验收流程:

  1. 打开 /notes/[id],改标题保存——当前页立即显示新标题。
  2. 导航到 /notes 列表——新标题同步出现(revalidatePath 生效)。
  3. 点「撤销上一版」——标题回到旧值,revision 继续增长而不是倒退(撤销也是一次变更)。
  4. Ctrl+Shift+R 硬刷新——内容与撤销后的状态一致。

关键点:撤销不是「反向写」,而是「再写一次旧快照」。所有变更都经过同一条写路径,note_history 追加式增长,排查问题时它就是时间线。

动手任务

在本机工程里完成:

  1. 补出 note_history 表与上述 Action,跑通「编辑 → 撤销」闭环。
  2. 从两个入口(列表点进、直接输 URL)分别验证保存后读到新值。
  3. 保存后立即硬刷新,确认内容不回跳旧版本;若回跳,用 Network 面板定位是哪一层缓存。

常见错误

  • 撤销与更新走了两套代码:一套忘了 revalidate 或校验,行为悄悄分叉;撤销必须复用写路径。
  • 快照和更新不在一个事务里:中途失败留下「有历史没更新」或反之的脏状态。
  • 只 revalidate 当前页:列表页还躺着旧标题,看起来像「保存失败」。
  • 未保存草稿只放在组件 state 里:刷新即丢且无提示;草稿要么落库(草稿表),要么离开页面时警告。

检查与复盘

自查清单:

  • 编辑、撤销、刷新之后,列表与详情两个入口看到的数据一致
  • note_history 里能找到每次变更前的快照
  • 说不一致时知道排查顺序:先查缓存层,再查数据库

参考资料

检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1多选简单(多选,全部选对才算正确)

单人知识站(Next.js App Router + 本地 SQLite)里,下列数据访问方式哪些是合理的?(多选)

读完这一课了?

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

Command Palette

Search for a command to run...