模块一:认知重构与快速起步 | 第06讲:项目启动——VibeNote V1.0 最小可用版本,笔记列表 + 本地存储
你好。这一讲进入主线项目 VibeNote 的第一个可运行版本:笔记列表 + 新建/编辑/删除 + localStorage 持久化。技术栈对齐专栏主线:Next.js(App Router)+ TypeScript + Tailwind。你会得到可直接粘贴进仓库的文件清单与完整代码,并理解为什么这样拆边界能为 Drizzle + PostgreSQL 铺路。
前提:你已完成第03讲,能在本地 pnpm dev 跑起 Next.js。若还没有,请先回到环境篇。
V1.0 规格与非目标
目标:单页完成列表 + 编辑;新增/改标题/改正文/删除;刷新后仍在;空状态;localStorage 异常不白屏。
非目标:登录、云端同步、富文本、Markdown、搜索、标签。
DoD:pnpm 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 持久化;拆分
VibeNoteApp、localNotes、Note类型;处理 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 可能指向不存在的记录。我们用一个小 useEffect 在 notes 变化后校验:若 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-col → md: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.query 与 db.insert。UI 组件应尽量只依赖「存储层接口」,而不是直接依赖 localStorage。现在存储层已经隔离,这就是预埋的钩子。
深度讲解:安全预告(很重要)
V1 只有纯文本,风险较低。未来若加入 Markdown 渲染、富文本、HTML 粘贴,就要面对 XSS。你会在后续专栏里看到「不可信内容如何展示」的系统做法。
深度讲解:如何把本讲当成 Code Review 练习
对照以下清单读你自己的 diff:
- 是否有未使用 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 忽略:.next 与 node_modules
不要提交构建产物与依赖目录。
再谈 Vercel 环境变量:本讲不需要
未来接数据库再引入。现在别提前复杂化。
再谈日志:下一步加 console.error 规范化
现在可以简单,但要有意识:线上排障靠日志。
再谈监控:个人项目也要最小观测
至少知道部署是否成功、页面是否可访问。
再谈回滚:Git revert 与 Vercel 回滚
学会回滚比学会发布更重要。第一次发布就要演练回滚路径。
再谈产品名:VibeNote 只是代号
你可以改名,但请同步改存储 key 或做迁移,否则会丢数据。
再谈数据迁移:版本化 key
未来从 v1 到 v2:读取旧 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 交付」自检
- 你能解释数据如何进入 localStorage 吗?2) 你能解释 hydrated 的作用吗?3) 你能走完 CRUD 手动回归吗?4) 你能部署并在手机打开吗?5) 你能说出三个非目标吗?6) 你能指出未来数据库接入点吗?7) 你能复述损坏 JSON 的兜底策略吗?8) 你能说明删除摩擦为什么重要吗?9) 你能把本讲讲给一个朋友听清楚吗?10)你愿意接下来一周每天用一次 VibeNote 吗?
收束:给「想加功能」的你
先把十条自检打勾,再加功能。否则你只是用新功能逃避基础不牢。
收束:模块一最后一句话
开始很简单,坚持很难,工具很多,纪律很少。选纪律,你会走更远。把 V1 当作真正的产品发布,而不是课程作业,你的成长速度会完全不同。
附:当你卡住时,按顺序做这三步
- 对照本讲 DoD 找到缺失环节。2) 把报错全文贴给 AI 并附上文件路径。3) 最小复现:新建干净项目只加存储层测试。三步走完还不行,就回到第03讲环境自检。
附:我对你唯一的硬要求
运行、提交、部署预览至少一次。没有预览链接,不算真正完成模块一。现在就去部署预览,别等明天。行动会让地图变成路线。模块二再见。继续迭代,别停。冲呀。