用前端技术做个人工具:开发本地图书管理系统(Vue3+IndexedDB)
一篇从零到一的实践型文章:用 Vue3 组合式 API 搭配 IndexedDB,打造离线可用、数据本地持久化的本地图书管理系统。适合个人藏书管理、借阅记录和标签分类等场景。
目标与特性
- 纯前端本地运行,无需后端。
- 数据存储使用
IndexedDB,支持大数据量与结构化索引。 - 基础功能:新增/编辑/删除图书,搜索、标签分类,借阅归还。
- 扩展功能:批量导入导出、封面图片存储、统计视图、离线可用。
- 技术栈:
Vue3 + TypeScript + IndexedDB。
技术选型与架构
Vue3:组合式 API 更易拆分业务逻辑,便于小型工具维护与扩展。IndexedDB:浏览器原生数据库,支持对象存储、索引、事务;适合离线、本地持久化场景。- 架构要点:
- 数据层:统一的
db.ts封装,提供 CRUD 与查询接口。 - 业务层:
useBooks.ts组合式模块,负责状态与交互。 - UI层:
Books.vue页面组件,包含表单、列表、搜索与导入导出。
- 数据层:统一的
数据模型设计
books对象存储:- 主键:
id(自增) - 字段:
isbn,title,author,tags[],status,createdAt,updatedAt,coverBlob? - 索引:
isbn,title,author,status,createdAt
- 主键:
loans对象存储:- 主键:
id(自增) - 字段:
bookId,borrower,loanDate,returnDate,status - 索引:
bookId,status,loanDate
- 主键:
IndexedDB 封装(db.ts)
ts
const DB_NAME = 'local-library'
const DB_VERSION = 1
function openDB() {
return new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains('books')) {
const store = db.createObjectStore('books', { keyPath: 'id', autoIncrement: true })
store.createIndex('isbn', 'isbn', { unique: true })
store.createIndex('title', 'title', { unique: false })
store.createIndex('author', 'author', { unique: false })
store.createIndex('status', 'status', { unique: false })
store.createIndex('createdAt', 'createdAt', { unique: false })
}
if (!db.objectStoreNames.contains('loans')) {
const store = db.createObjectStore('loans', { keyPath: 'id', autoIncrement: true })
store.createIndex('bookId', 'bookId', { unique: false })
store.createIndex('status', 'status', { unique: false })
store.createIndex('loanDate', 'loanDate', { unique: false })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
function tx(db: IDBDatabase, names: string[], mode: IDBTransactionMode) {
return db.transaction(names, mode)
}
export async function addBook(payload: any) {
const db = await openDB()
const t = tx(db, ['books'], 'readwrite')
const s = t.objectStore('books')
const now = Date.now()
const data = { ...payload, createdAt: now, updatedAt: now }
return new Promise<number>((resolve, reject) => {
const req = s.add(data)
req.onsuccess = () => resolve(req.result as number)
req.onerror = () => reject(req.error)
})
}
export async function updateBook(payload: any) {
const db = await openDB()
const t = tx(db, ['books'], 'readwrite')
const s = t.objectStore('books')
const data = { ...payload, updatedAt: Date.now() }
return new Promise<void>((resolve, reject) => {
const req = s.put(data)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
}
export async function removeBook(id: number) {
const db = await openDB()
const t = tx(db, ['books'], 'readwrite')
const s = t.objectStore('books')
return new Promise<void>((resolve, reject) => {
const req = s.delete(id)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
}
export async function getBook(id: number) {
const db = await openDB()
const t = tx(db, ['books'], 'readonly')
const s = t.objectStore('books')
return new Promise<any>((resolve, reject) => {
const req = s.get(id)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
export async function getAllBooks() {
const db = await openDB()
const t = tx(db, ['books'], 'readonly')
const s = t.objectStore('books')
return new Promise<any[]>((resolve, reject) => {
const req = s.getAll()
req.onsuccess = () => resolve(req.result as any[])
req.onerror = () => reject(req.error)
})
}
export async function searchBooks(q: string, status?: string, tags?: string[]) {
const list = await getAllBooks()
const k = q.trim().toLowerCase()
return list.filter(b => {
const hit = !k || [b.title, b.author, b.isbn].some(v => String(v || '').toLowerCase().includes(k))
const sOk = !status || b.status === status
const tOk = !tags || tags.every(t => (b.tags || []).includes(t))
return hit && sOk && tOk
})
}
export async function addLoan(payload: any) {
const db = await openDB()
const t = tx(db, ['loans'], 'readwrite')
const s = t.objectStore('loans')
return new Promise<number>((resolve, reject) => {
const req = s.add(payload)
req.onsuccess = () => resolve(req.result as number)
req.onerror = () => reject(req.error)
})
}
export async function exportBooks() {
const list = await getAllBooks()
return JSON.stringify(list)
}
export async function importBooks(items: any[]) {
const db = await openDB()
const t = tx(db, ['books'], 'readwrite')
const s = t.objectStore('books')
await Promise.all(items.map(item => new Promise<void>((resolve, reject) => {
const req = s.put({ ...item, id: item.id })
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})))
}
组合式业务模块(useBooks.ts)
ts
import { ref, computed } from 'vue'
import { addBook, updateBook, removeBook, searchBooks, getAllBooks, exportBooks, importBooks } from './db'
export function useBooks() {
const books = ref<any[]>([])
const loading = ref(false)
const q = ref('')
const status = ref<string | undefined>(undefined)
const tags = ref<string[]>([])
async function refresh() {
loading.value = true
books.value = await getAllBooks()
loading.value = false
}
async function search() {
loading.value = true
books.value = await searchBooks(q.value, status.value, tags.value)
loading.value = false
}
async function add(payload: any) {
await addBook(payload)
await refresh()
}
async function update(payload: any) {
await updateBook(payload)
await refresh()
}
async function remove(id: number) {
await removeBook(id)
await refresh()
}
async function exportJSON() {
const s = await exportBooks()
return s
}
async function importJSON(s: string) {
const list = JSON.parse(s)
await importBooks(list)
await refresh()
}
const count = computed(() => books.value.length)
return { books, loading, q, status, tags, refresh, search, add, update, remove, exportJSON, importJSON, count }
}
页面组件示例(Books.vue)
vue
<template>
<section>
<header>
<input v-model="q" placeholder="搜索标题/作者/ISBN" />
<select v-model="status">
<option value="">全部</option>
<option value="available">在库</option>
<option value="loaned">借出</option>
</select>
<button @click="search">搜索</button>
<button @click="refresh">重载</button>
<button @click="onExport">导出JSON</button>
<input type="file" accept="application/json" @change="onImport" />
</header>
<form @submit.prevent="onSubmit">
<input v-model="form.title" placeholder="书名" />
<input v-model="form.author" placeholder="作者" />
<input v-model="form.isbn" placeholder="ISBN" />
<input v-model="tagsInput" placeholder="标签,逗号分隔" />
<select v-model="form.status">
<option value="available">在库</option>
<option value="loaned">借出</option>
</select>
<button type="submit">保存</button>
</form>
<ul>
<li v-for="b in books" :key="b.id">
<strong>{{ b.title }}</strong> --- {{ b.author }} --- {{ b.isbn }}
<em>标签: {{ (b.tags || []).join(', ') }}</em>
<em>状态: {{ b.status }}</em>
<button @click="edit(b)">编辑</button>
<button @click="remove(b.id)">删除</button>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useBooks } from './useBooks'
const { books, q, status, search, refresh, add, update, remove, exportJSON, importJSON } = useBooks()
const form = reactive<any>({ id: undefined, title: '', author: '', isbn: '', tags: [], status: 'available' })
const tagsInput = ref('')
function edit(b: any) {
Object.assign(form, b)
tagsInput.value = (b.tags || []).join(',')
}
async function onSubmit() {
form.tags = tagsInput.value.split(',').map(s => s.trim()).filter(Boolean)
if (form.id) {
await update({ ...form })
} else {
await add({ ...form })
}
Object.assign(form, { id: undefined, title: '', author: '', isbn: '', tags: [], status: 'available' })
tagsInput.value = ''
}
async function onExport() {
const s = await exportJSON()
const blob = new Blob([s], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'books.json'
a.click()
URL.revokeObjectURL(url)
}
async function onImport(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const s = await file.text()
await importJSON(s)
}
</script>
<style scoped>
section { max-width: 900px; margin: 0 auto; padding: 24px }
header { display: grid; grid-template-columns: 1fr auto auto auto auto; gap: 8px; margin-bottom: 16px }
form { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-bottom: 16px }
ul { list-style: none; padding: 0 }
li { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr auto auto; gap: 8px; padding: 8px 0; border-bottom: 1px solid #eee }
</style>
封面图片存储(Blob)
- 将封面图片以
Blob保存到books.coverBlob字段,或单独的covers存储。 - 展示时通过
URL.createObjectURL(blob)生成临时 URL。 - 图片字段不参与全文搜索,仅配合
id关联。
示例:
ts
export async function setCover(id: number, blob: Blob) {
const b = await getBook(id)
if (!b) return
await updateBook({ ...b, coverBlob: blob })
}
性能与可用性
- 搜索优化:为高频字段建立索引;长列表使用分页或虚拟列表。
- 交互优化:输入搜索加防抖;批量导入采用事务和并发控制。
- 数据安全:定期导出备份;版本升级中谨慎迁移结构。
- 兼容性:Safari 私密模式禁用 IndexedDB;需提示与降级方案。
版本升级策略
- 变更对象存储或索引时提升
DB_VERSION。 - 在
onupgradeneeded中进行迁移,避免阻塞主逻辑。 - 保持向后兼容,尽量不破坏旧数据的键路径与字段语义。
离线增强(PWA,选做)
ts
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
- 将静态资源与核心页面缓存到 Service Worker。
- 用户可在无网络环境下继续管理数据。
测试与数据准备
- 使用假数据快速填充:
ts
function fakeBook(i: number) {
return { title: `书名${i}`, author: `作者${i}`, isbn: `ISBN${100000+i}`, tags: ['技术'], status: 'available' }
}
- 批量注入后测试搜索与导出。
常见坑与规约
- 事务作用域:跨存储操作需在同一事务中处理。
- 索引唯一性:
isbn唯一,批量导入需处理冲突。 - Blob 存储:大图片会增大数据库;可只存缩略图。
- 异常恢复:
onerror全面捕获并提示用户导出备份。
收尾与扩展方向
- 借阅视图:基于
loans生成借阅统计与提醒。 - 标签系统:支持多标签筛选和标签云。
- 聚合统计:作者、年份、标签分布图(结合 Canvas/SVG)。
- 多端同步:后续可通过云端接口或 WebRTC 做点对点同步。
总结
Vue3 + IndexedDB能轻量实现个人工具的核心能力:离线、持久、足够快。- 用组合式模块隔离数据与业务,UI 简洁可维护;随着需求增长再渐进增强。
- 充分利用索引与事务,谨慎处理版本升级与导入导出,能显著提升可靠性与体验。