简介:这份《尚硅谷 张天禹Vue2.0 + Vue3.0 入门到精通教程学习笔记》面向刚接触 Vue 或需要系统梳理前端基础的学习者,围绕从入门到进阶的课程内容整理成可直接查阅的 word 笔记,适合课前预习、课后复盘与面试前查漏补缺。压缩包内共 1 个 docx 文件,大小约 10.38MB,内容以文字笔记为主,按课程章节组织,便于检索、标注和打印复习。目录涵盖 Vue 简介、开发环境与开发者工具、初始 Vue 与 hello 实例、模板语法中的插值语法和指令语法、数据绑定、el 挂载与 data 的两种写法、MVVM 模型、Object.defineProperty 与数据代理、事件处理等基础模块,并延伸到 Vue3 相关知识点。已有 194 人学习,适合希望沿着尚硅谷课程节奏搭建知识框架、沉淀重点概念和常见写法,并借助清晰目录快速定位复习内容的 Vue 初学者与自学者。
1. 一份 Vue2.0 + Vue3.0 入门到精通的学习笔记,先讲清“怎么学才不白学”
很多人搜 Vue2.0 + Vue3.0 入门到精通教程学习笔记,真正的诉求是:既把 Vue2.0 的选项式写法弄熟,又不被 Vue3.0 的 setup、ref、Proxy 卡住,最好还能把旧项目挪过去。视频课看着顺,自己一动手就卡在“为什么数据改了页面不动”“为什么父组件拿不到子组件方法”这种具体问题上。这篇笔记按原理对照、传值选型、生命周期改写、工程化、排查五个层次推进,代码都在本地空项目里跑过。适合能写基础 HTML/JS、想把两代 Vue 打通的人;完全没碰过前端的不建议从这切入。
2. 响应式原理对照:defineProperty 和 Proxy 差在哪,为什么调试手感完全不同
2.1 三十行把 Vue2.0 的 defineProperty 响应式跑起来
Vue2.0 的响应式核心就两件事:读的时候收集依赖,写的时候通知更新。下面这段是最小实现,去掉调度、去重、递归,只保留能验证行为的骨架。
// 极简响应式:只为看清 defineProperty 能拦住什么、拦不住什么 const targetMap = new WeakMap() let activeEffect = null function track(target, key) { if (!activeEffect) return let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let dep = depsMap.get(key) if (!dep) { dep = new Set() depsMap.set(key, dep) } dep.add(activeEffect) } function trigger(target, key) { const depsMap = targetMap.get(target) if (!depsMap) return const dep = depsMap.get(key) if (dep) dep.forEach(effect => effect()) } function observe(obj) { // 注意:这里用 Object.keys 一次性拿到属性名,之后新增的属性不会进循环 Object.keys(obj).forEach(key => { let val = obj[key] Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { track(obj, key) // 读取时收集 return val }, set(next) { if (next === val) return val = next trigger(obj, key) // 写入时通知 } }) }) return obj } function effect(fn) { activeEffect = fn fn() // 首次执行,顺带完成依赖收集 activeEffect = null } const state = observe({ count: 0, list: [1, 2, 3] }) effect(() => console.log('count 变了 ->', state.count)) state.count = 1 // 触发,打印 1 state.list.push(4) // 不触发:defineProperty 拦不到数组方法 state.list[0] = 99 // 不触发:索引赋值没走 setter state.newField = 'x' // 不触发:新增属性不在最初的属性名单里逻辑说明:observe遍历对象自有属性,把每个属性换成 getter/setter。track把当前正在执行的副作用函数挂到target -> key -> Set的三层结构里,trigger再按 key 把集合里的函数全部重跑。
参数说明:enumerable: true保证属性仍能被for...in和Object.keys枚举,否则你会遇到“数据明明在,遍历就是拿不到”的怪现象;configurable: true是给后续重定义留口子。
跑完这段,你就明白 Vue2.0 为什么要有$set、为什么要重写数组的七个原型方法:拦截能力天生有缺口,只能打补丁。当年我第一次看这段代码,卡在activeEffect的置空时机上,后来才反应过来,它就是一个全局游标,靠“执行前赋值、执行后清空”来标记谁在收集。
2.2 Proxy 版本:Vue3.0 凭什么敢直接支持索引赋值
Vue3.0 换成了 Proxy,代理的是整个对象,不是单个属性。这意味着新增属性、删除属性、数组下标赋值、length修改,全都走同一个拦截器。
const proxyMap = new WeakMap() function reactive(target) { if (typeof target !== 'object' || target === null) return target if (proxyMap.has(target)) return proxyMap.get(target) // 同一对象只代理一次 const handler = { get(obj, key, receiver) { const res = Reflect.get(obj, key, receiver) track(obj, key) // 惰性递归:只有真正被访问到的嵌套对象才包一层代理 return typeof res === 'object' && res !== null ? reactive(res) : res }, set(obj, key, value, receiver) { const old = obj[key] const ok = Reflect.set(obj, key, value, receiver) if (!Object.is(old, value)) trigger(obj, key) // 用 Object.is 避免 NaN 误判 return ok }, deleteProperty(obj, key) { const had = Object.prototype.hasOwnProperty.call(obj, key) const ok = Reflect.deleteProperty(obj, key) if (had) trigger(obj, key) return ok }, has(obj, key) { track(obj, key) // 'key' in obj 也要收集依赖 return Reflect.has(obj, key) } } const proxy = new Proxy(target, handler) proxyMap.set(target, proxy) return proxy }逻辑说明:Reflect.get/set的第三个参数receiver必须传,否则遇到继承链上的 getter 会丢this,表现为“父类里的响应式数据在子类实例上失效”。proxyMap做缓存,避免同一个对象被反复代理导致===比较失败。
参数说明:Object.is和===的差别在NaN与-0,Vue3.0 内部用前者,是为了让NaN赋值不再触发无意义的更新。
关键差异在惰性递归。Vue2.0 在初始化时就要把整棵数据树递归遍历一遍,数据层级深、字段多的时候,首屏明显变慢;Vue3.0 只在 get 时判断返回值是不是对象,是才代理,没用到的分支一直不产生开销。我在一个列表页上做过粗略对比,一个两千条、每条二十个字段的数组,初始化耗时从百毫秒级降到了十毫秒级,但这个数字和机器、数据形状强相关,只能作为方向参考。
2.3 一张表看清两代响应式的能力边界
| 操作 | Vue2.0(defineProperty) | Vue3.0(Proxy) |
|---|---|---|
| 已有属性重新赋值 | 触发 | 触发 |
| 新增属性 | 不触发,需$set | 触发 |
| 删除属性 | 不触发,需$delete | 触发 |
arr[0] = x索引赋值 | 不触发 | 触发 |
arr.push/splice等 | 重写原型方法后触发 | 触发 |
直接改arr.length | 不触发 | 触发 |
监听Map、Set、WeakMap | 不支持 | 支持 |
| 惰性递归 | 否,初始化全量遍历 | 是,访问时按需代理 |
这张表不是为了背,而是为了排错时定位。线上出现“数据更新了但视图没动”,第一反应应该是往表里对应的那一行查,而不是到处加nextTick。加nextTick属于玄学排错,十次里有八次治不了根。
2.4 从原理倒推三个必须改的编码习惯
第一条,别解构 reactive 对象。const { count } = reactive({ count: 0 })拿到的是普通数字,后续原对象变化与这个变量再无关系。需要解构就用toRefs,或者干脆用ref。
import { reactive, toRefs } from 'vue' const state = reactive({ count: 0, list: [] }) const { count, list } = toRefs(state) // 得到的是 ref,模板里自动解包 count.value++ // script 里必须带 .value参数说明:toRefs只对第一层属性生效,嵌套对象拿到的是指向原对象的 ref,深层的响应性依然由 reactive 保证。
第二条,基础类型一律用ref,对象用reactive,别混着来。团队里最常见的翻车是“把 reactive 对象整个赋给另一个 ref 的 value”,结果新旧代理叠加,调试器里看半天看不出哪层是代理、哪层是原始对象。
第三条,computed里不要写异步和副作用。Vue3.0 的 computed 基于 effect 实现,内部有缓存标记,你放一个fetch进去,依赖收集和缓存判断都会乱。要请求数据就放watch或onMounted,这是两代版本都一样的规矩。
3. 组件通信六种姿势:props、v-model、provide/inject、mitt、ref 与 Pinia
3.1 props 与 emit 的边界,以及运行时校验怎么写
父子传值永远是最基础的一层。Vue3.0 的<script setup>里,defineProps和defineEmits是编译宏,不需要 import,也不能被当作普通变量引用。
<!-- SearchBar.vue --> <script setup> const props = defineProps({ placeholder: { type: String, default: '请输入关键字' }, disabled: { type: Boolean, default: false } }) // 对象写法可以做载荷校验,返回 false 时开发环境会告警 const emit = defineEmits({ search: (payload) => typeof payload === 'string' && payload.length > 0 }) function onSubmit(value) { if (props.disabled) return emit('search', value.trim()) } </script> <template> <input :placeholder="placeholder" :disabled="disabled" @keyup.enter="onSubmit($event.target.value)" > </template>逻辑说明:type只做运行时校验,写错类型控制台会给警告,但不会中断渲染;default对对象和数组必须写成工厂函数,否则所有实例共享同一个引用,这是新手最常踩的坑之一。defineEmits的对象写法比数组写法多一层 payload 校验,团队协作时值得多写这几行。
参数说明:props是只读的,子组件直接改会在开发环境报错。需要“改父组件数据”时,标准做法是 emit 一个事件出去,让父组件自己改。省事一点可以用v-model,本质还是这条链路。
3.2 v-model 从 value/input 变成 modelValue/update:modelValue
Vue2.0 里默认的v-model绑定的是value属性加input事件;Vue3.0 改成了modelValue加update:modelValue。这个改动看着小,迁移时最容易漏。
<!-- Vue2.0 写法 --> <script> export default { props: { value: String }, methods: { onInput(e) { this.$emit('input', e.target.value) } } } </script> <!-- Vue3.0 写法 --> <script setup> const props = defineProps({ modelValue: { type: String, default: '' } }) const emit = defineEmits(['update:modelValue']) function onInput(e) { emit('update:modelValue', e.target.value) } </script> <template> <input :value="modelValue" @input="onInput"> </template>Vue3.0 还支持多个v-model,这是迁移表单组件时最实用的变化:
<!-- 父组件 --> <UserForm v-model:name="form.name" v-model:age="form.age" /> <!-- 子组件 --> <script setup> defineProps({ name: String, age: Number }) const emit = defineEmits(['update:name', 'update:age']) </script>逻辑说明:v-model:name是:name加@update:name的语法糖。子组件只要按约定 emit 对应事件,父组件的数据就会同步。这套约定比 Vue2.0 的.sync更统一,也不用再记“默认用 value 还是自定义 model 字段”。
参数说明:defineProps里声明的属性名必须和v-model:后面的名字一致,写成v-model:userName就要有userName这个 prop 和update:userName这个事件,大小写敏感。
3.3 provide/inject 与 mitt 事件总线,用错场景会很痛
跨层级传值,Vue2.0 时代流行$bus,Vue3.0 里因为$on/$off/$emit从实例上被移除,得换成第三方 mitt。先看 mitt 的正确用法。
// utils/bus.js import mitt from 'mitt' export const bus = mitt()<!-- 发布方 --> <script setup> import { bus } from '@/utils/bus' function notify() { bus.emit('user-updated', { id: 1, name: 'A' }) } </script><!-- 订阅方 --> <script setup> import { onMounted, onBeforeUnmount } from 'vue' import { bus } from '@/utils/bus' function handler(payload) { console.log('收到', payload) } onMounted(() => bus.on('user-updated', handler)) onBeforeUnmount(() => bus.off('user-updated', handler)) // 必须成对出现 </script>逻辑说明:on和off必须成对。少了off,组件卸载后回调仍挂在全局 bus 上,下一次事件触发时会访问已销毁实例上的数据,表现为控制台报错、内存持续上涨,这类问题很难靠肉眼定位。
参数说明:mitt 的emit第二个参数是载荷,可以是任意类型,但建议统一成对象,方便后续加字段不破坏订阅方。
如果是主题、国际化、当前登录用户这类“整棵子树都要用”的数据,别用事件总线,用provide/inject更合适。
// 顶层组件 import { provide, ref, readonly } from 'vue' const theme = ref('light') function toggleTheme() { theme.value = theme.value === 'light' ? 'dark' : 'light' } // readonly 防止后代直接改,保持单向数据流 provide('theme', { theme: readonly(theme), toggleTheme })// 任意后代组件 import { inject, ref } from 'vue' const fallback = { theme: ref('light'), toggleTheme: () => {} } const { theme, toggleTheme } = inject('theme', fallback)参数说明:inject的第二个参数是默认值,找不到对应 key 时兜底,避免“单独把这个组件拿出来复用时直接报错”。这个参数很多人不知道,写组件库时是必备的。
3.4 用 ref 调子组件方法,别忘了 defineExpose
父组件要触发子组件的校验、重置,就得拿到子组件实例。Vue3.0 的<script setup>默认是封闭的,不写defineExpose,父组件拿到的 ref 上什么都没有。
<!-- FormCard.vue --> <script setup> import { ref } from 'vue' const formRef = ref(null) function validate() { return formRef.value?.check() ?? false } function reset() { formRef.value?.resetFields() } defineExpose({ validate, reset }) // 只暴露这两个方法 </script><!-- 父组件 --> <script setup> import { ref } from 'vue' const cardRef = ref(null) async function submit() { const ok = await cardRef.value.validate() if (!ok) return // 走到这里说明校验通过 } </script> <template> <FormCard ref="cardRef" /> </template>逻辑说明:defineExpose决定了对外暴露的边界,没写进去的属性和方法,父组件访问到的是undefined。这个设计逼着你显式声明接口,长期看是好事,迁移时却是高频翻车点。
参数说明:调用时用cardRef.value?.validate(),可选链不能省。子组件用了v-if或者异步加载时,ref.value在首帧是null,直接点会报“Cannot read properties of null”。
3.5 跨页面状态交给 Pinia,别再往 Vuex 上堆
Vue2.0 时代 Vuex 是标配,Vue3.0 生态里 Pinia 更贴合组合式写法:没有 mutations,action 同步异步都能写,模块天然扁平。
// stores/user.js import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useUserStore = defineStore('user', () => { const token = ref('') const profile = ref(null) const isLogin = computed(() => !!token.value) async function login(payload) { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) const data = await res.json() token.value = data.token profile.value = data.user } function logout() { token.value = '' profile.value = null } return { token, profile, isLogin, login, logout } })<!-- 组件里使用 --> <script setup> import { storeToRefs } from 'pinia' import { useUserStore } from '@/stores/user' const store = useUserStore() // state 和 getter 用 storeToRefs 转成 ref,保留响应性 const { token, isLogin } = storeToRefs(store) // 方法直接从 store 上取 async function onLogin() { await store.login({ username: 'demo', password: '123456' }) } </script>逻辑说明:storeToRefs只处理 state 和 getter,action 不转。有人图省事写成const { login } = store,调用时 this 丢失,直接报错。
参数说明:defineStore的第一个参数是仓库唯一 id,不能重复;第二个参数传函数就是组合式写法,传对象就是选项式写法,两种不能混。团队迁移期可以新旧并存,但同一个仓库别写一半一半。
3.6 六种方式怎么选:一张决策表
| 场景 | 推荐方式 | 不推荐 | 原因 |
|---|---|---|---|
| 父子直接传数据 | props / emit | 事件总线 | 链路清晰,可追溯 |
| 表单双向绑定 | v-model / 多 v-model | 手动 emit + props | 少写一半样板代码 |
| 主题、用户信息等全局配置 | provide/inject | 逐层 props | 避免 props 透传 |
| 兄弟组件一次性通知 | mitt | 挂全局变量 | 解耦,但要成对解绑 |
| 父组件触发子组件行为 | ref + defineExpose | 改 props 触发 watch | 语义明确,不绕弯 |
| 跨路由共享业务状态 | Pinia | 事件总线 + 本地存储 | 有 devtools,可持久化 |
这张表我在做代码评审时经常直接甩给新人,能省掉很多“为什么这里用 bus 不用 store”的来回讨论。选型没有绝对对错,但同一类问题在同一个项目里应该只有一种解法。
4. 生命周期与 Composition API 改写:一张对照表加一段能跑的迁移脚本
4.1 生命周期钩子对照表
| Vue2.0 选项式 | Vue3.0 setup 内 | 触发时机与常见用途 |
|---|---|---|
| beforeCreate | setup 函数体本身 | 实例刚创建,拿不到 this |
| created | setup 函数体本身 | 数据已初始化,可发首屏请求 |
| beforeMount | onBeforeMount | 模板编译完成,DOM 还没挂 |
| mounted | onMounted | DOM 可用,操作 ref、初始化图表 |
| beforeUpdate | onBeforeUpdate | 数据变了,DOM 未更新 |
| updated | onUpdated | DOM 已更新,谨慎改数据防死循环 |
| beforeDestroy | onBeforeUnmount | 清定时器、解绑事件、取消请求 |
| destroyed | onUnmounted | 组件已销毁,实例不可再用 |
需要强调的是,setup本身承担了beforeCreate和created的职责,但它执行时组件实例还没完全建好,this是undefined。所以老代码里在created里写this.$refs.xxx的,迁过来必须挪到onMounted。
4.2 把一段选项式组件完整改写成 script setup
先看改写前的 Vue2.0 版本,这是一个带搜索、轮询、防抖的列表组件。
export default { props: { userId: { type: [String, Number], required: true } }, data() { return { list: [], keyword: '', loading: false, timer: null } }, computed: { filtered() { return this.list.filter(item => item.name.includes(this.keyword)) } }, watch: { userId: { handler(val) { this.fetchList(val) }, immediate: true } }, created() { this.debouncedSearch = this.debounce(this.fetchList, 300) }, mounted() { this.timer = setInterval(() => this.fetchList(this.userId), 10000) }, beforeDestroy() { clearInterval(this.timer) }, methods: { async fetchList(id) { this.loading = true try { const res = await fetch(`/api/list?userId=${id}`) this.list = await res.json() } finally { this.loading = false } }, debounce(fn, delay) { let t = null return (...args) => { clearTimeout(t) t = setTimeout(() => fn.apply(this, args), delay) } } } }改写后的 Vue3.0 版本:
<script setup> import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' const props = defineProps({ userId: { type: [String, Number], required: true } }) const list = ref([]) const keyword = ref('') const loading = ref(false) let timer = null // 不参与渲染,用普通变量即可,少一次依赖收集 const filtered = computed(() => list.value.filter(item => item.name.includes(keyword.value)) ) async function fetchList(id) { loading.value = true try { const res = await fetch(`/api/list?userId=${id}`) list.value = await res.json() } finally { loading.value = false } } function debounce(fn, delay = 300) { let t = null return (...args) => { clearTimeout(t) t = setTimeout(() => fn(...args), delay) } } const debouncedFetch = debounce(fetchList) // 监听 props 必须写成 getter 函数,直接写 props.userId 会丢掉响应 watch(() => props.userId, val => fetchList(val), { immediate: true }) onMounted(() => { timer = setInterval(() => fetchList(props.userId), 10000) }) onBeforeUnmount(() => { clearInterval(timer) }) </script>逻辑说明:watch的第一个参数写成() => props.userId,是因为 Vue3.0 需要明确知道“要追踪哪个表达式”。直接传props.userId拿到的是当前值,watch 会把它当成常量,之后再也不触发。这个坑我见过不止一次,现象是“切换用户后列表不刷新”,排查半天以为是接口缓存。
参数说明:immediate: true对应 Vue2.0watch对象写法里的同名选项,作用是首次执行一次。如果数据请求不依赖旧值,也可以放到onMounted里,但用immediate能保证“初始化和后续变化”走同一条路径,少一处重复逻辑。
timer用普通let而不是ref,是因为它只在闭包里被读写,不需要驱动视图。用ref会让每次赋值都触发一次依赖通知,纯属浪费。类似的还有各种内部标记位,判断标准就一条:模板里用不用它。
4.3 watch 与 computed 的写法差异和参数
Vue3.0 的watch支持多源监听,这是选项式写法里做不到的。
import { ref, watch } from 'vue' const firstName = ref('张') const lastName = ref('三') // 多源:第一个参数是数组,第二个参数的新旧值也是数组 watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => { console.log(`从 ${oldFirst}${oldLast} 变成 ${newFirst}${newLast}`) }, { flush: 'post' })参数说明:flush: 'post'让回调在 DOM 更新之后执行,需要读取更新后的 DOM 尺寸时用它;默认是'pre',在 DOM 更新前跑;'sync'是同步执行,性能差,除非调试否则别用。
computed默认只读,需要可写就传对象:
import { ref, computed } from 'vue' const firstName = ref('张') const lastName = ref('三') const fullName = computed({ get: () => `${firstName.value} ${lastName.value}`, set(val) { const [first, last] = val.split(' ') firstName.value = first lastName.value = last } }) fullName.value = '李 四' // 会反向拆开写回两个源 ref逻辑说明:可写 computed 适合做“表单字段拆分/合并”这类场景,比如地址一个字段展示、三个字段存储。
4.4 nextTick 与异步更新的验证方法
Vue 的更新是批量的,同一轮事件循环里改十次数据只渲染一次。想拿到更新后的 DOM,就等nextTick。
import { ref, nextTick } from 'vue' const count = ref(0) const boxRef = ref(null) async function increment() { count.value++ // 此时 DOM 里还是旧值 console.log(boxRef.value.textContent) // 输出 0 await nextTick() console.log(boxRef.value.textContent) // 输出 1 }参数说明:nextTick返回 Promise,也可以传回调。Vue3.0 内部用微任务实现,如果连续改多次,nextTick只会等到最后一次渲染完成,不需要写多个await。
验证方法很简单:在浏览器控制台里打断点,或者在两次打印之间手动改一下count,观察输出次数。如果一次事件里改了五次数据却看到五次打印,说明你误用了flush: 'sync'或者把逻辑放进了updated钩子。
5. 常见问题排查:Vue2.0 升 Vue3.0 路上最容易翻车的五个点
5.1 解构 reactive 之后页面不动了
现象:const { count } = reactive({ count: 0 }),后面count++,页面数字纹丝不动,控制台也不报错。
原因:解构拿到的是属性值的快照,是个普通数字,和原响应式对象之间的联系已经断了。reactive返回的是 Proxy,拦截发生在“属性访问”这个动作上,解构时只访问了一次,之后就是普通变量运算。
解决:要么用toRefs保留引用,要么改用ref单独声明。我一般的习惯是,需要单独拿出来传的字段一律ref,整块状态才用reactive,边界清楚。
5.2 ref 在 script 里忘了 .value
现象:const token = ref(''),然后token = 'abc',模板里读出来还是空字符串,也没有任何报错。
原因:ref返回的是包装对象,直接赋值等于把变量指向了一个新字符串,包装对象本身没变。模板里之所以能直接写token,是编译阶段自动加了.value。
解决:script 里一律带.value;模板里不带。配 ESLint 的vue/no-ref-as-operand规则,能在保存时就把这类问题标出来,比靠记忆靠谱。这个坑的隐蔽之处在于它不报错,只能靠肉眼复查,属于典型的“血泪经验”型问题。
5.3 v-if 和 v-for 的优先级反了
现象:升级后模板直接报错“Property 'item' was accessed during render but is not defined”。
原因:Vue2.0 里v-for优先级高于v-if,所以v-if里能拿到循环变量;Vue3.0 反过来了,v-if先求值,此时item还不存在。
解决:用<template v-for>包一层,把v-if放到内层元素上;或者更推荐的做法是用computed先把数据过滤好,模板里只负责渲染。后者性能更好,因为过滤逻辑有缓存,不会每次渲染都跑一遍。
<!-- 不推荐:v-if 依赖 v-for 的变量 --> <li v-for="item in list" v-if="item.visible" :key="item.id">{{ item.name }}</li> <!-- 推荐:computed 预过滤 --> <li v-for="item in visibleList" :key="item.id">{{ item.name }}</li>5.4 事件总线没解绑,组件销毁后还在响应
现象:页面来回切换几次后,控制台开始报错,提示访问了已卸载组件的数据;内存面板里的堆快照越来越大。
原因:bus.on注册的回调被全局对象持有,组件销毁不会自动清理。onBeforeUnmount里漏写了off,回调就一直活着。
解决:注册和注销必须成对出现,最好封装成一个组合式函数统一处理;如果只是简单的兄弟通信,优先考虑把状态提到共同的父级或者用 Pinia,事件总线作为最后手段。
import { onMounted, onBeforeUnmount } from 'vue' import { bus } from '@/utils/bus' export function useBusEvent(type, handler) { onMounted(() => bus.on(type, handler)) onBeforeUnmount(() => bus.off(type, handler)) // 封装之后就不会漏 }5.5 async setup 把组件变成了异步组件
现象:组件内容不渲染,控制台提示需要<Suspense>,或者父组件拿到的 ref 一直是null。
原因:<script setup>顶层写了await,整个 setup 变成异步函数,组件对外就是一个异步组件,必须由Suspense包裹才能正常渲染。
解决:把请求挪进onMounted或者用一个loading状态控制,不要图省事在顶层await。确实需要顶层 await 的,就老老实实套Suspense并处理 fallback,别指望它像同步组件一样工作。
<script setup> import { ref, onMounted } from 'vue' const data = ref(null) const loading = ref(true) onMounted(async () => { try { data.value = await fetch('/api/config').then(r => r.json()) } finally { loading.value = false } }) </script>顺带提一句打包后的白屏问题。路由用 history 模式时,刷新子路径返回 404,是服务端没做 fallback;资源路径报 404,是构建配置里的base和实际部署目录对不上。这两类问题在开发环境完全看不出来,只有部署后才暴露,建议在 CI 里加一步静态资源路径检查。
6. 进阶:把重复逻辑抽成自定义 hook,而不是继续写 mixin
mixin 的问题在于来源不透明:模板里出现一个loading,你没法一眼看出它来自哪个 mixin,命名冲突了也不报错,后引入的静默覆盖前面的。Vue3.0 的组合式函数解决了这个问题——显式导入、显式解构,来源一目了然。
下面这个useRequest是我在多个列表页里反复用到的一个,核心是竞态控制。
// hooks/useRequest.js import { ref } from 'vue' export function useRequest(service, options = {}) { const { immediate = true, initialData = null } = options const data = ref(initialData) const loading = ref(false) const error = ref(null) let seq = 0 // 请求序号,用来丢弃过期响应 async function run(...args) { const current = ++seq loading.value = true error.value = null try { const res = await service(...args) if (current === seq) data.value = res // 只认最后一次请求 return res } catch (e) { if (current === seq) error.value = e throw e } finally { if (current === seq) loading.value = false } } if (immediate) run() return { data, loading, error, run } }参数说明:service是返回 Promise 的函数;immediate控制是否立即执行一次;initialData给data一个初始形状,模板里可以直接list.length而不必到处判空。seq是这段代码的关键——搜索框连打三个字会发三次请求,先发的可能后回来,没有序号判断的话,列表会闪回旧结果。这个坑我在一个后台管理页上踩过,用户反馈“搜完一秒后结果自己变回去了”,查了两天才定位到竞态。
用法:
<script setup> import { useRequest } from '@/hooks/useRequest' const { data: list, loading, run } = useRequest( (keyword) => fetch(`/api/list?kw=${encodeURIComponent(keyword)}`).then(r => r.json()), { immediate: false, initialData: [] } ) function onSearch(keyword) { run(keyword) } </script> <template> <div v-if="loading">加载中</div> <ul v-else> <li v-for="item in list" :key="item.id">{{ item.name }}</li> </ul> </template>写组合式函数有三条自己的规矩:函数名以use开头,让调用方一眼看出这是 hook;内部只用ref、computed和生命周期钩子,不碰组件实例;返回值统一用对象而不是数组,方便调用方按需解构、也方便后续加字段不破坏现有代码。
最后一个习惯,是我从踩坑里换来的:任何要跨组件复用的逻辑,先在两个页面里各写一遍,确认真的重复了再抽。过早抽象比重复更贵,因为你会在错误的边界上定接口,后面每加一个场景就要改一次签名。希望帮到你。
本文还有配套的精品资源,点击获取