☰
Vue 3进阶笔记:响应式原理、组件通信与性能优化实战
2026/10/15 16:04:02 网站建设 项目流程

1. 整体设计与定位:这份Vue笔记要解决什么问题

接触Vue也几年了,从当年2.x的options写法一路写到3.x的composition风格,中间踩过的坑、绕过的弯,零零散散记了不少东西。身边经常有朋友问我:“Vue到底怎么学才高效?笔记记了厚厚一本,真到做项目还是懵。”其实问题不在于记不记笔记,而在于笔记里记的是什么。

我自己的体会是,一份真正有用的Vue笔记,绝不是API文档的搬家,也不是把所有官方示例抄一遍就完事。它应该回答三件事:第一,Vue的核心机制到底是什么,为什么这样设计;第二,真实项目里最常遇到的那些场景,代码到底应该怎么写;第三,出问题的时候,按照什么思路去定位和解决。

这份笔记我按这个思路整理了很长时间。内容包括响应式系统的底层原理、组件通信的完整图谱、生命周期与副作用管理、Composition API 的实战姿势、状态管理的选型逻辑、性能优化的落地手段,最后附一份高频问题的排查手册。适合正在学Vue想建立系统认知的开发者,也适合已经写了段时间但总觉得哪里没学透的兄弟。看完如果能让你少翻几次搜索引擎、少踩几个隐性坑,这份笔记就没白写。

先说清楚一件事:笔记里所有的代码示例都基于Vue 3.x,部分会对比Vue 2.x的写法差异。版本差异是很多bug的根源,后文会单独说。

2. 响应式体系的底层逻辑

2.1 Proxy 与 defineProperty 的本质区别

Vue 3 的响应式核心换成 Proxy,这是整个框架升级最底层的变化。Vue 2 用 Object.defineProperty 逐个拦截对象的属性读写,这带来几个先天限制:新增属性不会触发更新,必须用 Vue.set;通过索引直接修改数组元素的某个下标也不会触发更新;对象层级很深时,初始化要递归遍历所有属性做劫持,性能开销大。

Vue 3 直接对整个对象做 Proxy 代理,读取和写入都走拦截逻辑,所以新增属性天然就是响应式的,而不再需要额外调用 API。数组的索引修改、length 变化这些 Vue 2 时代的痛点也一并解决了。深层响应不再是递归一次性完成的,而是在属性被访问到时才去建立代理,这个称之为懒响应,初始化的性能比 Vue 2 好很多。

这里给个最简单的对照示例:

// Vue 2 方式——新增属性需要特殊处理 const vm = new Vue({ data: { user: { name: 'Tom' } } }) // 这样不响应 vm.user.age = 20 // 必须这样 Vue.set(vm.user, 'age', 20) // Vue 3 方式——直接赋值即可 const state = reactive({ user: { name: 'Tom' } }) state.user.age = 20 // 响应式生效

注意:虽然 Proxy 解决了新增属性问题,但如果把整个对象重新赋值(比如state.user = newObj),state 本身是响应式的可以拦截这个写入,但 newObj 传入 reactive 时会包装为其代理对象。这里有一个很小的坑:从 Vue 3 的响应式对象上解构出来的普通变量会失去响应式,需要toRefs或toRef来保持。

2.2 ref 与 reactive 的取舍规则

很多初学者纠结一个问题:到底什么时候用 ref,什么时候用 reactive?网上各种说法都有,我按照实际踩坑经验总结出一条简单规则:基本类型用 ref,对象和数组按照是否需要对内部成员做深层次修改来决定。但更省心的方案是,统一用 ref 包裹一切,因为 ref 内部其实就是reactive({ value: ... }),它同样能处理对象。

ref 的优点在于语义统一,不管是数字、字符串、对象还是数组,写起来都是.value。模板里自动解包,不用写 value。reactive 的好处是对象操作时不用加.value,代码看起来更轻,但在解构、传参时非常容易意外丢失响应式,整个团队如果水平参差,很难保证人人都注意到这个问题。

我自己的项目实际代码里,业务状态几乎全用 ref,只是在不同层级的组合式函数内部返回时会看情况决定是否展开。

import { ref, reactive, toRefs } from 'vue' // 推荐:统一用 ref const count = ref(0) const formData = ref({ name: '', tags: [] }) formData.value.tags.push('a') // 仍然响应式 // 如果你偏爱 reactive,解构时不要忘记 toRefs const state = reactive({ count: 0, list: [] }) const { count, list } = toRefs(state) // 这样解构出来才是响应式的 count.value++

2.3 响应式丢失的常见陷阱

响应式丢失是 Vue 3 笔记里必须单开一节的内容。最常见的三种情况:

第一种是解构丢失。const { name } = formData.value,这之后 name 就是个普通字符串,怎么改都不触发更新。第二种是函数传参丢失,把一个 ref 或 reactive 对象的成员作为参数传进普通函数,函数内部直接改参数,同样不响应。第三种是赋值覆盖,把整个响应式对象赋值给了普通变量,或者反过来把普通对象的属性赋给响应式对象。

这三种情况的根因都一样:响应式的核心是代理对象本身,脱离了代理对象的操作就是普通操作。解决方案也很固定:要么用toRefs把每个属性转成单独的 ref 再解构,要么在组合式函数里始终返回 ref 让调用方保持引用。

排查手法上,我一般会在浏览器里打印一下数据,看值是对象还是 Proxy。如果打印出来是纯对象、没有 [[Handler]] 之类的东西,那八成已经丢失响应式了。这种检查在改完代码后随手做一遍,比反复刷新页面看效果要快得多。

3. 组件通信的完整图谱

3.1 父子通信:props 与 emit 的标准姿势

父子通信是 Vue 最基础的能力,但实际项目里很多人写得不够严谨。props 方面有几个经验:

命名统一使用 camelCase 声明,模板中使用 kebab-case 传入。布尔类型的 props 要注意默认值,不要默认 true 还是 false 靠猜。props 永远不要直接修改,Vue 3 的约束比 Vue 2 更严格,直接改会告警,真正要做的是通过 emit 让父组件去改。

emit 的命名建议统一格式,比如update:xxx配合v-model:xxx,这样父子绑定写起来非常顺。用法上有个v3小技巧:子组件声明const props = defineProps(['modelValue'])和const emit = defineEmits(['update:modelValue']),父组件直接<Child v-model="parentData" />,这就实现了自定义组件的双向绑定。

<!-- 子组件 Child.vue --> <script setup> const props = defineProps({ modelValue: { type: String, default: '' } }) const emit = defineEmits(['update:modelValue']) function update(val) { emit('update:modelValue', val) } </script> <template> <input :value="modelValue" @input="update($event.target.value)" /> </template>

3.2 跨层级通信:provide/inject 与依赖注入的实战边界

跨层级通信有两条路线:Vuex/Pinia 这类全局状态库,以及 provide/inject 这种依赖注入。provide/inject 的定位很精确:解决深层组件需要读取上层数据、但中间组件完全不需要关心这份数据的场景。

一个典型的业务场景:Page 页面组件加载了用户权限信息,内部几十层深的某个按钮组件需要根据权限决定是否渲染。如果每一层都通过 props 传,中间组件都得声明与自身业务无关的 props,极其恶心。provide/inject 就能让 Page 直接给按钮组件提供数据。

但要有边界意识:provide/inject 是链路式的,一个组件 provide 了数据,它下面所有的后代都能 inject,容易出现“我的数据到底是从哪个祖先注入过来的”这种问题。项目大了以后,我习惯把所有 provide 的 key 用 Symbol 定义到一个单独文件里,防止命名冲突,也让注入关系一目了然。

// injection-keys.js export const PERMISSION_KEY = Symbol('permission') export const USER_INFO_KEY = Symbol('userInfo') // 祖先组件 import { provide } from 'vue' provide(PERMISSION_KEY, permissionData) // 后代组件 import { inject } from 'vue' const permission = inject(PERMISSION_KEY)

注意:inject 如果取不到值,默认情况下会得到 undefined。建议用inject(PERMISSION_KEY, null)给定默认值,避免 undefined 进一步参与逻辑判断导致报错,这种问题排查起来挺浪费时间。

3.3 事件总线在 Vue 3 里的替代方案

Vue 3 移除了$on/$off实例方法,这导致很多 Vue 2 时期用事件总线做跨组件通信的老写法直接失效。有些团队会引入第三方库重新实现事件总线,技术上没问题,但从项目维护角度看我不建议。事件总线的缺点是事件名散布在代码各处,项目大了根本查不到是谁发的、谁在听,出了 bug 很难追踪。

替代方案按场景划分:兄弟组件之间没有共享父级状态的情况下,可以考虑用一个共享的响应式模块文件,类似一个极简 store;如果交互逻辑复杂、多个组件之间同步状态,直接上 Pinia;如果只是简单通知式通信(比如 A 组件通知 B 组件刷新),用 provide 传一个回调函数或者事件注册器也行,范围清晰。

下面是一个极简的共享模块方式:

// shared.js import { ref } from 'vue' export const sharedData = ref({ counter: 0 }) // 任意组件 import { sharedData } from './shared' sharedData.value.counter++ // 引用了同一个 ref,天然双向响应

这种方案没有魔法、没有全局注册,import 进来的对象就是同一个引用,改动即时生效,调试非常直观。

4. 生命周期与副作用管理

4.1 组合式 API 下的生命周期映射

Vue 3 组合式 API 把生命周期钩子全部改名换面,很多人刚切换时容易懵。其实对应关系很明确:

  • beforeCreate 和 created 被 setup 取代。setup 在组件创建时立即执行,此时可以访问 props、emit、 slots、attrs,但不能访问组件实例 this,这是和 Vue 2 最大的心智差异。
  • onBeforeMount 对应 beforeMount,onMounted 对应 mounted。
  • onBeforeUpdate 对应 beforeUpdate,onUpdated 对应 updated。
  • onBeforeUnmount 对应 beforeDestroy,onUnmounted 对应 destroyed。

Vue 2 里 destroyed 钩子改为 unmounted,名字含义更准确。另一个常见需求场景是 onActivated / onDeactivated / onErrorCaptured,对应 keep-alive 组件激活、失活以及后代组件报错捕获,这些在列表页缓存这类场景里非常重要。

有个实用技巧:在 setup 里定义的异步逻辑如果绑定了 onBeforeUnmount 清理,一定记得清理的是同一个作用域。比如 setTimeout 的句柄、事件监听的解绑函数、轮询的定时器,统统在 unmounted 阶段清掉,不然组件销毁后定时器还在跑,会造成状态泄漏和内存泄漏。

import { onMounted, onBeforeUnmount } from 'vue' let timer = null onMounted(() => { timer = setInterval(() => { // 轮询逻辑 }, 1000) }) onBeforeUnmount(() => { if (timer) { clearInterval(timer) timer = null } })

4.2 watch 与 watchEffect 的正确打开方式

watch 和 watchEffect 都是响应式系统的副反应执行器,但是语义不同。watchEffect 是立即执行一次回调函数,并在回调中访问到的任何响应式依赖发生变化时重新执行。它不需要显式声明依赖,代码更简洁,但正因为自动追踪,容易不小心依赖了不该依赖的东西。

watch 则是显式监听一个或几个数据源,回调只在数据源变化时触发。它支持配置 immediate 立即执行、deep 深度监听。deep 的代价是递归遍历,数据量大时开销不低,能用 getter 返回特定字段就尽量别用 deep。

实操心得:展示类逻辑用 computed,这个没有争议。执行操作类逻辑,少用 watchEffect 多用 watch,因为 watch 的触发时机是可控的。watchEffect 里如果有副作用逻辑(比如发请求),初始化时立即执行一次可能不是想要的行为,可能造成多余请求。深挖业务场景时,watch 带 immediate 可以达成同样效果,还多一个 pre/post 刷新的控制能力。

import { watch, ref } from 'vue' const keyword = ref('') // 只有在输入停止后才请求,适合搜索场景 watch(keyword, async (newVal) => { if (newVal.length < 2) return // 发起搜索请求 })

关于 watch 和 watchEffect 触发的时机还牵扯到组件更新顺序。默认情况下 watch 回调在组件更新前触发,如果你需要在 DOM 更新后再操作,可以传{ flush: 'post' }。这个细节在操作 DOM 尺寸、滚动位置时特别有用,平地一声雷的坑。

4.3 副作用清理的边界与错误示范

副作用清理不只是定时器。我见过一个排查了很久的问题:组件 A 里监听了全局鼠标事件,组件销毁时事件没解绑,导致后续所有页面都出现无意义的重复触发,而且点击一次执行两次逻辑。这就是典型的副作用泄漏。

在组合式 API 里,watch 和 watchEffect 的返回值本身是个停止函数,在onBeforeUnmount里调用即可,不必额外维护变量:

const stopWatch = watch(() => props.id, loadData) onBeforeUnmount(() => stopWatch())

但如果你在一个被 keep-alive 缓存的组件里,注意 unmounted 在缓存状态下是不会被触发的,清理逻辑要放进 onDeactivated 处理。比如退出缓存页面时,事件监听应该暂停,重新激活时恢复。这块区分清楚了,列表页的滚动位置保持和定时器暂停就能正确实现,二分情况也不难处理。

5. Composition API 与逻辑复用

5.1 从 Options 到 Composition 的心智转变

很多从 Vue 2 迁过来的人,最大的障碍不是语法,而是思维方式的调整。Options API 把所有代码按类型组织:data 放数据、methods 放方法、computed 放计算属性。一个完整业务逻辑的三个部分散落在不同区块里,当组件变大,你会发现每个逻辑的代码被强行拆开,维护时要上下翻跳,非常痛苦。

Composition API 按逻辑关注点组织代码,一个业务功能的数据、计算、方法、副作用生命周期被组合在一起,你可以把它抽象成一个组合式函数,以后在别的组件里直接复用。这一点是 Vue 生态的一次不小的进步。

一开始会用 Composition API 写组件时,代码可能看起来像是把所有东西堆在 setup 里,但这只是过渡期的自然状态。等意识到函数抽离的价值后,组件代码会越来越薄,业务逻辑全部下沉到组合式函数里,组件只剩下模板和极少的粘合代码。这跟我之前写 React Hooks 的经验有些相似,只是 Vue 的响应式系统让这套模式用起来更顺滑。

5.2 自定义组合式函数的设计范式

组合式函数的核心是返回一个干净的接口,内部封装所有细节。设计上遵循三个原则:

第一,命名以 use 开头。第二,输入参数和返回值的类型尽量明确,最好用 TypeScript 定义。第三,只暴露该业务需要的状态和方法,internal 的 ref、辅助函数不要在返回对象里透传。

一个典型的例子是封装一个分页加载的通用逻辑,列表请求、加载状态、当前页码、总页数、加载更多方法都在内部维护。任何组件只要调这个函数就能得到全部能力,几十行逻辑不用重复写。

import { ref } from 'vue' export function usePagination(loadFn) { const list = ref([]) const page = ref(1) const pageSize = ref(10) const total = ref(0) const loading = ref(false) const finished = ref(false) async function loadMore() { if (loading.value || finished.value) return loading.value = true try { const res = await loadFn(page.value, pageSize.value) list.value.push(...res.list) total.value = res.total finished.value = list.value.length >= total.value page.value += 1 } finally { loading.value = false } } function reset() { list.value = [] page.value = 1 total.value = 0 finished.value = false } return { list, loading, finished, loadMore, reset } }

这种封装的意义不只是少写代码,而是把容易出错的边界逻辑(防重复加载、判断是否到底、重置状态)集中维护,改一处全都生效。

5.3 响应式工具函数在业务中的妙用

除了 useXxx 自定义函数,Vue 3 内置的一批响应式工具也值得仔细用起来:computed、watch、customRef、triggerRef、shallowRef、shallowReactive、readonly、toRaw 等。把这些工具用对,业务逻辑能少掉不少“临时状态”。

shallowRef 在需要频繁修改整个大对象时性能优势明显,也避免了一层层代理带来的维护负担;triggerRef 用于强制触发 shallowRef 的更新,跳过 proxy 的自动依赖收集。customRef 则适合做防抖搜索之类的场景,可以自定义 get 和 set 时机的精确操作。

import { customRef } from 'vue' function useDebouncedRef value delay = 300 { let timeout return customRef((track, trigger) => ({ get() { track() return value }, set(newVal) { clearTimeout(timeout) timeout = setTimeout(() => { value = newVal trigger() }, delay) } })) } const searchText = useDebouncedRef('', 400)

这段代码在每次赋值时延迟触发响应更新,用户快速输入过程中不会频繁触发下游的搜索请求。这是我在实际项目中用得最顺手的一个工具函数,写起来短、效果好、复用性极高。

6. 状态管理的选型与实践

6.1 什么时候该用状态管理库

这是很多项目反复出现的问题:状态管理库是不是必须的?答案是否定的。如果你的状态只在组件内部或几个兄弟节点之间流转,用 ref 配合 provide/inject 完全够用。滥用全局状态库会让项目状态流非常混乱,出问题比不用它还难调。

什么时候要考虑引入全局状态管理?我的标准有三个:第一,同一份数据被多个不同层级、不同路由的组件共享,并且这些组件是异步渲染的;第二,数据的更新链路比较复杂,涉及异步请求、缓存、失效重试等逻辑,需要清晰的 action 层来维护;第三,需要做到时间旅行调试或者跨组件消息同步的复杂场景。命中两条以上就值得上状态管理库,不然别引入额外复杂度。

6.2 Pinia 的核心概念与使用姿势

Vue 3 生态下,状态管理库默认推荐 Pinia。对比 Vuex 4,Pinia 更轻、对 TypeScript 的支持更彻底、去掉了 mutations 概念,同步和异步都可以在 actions 里直接写。一个典型的 store 定义如下:

import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useUserStore = defineStore('user', () => { const token = ref('') const userInfo = ref(null) const isLoggedIn = computed(() => Boolean(token.value)) async function login(credentials) { const res = await api.login(credentials) token.value = res.token userInfo.value = res.user } function logout() { token.value = '' userInfo.value = null } return { token, userInfo, isLoggedIn, login, logout } })

这种 setup store 风格和组合式函数的写法几乎一致,换到组件里使用非常顺滑。注意一点:store 内部的 ref 在组件里访问时是要.value的,但如果整个 store 实例经过 storeToRefs 解构出来,会保留响应性。

import { storeToRefs } from 'pinia' const store = useUserStore() const { token, userInfo } = storeToRefs(store) // 保持响应式 const { login, logout } = store // 方法直接从 store 上取

这个storeToRefs和之前 reactive 里toRefs的原理是相通的,千万别忘了,否则解构出来的状态会变成普通值,改动不触发任何视图更新。我见过不止一个兄弟在这个小细节上卡了半天。

6.3 跨应用状态共享与模块化组织

Pinia 本身是支持多实例共享的,同一个 store 在多个 Vue 应用实例里可以挂载同一个 Pinia。这种方式适合微前端或者多窗口通信,原理是利用了 Pinia 内部通过 effect scope 和全局缓存共享 state。

模块化组织方面,更推荐按业务域而不是按页面来拆分 store,比如 user、cart、settings、orders。每个 store 只负责自己的领域,不要做全局的东西。跨 store 访问时,直接在 action 里调用其它 store 实例的方法是没问题且推荐的做法,不要为了解决同步问题把所有状态塞到一个巨型 store 里。

注意:Pinia 只共享状态,不共享方法内执行的任意代码。换句话说,如果你的需求只是让两个不相关的 Vue 实例执行同一个逻辑,可以直接抽公共函数,不必通过状态管理来解决。

7. 性能优化的实用手段

7.1 渲染优化基础:computed 缓存与 v-memo

这一节说的性能优化,是在真实项目中能够直接落地的细节。computed 是自带缓存的计算属性,只有依赖的响应式数据变化时才重新计算。大量使用 computed 代替 methods 里做计算,能有效减少重复计算开销。

v-memo 是 Vue 3.2 引入的指令,作用是缓存一段模板的渲染结果,只有当绑定的值变化时才重新渲染。典型应用场景是列表中的每项渲染成本较高而大部分项不太变化时。要注意 v-memo 和 v-for 同时使用时的绑定逻辑,一般是把列表项的 id 作为 memo 依赖。

<div v-for="item in list" :key="item.id" v-memo="[item.id]"> <!-- 复杂子组件渲染 --> <ItemCard :data="item" /> </div>

如果列表项频繁发生局部变化,v-memo 反而可能因为依赖数组的比对开销增大而产生负优化。这个指令是优化手段但不是银弹,需要先分析渲染瓶颈再决定是否使用。

7.2 大数据列表的性能突破口

渲染几千行甚至几万行数据时,单纯靠 v-for 会卡到怀疑人生。这时候有两个方向:虚拟滚动和分批渲染。

虚拟滚动的思路是只渲染可见区域内的少量节点,核心逻辑是监听容器滚动事件,计算当前可见行的起始索引和结束索引,动态修改渲染的数据切片。很多现成库能做这件事,比如官方推荐的虚拟滚动方案或者一些社区封装组件。如果项目对体积敏感,手写一个简单的虚拟滚动也不算复杂,关键就是维护好 scrollTop 到 startIndex 的映射。

另一种方案是分批渲染,利用 requestIdleCallback 或者 setTimeout 分段插入数据。在真实项目里,我处理过一个导出任务日志的页面,接口一次返回 10000 条以上记录,就是在实现虚拟滚动后流畅度才有明显好转的。这个优化前后的感知差别,用“卡死”到“丝滑”来形容一点也不夸张。

7.3 组件级优化与异步渲染策略

组件层面的优化,第一条是合理拆分。把一个巨型组件拆成多个职责单一的子组件,不只是为了代码可读性,更是为了让局部更新不需要整棵子树重新渲染。虽然 Vue 的虚拟 DOM 和响应式系统已经做了大量优化,但你的更新粒度越细,实际重渲染的节点越少,性能越好。

异步渲染策略方面,defineAsyncComponent 配合 Suspense 可以做到组件级别的懒加载。路由组件懒加载 + 首屏资源按需分割,是 SPA 性能优化最直接的手段之一。

import { defineAsyncComponent } from 'vue' const HeavyComponent = defineAsyncComponent(() => import('./HeavyComponent.vue') )

这里有一个经验之谈:不是所有组件都适合异步加载,只有首屏不需要、用户操作后才有可能出现的组件才值得拆出来异步加载。比如弹窗里嵌套的复杂表单、图表组件这类,用异步加载效果很好;一个直接可见的头部组件强行异步反而增加了一次加载的白屏等待,体验更差。

7.4 内存泄漏排查思路

内存泄漏是前端项目最难查的问题之一,因为它不是一闪而过的 bug,而是运行越久页面越卡、内存占用持续增长的慢性病。

前端最常见的泄漏来源有这五类:一是全局事件监听没解除,二是定时器没清理,三是意外把大对象保存在全局变量里,四是 DOM 引用被事件闭包捕获,五是单例模式里维护了无用的长驻状态。

排查方法论很固定:打开 DevTools 的 Memory 面板,录制几次堆快照,对比两次快照之间的对象增长;或者用 Performance 面板记录一定时间内的操作,观察内存曲线是不是持续上升不回落。前者适合定位对象泄漏,后者适合定位事件累计。看到一个 Unknown 或者 detached DOM 数量大量增长的时候,几乎可以断定是事件监听或 DOM 引用没有释放。

我花过最长的一次内存排查,耗费一整天,最终定位到某个组件里注册了滚动事件,但使用了箭头函数导致解绑时引用不同,解绑失败。改用具名函数后问题解决。这个细节真的很值得记住,代码里用removeEventListener时,回调函数必须在 add 和 remove 中使用同一个引用。

8. 高频问题排查与经验速查

8.1 模板渲染不更新的常见起因

“数据明明改了,页面怎么不更新”是 Vue 社区里出现频率最高的问题。归纳下来无非这几种:

第一,数据本身不是响应式的。引入了一个普通对象,直接赋值或者从别的模块拿来的全局常量,改动后 Vue 根本感知不到。检查方法很简单:打印出来看是不是 Proxy。

第二,直接修改了 props。props 在 Vue 3 里是只读代理,改了虽然数据变化但视图不更新,因为 Vue 不允许这个路径。

第三,对象新增属性在 Vue 2 里不响应,在 Vue 3 里如果是 reactive 包装的对象则已经解决,但如果是 reactive 替换了整个对象却是在普通容器里触发的,也会出问题。

第四,使用了索引赋值的方式修改数组,Vue 3 的 Proxy 虽然能拦截,但如果你用的是普通数组而不是 ref 或 reactive 包装的数组,一样不响应。

排查顺序我一般按从外到内:先看数据在不在、是不是响应式的,再看修改方式走没走响应式系统,最后看模板引用对不对。这套顺序能覆盖绝大多数场景。

8.2 Router 与 Pinia 状态持久化之间的坑

Vue Router 和 Pinia 协作起来有个高频坑:页面刷新之后,Pinia 里所有状态全部初始化,用户登录态丢失,业务跳回登录页。这个问题的本质是 Pinia 默认不持久化,状态只存在内存里。

解决方案有三种路线。最简单的是把关键状态写入 localStorage 或 sessionStorage,刷新时读取并在 store 初始化时恢复。比较稳妥的是直接集成一个持久化中间件,在 store 定义时配置需要持久化的字段,自动处理存和取。更繁琐但可控的是在应用入口手动恢复。

我的实践观点:不要把整个 store 持久化,只持久化必要字段,比如 token、用户ID、偏好配置。一次性塞入大量不可序列化的数据(如函数、复杂对象)会埋下隐患。字段粒度的持久化设计能让刷新恢复逻辑清晰,也避免状态过期问题。路由守卫和持久化的时序也要注意,恢复状态一定要在路由判断守卫之前完成,否则用户会被误踢到登录页。

// 路由守卫中判断登录态前,确保 store 已经从存储恢复完成 router.beforeEach((to) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.isLoggedIn) { return '/login' } })

提前把恢复逻辑放到应用初始化时执行、保证 beforeEnter 时状态已就绪,这个顺序问题能省下很多无头绪的排查。

8.3 其他容易忽视的 Vue 3 变更清单

除了上面提到的大项,还有几个版本的破坏性变更容易让老 Vue 2 开发者踩坑:

  • v-model参数可以自定义,modelValue取代了 Vue 2 的value,update:modelValue取代input事件。
  • 全局 API 改为实例方法,Vue.use变成app.use,Vue.directive变成app.directive。
  • filter 被移除,数据过滤统一用计算属性或方法替代。
  • $children被移除,通过 ref 或 provide 替代。
  • 自定义事件验证被移除,但 props 验证保留。
  • 异步组件写法有变化,多了一个defineAsyncComponent。

这份清单建议贴在项目代码库里,转换老代码的时候对照着改,能少踩不少无声无息的坑。我见过因为 v-model 语法变了而不自知,结果输入框绑定的数据一直同步不到父组件的案例,排查很久才发现是因为用法还是 Vue 2 的旧写法。

9. 写给自己和同路人的几句实在话

整理完这份笔记,再回头看标题里那句"vue笔记",突然觉得这几个字的分量其实很重。一套框架学到什么程度才算真正掌握?我的理解是,不是能写组件、能跑起个项目就算会,而是遇到一个问题时能快速定位到底是响应式链路断了、生命周期次序错了、还是状态管理设计不合理。这种判断力,全靠项目实战和时间喂出来,没有捷径。

我给自己的要求是:每完成一个项目,就回头把笔记里过时的、冗余的、没经住验证的内容删掉,换成这次踩坑后的新理解。笔记是活的东西,不是落灰的日记本。今后再看这份笔记时,希望我自己也能笑着感叹一句:"这坑,当初还真踩得值。"

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询