
简介这份《尚硅谷 张天禹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与-0Vue3.0 内部用前者是为了让NaN赋值不再触发无意义的更新。关键差异在惰性递归。Vue2.0 在初始化时就要把整棵数据树递归遍历一遍数据层级深、字段多的时候首屏明显变慢Vue3.0 只在 get 时判断返回值是不是对象是才代理没用到的分支一直不产生开销。我在一个列表页上做过粗略对比一个两千条、每条二十个字段的数组初始化耗时从百毫秒级降到了十毫秒级但这个数字和机器、数据形状强相关只能作为方向参考。2.3 一张表看清两代响应式的能力边界操作Vue2.0definePropertyVue3.0Proxy已有属性重新赋值触发触发新增属性不触发需$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 与 Pinia3.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 :placeholderplaceholder :disableddisabled keyup.enteronSubmit($event.target.value) /template逻辑说明type只做运行时校验写错类型控制台会给警告但不会中断渲染default对对象和数组必须写成工厂函数否则所有实例共享同一个引用这是新手最常踩的坑之一。defineEmits的对象写法比数组写法多一层 payload 校验团队协作时值得多写这几行。参数说明props是只读的子组件直接改会在开发环境报错。需要“改父组件数据”时标准做法是 emit 一个事件出去让父组件自己改。省事一点可以用v-model本质还是这条链路。3.2 v-model 从 value/input 变成 modelValue/update:modelValueVue2.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 :valuemodelValue inputonInput /templateVue3.0 还支持多个v-model这是迁移表单组件时最实用的变化!-- 父组件 -- UserForm v-model:nameform.name v-model:ageform.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 时代流行$busVue3.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 refcardRef / /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 更贴合组合式写法没有 mutationsaction 同步异步都能写模块天然扁平。// 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 和 getteraction 不转。有人图省事写成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 内触发时机与常见用途beforeCreatesetup 函数体本身实例刚创建拿不到 thiscreatedsetup 函数体本身数据已初始化可发首屏请求beforeMountonBeforeMount模板编译完成DOM 还没挂mountedonMountedDOM 可用操作 ref、初始化图表beforeUpdateonBeforeUpdate数据变了DOM 未更新updatedonUpdatedDOM 已更新谨慎改数据防死循环beforeDestroyonBeforeUnmount清定时器、解绑事件、取消请求destroyedonUnmounted组件已销毁实例不可再用需要强调的是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-foritem in list v-ifitem.visible :keyitem.id{{ item.name }}/li !-- 推荐computed 预过滤 -- li v-foritem in visibleList :keyitem.id{{ item.name }}/li5.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而不是继续写 mixinmixin 的问题在于来源不透明模板里出现一个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-ifloading加载中/div ul v-else li v-foritem in list :keyitem.id{{ item.name }}/li /ul /template写组合式函数有三条自己的规矩函数名以use开头让调用方一眼看出这是 hook内部只用ref、computed和生命周期钩子不碰组件实例返回值统一用对象而不是数组方便调用方按需解构、也方便后续加字段不破坏现有代码。最后一个习惯是我从踩坑里换来的任何要跨组件复用的逻辑先在两个页面里各写一遍确认真的重复了再抽。过早抽象比重复更贵因为你会在错误的边界上定接口后面每加一个场景就要改一次签名。希望帮到你。本文还有配套的精品资源点击获取