1.7 项目启动——VibeNote V1.0 最小可用版本,笔记列表 + 本地存储

2 阅读20分钟

模块一:认知重构与快速起步 | 第06讲:项目启动——VibeNote V1.0 最小可用版本,笔记列表 + 本地存储

你好。这一讲进入主线项目 VibeNote 的第一个可运行版本:笔记列表 + 新建/编辑/删除 + localStorage 持久化。技术栈对齐专栏主线:Next.js(App Router)+ TypeScript + Tailwind。你会得到可直接粘贴进仓库的文件清单与完整代码,并理解为什么这样拆边界能为 Drizzle + PostgreSQL 铺路。

前提:你已完成第03讲,能在本地 pnpm dev 跑起 Next.js。若还没有,请先回到环境篇。

V1.0 规格与非目标

目标:单页完成列表 + 编辑;新增/改标题/改正文/删除;刷新后仍在;空状态;localStorage 异常不白屏。

非目标:登录、云端同步、富文本、Markdown、搜索、标签。

DoDpnpm dev 可跑;CRUD + 刷新验证;删除需确认;移动端基本可用。

flowchart TB
  UI[VibeNoteApp] --> ST[localNotes]
  ST --> LS[(localStorage)]
flowchart LR
  P[src/app/page.tsx] --> A[src/components/VibeNoteApp.tsx]
  A --> S[src/lib/localNotes.ts]
  S --> T[src/types/note.ts]

创建项目

pnpm create next-app@latest vibenote --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd vibenote
pnpm dev

src/types/note.ts

export type NoteId = string;

export type Note = {
  id: NoteId;
  title: string;
  body: string;
  updatedAt: string;
};

src/lib/localNotes.ts

import type { Note } from "@/types/note";

const STORAGE_KEY = "vibenote.notes.v1";

function isNoteArray(value: unknown): value is Note[] {
  if (!Array.isArray(value)) return false;
  return value.every(
    (n) =>
      n &&
      typeof n === "object" &&
      typeof (n as Note).id === "string" &&
      typeof (n as Note).title === "string" &&
      typeof (n as Note).body === "string" &&
      typeof (n as Note).updatedAt === "string",
  );
}

export function loadNotes(): Note[] {
  if (typeof window === "undefined") return [];
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) return [];
    const parsed: unknown = JSON.parse(raw);
    if (!isNoteArray(parsed)) return [];
    return parsed.slice().sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
  } catch {
    return [];
  }
}

export function saveNotes(notes: Note[]): void {
  if (typeof window === "undefined") return;
  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(notes));
}

export function createEmptyNote(): Note {
  const id =
    typeof crypto !== "undefined" && "randomUUID" in crypto
      ? crypto.randomUUID()
      : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
  const now = new Date().toISOString();
  return { id, title: "未命名笔记", body: "", updatedAt: now };
}

src/components/VibeNoteApp.tsx

"use client";

import { useEffect, useMemo, useState } from "react";
import type { Note } from "@/types/note";
import { createEmptyNote, loadNotes, saveNotes } from "@/lib/localNotes";

export function VibeNoteApp() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [activeId, setActiveId] = useState<string | null>(null);
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    const loaded = loadNotes();
    setNotes(loaded);
    setActiveId((prev) => prev ?? loaded[0]?.id ?? null);
    setHydrated(true);
  }, []);

  useEffect(() => {
    if (!hydrated) return;
    saveNotes(notes);
  }, [notes, hydrated]);

  useEffect(() => {
    if (!activeId) return;
    if (!notes.some((n) => n.id === activeId)) {
      setActiveId(notes[0]?.id ?? null);
    }
  }, [notes, activeId]);

  const activeNote = useMemo(
    () => notes.find((n) => n.id === activeId) ?? null,
    [notes, activeId],
  );

  function upsertNote(next: Note) {
    setNotes((prev) => {
      const idx = prev.findIndex((n) => n.id === next.id);
      if (idx === -1) return [next, ...prev];
      const copy = prev.slice();
      copy[idx] = next;
      return copy.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
    });
  }

  function handleCreate() {
    const n = createEmptyNote();
    setNotes((prev) => [n, ...prev]);
    setActiveId(n.id);
  }

  function handleDelete(id: string) {
    const ok = window.confirm("确定删除这条笔记?此操作不可恢复。");
    if (!ok) return;
    setNotes((prev) => prev.filter((n) => n.id !== id));
  }

  function updateActive(partial: Partial<Pick<Note, "title" | "body">>) {
    if (!activeNote) return;
    upsertNote({
      ...activeNote,
      ...partial,
      updatedAt: new Date().toISOString(),
    });
  }

  return (
    <div className="min-h-dvh bg-zinc-950 text-zinc-50">
      <div className="mx-auto flex min-h-dvh max-w-6xl flex-col gap-4 p-4 md:flex-row md:gap-6 md:p-8">
        <aside className="w-full shrink-0 md:w-80">
          <div className="flex items-center justify-between gap-3">
            <div>
              <div className="text-xs text-zinc-400">VibeNote</div>
              <div className="text-lg font-semibold tracking-tight">本地笔记 V1</div>
            </div>
            <button
              type="button"
              onClick={handleCreate}
              className="rounded-xl bg-violet-600 px-3 py-2 text-sm font-medium text-white hover:bg-violet-500"
            >
              新建
            </button>
          </div>

          <div className="mt-4 space-y-2">
            {notes.length === 0 ? (
              <div className="rounded-2xl border border-zinc-800 bg-zinc-900/40 p-4 text-sm text-zinc-300">
                还没有笔记。点「新建」开始记录你的想法。
              </div>
            ) : (
              notes.map((n) => {
                const active = n.id === activeId;
                return (
                  <button
                    key={n.id}
                    type="button"
                    onClick={() => setActiveId(n.id)}
                    className={[
                      "w-full rounded-2xl border px-3 py-3 text-left transition",
                      active
                        ? "border-violet-500/60 bg-zinc-900"
                        : "border-zinc-800 bg-zinc-950 hover:border-zinc-700",
                    ].join(" ")}
                  >
                    <div className="truncate text-sm font-medium">{n.title || "未命名笔记"}</div>
                    <div className="mt-1 line-clamp-2 text-xs text-zinc-400">{n.body || "(空内容)"}</div>
                    <div className="mt-2 text-[11px] text-zinc-500">
                      更新于 {new Date(n.updatedAt).toLocaleString()}
                    </div>
                  </button>
                );
              })
            )}
          </div>
        </aside>

        <main className="flex-1">
          {!activeNote ? (
            <div className="rounded-3xl border border-zinc-800 bg-zinc-900/30 p-8 text-zinc-300">
              选择一条笔记,或创建一条新笔记。
            </div>
          ) : (
            <div className="rounded-3xl border border-zinc-800 bg-zinc-900/30 p-5 md:p-7">
              <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
                <div className="text-sm text-zinc-400">编辑区</div>
                <button
                  type="button"
                  onClick={() => handleDelete(activeNote.id)}
                  className="rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-200 hover:bg-red-500/15"
                >
                  删除
                </button>
              </div>

              <label className="mt-5 block text-xs font-medium text-zinc-400">标题</label>
              <input
                value={activeNote.title}
                onChange={(e) => updateActive({ title: e.target.value })}
                className="mt-2 w-full rounded-2xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-base outline-none focus:border-violet-500/60"
                placeholder="给笔记起个名字"
              />

              <label className="mt-5 block text-xs font-medium text-zinc-400">正文</label>
              <textarea
                value={activeNote.body}
                onChange={(e) => updateActive({ body: e.target.value })}
                className="mt-2 min-h-[320px] w-full resize-y rounded-2xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-sm leading-6 outline-none focus:border-violet-500/60 md:min-h-[520px]"
                placeholder="写下灵感、待办、会议纪要……"
              />

              <div className="mt-4 text-xs text-zinc-500">
                提示:V1 使用浏览器本地存储。清理站点数据会删除笔记;后续版本会接入数据库。
              </div>
            </div>
          )}
        </main>
      </div>
    </div>
  );
}

src/app/page.tsx

import { VibeNoteApp } from "@/components/VibeNoteApp";

export default function Page() {
  return <VibeNoteApp />;
}

src/app/layout.tsx(按需调整 metadata)

import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "VibeNote | 本地笔记 V1",
  description: "Vibe Coding 专栏主线项目:VibeNote 最小可用版本(localStorage)",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body className="min-h-dvh bg-zinc-950 antialiased">{children}</body>
    </html>
  );
}

src/app/globals.css

保留 create-next-app 默认 Tailwind 指令即可;本组件不额外要求全局样式。

运行

pnpm dev

与 AI 协作提示词(可复制)

在 Next.js App Router 项目中实现 VibeNote V1:localStorage 持久化;拆分 VibeNoteApplocalNotesNote 类型;处理 JSON 损坏;Tailwind UI;移动端优先。

本讲小结

  • 三分离:类型 / 存储 / UI,后续替换存储层即可接入 Postgres。
  • hydrated 门闩避免 SSR 与客户端状态冲突,并避免首次空写覆盖。
  • 产品底线:空状态、删除确认、损坏数据不白屏。

实操作业

手写改动 UI;尝试加「导出 JSON」按钮;提交 feat(vibenote): bootstrap v1

思考题

频繁写入 localStorage 的性能与体验问题?未来数据库迁移保留哪些类型?哪些状态不适合放 URL?

下一讲预告

组件细化、校验、错误边界与体验打磨,让 VibeNote 从能用到好用。

深度讲解:为什么 V1 必须「先把边界画小」

VibeNote 最终会走向全栈与 AI,但如果你从第一天就把目标画成「Notion + Obsidian + AI」,你会同时面对:编辑器、协同、同步冲突、权限、向量检索……这不是勇敢,这是把失败概率调高。V1 的价值在于:用最小成本验证你是否真的会持续使用自己的产品。如果你自己都不每天用,别指望用户会用。

深度讲解:localStorage 不是「低级」,是「真实」

localStorage 有明确边界:容量有限、用户清理浏览器会丢、隐私模式策略各异。但正是这些边界,会逼你提前思考备份、导出、迁移——这些思考在接入数据库后仍然成立。换句话说,localStorage 是一门便宜的「数据责任课」。

深度讲解:为什么用 useEffect 修正 activeId

删除笔记后,当前选中 id 可能指向不存在的记录。我们用一个小 useEffectnotes 变化后校验:若 activeId 不在列表里,就自动切换到第一条或清空。这比在 setNotes 的 updater 里嵌套 setActiveId 更符合 React 习惯,也更容易推理。

深度讲解:hydrated 门闩到底防什么

Next.js 会先服务端渲染一版页面,再客户端 hydrate。若你在服务端就读 localStorage,会直接报错;若你在客户端首帧就把空数组写回存储,可能覆盖用户真实数据。我们用 hydrated 控制「什么时候开始持久化」,这是把 SSR 现实浏览器存储接起来的常见模式。

深度讲解:为什么存储层单独文件

你可能会想把读写散落在组件里,短期更快,长期更痛。独立 localNotes.ts 的意义是:下一讲你要加导出、加加密占位、加迁移版本号(例如 vibenote.notes.v2),你只需要改这一层,而不是全项目搜索 localStorage

深度讲解:为什么做 JSON 校验

真实用户数据会被破坏:手动编辑、浏览器插件、半写入中断。isNoteArray 这种「笨」校验,能换回来的是应用不白屏。白屏一次,用户信任归零。

深度讲解:删除为什么用 confirm

V1 用浏览器原生确认框足够。你要学的是「破坏性操作必须有摩擦」,而不是一上来就做华丽 UI。摩擦可以逐步产品化,但安全底线要先有。

深度讲解:排序策略与笔记心智

updatedAt 倒序,是「最近编辑在上」。如果你更想要「创建时间优先」,可以新增 createdAt 字段并在排序中使用——这就是数据模型演进的第一课。

深度讲解:状态放在组件里够不够

V1 够。等你要做路由级详情页、要做服务端渲染、要做协作,再把状态上移到更合适的边界(URL、服务器、全局 store)。现在上移是过度设计。

深度讲解:输入体验与持久化频率

当前实现是「每次 notes 变化就保存」。对 localStorage 与 JSON.stringify 全量写入来说,笔记很大时会抖。你可以在未来加 debounce(防抖)或把「保存」改成显式按钮,权衡自动保存与性能。这里先保持简单,避免新手同时面对太多旋钮。

深度讲解:移动端布局为什么用 flex-colmd:flex-row

小屏优先是移动用户现实。笔记应用尤其可能在手机上随手记。你用响应式不是「更好看」,是「更像产品」。

深度讲解:Tailwind 在这里的角色

Tailwind 让你用类名快速迭代视觉,同时强迫你把样式写在结构旁边,适合 AI 协作。后续我们会讲设计系统与组件抽象,但 V1 先追求清晰与可改。

深度讲解:TypeScript 为什么是护栏

Note 类型让 AI 更难把字段拼错,也让你在重构时更快收到错误信号。别在 V1 用 any 逃避,逃避会在 V3 利滚利。

深度讲解:如何把这一版部署到 Vercel

推送到 GitHub → Vercel Import → Framework Preset 选 Next.js → Deploy。localStorage 在浏览器端运行,部署后每位用户的数据仍在各自浏览器。要理解:这不是多用户产品,只是上线演示与个人使用。

深度讲解:与 Drizzle/PostgreSQL 的衔接点

未来你会保留 Note 类型的大部分字段,把 loadNotes/saveNotes 换成 db.querydb.insert。UI 组件应尽量只依赖「存储层接口」,而不是直接依赖 localStorage。现在存储层已经隔离,这就是预埋的钩子。

深度讲解:安全预告(很重要)

V1 只有纯文本,风险较低。未来若加入 Markdown 渲染、富文本、HTML 粘贴,就要面对 XSS。你会在后续专栏里看到「不可信内容如何展示」的系统做法。

深度讲解:如何把本讲当成 Code Review 练习

对照以下清单读你自己的 diff:

  1. 是否有未使用 import?2) 是否有潜在空指针?3) 是否有破坏性操作缺确认?4) 是否对损坏数据兜底?5) 是否有用户体验断点(无笔记、无选中)?

深度讲解:常见报错与排查

  • localStorage is not defined:说明你在服务端直接访问了存储层;检查是否在 useEffect 内访问。
  • Hydration mismatch:通常是服务端与客户端初始状态不一致;本讲用 hydrated 规避写入,但仍要注意不要在 SSR 渲染依赖随机值。

深度讲解:如何把任务拆给 AI(分步)

第一步只生成类型与存储层;第二步生成 UI 骨架;第三步接通交互;第四步加样式与移动端。分步比「一次生成全部」更稳。

深度讲解:为什么要提交 git

这是你第一次把「作品」变成可回溯资产。每一里程碑一次提交,未来你会感谢自己。

深度讲解:模块一结束后的自我评估

如果你能独立解释:为什么三分离、为什么 hydrated、为什么校验 JSON、为什么非目标重要,你就具备了进入模块二的基础。否则不要硬进,回去重做一遍 V1。

练习:加 createdAt 字段

尝试扩展 Note 并做数据迁移:老数据没有 createdAt 时补默认值。你会第一次写「迁移思维」。

练习:加导出 JSON

notes 下载为 .json 文件。你会理解「用户数据属于用户」。

练习:加导入 JSON(谨慎)

导入要校验,拒绝不可信文件。你会理解「任何输入都不可信」。

练习:把列表项改成键盘可导航

可访问性是专业度的一部分。先从不高的门槛开始。

练习:给笔记加 pinned 置顶

小功能练的是数据模型与排序策略,不是 UI 炫技。

反模式:把业务逻辑写进 onChange

可读性会差。V1 已尽量保持函数短小,但仍建议你把复杂逻辑抽到纯函数里(后续讲)。

反模式:把密钥写进客户端

未来接 AI API 时,密钥只能走服务端。现在先建立意识。

反模式:无限制同步到云端

同步是硬问题。别在没设计冲突解决前承诺「实时同步」。

术语:客户端组件

use client 表示该模块在客户端运行,可使用浏览器 API 与 React hooks。

术语:持久化

把内存态写入可恢复介质。localStorage 是一种持久化,数据库是另一种。

术语:MVP slice

最小切片。V1 就是 slice,不是最终产品形态。

长文:VibeNote 的产品叙事怎么讲

你可以这样对朋友介绍:「这是一个我每天都在用的笔记应用,从本地开始,逐步加上云与 AI。」这句话比「我学了很多框架」更有力量。

长文:为什么我不在这一讲接 OpenAI

因为你会先把数据与交互做稳。AI 不是胶水,不能掩盖糟糕的数据模型。

长文:独立开发者的节奏建议

每天 30 分钟也比周末一天十小时更可持续。V1 适合每天推进一点点。

长文:团队开发的前置习惯

从现在开始写清楚非目标与 DoD,未来带人不累。

复盘模板

  • 我今天是否完成了 DoD?2) 我是否引入了计划外功能?3) 我明天唯一任务是什么?

与第05讲对齐:这是 Outcome 的落地

第05讲你写了 Outcome 与里程碑;这一讲就是 M1/M2 的实现版本。请回到笔记对照:你是否一致?

与第04讲对齐:从主页到应用

主页是展示,VibeNote 是工具。工具更看重效率、错误态与数据安全。

与第02讲对齐:工具如何帮助改 V1

用 Cursor 分文件改、用规则约束 Tailwind 与 TS,减少胡来。

与第01讲对齐:Agentic Engineering

计划(规格)→ 实现(AI+你)→ 审查(你)→ 测试(手动路径)→ 提交。V1 也要走完闭环。

结束语

模块一到这里收尾:你有了认知地图、工具策略、环境、上线体验、能力迁移方法,以及 VibeNote 的真实代码底座。接下来进入模块二,我们会专注「好用」:交互、校验、组件化与工程细节。别跳过复盘,带着问题前进更快。

最后一句话

把 VibeNote V1 跑起来,部署一次,把链接发给自己。你会第一次真正感到:这不是课,这是产品。

再补一张「数据流」时序图(帮助你读代码)

sequenceDiagram
  participant U as 用户
  participant UI as VibeNoteApp
  participant S as localNotes
  participant L as localStorage

  U->>UI: 打开页面
  UI->>S: loadNotes (client)
  S->>L: getItem
  L-->>S: JSON 字符串/空
  S-->>UI: Note[]

  U->>UI: 编辑标题/正文
  UI->>UI: setState 更新 notes
  UI->>S: saveNotes (hydrated 后)
  S->>L: setItem

再加一份「从 V1 到 V3」演进路线(心里要有数)

  • V1:localStorage + 单用户 + 纯文本(你在这里)。
  • V2:PostgreSQL + Drizzle + 账号体系(或先做匿名用户密钥)。
  • V3:AI 辅助:续写、摘要、标题建议(API 密钥只在服务端)。

路线图的意义是防止你「边做边发明新宇宙」。新宇宙可以,但要进 backlog。

再谈组件拆分:什么时候拆才划算

当出现以下信号再拆:单文件超过 300 行且职责明显两块以上;同一 UI 在多处复用;你需要单独测试某一模块。否则不要为拆而拆。

再谈测试:V1 需要自动化测试吗

不强制,但建议你至少维护一条「手动回归路径」文档:新建→编辑→刷新→删除→刷新。未来再把这条路径自动化。

再谈格式化:Prettier 与 ESLint

保持默认即可。团队期再把规则写进 CI。现在别在工具配置上花三天。

再谈依赖:不要急着加一堆库

状态管理、表单库、动画库都很香,但 V1 越薄越好。每加一个依赖,都是未来升级与审计成本。

再谈设计:深色主题不是必须

你完全可以改成浅色。关键是对比度与层级。让 AI 改配色时,要求它保持可读性指标。

再谈性能:大笔记怎么办

未来可以分页、懒加载、虚拟列表。V1 先假设笔记数量不大。产品早期别为百万用户优化。

再谈隐私:本地存储也意味着本地责任

提醒用户备份;提供导出;不要假装「绝对安全」。诚实是产品信任的一部分。

再谈多语言:为什么 lang="zh-CN"

影响字体与可访问性默认值。若你做英文版,记得同步调整文案与排版习惯。

再谈 SEO:V1 需要吗

单客户端应用 SEO 弱一些,但至少有 title/description。后续可做落地页与博客分离。

再谈错误边界:下一步会加

现在先保证存储损坏不白屏。下一步加 React Error Boundary 捕获渲染错误。

再谈路由:为什么 V1 不做 /notes/[id]

你可以做,但会增加状态同步复杂度。V1 先单页,把交互跑顺,再引入路由级状态。

再谈键盘快捷键:可以但谨慎

快捷键很爽也容易冲突。若要加,先列表记录并允许关闭。

再谈同步:不要偷换概念

「刷新仍在」是本地持久化,不是云同步。对用户说明白,避免误期待。

再谈备份:最简单的备份就是导出文件

这比任何口号都实在。用户要的是可控。

再谈协作:多人编辑是硬问题

没有 OT/CRDT 不要承诺。先把单人体验做到极致。

再谈搜索:为什么不在 V1

搜索需要索引策略与性能考量。先做小数据线性扫描可以,但要诚实标注「仅适合少量笔记」。

再谈标签:数据结构要先想清

标签 many-to-many,将来数据库建模会更复杂。进 backlog,不要偷偷加 half-baked。

再谈附件:文件上传是另一个宇宙

涉及存储桶、病毒扫描、权限。别在 V1 开口子。

再谈 Markdown:渲染要小心 XSS

未来用可信渲染器与消毒策略。现在纯文本最安全。

再谈主题切换:练习可做

localStorage 记录主题或 prefers-color-scheme。练习状态与持久化组合。

再谈动画:少即是多

笔记工具首要是快与稳。动画点缀即可。

再谈字体:系统字体栈最省心

后续可接入 webfont,但要评估加载与许可。

再谈图标:不要堆图标

信息密度过高会降低可读性。V1 以文字层级为主。

再谈表单:textarea 的边界

移动端键盘遮挡是经典问题。后续可加 scrollIntoView 或调整布局。

再谈状态:activeId 为 null 的含义

表示没有选中。要有 UI 提示,不要空白死寂。

再谈空标题:显示策略

列表显示「未命名笔记」比空白更友好。后续可在保存时规范化。

再谈时间格式:toLocaleString

不同机器显示不同,属于正常。未来可做统一格式化工具函数。

再谈 UUID:randomUUID 不可用环境

已提供退化方案。你要理解「环境差异」永远存在。

再谈 TypeScript 配置:strict

建议保持严格。宽松配置会在项目变大后反噬。

再谈路径别名:@/*

让 import 更稳定。别用深层相对路径 ../../..

再谈 ESLint:react-hooks/exhaustive-deps

你可能会遇到依赖数组警告。先理解再忽略,不要无脑 disable。

再谈 Git 忽略:.nextnode_modules

不要提交构建产物与依赖目录。

再谈 Vercel 环境变量:本讲不需要

未来接数据库再引入。现在别提前复杂化。

再谈日志:下一步加 console.error 规范化

现在可以简单,但要有意识:线上排障靠日志。

再谈监控:个人项目也要最小观测

至少知道部署是否成功、页面是否可访问。

再谈回滚:Git revert 与 Vercel 回滚

学会回滚比学会发布更重要。第一次发布就要演练回滚路径。

再谈产品名:VibeNote 只是代号

你可以改名,但请同步改存储 key 或做迁移,否则会丢数据。

再谈数据迁移:版本化 key

未来从 v1v2:读取旧 key,写入新 key,删除旧 key(谨慎)。迁移要写清单。

再谈多设备:localStorage 不同步

要同步就必须账号与服务器。别自欺欺人。

再谈离线:PWA 可以后续做

先把 Web 体验做稳。

再谈桌面端:Electron 不是现在

包装桌面是另一条产品线。先 Web。

再谈移动端 App:React Native 不是现在

先把响应式 Web 做好。

再谈国际化:i18n 框架后续上

现在中文跑通即可。

再谈可访问性:下一步加强

aria、键盘导航、焦点管理。V1 先不翻车即可。

再谈设计系统:组件库后续引入

先手写理解约束,再用库提效。

再谈 AI 生成 UI 的坑

类名爆炸、结构深层、无意义 div。要学会让 AI「收敛」。

再谈代码所有权

即使是 AI 写的,也是你的代码。你要能讲清楚每一行为什么存在。

再谈学习笔记:建议记录「决策日志」

为什么选 localStorage、为什么 hydrated、为什么 confirm。决策日志比抄代码重要。

再谈对比:和记事本应用差在哪

差在结构:数据模型、持久化、交互闭环、可演进架构。你不是在做作业,你是在做软件。

再谈完成感:如何奖励自己

部署链接发朋友、写一条复盘 tweet、或给自己买杯咖啡。完成需要正反馈。

再谈下一模块:你会更累也更爽

更累是因为细节变多;更爽是因为产品开始像「真的」。

最后确认:你是否满足 DoD

如果满足,模块一毕业。如果不满足,回到本讲代码逐项对照,不要带着裂缝前进。

终极提醒

软件是长跑。V1 只是起跑。带着规格、审查与部署习惯继续跑,你会超过大多数只看不写的人。

终极练习:给 VibeNote 写一页「V1 说明书」

面向用户写:能做什么、不能做什么、数据存在哪、如何备份。你会第一次用用户语言检验自己。

终极思考题

你愿意为「数据不丢」付出什么成本?这个答案会决定你何时上数据库与备份系统。

再见模块一

我们模块二见。把项目跑起来,再读一遍这篇讲解,你会看懂更多。

收束:十条「V1 交付」自检

  1. 你能解释数据如何进入 localStorage 吗?2) 你能解释 hydrated 的作用吗?3) 你能走完 CRUD 手动回归吗?4) 你能部署并在手机打开吗?5) 你能说出三个非目标吗?6) 你能指出未来数据库接入点吗?7) 你能复述损坏 JSON 的兜底策略吗?8) 你能说明删除摩擦为什么重要吗?9) 你能把本讲讲给一个朋友听清楚吗?10)你愿意接下来一周每天用一次 VibeNote 吗?

收束:给「想加功能」的你

先把十条自检打勾,再加功能。否则你只是用新功能逃避基础不牢。

收束:模块一最后一句话

开始很简单,坚持很难,工具很多,纪律很少。选纪律,你会走更远。把 V1 当作真正的产品发布,而不是课程作业,你的成长速度会完全不同。

附:当你卡住时,按顺序做这三步

  1. 对照本讲 DoD 找到缺失环节。2) 把报错全文贴给 AI 并附上文件路径。3) 最小复现:新建干净项目只加存储层测试。三步走完还不行,就回到第03讲环境自检。

附:我对你唯一的硬要求

运行、提交、部署预览至少一次。没有预览链接,不算真正完成模块一。现在就去部署预览,别等明天。行动会让地图变成路线。模块二再见。继续迭代,别停。冲呀。