L08 · 本地持久化:localStorage + Composable
🎯 本节目标:将 Todo 数据持久化到 localStorage,并抽取第一个 Composable
📦 本节产出:刷新不丢失数据的 Todo App + useLocalStorage composable
🔗 前置钩子:L07 的 watch(用于监听数据变化自动保存)
🔗 后续钩子:Phase 2 (L09) 将在此基础上重构为任务管理系统1. 为什么需要持久化
目前应用刷新后数据全丢——因为 Vue 的响应式数据存在内存中。浏览器关闭或刷新,内存清空。
2. 直接实现:watch + localStorage
以下代码用于浏览器端的 Todo 应用。localStorage 按来源隔离,只保存字符串;浏览器可能禁止访问或耗尽配额,数据也可能被用户清除。它不能保证永久保存。
2.1 保存
import { ref, watch } from 'vue'
const todos = ref<Todo[]>([])
// 监听 todos 变化,自动保存到 localStorage
watch(todos, (newTodos) => {
try {
localStorage.setItem('vue-todo-list', JSON.stringify(newTodos))
} catch (error) {
console.error('保存 Todo 失败,当前修改仅保留在内存中', error)
}
}, { deep: true }) // deep: true 监听数组元素属性的变化2.2 恢复
先在 src/types/todo.ts 中保留 Todo 接口,并追加校验函数。JSON.parse 成功只说明 JSON 语法有效,不代表数据满足 TypeScript 类型:
// src/types/todo.ts,追加在已有 Todo 接口后
export function isTodoList(value: unknown): value is Todo[] {
if (!Array.isArray(value)) return false
const ids = new Set<number>()
return value.every((item: unknown) => {
if (typeof item !== 'object' || item === null) return false
const todo = item as Record<string, unknown>
if (
typeof todo.id !== 'number' || !Number.isSafeInteger(todo.id) || todo.id < 1 ||
typeof todo.text !== 'string' || !todo.text.trim() ||
typeof todo.done !== 'boolean' ||
(todo.priority !== 'low' && todo.priority !== 'medium' && todo.priority !== 'high') ||
typeof todo.createdAt !== 'string' || ids.has(todo.id)
) return false
ids.add(todo.id)
return true
})
}
// 通常取现有最大 ID + 1;到达安全整数上限时复用未占用的正整数
export function createTodoId(todos: readonly Todo[]): number {
const highest = todos.reduce((max, todo) => Math.max(max, todo.id), 0)
if (highest < Number.MAX_SAFE_INTEGER) return highest + 1
const used = new Set(todos.map(todo => todo.id))
let id = 1
while (used.has(id)) id++
return id
}
export type FilterType = 'all' | 'active' | 'done'
export function isFilterType(value: unknown): value is FilterType {
return value === 'all' || value === 'active' || value === 'done'
}import { isTodoList, type Todo } from './types/todo'
// 初始化时读取,访问存储本身也可能抛错
function loadTodos(): Todo[] {
try {
const stored = localStorage.getItem('vue-todo-list')
if (stored === null) return []
const parsed: unknown = JSON.parse(stored)
return isTodoList(parsed) ? parsed : []
} catch (error) {
console.error('读取 Todo 失败,使用空列表', error)
return []
}
}
const todos = ref<Todo[]>(loadTodos())用这个 todos 声明替换 §2.1 的空数组初始化,保留后面的 watch;不要重复声明同名变量。
2.3 问题:代码散落在组件中
如果多个组件都需要本地持久化,每次都要写一遍 watch + localStorage.getItem/setItem + JSON.parse/stringify + try/catch……
解决方案:抽取为 Composable。
3. 什么是 Composable
Composable 是 Vue 3 中复用有状态逻辑的核心模式。
类比理解:
| React | Vue 3 | |
|---|---|---|
| 复用逻辑的方式 | Custom Hook | Composable |
| 命名规范 | useXxx() | useXxx() |
| 本质 | 函数 | 函数 |
| 关键区别 | 每次渲染重新执行 | setup 中只执行一次 |
3.1 创建 useLocalStorage
// src/composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue'
/** 适用于本课的 JSON 数据;在组件 setup 中同步调用。 */
export function useLocalStorage<T>(
key: string,
defaultValue: T,
isValid: (value: unknown) => value is T,
): Ref<T> {
// 调用者传入普通、可 JSON 序列化的默认值;复制以免共享默认对象
let initialValue: T = JSON.parse(JSON.stringify(defaultValue))
try {
const stored = localStorage.getItem(key)
if (stored !== null) {
const parsed: unknown = JSON.parse(stored)
if (!isValid(parsed)) throw new Error(`无效的存储数据: ${key}`)
initialValue = parsed
}
} catch (error) {
console.error(`读取 ${key} 失败,使用默认值`, error)
}
// T 限定为本课的普通 JSON 数据,不包含嵌套 ref
const data = ref(initialValue) as Ref<T>
watch(data, (newValue) => {
try {
localStorage.setItem(key, JSON.stringify(newValue))
} catch (error) {
console.error(`保存 ${key} 失败,修改仅保留在内存中`, error)
}
}, { deep: true })
return data
}默认值只用于初始化,没有设置 immediate: true,因此不会在读取失败后立刻覆盖原存储。本实现只同步本次调用的状态;多个调用或多个标签页之间不会自动同步,需要共享状态或额外处理 storage 事件。
3.2 使用 Composable
将 L07 的两个 ref 初始化替换成下面的调用,其他业务代码保持不变。恢复数据后,新增 ID 不能重新从 5 开始。删除旧的 nextTodoId 计数器,导入本节的 createTodoId,把 addTodo 中的 ID 改为 id: createTodoId(todos.value);它会避开已占用 ID,并处理安全整数上限。
<!-- src/App.vue -->
<script setup lang="ts">
import { useLocalStorage } from './composables/useLocalStorage'
import { createTodoId, isTodoList, isFilterType, type Todo, type FilterType } from './types/todo'
// 一行代码完成持久化 🎉
const todos = useLocalStorage<Todo[]>('vue-todo-list', [
{ id: 1, text: '搭建项目脚手架', done: true, priority: 'low', createdAt: '2024-01-01' },
{ id: 2, text: '学习 Vue 3 基础', done: false, priority: 'high', createdAt: '2024-01-02' },
], isTodoList)
// 筛选条件也可以持久化
const currentFilter = useLocalStorage<FilterType>('vue-todo-filter', 'all', isFilterType)
</script>4. Composable 设计原则
4.1 命名规范
✅ useLocalStorage — 以 use 开头
✅ useTodoStats — 描述功能
✅ useMousePosition — 描述数据来源
❌ localStorage — 没有 use 前缀
❌ useDoStuff — 名字不描述功能4.2 组合 Composable
Composable 可以调用其他 Composable:
// src/composables/useTodos.ts
import { computed } from 'vue'
import { useLocalStorage } from './useLocalStorage'
import { createTodoId, isTodoList, isFilterType, type Todo, type FilterType } from '@/types/todo'
export function useTodos() {
const todos = useLocalStorage<Todo[]>('vue-todo-list', [], isTodoList)
const filter = useLocalStorage<FilterType>('vue-todo-filter', 'all', isFilterType)
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active': return todos.value.filter(t => !t.done)
case 'done': return todos.value.filter(t => t.done)
default: return todos.value
}
})
const stats = computed(() => {
const total = todos.value.length
const doneCount = todos.value.filter(t => t.done).length
return {
total,
doneCount,
activeCount: total - doneCount,
donePercent: total > 0 ? Math.round((doneCount / total) * 100) : 0,
}
})
function addTodo(text: string) {
const trimmed = text.trim()
if (!trimmed) return
todos.value.push({
id: createTodoId(todos.value),
text: trimmed,
done: false,
priority: 'medium',
createdAt: new Date().toISOString().slice(0, 10), // UTC 日期
})
}
function toggleTodo(id: number) {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
function deleteTodo(id: number) {
todos.value = todos.value.filter(t => t.id !== id)
}
function updateTodo(id: number, text: string) {
const todo = todos.value.find(t => t.id === id)
if (todo && text.trim()) todo.text = text.trim()
}
function clearDone() {
todos.value = todos.value.filter(t => !t.done)
}
return {
todos,
filter,
filteredTodos,
stats,
addTodo,
toggleTodo,
deleteTodo,
updateTodo,
clearDone,
}
}4.3 App.vue 变得极简
只在 App.vue 调用一次 useTodos(),把状态和操作传给子组件;普通 composable 每次调用都会创建一份状态,并不会自动变成全局 Store。
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TodoItem from './components/TodoItem.vue'
import { useTodos } from './composables/useTodos'
import type { FilterType } from './types/todo'
const {
filteredTodos, filter: currentFilter, stats,
addTodo: appendTodo, toggleTodo, deleteTodo, updateTodo, clearDone,
} = useTodos()
const newTodoText = ref('')
function addTodo() {
if (newTodoText.value.trim()) {
appendTodo(newTodoText.value.trim())
newTodoText.value = ''
}
}
</script>
<!-- 保留 L07 的 template 和 style;别名保留了 currentFilter / addTodo -->5. 更多 Composable 示例
5.1 useMousePosition
// src/composables/useMousePosition.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMousePosition() {
const x = ref(0)
const y = ref(0)
function update(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}5.2 useWindowSize
// src/composables/useWindowSize.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {
const width = ref(0)
const height = ref(0)
function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => {
update()
window.addEventListener('resize', update)
})
onUnmounted(() => window.removeEventListener('resize', update))
return { width, height }
}这些 composable 需要在组件 setup 中同步调用,以便生命周期钩子绑定到组件。
useWindowSize在挂载后才读取浏览器对象,服务端渲染阶段不会访问window。本课的 localStorage composable 则面向浏览器端应用,迁移到 SSR 时还需设计客户端恢复时机。
参考 Vue Composables、MDN localStorage 和 storage 事件。
6. Phase 1 总结
恭喜你完成了 Phase 1!让我们回顾所有概念的关联:
Phase 1 知识清单
| 概念 | 课时 | 掌握标志 |
|---|---|---|
| Vite + SFC | L01 | 能创建项目并解释结构 |
| 组件 + Props | L02 | 能定义 Props 并解释单向数据流 |
| ref / reactive | L03 | 能选择合适的 API 并解释 .value |
| v-for + key | L04 | 能渲染列表并解释 key 的作用 |
| v-if + 事件 + emit | L05 | 能实现父子通信 |
| v-model | L06 | 能解释语法糖本质 |
| computed + watch | L07 | 能区分使用场景 |
| Composable | L08 | 能抽取可复用逻辑 |
🔬 深度专题
📖 D09 · Composables vs React Hooks — 同样是"钩子",为什么心智模型完全不同?
Git 提交
git add .
git commit -m "L08: localStorage 持久化 + Composable 抽取 [Phase 1 完成]"
git tag phase-1-complete🔗 钩子连接
→ Phase 2:L09 · 架构升级:从单文件到工程化
Phase 2 将在 Phase 1 的代码基础上演进(不是推倒重来),升级为任务管理系统:
- L09:将 App.vue 拆分为多组件架构 + Slots 插槽
- L10:添加 Vue Router 实现多页面
- L11:引入 Pinia 管理共享状态;localStorage 仍可承担持久化,两者职责不同
- L12-L18:标签分类、拖拽、测试、部署……
Phase 2 会复用组件与业务逻辑,并按新的共享状态和路由需求调整接口。