路由、持久化与筛选
实战:任务管理应用 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
给看板接上路由与持久化:URL 驱动筛选、路由切换不丢状态、刷新后任务还在。这一节交付「可以分享筛选链接、关掉浏览器再回来数据完好」的版本。
问题场景
看板功能完整了,但:刷新页面任务全没;同事想分享「只看高优先级」的视图,只能口头说;切到「关于」页再回来,看板状态正常(store 还在),但刷新后又丢了。持久化和 URL 状态是这个阶段必须解决的两件事。
核心解释
持久化(V11 的模式,套到任务 store):读时校验、写时监听、版本 key:
// stores/tasks.js
import { ref, watch } from "vue";
const STORAGE_KEY = "taskboard.tasks.v1";
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const data = JSON.parse(raw);
if (!Array.isArray(data)) return [];
return data.filter(
(t) =>
t &&
typeof t.id === "string" &&
typeof t.title === "string" &&
["todo", "doing", "done"].includes(t.status)
);
} catch {
return [];
}
}
export const useTaskStore = defineStore("tasks", () => {
const tasks = ref(load());
watch(
tasks,
(value) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
/* 存储不可用降级 */
}
},
{ deep: true }
);
// ...
});
URL 驱动筛选:筛选条件(关键词、优先级、只看某列)写入 route.query,组件从 query 派生:
<!-- BoardView.vue -->
<script setup>
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTaskStore } from "../stores/tasks";
const route = useRoute();
const router = useRouter();
const store = useTaskStore();
const keyword = ref(String(route.query.q ?? ""));
const priority = computed(() => String(route.query.priority ?? "all"));
watch(
() => route.query.q,
(q) => {
keyword.value = String(q ?? "");
}
);
function applyFilter() {
router.push({
query: {
...(keyword.value ? { q: keyword.value } : {}),
...(priority.value !== "all" ? { priority: priority.value } : {}),
},
});
}
const filteredByStatus = computed(() => {
const q = String(route.query.q ?? "").trim().toLowerCase();
const p = String(route.query.priority ?? "all");
return Object.fromEntries(
Object.entries(store.byStatus).map(([status, list]) => [
status,
list.filter(
(t) =>
(!q || t.title.toLowerCase().includes(q)) &&
(p === "all" || t.priority === p)
),
])
);
});
</script>
路由结构:
// router/index.js
const routes = [
{ path: "/", name: "board", component: () => import("../views/BoardView.vue") },
{ path: "/task/:id", name: "task", component: () => import("../views/TaskDetailView.vue"), props: true },
{ path: "/about", name: "about", component: () => import("../views/AboutView.vue") },
];
详情页从 URL 拿 id、从 store 找任务,找不到显示「任务不存在」——这样深链接直接打开也能工作(部署时的 history 回退由 V17 的 Nginx 配置保证)。
「切页面状态不丢」的原因:store 是应用级单例,路由切换只卸载页面组件,store 不受影响。这条要在项目里验证一次:加两条任务 → 切到关于页 → 切回看板 → 任务还在。如果不在,检查是不是把任务列表放在了页面组件的 ref 里。
示例拆解
验证持久化的三个场景(与 V11 一致,但这次在真实项目里跑):
1. 正常:新建两条任务 → 刷新 → 两条都在,状态正确
2. 脏数据:localStorage 改成 "{oops" → 刷新 → 应用正常启动,看板为空
3. 旧结构:删掉某条的 title 字段 → 刷新 → 该条被过滤掉,其余保留
第三个场景说明 load() 里的字段校验不是形式主义:坏数据要么被修正、要么被丢弃,绝不能让整个应用白屏。
动手任务
- 实现持久化并逐条验证三个场景。
- 实现 URL 筛选:关键词 + 优先级,验证「分享链接给对方 → 对方打开看到同样的筛选结果」。
- 实现任务详情页
/task/:id,直接输入不存在的 id 时显示友好的空状态。
常见错误
- 只在任务增删时写 localStorage:编辑标题、切换状态不写,刷新丢修改。用 deep watch 统一监听。
- 筛选状态放组件 ref:刷新丢、无法分享。放 query。
- 详情页从 store 找不到任务就白屏:深链接或删除后访问会崩。渲染空状态。
- 持久化里保存了「当前筛选」:用户分享的链接带着自己的筛选,或者刷新后筛选还在但 URL 没有——状态源分裂。筛选只放 URL。
检查与复盘
- 刷新后任务与状态完整保留
- 筛选链接可分享、可后退
- 详情页深链接直接打开正常、异常 id 有兜底
参考资料
- Vue Router 官方文档:query 与动态参数
- Pinia 官方文档:跨页面状态保持
- MDN:localStorage 的容错处理
读完这一课了?
标记完成后,工作台会安排接下来的学习。