去年接手一个后台系统的重构打开代码仓库的时候我愣了一下同一个目录里并存着三种 Vue 写法——老页面还是data、methods那一套选项式 API新页面用的是script setup中间还夹着几个用defineComponent手写setup()返回对象的组件。三个人三种风格改一个表单校验逻辑得在data、computed、methods之间反复横跳。那阵子我把 Vue3 的核心语法从头捋了一遍重点不是背 API而是搞清楚每条语法为什么这么设计、什么时候该用哪一个。这份梳理后来成了团队内部的入门材料也让几个刚接触组合式 API 的同事少走了不少弯路。Vue3 的核心语法说白了就是三块东西响应式数据的声明方式ref / reactive、组件逻辑的组织方式setup 与 script setup、以及模板与组件之间的通信方式指令、props、emits、生命周期钩子。它解决的是 Vue2 时代逻辑按选项类型割裂、类型推断弱、复杂组件难以复用的问题。这篇内容适合三类人看刚从 Vue2 迁过来的开发者、写过几个 Vue3 页面但总是被.value和“响应式丢失”搞晕的人、以及想把团队写法统一起来的技术负责人。下面我按“为什么这样设计 → 怎么写 → 会踩什么坑”的顺序展开。1. 从选项式到组合式语法变革真正改掉的是什么很多人对 Vue3 的第一印象是“多了个 setup多了个 .value”把它当成一次 API 层面的小修小补。实际用久了会发现变的不只是写法而是代码的组织维度Vue2 按“数据类型”分区数据放 data、方法放 methods、计算放 computedVue3 按“业务逻辑”分区一个功能相关的状态、计算、副作用可以写在相邻的几行里。1.1 同一个计数器两种写法的差异在哪先看一段最朴素的对比。选项式写法export default { data() { return { count: 0 } }, computed: { double() { return this.count * 2 } }, methods: { inc() { this.count } } }组合式写法import { ref, computed } from vue const count ref(0) const double computed(() count.value * 2) function inc() { count.value }功能完全等价但当组件有 500 行、涉及十几个状态时差别就出来了。选项式写法里count相关的逻辑被切成了三块分散在不同选项里组合式写法里它们挨在一起读代码的时候是一个完整的“计数器功能块”。这就是所谓的逻辑关注点聚合也是组合式 API 最核心的价值跟语法甜不甜关系不大。顺带说一句组合式 API 让“逻辑复用”从 mixin 进化成了函数。mixin 的老问题是来源不清晰、命名冲突、隐式依赖而一个useCounter()函数返回什么、依赖什么看函数签名就知道。1.2 setup 的执行时机与 this 的消失setup()在组件实例被创建之前执行具体位置在beforeCreate之前。这意味着两件事第一setup 里拿不到 this任何依赖组件实例的操作比如访问this.$refs、this.$router都不能在这里直接做第二props 在 setup 里已经可用了因为父组件传参发生在创建子组件实例之前。export default { props: [userId], setup(props, context) { // props 可读但不能直接修改 console.log(props.userId) // context 包含 attrs / slots / emit / expose return {} } }这个时机设计带来的直接好处是状态初始化不再依赖this你可以把一段逻辑抽成普通函数在任何地方调用不必挂载到组件上。副作用是新手常犯的一个错——在 setup 里写this.xxx直接报Cannot read properties of undefined。我的建议是彻底放弃在 Vue3 里找this需要路由、状态库这类全局能力时用对应的组合式函数useRouter()、useStore()显式引入。1.3 script setup 到底省掉了什么script setup是编译期语法糖不是运行时特性。它省掉的主要是三样东西对比项普通 setup()script setup顶层变量暴露需要 return 一个对象自动暴露给模板组件注册需要 components 选项直接 import 即可用props / emits 声明setup 参数或选项defineProps / defineEmits 宏类型推断较弱编译期直接推导IDE 提示完整运行时开销需要创建返回对象编译后内联开销更低有个细节值得注意script setup里的顶层变量并不是“自动变成响应式的”它只是自动暴露给模板。你写const a 1模板里能用{{ a }}但 a 永远是 1。响应式仍然要靠ref或reactive显式创建。这一点是我见过最多的误解之一。另外script setup中的代码会被编译进组件的setup()函数体所以它默认是私有的。父组件想通过模板 ref 调用子组件的方法必须在子组件里用defineExpose显式导出否则拿到的 ref 上什么都没有。2. ref 与 reactive两个入口背后的取舍这两个 API 是新手最容易纠结的地方。网上的说法五花八门有说“基本类型用 ref、对象用 reactive”的有说“统一用 ref 就完事了”的。要做出自己的判断得先明白它们底层实现不一样。2.1 ref 的 .value 不是累赘是被迫的设计ref内部对传入的值做了一层包装用一个带get value/set value的对象来拦截读写。为什么要多这一层因为 JavaScript 的 Proxy 只能代理对象拦截不了基本类型的赋值。let count 0; count这种操作任何框架都没法感知。所以ref把值塞进一个对象里赋值变成obj.value 1这就有了拦截的机会。import { ref } from vue const count ref(0) console.log(count.value) // 0 count.value // 触发依赖更新在模板里不需要写.value是因为模板编译时对 setup 返回的 ref 做了自动解包处理。但在 JS 代码里包括 computed、watch 回调、事件处理函数.value一个都不能少。我个人的经验是把.value当成“我正在读写响应式数据”的视觉标记而不是负担。漏写.value导致的 bug 通常很隐蔽——比如count 1不报错只是算出来是[object Object]1。还有一个容易忽略的点ref放在reactive对象里会自动解包。const r ref(1) const state reactive({ r }) console.log(state.r) // 1自动解包不需要 .value但数组和 Map 里不会解包state.list[0]拿到的还是 ref 对象。这个不一致性经常在循环渲染时坑人。2.2 reactive 的边界解构即失联reactive基于 Proxy直接代理整个对象访问属性时天然就是响应式的不用.value。但它有四条硬边界我用表格整理一下边界情况表现处理方式传入基本类型直接失效控制台警告改用 ref解构赋值得到的是普通值失去响应用 toRefs 或 toRef整体替换对象原代理对象与新对象断开用 Object.assign 合并传入另一个 reactive返回原代理不是新代理无需处理直接用最常见的问题是解构const state reactive({ count: 0, name: a }) const { count } state count // 页面不更新且不会报错原因很直白解构的那一刻count拿到的是0这个数值本身后续的count操作的是一个普通变量跟state已经没关系了。要用toRefs保留连接import { toRefs } from vue const { count, name } toRefs(state) count.value // 正常触发更新整体替换的问题也常见于接口请求let state reactive({ list: [] }) state { list: [1, 2, 3] } // 错误state 变量指向新对象模板还看着旧代理 Object.assign(state, { list: [1, 2, 3] }) // 正确2.3 我在项目里定下的混用规则团队里如果一半人用 ref、一半人用 reactive代码会很难读。我一般会约定一条简单规则单值和需要整体替换的对象用 ref表单这类字段多、只做属性读写的用 reactive。理由是这样的ref 在赋值、传参、返回时最不容易出问题而表单场景用 reactive 可以减少大量.value噪音。需要把 reactive 对象的某个字段传给子组件时一定要用toRef而不是直接取属性值const keyword toRef(state, keyword) // 保持响应式连接至于shallowRef它只对.value的整体赋值做响应不深度代理内部属性。处理大列表、第三方实例图表对象、地图对象时用它能省掉大量无意义的代理开销。这个在后面的性能部分还会提到。3. 模板语法与指令看着眼熟但行为变了的部分从 Vue2 过来的人最容易在模板层翻车因为很多指令名字没变行为却变了。这些变化不会在编译时报错只会在运行时给你一个莫名其妙的空值或者意外结果。3.1 v-model 从单绑到多绑Vue2 里一个组件只能有一个v-model多出来的要用.sync修饰符。Vue3 把.sync干掉了改成支持多个v-modeluser-form v-model:nameform.name v-model:ageform.age /对应的子组件声明const props defineProps({ name: String, age: Number }) const emit defineEmits([update:name, update:age])默认的v-modelx等价于:modelValuex加update:modelValuex $event。Vue 3.4 之后又提供了defineModel宏可以进一步简化const name defineModel(name, { type: String }) // 直接 name.value xx 就会同步到父组件这里有个坑值得单独说不要直接修改 props。props.name xxx在开发环境会警告生产环境静默失败但不生效。所有对父组件数据的修改都必须走 emit。我在几个老项目里见过用Object.assign(props, xxx)强行改的短期看着能用实际上破坏了单向数据流父子组件状态会打架。3.2 v-if 与 v-for 的优先级反转这是 Vue3 迁移中最经典的坑。Vue2 里v-for优先级高于v-if所以下面这种写法能跑!-- Vue2 可以Vue3 报错 -- li v-foritem in list v-ifitem.visible :keyitem.id{{ item.name }}/liVue3 里v-if优先级更高判断时item还不存在直接抛Property item was accessed during render but is not defined。修正方式有两种!-- 方案一外层套 template -- template v-foritem in list :keyitem.id li v-ifitem.visible{{ item.name }}/li /template !-- 方案二先用 computed 过滤 -- li v-foritem in visibleList :keyitem.id{{ item.name }}/li我更推荐第二种。过滤逻辑放到 computed 里模板只负责渲染职责更清晰而且过滤只算一次比在渲染时逐项判断效率更高。3.3 属性透传、动态参数与 key 的处理Vue3 支持多根节点组件这带来了一个新问题多根节点时父组件传下来的非 props 属性不会自动透传因为框架不知道挂到哪个根上。解决方式是显式用$attrstemplate header标题/header main v-bind$attrs正文/main /template单根节点时属性会默认透传到根元素如果不希望这样设置defineOptions({ inheritAttrs: false })。这个在封装基础组件时很关键我封装一个带class的按钮组件结果父组件传的class被合并到了外层包裹 div 上样式怎么调都不对查了半天才发现是透传导致的。动态参数v-bind:[key]也值得一提。Vue3 对动态参数的表达式值有约束必须是字符串或nullnull 表示移除。写成数字或者对象会警告。key的处理上Vue3 的 diff 算法变了但规则没变用稳定且唯一的 id不要用数组下标。用下标作为 key在列表中间插入或删除元素时组件实例会被错误复用出现表单值错位、动画错乱这类问题。这个坑看起来老生常谈但每次 review 代码都能看到几个。4. computed、watch 与副作用三者的分工边界这三个 API 都能“跟着数据变化做事”但适用场景差别很大。分不清的话代码能跑但会有奇怪的时序问题和性能问题。4.1 computed 的缓存机制与可写计算computed的核心是基于依赖追踪的缓存。它的依赖不变重复读取不会重新执行函数直接返回上次的结果。这跟 methods 有本质区别模板里调用 method每次重渲染都会执行一遍。const list ref([1, 2, 3]) const total computed(() list.value.reduce((a, b) a b, 0)) // 多次访问 total.valuereduce 只跑了第一次正因为有缓存computed里绝对不能写副作用。我就见过有人在 computed 里发请求、改别的响应式变量结果因为缓存的存在有时候执行有时候不执行排查起来极其痛苦。规则很简单computed 只做纯计算输入相同输出必相同。可写 computed 用一个 get/set 对象实现适合“读取时是派生值、写入时反向更新源数据”的场景比如全选/反选的复选框const allChecked computed({ get: () list.value.every(i i.checked), set: (val) list.value.forEach(i { i.checked val }) })这种写法比在事件里手动同步两个状态要稳因为逻辑只有一处。4.2 watch 与 watchEffect 的选择题两者的区别我整理成表格更清楚维度watchwatchEffect依赖收集显式声明自动收集回调中用到的响应式数据首次执行默认不执行可配 immediate立即执行一次新旧值可获取拿不到适用场景需要对比新旧值、需要精确控制依赖依赖多、只关心“变了就重跑”深度监听需要 deep: true自动深度追踪选型上我的经验是能从需求里清晰说出“我要监听谁”的用 watch副作用里用到一堆状态、只想在任意一个变化时重新执行的用 watchEffect。watch还有几个容易忽略的参数。flush控制执行时机默认pre组件更新前改成post可以拿到更新后的 DOMwatch(list, () { // 此时 DOM 已更新可以量高度 }, { flush: post })另外监听reactive对象必须加deep: true否则报错或无效监听 ref 对象本身则不需要。监听多个源用数组watch([a, b], ([newA, newB], [oldA, oldB]) {})。4.3 清理副作用异步请求的竞态问题这是我觉得最容易被忽视的一块。监听一个 id 变化去请求详情接口如果用户快速切换请求返回的顺序不保证可能出现先发的请求后回来把数据覆盖成错误的那条。watch(currentId, async (id, oldId, onCleanup) { let cancelled false onCleanup(() { cancelled true }) const res await fetchDetail(id) if (!cancelled) { detail.value res } })onCleanup在回调被重新触发或组件卸载时执行用来做清理。同样的机制在watchEffect里是通过回调参数传入的watchEffect((onCleanup) { const timer setInterval(poll, 3000) onCleanup(() clearInterval(timer)) })对于事件监听、定时器、WebSocket、IntersectionObserver 这类需要配对的资源用这个模式能把泄漏问题挡在源头。我在一个列表页里漏了clearInterval页面切走十分钟后定时器还在跑内存曲线一路上涨最后是靠 Performance 面板才定位到的。5. 生命周期与组件通信新写法下的老问题组合式 API 把生命周期钩子变成了独立函数注册方式和限制都变了。组件通信的宏也有编译期约束。这两块是新手写 Vue3 时最频繁卡住的地方。5.1 钩子注册的时机陷阱生命周期钩子必须在setup的同步执行阶段注册。因为注册时需要拿到当前组件实例而实例是通过一个全局变量在 setup 执行期间临时暴露的setup 一旦返回或者遇到第一个 await这个上下文就被清理了。import { onMounted } from vue // 正确 onMounted(() { initChart() }) // 错误await 之后注册钩子不会执行 await fetchConfig() onMounted(() { initChart() }) // 静默失效无警告script setup支持顶层 await很多人喜欢直接写然后发现 onMounted 不执行。解决办法是把依赖异步数据的初始化放到同步钩子内部onMounted(async () { const cfg await fetchConfig() initChart(cfg) })另外beforeCreate和created在组合式 API 里没有对应钩子因为setup本身的执行时机就等价于这两个阶段。beforeDestroy改名onBeforeUnmountdestroyed改名onUnmounted名字变了逻辑没变但迁移时容易漏改。5.2 defineProps / defineEmits 的编译期约束这两个是编译宏不是运行时函数不需要 import也不能像普通函数那样赋值给变量或者引用外部作用域的东西。常见错误是在defineProps的类型声明里引用一个运行时变量const type primary // 错误宏在编译时展开拿不到运行时变量 const props defineProps({ type: { type: String, default: type } })defineProps返回的对象是只读的解构会失去响应性。如果需要基于 props 派生出本地状态用toRefconst props defineProps({ userId: String }) const userIdRef toRef(props, userId)或者用const { userId } toRefs(props)。Vue 3.5 之后解构 props 会保持响应性但老项目里靠这个特性容易出问题我建议还是显式写toRef或props.xxx兼容性最好。defineEmits用数组形式声明事件名用对象形式可以带校验函数。事件名建议统一用 kebab-caseupdate-name因为 HTML 模板里属性名不区分大小写写 camelCase 在 DOM 模板中会失效。5.3 provide/inject 与模板引用跨层级通信用provide/inject。一个实践细节key 用 Symbol 而不是字符串避免多层级组件库之间的命名冲突。// 父组件 import { provide, readonly } from vue const themeKey Symbol(theme) provide(themeKey, readonly(themeState)) // 孙组件 const theme inject(themeKey, defaultValue)readonly包裹一下能防止下层组件意外修改上层状态调试的时候定位来源也方便。模板引用在 Vue3 里统一用 ref 变量const inputRef ref(null) onMounted(() { inputRef.value?.focus() })注意onMounted之前inputRef.value是 null所有访问都要加可选链。Vue 3.5 引入了useTemplateRef把模板 ref 和普通 ref 分开管理多根节点组件里用起来更清爽。子组件的方法要暴露给父组件必须defineExpose否则父组件拿到的是一个空对象。6. 从能跑到跑好几条落地的经验语法层面讲完剩下的都是实操层面的判断。这部分内容在文档里基本看不到但决定了代码半年后还能不能维护。6.1 响应式丢失的排查顺序遇到“数据变了但页面不动”我一般按这个顺序查命中率很高是不是解构了 reactive 或 props——最常见改用toRefs。是不是整体替换了 reactive 对象——改用Object.assign。是不是从数组或 Map 里取出了 ref 没加 .value——检查取值处。是不是在 setup 之外比如普通工具函数里创建了状态——脱离组件上下文需要提升到 composable 里。是不是用了Object.freeze或readonly包裹的对象——Proxy 不会拦截冻结对象。另外console.log打印 reactive 对象时看到的是当前快照展开时才是最新值这个表现容易让人误判。要看实时值用JSON.parse(JSON.stringify(state))或者 Vue DevTools 的组件面板。6.2 几个真能省性能的细节做法适用场景收益shallowRef替代ref大数组、第三方实例省掉深层代理开销markRaw标记图表实例、地图实例放进响应式对象避免无意义代理v-memo长列表中的部分行需要缓存跳过不必要的子树更新computed 替代模板内方法调用模板里做过滤、排序避免每次重渲染重算v-once纯静态内容只渲染一次markRaw是我用得最多的一个。把 ECharts 实例放进reactive里Vue 会尝试代理整个图表对象里面几千个属性性能直接掉一截还可能出现图表内部状态被追踪导致的诡异重绘。用markRaw(chartInstance)包一下就好了。6.3 团队协作层面的约定语法本身不难难的是几个人写出几种风格。我们后来定了几条规矩摩擦明显减少状态统一用 ref只有表单这类字段密集的场景才用 reactive且必须配toRefs导出。业务逻辑抽成useXxx组合式函数放在composables目录返回 ref 而不是 reactive 对象调用方可以随意解构。组件通信只走 props / emits禁止直接改 props跨层级用 provide/inject且 key 用 Symbol。生命周期里注册的资源必须在onUnmounted或onCleanup里释放这条写进 code review checklist。v-for的 key 禁止用 indexlint 规则里加一条自定义检查。这套约定跑了大半年新同事上手的时间从两周压缩到三天左右改老代码时也不用每次都先猜作者的习惯。最后分享一个小技巧。如果你在维护一个正在从 Vue2 逐步迁移的项目不必强行把老组件全部重写。Vue3 完全支持选项式 APIvue/compat之类的迁移构建也能帮助渐进过渡。我的做法是新功能一律用script setup老组件只在有改动需求时顺手重构。这样迁移成本被摊薄到日常迭代里不会形成一次伤筋动骨的大改。真正需要优先重构的是那些状态多、逻辑绕、已经改不动的组件——把它们按业务拆成几个组合式函数收益最明显。