Skip to content

L08 · 本地持久化:localStorage + Composable ​

🎯 本节目标:将 Todo 数据持久化到 localStorage,并抽取第一个 Composable
📦 本节产出:刷新不丢失数据的 Todo App + useLocalStorage composable
🔗 前置钩子:L07 的 watch(用于监听数据变化自动保存)
🔗 后续钩子:Phase 2 (L09) 将在此基础上重构为任务管理系统

1. 为什么需要持久化 ​

目前应用刷新后数据全丢——因为 Vue 的响应式数据存在内存中。浏览器关闭或刷新,内存清空。


2. 直接实现:watch + localStorage ​

以下代码用于浏览器端的 Todo 应用。localStorage 按来源隔离,只保存字符串;浏览器可能禁止访问或耗尽配额,数据也可能被用户清除。它不能保证永久保存。

2.1 保存 ​

typescript
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 类型:

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'
}
typescript
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 中复用有状态逻辑的核心模式。

类比理解:

ReactVue 3
复用逻辑的方式Custom HookComposable
命名规范useXxx()useXxx()
本质函数函数
关键区别每次渲染重新执行setup 中只执行一次

3.1 创建 useLocalStorage ​

typescript
// 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,并处理安全整数上限。

vue
<!-- 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:

typescript
// 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。

vue
<!-- 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 ​

typescript
// 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 ​

typescript
// 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 + SFCL01能创建项目并解释结构
组件 + PropsL02能定义 Props 并解释单向数据流
ref / reactiveL03能选择合适的 API 并解释 .value
v-for + keyL04能渲染列表并解释 key 的作用
v-if + 事件 + emitL05能实现父子通信
v-modelL06能解释语法糖本质
computed + watchL07能区分使用场景
ComposableL08能抽取可复用逻辑

🔬 深度专题 ​

📖 D09 · Composables vs React Hooks — 同样是"钩子",为什么心智模型完全不同?

Git 提交 ​

bash
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 会复用组件与业务逻辑,并按新的共享状态和路由需求调整接口。