
看到有人问“怎么用 Vue 3 TS Vuex 搭一个响应式个人博客”这个组合其实挺有代表性。Vue 3 的 Composition API 加上 TypeScript 的类型约束再加上 Vuex 做全局状态管理用来做一个内容不多不少的中小型博客站刚好能把这三样东西的配合讲透。前端圈现在很流行“全栈 React、生态 Vue”的说法但在状态管理这一块Vuex 依然是很多团队的首选尤其当你的项目里有大量共享状态、跨组件同步、持久化需求的时候Vuex 的清晰架构比什么都好用。这篇文章不是给你贴一个完整的博客源码而是想分享我实际操作中的完整思路——从技术选型、目录规划、响应式机制、核心模块实现到后期踩过的坑和排查过程。内容偏实战适合已经掌握 Vue 基础、想尝试 Vue 3 TS 组合、或者正在纠结“到底要不要用 Vuex”的开发者。我会尽量把每一步为什么这样做讲清楚让你不仅会写还知道为什么这么写。1. 技术选型背后的真实理由为什么是 Vue 3 TS Vuex1.1 博客站点的技术需求画像个人博客在功能上说到底就是“文章列表 文章详情 标签分类 主题切换”听起来很简单但如果你认真拆解一下状态流会发现它比想象中更需要全局状态管理。以我自己的博客为例导航栏要显示当前文章所属的分类、侧边栏要显示热门标签、文章列表要根据标签切换筛选、右上角有深浅色主题切换按钮。这些组件分布在不同的 DOM 层级彼此之间没有直接的父子关系。如果不用 Vuex你只能在 App.vue 里维护一大堆 props然后在各个子组件里一层层 emit 事件代码很快就会变成一团乱麻。更麻烦的是主题切换。深浅色模式不是一个组件内部的小状态它意味着全局所有组件的样式都要跟着变。在 Vue 3 里虽然可以用 provide/inject 做依赖注入但真正要做到“切换一次、全局响应”Vuex 才是最直观的地方。1.2 为什么不用 Pinia 而选 Vuex可能有人会问Vue 3 都出了这么久了新项目为什么不用官方推荐的 Pinia我承认 Pinia 的 API 更简洁、类型推导更友好但 Vuex 依然有自己的优势。首先是团队心智。Vuex 从 Vue 2 时代就是主流方案网上大量的教程、开源项目都是基于 Vuex 的很多团队里老成员熟悉的还是 Vuex 的 mutation/action 架构。我在实际接手的项目里也大多是 Vuex 3/4 的代码。如果你去维护一个老项目或者加入一个技术栈偏保守的团队Vuex 依然是躲不掉的。其次是项目规模。博客这种中小型项目Vuex 的所谓“样板代码”其实不算缺点。mutation 强制同步、action 处理异步这套约束在多人协作时反而是一种保护。Pinia 虽然更自由但也更容易写出随手一放的状态代码。最后是我自己的偏好。Vuex 的 getter 体系天然适合博客场景——比如文章列表需要根据标签筛选、根据时间排序这些都可以写成 getter既可以在模板里直接使用又能在组件间复用。Vue 3 下 Vuex 4 使用createStore创建 store和 Vue 3 的响应式系统完美兼容并没有“过时”的问题。1.3 TypeScript 在一开始就介入的价值我在很多文章里强调过一个观点TypeScript 的价值不在于“写类型”而在于“改代码的时候少掉头发”。博客项目虽然不大但文章数据结构是相对固定的——标题、摘要、标签、发布时间、正文内容。这些字段一旦在 TypeScript 里定义好后面写组件、写 getter、写 action 的时候编辑器会一直在提醒你“这个对象没有这个字段”“这个函数需要这个参数”效率提升非常明显。比如你定义一个Post接口// src/types/post.ts export interface Post { id: number; title: string; summary: string; content: string; tags: string[]; category: string; createTime: number; updateTime: number; views: number; }有了这个接口之后store 的 state、组件里的 props、接口返回的数据全部可以对标检查。TypeScript 的价值不在写的那一下光鲜亮丽而在第二天改代码时编译器能第一时间告诉你哪里不匹配。2. 搭建项目时的核心约定与目录规划2.1 使用 Vite 创建项目模板博客项目推荐使用 Vite 的vue-ts模板这个模板自带 Vue 3 TypeScript Vite 的基础配置省去手动配置tsconfig和vite.config的麻烦。npm create vitelatest personal-blog -- --template vue-ts cd personal-blog npm install装完基础依赖后需要额外装 Vuex 4 和路由。npm install vuexnext vue-router4这里要注意的是Vuex 4 的包名虽然还是vuex但版本必须是 4.x因为 Vuex 3 是为 Vue 2 设计的API 虽然差不多但内部对响应式的处理完全不同。装完之后在main.ts里通过app.use(store)注册。2.2 目录结构如何规划才不互相打架博客项目不大但如果规划不好随着功能增多还是会乱。我一般会把 store 单独拆成模块modules每个模块负责一个领域的状态。这是我的目录结构src/ ├── api/ │ └── post.ts ├── assets/ ├── components/ │ ├── layout/ │ │ ├── AppHeader.vue │ │ ├── AppSidebar.vue │ │ └── AppNav.vue │ └── post/ │ ├── PostCard.vue │ ├── PostList.vue │ └── TagChip.vue ├── router/ │ └── index.ts ├── store/ │ ├── index.ts │ └── modules/ │ ├── post.ts │ ├── theme.ts │ └── user.ts ├── types/ │ ├── post.ts │ ├── theme.ts │ └── store.ts ├── views/ │ ├── HomeView.vue │ ├── PostDetailView.vue │ └── ArchiveView.vue ├── App.vue └── main.ts这个结构的关键在于store/modules负责状态逻辑types负责类型定义components负责纯展示。模块和模块之间不要互相引用如果需要跨模块取数据统一通过rootState或者单独抽公共逻辑。2.3 带 TypeScript 的 Vuex store 初始化Vuex 4 在 TypeScript 支持上比 Vuex 3 强了不少但依然需要你自己定义InjectionKey来获得类型提示。这是我的store/index.ts写法// src/store/index.ts import { createStore, Store, useStore as baseUseStore } from vuex; import { InjectionKey } from vue; import { postModule, PostState } from ./modules/post; import { themeModule, ThemeState } from ./modules/theme; export interface RootState { post: PostState; theme: ThemeState; } export const key: InjectionKeyStoreRootState Symbol(store); export const store createStoreRootState({ modules: { post: postModule, theme: themeModule, }, }); export function useStore() { return baseUseStore(key); }注意看这里InjectionKey是 Vue 3 提供的一种类型标识方式。你在main.ts里app.use(store, key)传入同一个 key这样组件里调用useStore()时就能直接拿到RootState的类型推导。在模块文件里我会单独声明每个模块自己的 State 类型然后在模块内部用ModuleS, RootState泛型约束// src/store/modules/post.ts import { Module } from vuex; import { Post } from /types/post; import { RootState } from /store/index; export interface PostState { posts: Post[]; currentPost: Post | null; activeTag: string; loading: boolean; } const state (): PostState ({ posts: [], currentPost: null, activeTag: , loading: false, }); export const postModule: ModulePostState, RootState { namespaced: true, state, mutations: { ... }, actions: { ... }, getters: { ... }, };这里的namespaced: true也很关键。开启命名空间之后各个模块的 mutation、action、getter 都挂到模块名下面避免不同模块的同名方法互相覆盖。你在组件里调用的时候写法是store.commit(post/setPosts, data)虽然没有不带命名空间舒服但项目一大了还是建议开着安全。3. 博客场景下“响应式”到底指什么3.1 响应式并不只是“数据变了页面就变”一说起响应式很多人第一反应是 Vue 的双向绑定。但博客场景下响应式其实分好几个层次第一层是数据驱动视图。文章列表数据变化时页面自动更新。这是 Vue 本身就有的能力。第二层是跨组件状态共享。主题切换按钮在 header但整个页面的颜色、背景、字体都跟着响应。这需要全局状态。第三层是异步数据的同步响应。文章数据从接口拉回来后store 里的 state 更新然后所有依赖这个 state 的组件一起更新。这需要 action mutation 的配合。第四层是持久化状态的恢复和同步。用户选好了深色模式刷新浏览器后要能记住。用户在一台设备上切换了主题同一浏览器另一个 tab 页要能感知到。这四个层次不是每一个框架都能自动做到的所以才需要 Vuex 手动持久化 存储事件监听。3.2 Vue 3 响应式核心在 Vuex 里是怎么体现的Vuex 4 之所以可以在 Vue 3 里无缝工作是因为它内部用Vue.reactive()来做 state 的响应式包装。你的state不是一个普通对象而是一个经过 reactive 代理的对象。当你在 mutation 里给 state 的属性赋新值时依赖它的 computed 和组件的 render 函数都会排队触发更新。这套机制在博客项目里最典型的体现是筛选功能。我维护了一个activeTag字段点击侧边栏的某个标签时commitsetActiveTag然后文章列表的 getter 会根据当前的activeTag过滤数据getters: { filteredPosts: (state) { return state.activeTag ? state.posts.filter((post) post.tags.includes(state.activeTag)) : state.posts; }, sortedPosts: (state, getters) { return [...getters.filteredPosts].sort( (a, b) b.createTime - a.createTime ); }, },模板里直接使用computed(() store.getters[post/sortedPosts])当activeTag变化时sortedPosts会重新计算列表组件通过响应式依赖自动更新。中间不需要任何手动刷新逻辑。3.3 响应式依赖追踪的边界问题用了 Vuex 之后有一个容易忽略的坑如果你直接把 store 的 state 赋值给组件里的普通变量这个变量不会响应。script setup langts import { useStore } from /store; const store useStore(); // 错误示例themeMode 不会响应 store 的变化 const themeMode store.state.theme.mode; // 正确示例包一层 computed const themeMode computed(() store.state.theme.mode); /script这一点和 Composititon API 里的ref解构问题一样根源都是“响应式代理只存在于对象本身赋值操作会打断依赖关系”。所以我在团队里定了一个规范所有从 Vuex 取数据的场景一律用computed包一层不允许直接解构 state。另一个边界是 Vuex 官方明确要求的state 的修改只能通过 mutation 提交action 里不能直接改 state。但很多人用 TypeScript 之后容易手滑在 getter 或 action 里直接修改了state.xxx。Vue 3 的响应式系统不会拦你只会默默更新视图但这段逻辑就失去了 Vuex 架构的意义变成了状态管理版“无组织无纪律”。4. 文章列表、标签筛选与深色模式核心模块的实现实录4.1 文章列表模块接口层与 store 层的分工博客的文章数据来源通常是 Markdown 文件或后端 API。我这里的项目用了一个简单的接口层先定义请求函数再在 action 里调用// src/api/post.ts import { Post } from /types/post; const mockApi { getPosts(): PromisePost[] { return new Promise((resolve) { setTimeout(() resolve(initialPosts), 200); }); }, getPostById(id: number): PromisePost { return Promise.resolve(initialPosts.find((p) p.id id)!); }, };在 store 的 action 里异步逻辑统一放在这里actions: { async fetchPosts({ commit }) { commit(setLoading, true); try { const posts await postApi.getPosts(); commit(setPosts, posts); } finally { commit(setLoading, false); } }, async fetchPostById({ commit }, id: number) { const post await postApi.getPostById(id); commit(setCurrentPost, post); }, },为什么要这么设计因为 action 是异步操作的唯一入口。组件里不会直接调用 API而是store.dispatch(post/fetchPosts)。这样如果后续要加缓存、防重复请求、接口错误统一处理都只需要在 action 里改组件代码完全不用动。4.2 标签筛选与 URL 状态同步标签筛选如果只存在 Vuex 里有个问题页面刷新后选中的标签就丢了。好的体验应该是标签和 URL 同步。我的做法是在路由守卫里做双向同步。当用户点击标签时除了 commitsetActiveTag还要用router.push更新 URL 的 query// 组件里 const handleTagClick (tag: string) { store.commit(post/setActiveTag, tag); router.push({ query: { ...route.query, tag } }); };在路由的beforeEach守卫里从 query 恢复状态router.beforeEach((to, from, next) { const tag to.query.tag; if (typeof tag string) { store.commit(post/setActiveTag, tag); } next(); });这样刷新页面时标签状态从 URL 恢复用户看到的列表和点击时的列表一致。如果你把activeTag同步到 URL 里还能直接分享一个“带标签筛选的列表链接”。4.3 深色模式使用 Vuex 管理 CSS 变量深色模式是有点“反直觉”的需求它的核心不完全是数据而是样式层面的全局切换。我的做法是在 store 里保存mode字段通过 mutation 修改然后组件里监听这个字段并切换根元素的class。先定义类型// src/types/theme.ts export type ThemeMode light | dark; export interface ThemeState { mode: ThemeMode; }模块实现// src/store/modules/theme.ts import { Module } from vuex; import { ThemeState, ThemeMode } from /types/theme; import { RootState } from /store/index; const state (): ThemeState ({ mode: light, }); const mutations { setMode(state: ThemeState, mode: ThemeMode) { state.mode mode; }, toggleMode(state: ThemeState) { state.mode state.mode light ? dark : light; }, }; export const themeModule: ModuleThemeState, RootState { namespaced: true, state, mutations, };组件或入口文件里监听mode的变化动态给document.documentElement添加 class// src/composables/useTheme.ts import { watch } from vue; import { useStore } from /store; export function useThemeEffect() { const store useStore(); watch( () store.state.theme.mode, (mode) { document.documentElement.classList.toggle(dark, mode dark); localStorage.setItem(theme-mode, mode); }, { immediate: true } ); }然后在App.vue的setup里调用一次useThemeEffect()。CSS 变量统一写在根样式里:root { --bg-color: #fff; --text-color: #333; } .dark { --bg-color: #1e1e1e; --text-color: #e0e0e0; }组件里一律只使用这些变量.container { background-color: var(--bg-color); color: var(--text-color); }这样切换主题时不需要任何组件重新渲染CSS 变量自动在所有用到var(--bg-color)的地方生效。Vuex 在这里扮演的角色是状态的中转站真正的视觉反馈来自 CSS 变量机制。5. 实际运行中踩过的坑与完整排查链路5.1 坑一Vuex 模块 state 在刷新后消失博客项目很常见的需求是用户刷新页面后文章列表应该还在当前选中的标签应该还在主题模式应该保持。但默认的 Vuex state 是存在内存里的刷新后直接清空。我一开始的做法是在App.vue的created里调用fetchPosts和fetchTheme等接口返回后再渲染。这样做的问题很明显每次刷新都要重新请求网络状态差的时候页面白屏时间太长。后来我改成初始化时从localStorage恢复// src/store/modules/theme.ts const STORAGE_KEY theme-mode; const state (): ThemeState ({ mode: (localStorage.getItem(STORAGE_KEY) as ThemeMode) || light, });文章列表的数据比较大不适合全部放 localStorage我改用 sessionStorage 做缓存保证会话期间刷新不丢关闭 tab 之后自动清理actions: { async fetchPosts({ commit }) { const cached sessionStorage.getItem(posts-cache); if (cached) { commit(setPosts, JSON.parse(cached)); return; } // 省略请求逻辑... const posts await postApi.getPosts(); commit(setPosts, posts); sessionStorage.setItem(posts-cache, JSON.stringify(posts)); }, },5.2 坑二TypeScript 类型推导在某些 getter 链上失效Vuex 4 的 getter 之间互相依赖时TypeScript 的类型推导偶尔会“罢工”。我遇到的情况是filteredPosts返回值正常但sortedPosts里展开getters.filteredPosts后类型变成了any。排查过程先在sortedPosts里打印 getter 的返回值确认运行时数据正常。在store/index.ts里检查RootState的定义发现post模块的类型的PostState没有正确导出。打开tsc --noEmit看报错信息提示Post[]和never[]不兼容。最终发现是state.posts的初始值写成了[]TypeScript 推导成never[]需要显式声明const state (): PostState ({ posts: [] as Post[], ... })。这个坑很多新手会踩本质上不是 Vuex 的问题而是 TypeScript 对空数组的类型推断默认是never[]。解决方案就是在初始化时显式标注类型。5.3 坑三持久化与多标签页状态不一致博客网站用户经常开多个标签页一个标签页里切换了深色模式另一个标签页还停留在浅色模式。Vuex 的响应式是模块内的跨标签页默认没有任何通信机制。我的排查链路是这样的先确认 Vuex 状态在一个 tab 内切换正常。然后怀疑是 localStorage 的写入时机问题检查了所有watch回调确认每次切换都会写入。再想到浏览器有storage事件可以在其他标签页监听这个事件来同步数据。最终方案// src/composables/useTheme.ts window.addEventListener(storage, (event) { if (event.key theme-mode event.newValue) { store.commit(theme/setMode, event.newValue as ThemeMode); } });storage事件只会在其他标签页修改 localStorage 时触发当前标签页不触发这个特性正好符合跨标签同步的需求也不会造成本地循环更新。5.4 坑四开发模式下热更新导致 Vuex 状态丢失Vite 开发模式非常快但有一个比较尴尬的问题当你修改了 store 模块的源码HMR热更新会重新加载模块旧模块里的 state 就丢了。你正在浏览的页面突然变成空白文章列表消失主题变回浅色。排查后我发现这是 Vite HMR 的一个边界情况。Vuex 的state是模块内部的闭包变量重新加载模块时闭包重新初始化旧 state 自然没了。有一个比较简单粗暴的解决方案在 Vite 配置里关闭 store 相关文件的 HMR改为整页刷新// vite.config.ts import { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ plugins: [vue()], server: { hmr: { overlay: true, }, }, });实际开发中我一般接受“改 store 就刷新页面”这个行为因为 state 丢失后重新拉取并初始化反而能发现一些初始化逻辑的遗漏。如果实在需要保留状态可以在main.ts里对store做一次缓存但复杂度会上升博客项目不值得。6. 持久化、多标签同步与后续扩展思路6.1 持久化策略的完整闭环持久化不能只靠一个 localStorage 写入了事必须是“初始化读取 状态变化写入 跨标签同步”三个环节的闭环。以主题为例完整链路是页面加载themeModule的state初始化时从 localStorage 读取初始值。App.vue里启动useThemeEffect()watch 模式变化写入 localStorage并切换根元素 class。同一个浏览器另一个标签页监听到storage事件更新自己的 Vuex state触发相应响应式更新。这个闭环的好处是数据存储、状态管理、样式应用三个环节完全解耦。你不需要在任何组件里手动调用setItem主题的持久化和同步逻辑全部在 composable 内部组件只需要 commit mutation。6.2 路由级懒加载与 store 模块按需加载博客项目如果想做得更细可以考虑路由懒加载和 store 模块动态注册。Vuex 支持在运行时动态注册模块配合路由懒加载可以实现“访问某个路由时才加载对应的状态模块”。具体做法是在views/ArchiveView.vue的setup里import { store } from /store; import { moduleName } from /store/modules/archive; if (!store.hasModule(moduleName)) { store.registerModule(moduleName, archiveModule); }这种模式适合博客后期增加复杂功能比如后台管理、评论系统这些模块不一定在首屏需要。但要注意动态注册的模块在切换路由后不会自动卸载记得在onUnmounted里unregisterModule。博客项目有一个简单的归档页就够了不需要一开始就做极端优化但知道这个扩展路子对后续功能演进有帮助。6.3 从 Vuex 到 Pinia 的迁移视角方案可平移Vuex 写久了之后你会发现项目里的状态逻辑其实是和具体框架绑得不太紧的。我们在 Vuex 里定义的 state、getter、action、mutation如果有一天团队决定迁移到 Pinia大部分逻辑可以平移。Pinia 的defineStore里export const usePostStore defineStore(post, { state: () ({ posts: [], activeTag: }), getters: { filteredPosts: (state) { ... }, }, actions: { async fetchPosts() { ... }, }, });Vuex 的 mutation 可以封装成 Pinia 的 actiongetter 的写法基本一致state 的定义思路完全一样。也就是说你在 Vuex 里积累的“什么是状态、什么是派生数据、什么是异步逻辑”这套方法论换框架时依然有效。架构设计能力永远比某个具体库的 API 更有价值。7. 实际开发中我个人坚持的几个小原则最后分享几条我踩过很多坑之后总结的经验帮助你在博客项目里少走弯路。第一store 里的数据不要直接赋值给组件 ref。哪怕你用ref(store.state.posts)也不行因为这个ref的值不会跟着 store 变化。统一用computed读取。第二mutation 里不要放异步逻辑。Vuex 的 mutation 设计成同步是为了让状态变化可以被开发者工具追踪。你把setTimeout放进 mutation一旦数据有问题很难定位是哪次异步操作改了 state。第三getter 里不要修改参数对象。虽然 ES6 的数组方法很多都返回新数组但如果你在 getter 里state.posts.push()就等于把 getter 写成了 mutation而且这个修改不会经过 mutation 追踪调试时非常痛苦。第四组件里不要直接 import 模块状态。一律通过useStore()访问这样模块的边界清晰后续做动态注册或模块拆分时不需要动组件代码。第五项目里如果只有一两个共享状态可以不引入 Vuex。但博客这种需要文章列表、标签筛选、主题切换、用户信息等多个状态之间相互协作的项目Vuex 的收益非常明显。不要为了用而用也不要为了“轻量”而坚持不用。这套组合跑下来的体验说实话比我想象中要稳。Vue 3 的 Composition API 让逻辑复用变得干净了很多TypeScript 在 store 类型定义上的表现虽然不如 Pinia 顺畅但加一层InjectionKey封装之后也完全够用Vuex 的模块化约束在多人协作时依然是一种保护。如果你正准备搭一个功能不太复杂的小型博客站这组技术栈值得你花一两天时间试试。