一、先说清楚要做什么
用 Vue 3 + Vite 的最小工程(不引 Nuxt、不引 UI 库、不引 Pinia)做一个本地书签管理器:添加网址与标题、按标签分组、关键词过滤、标记已读、刷新后数据还在。三句话说完。”数据只住在浏览器里”这个约束会让每个设计决策都变轻——没有接口、没有鉴权、没有并发,但状态管理、持久化容错、组件边界这三个问题一个不少,而且全都要你自己回答,没有框架替你兜。
| 指标 | 目标值 | 怎么测 |
|---|---|---|
| 运行时依赖 | 只有 vue 一个 | package.json 的 dependencies 数一下 |
| 数据不丢 | 刷新、关标签页再开,数据完整 | 按文末验收清单逐条手动走 |
| 脏数据不炸 | localStorage 被写坏后应用能启动并恢复默认 | 手动写入非法 JSON 再刷新(见验证节) |
| 主流程键盘可完成 | 添加/搜索/标记已读不碰鼠标 | 手测:Tab 与回车走完全流程 |
| 性能 | 百条书签过滤无感卡顿 | 定性体感,本文不测具体毫秒也不编数 |
二、整体架构与选型
App.vue(布局与页面骨架)
├── BookmarkForm 添加表单(输入层)
├── Toolbar 搜索框 + 标签过滤(输入层)
└── BookmarkList ──── BookmarkItem 列表展示(展示层)
│
▼
composables/useBookmarks.ts 唯一数据源:全部状态与增删改
│
▼
storage.ts 序列化与容错(唯一碰 localStorage 的地方)
│
▼
localStorage
先做第一个选型决策:为什么是 Vite 最小工程,而不是别的形态。
| 方案 | 优点 | 缺点 | 为什么选/不选 |
|---|---|---|---|
| CDN 单页 | 零构建 | 没有 SFC、没有类型检查,代码过百行就难受 | 不选:这个项目要写组件与组合式函数 |
| Vite 最小工程(选) | 三依赖起步、SFC、HMR 全有 | 路由/状态要自己组织 | 选:单视图无路由,正好逼出”状态怎么组织”的练习价值 |
| Nuxt | 全约定 | 本地工具用不上 SSR 与 server/,服务端白背 | 不选:没有”服务端在场”的诉求,见Nuxt 入门的选型判据 |
克制建议顺带说:不要因为”以后可能要同步”就现在上 Nuxt 或本地起后端。同步需求真来的那天,把这个项目的 storage.ts 换成接口调用就够了——下面会看到持久化被刻意收口在一个文件里,就是为这天留的门。
三、状态层:为什么先手写 composable,不先上 Pinia
状态放哪,三个候选:
| 方案 | 优点 | 缺点 | 适用边界 |
|---|---|---|---|
| 全放 App.vue | 最简单 | 子组件要改状态就得 props/emits 层层透传,第三层开始失控 | 父子两层以内的小演示 |
| 手写模块级 composable(选) | 零依赖;数据与操作内聚;单例天然跨组件共享 | 没有 devtools 时间旅行、没有正式的 store 规范 | 单一数据源、操作可数(增删改查)的小应用 |
| Pinia | 多 store 模块化、调试工具全、SSR 支持好 | 多一个依赖与一套概念,本项目用不出它的优势 | 多数据域交叉、跨页面共享、团队协作 |
决策链说完整:这个应用只有一个数据域(书签列表及其派生视图),操作一只手数得过来,没有第二屏——Pinia 解决的是”多 store 与规模化”,这里没有该问题,先手写。学到的是任何框架都带不走的东西:ref 在模块作用域创建就是全应用共享的单例,这就是 Pinia defineStore 的原型。
// composables/useBookmarks.ts
import { ref, computed, watch } from 'vue'
import { load, save } from '../storage'
export interface Bookmark {
id: string // crypto.randomUUID():key 与定位都靠它,不用数组下标
url: string
title: string
tags: string[]
read: boolean
createdAt: number
}
// 模块级 = 单例:两个组件 import 到的是同一个 ref,
// 这就是"不引 Pinia 也有全局状态"的全部秘密
const bookmarks = ref<Bookmark[]>(load())
// 持久化收口在 watch 一处:任何路径改了数组都会落盘(见第四节)
watch(bookmarks, () => save(bookmarks.value), { deep: true })
const keyword = ref('')
const activeTag = ref('')
// 派生一律 computed:依赖变了自动重算,不劳 watch 手动同步
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase()
return bookmarks.value.filter(b =>
(!kw || b.title.toLowerCase().includes(kw) || b.url.toLowerCase().includes(kw))
&& (!activeTag.value || b.tags.includes(activeTag.value)))
})
function add(input: Pick<Bookmark, 'url' | 'title' | 'tags'>) {
bookmarks.value.push({ ...input, id: crypto.randomUUID(), read: false, createdAt: Date.now() })
}
function toggleRead(id: string) {
const b = bookmarks.value.find(x => x.id === id)
if (b) b.read = !b.read // 直接改:deep watch 负责落盘
}
function remove(id: string) {
bookmarks.value = bookmarks.value.filter(x => x.id !== id)
}
export function useBookmarks() {
return { bookmarks, keyword, activeTag, filtered, add, toggleRead, remove }
}
反模式点名:把状态全放 App.vue 再一层层 props/emits 透传——BookmarkItem 要删除一条书签,事件得从孙组件冒泡两级再改数据,每加一个功能就多一对 props/emits。数据的主人(useBookmarks)直接给到需要的组件,表单与列表是兄弟而非上下级,透传自然消失。
四、持久化:localStorage 的三个坑
所有对 localStorage 的读写收口在 storage.ts,理由是序列化格式、容错、版本迁移只该有一个真相源:
// storage.ts
const KEY = 'bookmarks.v1'
export function load(): Bookmark[] {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return []
const data = JSON.parse(raw)
// 结构校验:坏数据宁可丢弃也不能进应用——
// 一条非法记录能让整个列表渲染崩掉
if (!Array.isArray(data?.items)) return []
return data.items
} catch {
return [] // JSON.parse 对非法串抛异常;隐私模式下 getItem 也可能抛
}
}
export function save(items: Bookmark[]) {
try {
// schemaVersion 从第一天就写上:未来改字段时靠它分支迁移,
// 否则老用户的数据会在升级当天变成"读不懂的坏数据"
localStorage.setItem(KEY, JSON.stringify({ schemaVersion: 1, items }))
} catch (e) {
// 配额满(约 5MB 上限)或隐私模式禁写:提示而不是白屏
console.warn('保存失败', e)
}
}
三个坑各自对应一段代码。坑一,脏数据:用户脚本、手滑 DevTools、旧版本格式都会让 getItem 返回解析不了的东西,load 的 try/catch 加结构校验保证最坏情况是回到空列表而不是应用崩死。坑二,写失败:QuotaExceededError 在隐私模式与容量打满时出现,save 里捕获降级为警告——本地工具丢了新数据可惜,但比崩溃好。坑三,写放大:deep watch 意味着每勾一次”已读”就全量序列化一次。定性判断:百条书签的 JSON 序列化是微秒到毫秒级,同步 API 在主线程上跑也无感,这个量级不做节流是对的——为不存在的问题加复杂度比问题本身更贵;真到万条,该换的是 IndexedDB 而不是给 localStorage 打补丁。
反模式点名:把 watch 回写写成 watch(filtered, ...)——持久化的必须是源数据而不是派生视图,否则一次搜索就把”过滤结果”存成全部家当,搜索一清空数据就没了。
五、组件拆分:边界在哪里
| 组件 | 职责 | props(进) | emits(出) |
|---|---|---|---|
| BookmarkForm | 收集输入、校验 url、提交后清空 | 无(自己调 useBookmarks().add) | 无 |
| Toolbar | 关键词与标签过滤的输入 | 无 | 无(v-model 绑定 composable 的 ref) |
| BookmarkList | 空态与列表容器 | items | 无 |
| BookmarkItem | 单条展示、已读切换、删除按钮 | bookmark | toggle-read、remove |
<!-- BookmarkItem.vue:纯展示组件,改数据的动作 emit 给父级 -->
<script setup lang="ts">
defineProps<{ bookmark: Bookmark }>()
defineEmits<{ toggleRead: [id: string]; remove: [id: string] }>()
</script>
<template>
<article :class="{ read: bookmark.read }">
<a :href="bookmark.url" target="_blank" rel="noopener">{{ bookmark.title }}</a>
<button @click="$emit('toggleRead', bookmark.id)">
{{ bookmark.read ? '标为未读' : '标为已读' }}
</button>
<button @click="$emit('remove', bookmark.id)">删除</button>
</article>
</template>
拆分时机两条判据:出现第二次复用(第二个地方要渲染单条书签),或文件超出一屏读不完。两者都不满足就别拆。列表里 v-for 的 :key 用 bookmark.id 不用数组下标——反模式点名:下标当 key,删除中间一条后,后面所有条目”错位复用”,输入焦点与过渡动画会串到别的书签上,这类 bug 在列表越动态时越明显。
六、验证
纯本地应用,验证靠一张手动清单,每条都可在两分钟内完成:
| 场景 | 操作 | 期望 | 曾出过的问题 |
|---|---|---|---|
| 常规流 | 添加 3 条 → 刷新 | 3 条都在 | watch 未加 deep,嵌套修改不落盘 |
| 过滤 | 输入关键词 → 清空 | 列表随输随变,清空恢复全量 | 用 watch 同步派生值,清空分支漏写 |
| 脏数据 | DevTools 执行 localStorage.setItem(KEY, ‘{oops’) 后刷新 | 空列表,应用可用 | load 未捕获异常,白屏 |
| 写失败 | 隐私模式打开 | 功能正常,控制台保存警告 | save 未捕获,第一次添加就抛错 |
| 删除 | 删中间一条,立即再删 | 删的是点中的那条 | 下标当 key,第二次删错条目 |
性能只给定性结论:百条量级下,过滤是 computed 全量数组遍历,输入无感延迟;不测具体毫秒也不引用基准数字,因为这类结论换个设备就不成立,对你没参考价值。诚实边界:这个架构的上限在 localStorage——同步 API、约 5MB、单浏览器。书签过千、要全文搜索或跨设备,正确的下一步是 IndexedDB 或接后端,而不是继续在 JSON 上加优化。
七、总结与边界
- 单一数据源用模块级 composable 就能成立,Pinia 的价值在规模化,不在”有状态”本身;知道何时不需要它,比会装它更重要。
- 持久化三件套缺一不可:读的容错校验、写的异常降级、格式里的版本字段——第一天就写,晚一天都是债。
- 派生数据一律 computed,持久化只认源数据;这条守住了,状态层的 bug 少一半。
- :key 用稳定 id,组件边界按”复用与一屏”拆,输入层直连数据源而不透传。
边界:本方案刻意只解决单人单浏览器;没有多端同步、没有导入导出、没有全文索引。要同步的那天,把 storage.ts 换成接口调用,组件层一行不改——这就是持久化收口的回报;要服务端,回到Nuxt 入门那条线。整条 Vue 系列的收束见Vue 3 学习路线,组件与响应式的 API 细节随手查参考字典;第一个应用里练过的 props/emits 在这里长成了架构决策,可以回头对照第一个应用看自己走了多远。
版本核实(撰写时检索,2025-03):所用 API(ref/computed/watch deep、defineProps/defineEmits 类型化声明、v-model、crypto.randomUUID)均为 Vue 3 与浏览器稳定特性;defineEmits 类型化签名与 defineProps 泛型为 Vue 3.3+ 稳定;Vue 当前稳定线 3.5.x。localStorage 为同步 Web API,配额约 5MB 属普遍共识,具体上限以各浏览器实现为准。