用 Vue 3 + Vite 做一个本地书签管理器

一、先说清楚要做什么

用 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 属普遍共识,具体上限以各浏览器实现为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇