前阵子带几个刚入行的同学做项目,发现一个挺普遍的现象:网上讲 Vue3 的文章一抓一大把,但真正能从零把环境跑通、把响应式讲明白、把组件通信和路由串起来的系统性内容反而不多。很多人卡在第一步——装完脚手架就不知道该写什么;或者能照着抄代码,但一旦报错就完全不知所措。所以我把这几年在项目里反复踩过、也反复讲给新人听的内容整理成这一篇,从创建工程一直写到组合式函数、性能优化和报错排查。这里不会堆砌 API 手册,重点讲的是 为什么这么写 、 什么场景该选哪个 、 哪里最容易翻车 。不管你是刚接触前端框架的新手,还是从旧版本迁移过来的老手,都能从里面找到能直接抄走的部分。
1. 动手之前先想清楚:版本、生态与心智模型
我见过太多人一上来就闷头敲代码,结果写到一半发现用的是两套互不兼容的写法,代码越写越乱。Vue3 这一代最大的变化不是多几个 API,而是整套组织代码的思路变了。所以在动手之前,先花十分钟把这几个前提想明白,后面能少走很多弯路。
1.1 选项式 API 与组合式 API 到底该怎么选
选项式 API(Options API)就是那种
data
、
methods
、
computed
、
mounted
分块罗列的写法。它的好处是结构固定,新手一眼能看懂哪块是数据、哪块是方法。但项目一变大,同一个业务逻辑会被拆散到四五个选项块里——比如一个"商品筛选"功能,它的状态在
data
、派生值在
computed
、请求调用在
methods
、初始化在
mounted
,你改需求的时候得在文件里来回跳。
组合式 API(Composition API)把同一个逻辑的所有代码放在一起,配上组合式函数还能整体抽走复用。我的实际判断标准很简单:
- 页面级组件、逻辑简单、只有两三个字段的,选项式写着更快;
- 有复用需求、状态超过五个、涉及多个异步请求的,一律用组合式;
- 一个项目里两种写法混用是允许的,但 同一个组件内不要混着写 ,维护成本会指数上升。
这里有个容易误解的点:
<script setup>
不是必须的。它只是一种编译期语法糖,写起来更短,但调试时栈信息会被编译器改写,出问题时行号可能跟源码对不上。我在排查一些诡异的渲染问题时,会临时把它改回
setup()
函数写法来定位。这是很实用的一招,很多人不知道。
还有一点提醒:
<script setup>
里定义的顶层变量默认全部暴露给模板,这意味着你写个
const temp = ...
也会被塞进模板作用域。虽然没有性能问题,但组件多了之后容易命名冲突,建议临时变量统一加前缀。
1.2 项目初始化:为什么我更愿意用 Vite
老项目里那套基于打包器的脚手架,改一行代码要等三四秒才刷新,热更新体验很差。现在的构建工具走的是原生模块按需编译路线,启动基本是秒级,热更新通常在一百毫秒以内。我实测过一个中型项目,冷启动从二十多秒降到一秒多,这个差距在频繁调试的时候非常影响状态。
初始化命令很简单:
# 交互式选择框架与语言
npm create vite@latest my-app
# 或者直接一步到位
npm create vite@latest my-app -- --template vue
如果要用 TypeScript,把模板换成
vue-ts
就行。命令行里的
--
是必须的,它告诉包管理器后面的参数要传给被调用的工具,而不是自己吞掉。这个细节坑过不少人,不加这个符号,你会看到工具启动了交互式提问,而不是按你的参数走。
装完依赖后执行
npm run dev
,默认端口是 5173。我建议在
vite.config.js
里顺手配置几个东西:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
open: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '')
}
}
}
})
alias
配了之后,任何层级都能直接
import xxx from '@/utils/xxx'
,不用写一串
../../../
。
server.proxy
是本地联调必备,前端跑在 5173,后端跑在 8080,跨域问题直接在开发服务器层解决掉,不用麻烦后端加响应头。这里有个坑:
rewrite
如果写错,接口会 404,但控制台不会报错,只是返回空白。排查方法是在浏览器网络面板里看实际请求的完整路径,一眼就能看出前缀有没有被正确剥掉。
1.3 目录结构该按什么逻辑分层
我不太建议一上来就套用复杂的分层规范。但有一条经验值得参考:
按业务域分目录,而不是按文件类型分目录
。也就是说,不要建一个巨大的
components
目录塞进三四十个组件,而是让每个业务模块自己管理自己的组件、接口和组合式函数。
src/
├── api/ # 接口定义,按模块拆分
├── assets/ # 静态资源
├── components/ # 全局通用组件(按钮、弹窗、表格)
├── composables/ # 组合式函数
├── router/ # 路由表
├── stores/ # 状态仓库
├── views/ # 页面级组件
│ └── order/
│ ├── index.vue
│ ├── components/
│ └── composables/
└── main.js
判断标准是:删掉这个模块,会不会影响其他模块?不会,就应该内聚到自己的目录里。我见过把业务组件全丢进
components
的项目,后来没人敢删任何东西,因为不知道谁在用。
2. 模板语法与响应式内核:把数据流捋顺
模板语法看着简单,但它是出错最多的地方,原因在于模板里写的东西和实际执行的 JavaScript 并不是一一对应。理解这层转换关系,很多"明明代码没错但页面不动"的问题就能自己定位了。
2.1 插值、指令与条件渲染的实际用法
模板里的双大括号只做文本插值,遇到对象会直接调用
toString
,所以渲染出来是
[object Object]
。想看结构得用
JSON.stringify
,或者干脆在调试工具里看。这是新手最常见的困惑之一。
指令部分有几个值得单独说的:
-
v-if是真正的条件渲染,条件为假时元素从 DOM 树里移除; -
v-show只是切换display样式,元素始终存在; -
v-for必须带key,而且key不能重复。
v-if
和
v-show
怎么选?我的经验是:频繁切换的用
v-show
,比如折叠面板、Tab 内容区;条件稳定、切换次数少的用
v-if
,比如权限控制下的按钮、加载完成的骨架屏。因为
v-if
每次切换都要创建销毁组件,如果组件里有请求或者定时器,反复切换会带来额外的开销。
v-for
的
key
是个高频事故点。用数组下标当
key
,在列表中间插入或删除元素时,组件会复用错位,表现出来就是输入框里的内容跑到了别的行、动画错乱。正确做法是用数据的唯一标识:
<template>
<ul>
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
</ul>
</template>
还有一个版本相关的细节必须提醒:
Vue3 里
v-if
的优先级高于
v-for
,而旧版本正好相反。这意味着你不能在同一个元素上写
v-if
去判断
v-for
里的循环变量,那个变量此时还不存在,会直接报错。正确写法是套一层
template
:
<template v-for="item in list" :key="item.id">
<li v-if="item.visible">{{ item.name }}</li>
</template>
从旧版本迁移过来的人如果没注意到这条,会看到一堆"变量未定义"的报错,而且很难联想到是优先级变了。
2.2 ref 与 reactive 的选择标准与 .value 的坑
这两个 API 是新手最容易被劝退的地方。用一句话概括:
ref
可以包装任何值,
reactive
只能包装对象和数组。既然
ref
更通用,是不是全部用
ref
就行?理论上是,实际项目里我也是这么做的——
统一使用
ref
,只在明确需要一组关联字段时才用
reactive
。
原因很实在:
reactive
有两个逃不掉的限制。第一,整体替换会丢失响应式:
let state = reactive({ count: 0 })
state = reactive({ count: 1 }) // 响应式断掉,模板不再更新
第二,解构赋值也会丢失响应式:
const state = reactive({ a: 1, b: 2 })
const { a } = state // a 变成普通数字,改了页面不动
要解决解构问题得用
toRefs
包一层,多了一步心智负担。而
ref
在模板里会自动解包,只有是在脚本里操作时才需要写
.value
。这个差异导致了一个经典 bug:模板里写
count
正常,脚本里写
count++
不生效,必须是
count.value++
。我在新人的代码里见过至少十次这个问题。
为了方便排查,我建议养成一个习惯:
凡是名字前面带
use
的组合式函数返回值,统一用
ref
返回
,调用方拿到就是
.value
,行为一致,不会出现"这个到底要不要加 value"的犹豫。
另外两个进阶 API 值得知道:
shallowRef
只对
.value
本身做响应式,不深入内部属性,适合承载大对象或者第三方实例;
markRaw
直接标记某个对象永不代理,适合放图表实例、地图实例这类不需要响应式的重型对象。用对了能省下可观的性能开销。
2.3 响应式底层:Proxy 相比 defineProperty 补上了什么
理解原理不是为了面试,而是为了在遇到"为什么这个改动没触发更新"的时候,能立刻判断出原因。
旧方案是在对象初始化时遍历所有属性,给每个属性装一对存取器。这带来三个硬伤:新增属性监听不到、删除属性监听不到、数组下标赋值和长度修改监听不到。所以旧版本才需要
$set
、
$delete
这些额外 API 来打补丁。
新方案用一个代理对象整体接管目标对象,所有读写、删除、
in
判断、遍历操作都会先经过代理。用生活类比:旧方案像给每个房间单独装门铃,新盖的房间没装就没反应;新方案是在整栋楼门口设一个管家,谁进谁出都要登记,房间怎么改都逃不掉。
这个变化带来的直接好处:
| 场景 | 旧方案 | 新方案 |
|---|---|---|
| 对象新增属性 | 需要额外 API | 自动响应 |
| 删除属性 | 需要额外 API | 自动响应 |
| 数组下标赋值 | 不响应 | 自动响应 |
| 数组长度修改 | 不响应 | 自动响应 |
Map
/
Set
| 不支持 | 支持 |
| 代理层级 | 初始化时全量递归 | 访问时才递归 |
最后一条是性能上的关键差异:新方案是懒递归,只有当某个嵌套属性真正被访问到时,才会为那一层创建代理。对于深层嵌套但只用到少数字段的数据结构,初始化开销小很多。
不过这里也有代价,而且是个坑: 代理是懒创建的,所以如果你把一个响应式对象传给第三方库,库内部存了引用并在之后修改它,改动会正常触发更新;但如果第三方库把这个对象深拷贝走,那改的就是副本,页面不会动。 我遇到过一次图表库不刷新的问题,排查半天发现是它内部做了深拷贝,最后改成每次数据更新时重建实例才解决。
3. 组件化实战:从单个 SFC 到可复用的模块
单文件组件(SFC)是这个框架最有辨识度的设计,一个
.vue
文件里同时放模板、逻辑和样式。把这套机制吃透,才算真正开始写 Vue3。
3.1 单文件组件三段式与 script setup 的编译收益
标准的组件结构是
template
、
script
、
style
三块。
style
上加
scoped
会给元素加上唯一属性选择器,实现样式隔离。但要注意,
scoped
对子组件的根元素是"放行"的,而且穿透到子组件内部需要
:deep()
:
<style scoped>
/* 想改子组件内部元素的样式 */
:deep(.inner-class) {
color: red;
}
</style>
这个
:deep()
用得很频繁,尤其是接第三方组件库的时候,几乎所有定制样式都得靠它。但滥用会破坏隔离性,我的原则是:能在自己的组件里通过 props 控制样式的,就不用穿透。
<script setup>
的编译收益值得单独说。它会把顶层绑定直接编译成渲染函数的局部变量,不需要经过
setup()
返回对象再挂到实例上这一步。实测下来,组件实例上的属性数量明显减少,内存占用和初始化时间都有改善。另外,
<script setup>
里定义的组件可以直接在模板里用,不用再注册。这个便利性提升很大。
需要和普通
<script>
并存的场景有两个:定义组件名(
defineOptions
也能做,但有些工具链还依赖普通 script),以及使用
export
导出一些常量。这时候可以写两个 script 块,
<script setup>
会自动和普通 script 合并。
3.2 组件通信的六种方式与选型对照
组件通信是新手最容易乱用的地方,很多人一遇到传值就上全局状态,结果整个项目耦合得一塌糊涂。我把常用方式整理成一张表,按"关系距离"来选就行。
| 方式 | 适用关系 | 是否响应式 | 典型场景 |
|---|---|---|---|
| props / emits | 父子 | 是 | 数据下发、事件上报 |
| v-model | 父子双向 | 是 | 表单控件封装 |
| 模板 ref | 父子(父调子方法) | 否 | 触发表单校验、播放器控制 |
| provide / inject | 跨层级 | 是 | 主题、语言、表单上下文 |
| 全局状态仓库 | 任意 | 是 | 登录信息、购物车 |
| 事件总线 | 任意 | 是 | 已不推荐,用仓库替代 |
props
有一条必须记住的规则:
单向数据流
。子组件不能直接改 props,否则控制台会有警告。想改就通过
emit
通知父组件,由父组件改。这个约束看着麻烦,实际是防止数据流变成一团乱麻。
emits
建议用数组或对象显式声明。用对象声明还能做参数校验:
const emit = defineEmits({
change: payload => typeof payload === 'number',
submit: null
})
不声明也能跑,但声明之后有两个好处:一是文档性质,别人看组件头部就知道会抛什么事件;二是能在开发环境捕获拼写错误。
模板 ref 的用法在新版本里有点变化,需要额外注意:
<template>
<ChildComp ref="childRef" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import ChildComp from './ChildComp.vue'
const childRef = ref(null)
onMounted(() => {
childRef.value.doSomething()
})
</script>
关键点:必须等挂载完成之后才能拿到实例,在
setup
顶层直接访问是
null
。另外,子组件如果用
<script setup>
,默认是
封闭
的,父组件拿到的实例上什么都访问不到。想让父组件调用方法,必须显式暴露:
defineExpose({ doSomething, reset })
我踩过这个坑:明明写了方法,父组件调用却报
is not a function
,排查了很久才发现是漏了
defineExpose
。
3.3 v-model 的进化:多参数绑定与自定义修饰符
表单封装是业务里最高频的组件需求。旧版本一个组件只能有一个
v-model
,多字段就得写一堆
:value
加
@input
。新版本支持具名绑定:
<template>
<UserForm v-model:name="form.name" v-model:age="form.age" />
</template>
子组件这边接:
const props = defineProps(['name', 'age'])
const emit = defineEmits(['update:name', 'update:age'])
写起来还是有点啰嗦。如果版本在 3.4 以上,可以直接用
defineModel
,一行搞定:
const name = defineModel('name')
const age = defineModel('age')
// 直接 name.value = 'xxx' 就会同步给父组件
这个 API 把双向绑定的样板代码全消掉了,实测下来表单组件的代码量能减三分之一。如果团队版本还停留在 3.2 或 3.3,那就老老实实写
props
+
emit
。
自定义修饰符是个冷门但好用的能力。想让父组件写
v-model.trim
而组件内部处理,可以通过
modelModifiers
拿到:
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) }
})
实际场景比如做一个金额输入组件,父组件写
v-model.number
,子组件内部就知道要做数值转换和边界处理,逻辑收在组件里,调用方只写一个修饰符,很干净。
4. 生命周期、计算属性与侦听器:副作用要管住
这一块是新旧写法差异最大的部分。选项式里钩子函数是"到了点自动执行",组合式里钩子是"在 setup 里主动注册"。理解这个转变,才能把副作用放在正确的位置。
4.1 生命周期钩子的对照表与落位场景
先给对照表,迁移项目的时候照着改就行:
| 选项式钩子 | 组合式注册函数 | 常见用途 |
|---|---|---|
| beforeCreate / created | setup 本体 | 初始化状态、调用组合式函数 |
| beforeMount | onBeforeMount | 极少用,DOM 还没生成 |
| mounted | onMounted | 访问 DOM、初始化图表、发首屏请求 |
| beforeUpdate | onBeforeUpdate | 更新前读一次旧 DOM |
| updated | onUpdated | 极少用,容易形成死循环 |
| beforeUnmount | onBeforeUnmount | 清除定时器、解绑事件 |
| unmounted | onUnmounted | 销毁第三方实例 |
| activated / deactivated | onActivated / onDeactivated | 缓存组件进入/离开 |
| errorCaptured | onErrorCaptured | 子组件错误兜底 |
几条实战经验:
第一,
请求数据尽量放在
onMounted
而不是
setup
顶层
。虽然在
setup
顶层发也能跑,但服务端渲染场景下会导致重复请求。统一放
onMounted
更保险。
第二,
updated
要慎用。在这个钩子里改状态,很容易触发无限更新循环,控制台会甩出一堆警告。真需要在更新后做点什么,用
nextTick
配合
watch
更可控。
第三,
onUnmounted
里一定要清理资源。我在一个项目里见过定时器没清的组件,页面来回切换十几次之后浏览器直接卡死,排查下来就是每次进入都新建一个定时器,从来没清过。
script setup
下的注册位置也有讲究:必须在同步执行阶段调用,不能放在
setTimeout
或者
await
之后:
// 错误写法,挂载后的代码才执行,钩子不会生效
setTimeout(() => {
onMounted(() => {})
}, 0)
// 正确写法
onMounted(() => {})
这个错误尤其隐蔽,因为控制台只会给一条警告,不仔细看就漏掉了。
4.2 computed 与 watch 的边界:缓存、副作用与回调时机
这三者的区别,我用一句判断准则概括:
要一个由其他数据推导出来的值,用
computed
;要在数据变化时做一件事,用
watch
;要自动追踪依赖执行副作用,用
watchEffect
。
computed
的核心特性是缓存。只要依赖没变,重复访问拿的是缓存结果,不会重新计算。这意味着不要在
computed
里写请求、写日志、改其他状态——它不是"计算完就扔"的函数,而是有生命周期的派生值。
const list = ref([])
const keyword = ref('')
const filtered = computed(() => {
return list.value.filter(i => i.name.includes(keyword.value))
})
上面这段,模板里访问十次
filtered
,实际过滤只执行一次,除非
list
或
keyword
变了。如果换成普通函数,每次渲染都会重算。
watch
有三个参数值得记牢:
watch(source, (newVal, oldVal) => {...}, {
immediate: true, // 立即执行一次
deep: true, // 深度监听对象内部
flush: 'post' // 回调时机:pre / post / sync
})
flush
是最容易被忽略的一个。默认
pre
,在 DOM 更新前执行;
post
在 DOM 更新后执行。什么时候需要
post
?当你需要在回调里读更新后的 DOM 尺寸时。比如监听列表变化后重新计算滚动高度,用默认值会拿到旧尺寸。
deep
也有代价。深层对象监听会递归遍历所有属性,数据量大时开销明显。如果只需要监听某个字段,直接写成 getter 函数更省:
watch(() => state.user.name, () => {...})
watchEffect
会自动收集依赖,写起来最省事,但依赖关系不直观。我的建议是:能用
watch
显式写依赖就用
watch
,团队协作时别人看代码能一眼知道"这个副作用是被什么触发的"。
4.3 副作用清理与内存泄漏的实测案例
搜索框实时请求是最经典的场景。用户快速输入,如果每个字符都发一次请求,前面那些请求的结果可能晚于后面的返回,导致最终显示的内容和输入框对不上。这就是竞态条件。
解决方式是在监听回调里做清理:
watch(keyword, async (val, oldVal, onCleanup) => {
let cancelled = false
onCleanup(() => { cancelled = true })
const res = await fetchSearch(val)
if (!cancelled) {
result.value = res
}
})
onCleanup
会在下一次回调执行前被调用,把上一次的请求标记为作废。这样既能避免竞态,也能在组件卸载时自动取消。
如果不想手写这套逻辑,常见的做法是配合防抖。防抖我做了一个简单的实现:
function useDebounce(fn, delay = 300) {
let timer = null
const run = (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
onUnmounted(() => clearTimeout(timer))
return run
}
注意最后那行清理,卸载时把待执行的定时器干掉,否则回调可能在组件已经销毁之后执行,报出"无法在已卸载组件上操作"的警告。
还有一类内存泄漏很隐蔽:事件监听。给
window
加滚动监听之后忘记移除,组件卸载了监听还在,回调里引用的组件状态无法回收。规范做法是成对出现:
onMounted(() => window.addEventListener('resize', handler))
onUnmounted(() => window.removeEventListener('resize', handler))
如果监听很多,可以写一个组合式函数统一管理,一次注册,卸载时批量清理。
5. 路由与状态管理:让多页面跑通
单页应用的核心是路由和状态。这两块配不好,项目结构会很别扭。
5.1 路由基础、动态参数与懒加载
初始化路由表:
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', component: () => import('@/views/Home.vue') },
{ path: '/order/:id', component: () => import('@/views/Order.vue'), props: true }
]
export default createRouter({
history: createWebHistory(),
routes,
scrollBehavior: () => ({ top: 0 })
})
props: true
这行很实用,它把路由参数直接当 props 传给组件,组件内部就不用
useRoute()
去取了,复用性更好、测试也更容易。
路由懒加载用动态 import 实现,构建时会自动拆包。我一般建议除首屏页面外全部懒加载,这样首包体积能明显下降。判断哪些放首屏:用户进来第一眼要看到的、以及点击后立刻跳转的,其余都拆出去。
两个取参数的 API 容易混:
useRouter()
拿的是路由器实例,用来做跳转;
useRoute()
拿的是当前路由对象,用来读参数。名字差一个字母,行为完全不同,我见过有人拿
useRoute()
去调
push
,报错信息很不直观。
路由守卫做权限控制:
router.beforeEach((to, from, next) => {
const token = getToken()
if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } })
} else {
next()
}
})
注意
next
必须被调用,否则导航会一直挂起,页面白屏。这是排查路由问题时第一个要检查的地方。
5.2 Pinia 的两种写法与 storeToRefs
状态仓库现在主流是 Pinia,两种写法都能用。选项式写法结构清晰:
export const useUserStore = defineStore('user', {
state: () => ({ name: '', token: '' }),
getters: {
isLogin: state => !!state.token
},
actions: {
async login(payload) {
const res = await apiLogin(payload)
this.token = res.token
this.name = res.name
}
}
})
组合式写法和组件里的写法完全一致,更贴近日常习惯:
export const useUserStore = defineStore('user', () => {
const name = ref('')
const token = ref('')
const isLogin = computed(() => !!token.value)
async function login(payload) {
const res = await apiLogin(payload)
token.value = res.token
name.value = res.name
}
return { name, token, isLogin, login }
})
我偏向组合式,因为它能把各种组合式函数直接用进来,逻辑复用没有障碍。
有个必须注意的地方:从仓库里解构出来的状态会丢失响应式:
const store = useUserStore()
const { name } = store // 不响应
const { name } = storeToRefs(store) // 响应,但只有状态和方法之间要区分
storeToRefs
只处理状态和 getter,方法还是直接从
store
上取。所以正确写法通常是:
const store = useUserStore()
const { name, isLogin } = storeToRefs(store)
const { login } = store
这套写法我一开始也觉得别扭,用几次就习惯了,而且能避免大量"明明数据改了页面不动"的问题。
5.3 provide/inject 适合什么场景
跨层级传值不一定非要上全局状态。如果是某个子树内共享的数据,用
provide/inject
更合适,因为它有天然的作用域边界,子树销毁就自动回收,不会污染全局。
典型场景是表单上下文。一个
FormItem
组件需要知道自己在不在
Form
里面、表单的方向和禁用状态:
// Form.vue
const formContext = reactive({ disabled: false, size: 'medium' })
provide('formContext', formContext)
// FormItem.vue
const ctx = inject('formContext', { disabled: false, size: 'medium' })
注意第二个参数是默认值,防止脱离
Form
单独使用时崩溃。这点很重要,组件库里的组件都会被单独使用,没有默认值就是一个必然踩的坑。
inject
拿到的如果是响应式对象,改它能同步到所有使用方。但要注意,注入方不应该随便改提供方的数据,这跟 props 的原则是一致的。稳妥做法是同时
provide
一个修改方法,让修改行为可控。
6. 工程化能力进阶:组合式函数、TypeScript 与性能
能跑通业务代码只是入场券,真正拉开差距的是代码组织能力和性能意识。
6.1 用组合式函数把逻辑抽出来
组合式函数(Composable)本质就是一个以
use
开头的函数,内部可以使用所有响应式 API 和生命周期钩子,返回响应式状态和方法。它是这套框架最有价值的复用机制。
一个典型的需求:多个页面都有"加载中、数据、错误"三态请求逻辑。如果不抽,每个页面都要写一遍,改起来到处改。
// composables/useRequest.js
import { ref, onUnmounted, unref } from 'vue'
export function useRequest(fn, options = {}) {
const { immediate = false } = options
const data = ref(null)
const loading = ref(false)
const error = ref(null)
let cancelled = false
async function run(...args) {
loading.value = true
error.value = null
cancelled = false
try {
const res = await fn(...args)
if (!cancelled) data.value = res
} catch (e) {
if (!cancelled) error.value = e
} finally {
if (!cancelled) loading.value = false
}
}
if (immediate) run()
onUnmounted(() => { cancelled = true })
return { data, loading, error, run }
}
页面里用起来就一行:
const { data, loading, error, run } = useRequest(fetchOrderList, { immediate: true })
cancelled
那个变量的作用是在组件卸载后阻止状态更新,避免警告。这个小细节是我在频繁切换页面时才发现的,之前只是偶发警告,没当回事,直到日志里堆了几千条才去改。
写组合式函数有两条约定:一是命名以
use
开头,让调用方知道它是组合式函数;二是返回值统一用对象,不用数组,因为数组要按顺序取值,加一个新字段就会破坏所有调用方。
6.2 TypeScript 接入的三个关键点
如果项目用 TS,有三个地方值得提前踩明白。
第一个是 props 的类型声明。用类型标注比运行时校验更简洁:
interface Props {
title: string
count?: number
list: Array<{ id: number; name: string }>
}
const props = withDefaults(defineProps<Props>(), {
count: 0
})
注意
withDefaults
是必需的,因为类型声明本身没法给默认值。另外,导入接口时如果写在
<script setup>
里,编译工具可能处理不了,稳妥做法是把接口定义在同文件内的普通
<script>
块中,或者用
import type
。
第二个是事件类型:
const emit = defineEmits<{
change: [value: number]
submit: [payload: { id: number }]
}>()
这是较新版本的语法,能看到每个事件的参数类型,比数组声明清晰得多。
第三个是模板 ref 的类型。
ref(null)
推不出具体类型,调用方法时会提示可能为 null。常见写法是:
const formRef = ref<InstanceType<typeof MyForm> | null>(null)
这样既有类型提示,又保留了 null 的可能性,调用前做判空即可。
6.3 几个真能提性能的手段
第一, 长列表不要一次渲染完 。几千条数据全渲染,首屏会明显卡顿。方案要么是虚拟列表(只渲染可视区域),要么分页。我做过一个对比测试,五百条带图标的列表用虚拟列表后,滚动帧率从明显掉帧恢复到基本稳定。
第二,
大对象用
shallowRef
。比如从接口拿到一个几百个字段的配置对象,只做整体替换、不改内部字段,用
shallowRef
能省掉深度代理的开销。
第三, 组件按需异步加载 :
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))
配合
<Suspense>
可以做加载占位。不过
Suspense
目前还是实验性能力,生产项目用之前要评估,简单场景用
v-if
加骨架屏更稳。
第四,
<KeepAlive>
缓存页面。列表页跳详情再返回,如果没有缓存,列表会重新请求、滚动位置丢失。加上
KeepAlive
之后体验好很多,但要注意配合
onActivated
重新拉数据,否则用户返回后看到的是旧数据。这个取舍需要按业务定:对时效敏感的数据在
onActivated
里刷新,不敏感的就不刷。
7. 报错排查实录与踩坑清单
这部分是我觉得最有价值的地方,因为大部分内容在文档里查不到,只能在项目里撞出来。
7.1 常见报错速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 改了数据页面不动 | 解构 reactive、整体替换对象 |
用
toRefs
、改用 ref 或
Object.assign
|
| 脚本里改值不生效 |
ref 漏写
.value
|
补
.value
,或用
storeToRefs
|
| 父组件调用子组件方法报错 |
子组件没
defineExpose
| 显式暴露方法 |
| 路由跳转白屏 |
守卫里没调用
next
| 每个分支都要调 |
| 组件样式不生效 |
scoped
阻隔
|
用
:deep()
穿透
|
| 列表输入内容错位 |
key
用了下标
| 改成唯一 id |
| 生命周期不执行 | 写在异步回调里 | 移到同步执行阶段 |
| 深度监听不触发 | 新增属性不在初始对象上 |
补
deep
或改成 getter
|
| 接口 404 但控制台无报错 | 代理 rewrite 配置有误 | 看网络面板实际路径 |
| 打包后白屏 | 资源路径不对 |
配
base
或改相对路径
|
7.2 我踩过并且印象最深的几个坑
第一个是整体替换
reactive
对象。当时的代码是从接口拿到数据后直接赋值:
let formData = reactive({})
const res = await fetchDetail()
formData = res.data // 响应式直接断掉
页面表现是:控制台打印
formData
有值,但模板就是不更新。排查了两小时,最后才想明白是重新赋值把代理对象换成了普通对象。正确做法是用
Object.assign
逐字段合并,或者干脆用
ref
包一层。
第二个是
watch
监听数组 push 不触发。这个跟数据层级有关,有些情况下浅监听就够了,有些情况下必须加
deep
。判断方法很简单:在回调里加一句
console.log
,如果压根没打印就是没触发,加上
deep
再试。当然更推荐的做法是直接监听
list.value.length
这类基础字段,开销更小。
第三个是为第三方组件库写自定义指令,结果指令的
updated
钩子在父子组件更新顺序上出现问题,拿到的 DOM 尺寸是旧的。解决办法是用
nextTick
包一层,等本轮更新完成后再读尺寸。这类问题非常隐蔽,因为大部分时候能正常工作,只有特定交互顺序下才出错。
第四个是循环依赖。两个模块互相 import,其中一个拿到的是
undefined
。这种报错信息通常是"不能读取 undefined 的属性",跟循环依赖毫无关系,非常难查。后来我形成一个习惯:看到莫名奇妙的
undefined
报错,先去检查一下是不是循环引用了。
7.3 调试手段:开发者工具与源码定位
浏览器里的开发者工具插件是必装的。它能直接看到每个组件的 props、状态、事件,还能在面板里临时改值看效果,比在代码里加
console.log
高效得多。
生产环境的报错定位,我一般开着构建的 sourcemap,出问题的报错能直接映射回源码行号。虽然会增加打包体积,但配合错误监控,定位效率提升很大。上线前把 sourcemap 文件单独上传到监控平台,不随代码发布出去,这样既能定位问题,又不暴露源码。
源码级的调试,浏览器开发者面板的断点功能足够用。想看清响应式依赖收集过程,可以在
effect
相关的位置打断点,走一遍流程。虽然平时用不上,但遇到很诡异的"更新了但没渲染"问题,走一遍能快速定位是哪一环断的。
另外提醒一句:开发环境下的性能面板和警告一定要重视。我把控制台的 Vue 警告当作错误来对待,团队里也这么约定。因为警告往往预示着某处逻辑有问题,积累多了会变成难查的 bug。这条纪律执行下来,项目的线上问题明显少了。
最后分享一个我用了很久的小习惯:新建项目时,先写一个最小的"能跑通"的垂直切片——一个页面、一个接口、一次状态更新、一个路由跳转,全部打通之后再往上堆功能。这样任何环境配置、依赖版本、路径别名的问题都会在第一小时内暴露,而不是写了三天才发现基础配置有坑。比起一上来就铺开目录结构,这个做法在实际项目里省下的时间远超想象。
本文系统梳理Vue3从项目初始化(Vite)、响应式原理(Proxy vs defineProperty)、组合式API与选项式API选型,到组件通信六种方式、路由(Vue Router)与状态管理(Pinia)、组合式函数封装、TypeScript集成及性能优化等核心工程实践。重点解析ref/reactive使用陷阱、v-model多参数绑定、provide/inject适用场景、副作用清理与内存泄漏防控,并结合真实踩坑案例提供可落地的调试与排查方法。
239

被折叠的 条评论
为什么被折叠?



