上篇回顾:2.1-03 把 JS 核心特性(原型链、闭包、this、事件循环、Promise/async)与 Java 对照讲透。本篇进入前端框架——Vue3。对后端工程师来说,Vue3 是最友好的框架:模板语法接近 HTML,响应式让 UI 自动更新。但「数据变了 UI 自动更新」背后到底是什么机制?
一、开篇:从命令式到声明式
原生 JS 写一个计数器:
letcount=0;constbtn=document.querySelector('#btn');constdisplay=document.querySelector('#display');btn.addEventListener('click',()=>{count++;display.textContent=count;// 手动更新 UI});每次数据变,手动更新 DOM。这是命令式——你告诉机器每一步怎么做。
Vue3 写同样的计数器:
<template> <button @click="count++">{{ count }}</button> </template> <script setup> import { ref } from 'vue'; const count = ref(0); </script>数据变了,UI 自动更新。这是声明式——你描述状态,框架帮你更新 UI。
魔法在哪?本篇拆解 Vue3 响应式的底层机制。
二、Vue2 的 defineProperty:为什么不够用
Vue2 用Object.defineProperty实现响应式:
Object.defineProperty(obj,'key',{get(){// 收集依赖returnvalue;},set(newVal){value=newVal;// 触发更新}});2.1 defineProperty 的三大缺陷
| 缺陷 | 说明 |
|---|---|
| 无法监听新增属性 | obj.newKey = 1不触发响应式,要用Vue.set |
| 无法监听删除属性 | delete obj.key不触发响应式 |
| 数组监听有问题 | 索引修改、length 修改不触发,要重写数组方法 |
2.2 Vue2 的 workaround
- 新增属性:
Vue.set(obj, 'key', value)或this.$set - 数组:重写 push/pop/splice 等方法
- 深层对象:递归遍历所有属性 defineProperty(初始化慢)
这些 workaround 让 Vue2 的响应式代码充满「特殊场景特殊处理」。
三、Vue3 的 Proxy:全面拦截
3.1 Proxy 是什么
Proxy 是 ES6 提供的代理机制,可以拦截对象的所有操作:
consthandler={get(target,key,receiver){track(target,key);// 收集依赖returnReflect.get(target,key,receiver);},set(target,key,value,receiver){Reflect.set(target,key,value,receiver);trigger(target,key);// 触发更新returntrue;}};constproxy=newProxy(obj,handler);3.2 Proxy 解决了 defineProperty 的所有缺陷
| 问题 | defineProperty | Proxy |
|---|---|---|
| 新增属性 | 不触发 | 触发(拦截 set) |
| 删除属性 | 不触发 | 触发(拦截 deleteProperty) |
| 数组索引/length | 不触发 | 触发 |
| 深层对象 | 递归初始化 | 惰性代理(访问时才代理) |
3.3 惰性代理
Vue3 不在初始化时递归代理所有层级,而是访问到某层时才代理该层:
get(target,key,receiver){constresult=Reflect.get(target,key,receiver);if(isObject(result)){returnreactive(result);// 访问到才代理}returnresult;}对于大对象,初始化性能大幅提升。
四、依赖收集与触发更新
4.1 track:读取时收集
letactiveEffect=null;functioneffect(fn){activeEffect=fn;fn();activeEffect=null;}functiontrack(target,key){if(!activeEffect)return;letdepsMap=targetMap.get(target);if(!depsMap){depsMap=newMap();targetMap.set(target,depsMap);}letdep=depsMap.get(key);if(!dep){dep=newSet();depsMap.set(key,dep);}dep.add(activeEffect);}4.2 trigger:修改时触发
functiontrigger(target,key){constdepsMap=targetMap.get(target);if(!depsMap)return;constdep=depsMap.get(key);if(dep){dep.forEach(effect=>effect());}}4.3 完整流程
1. 组件渲染时执行 render 函数 → activeEffect = render 2. render 读取 count → track(count) → 把 render 加入 count 的依赖集合 3. 用户点击 → count++ → set 拦截 → trigger(count) → 执行 render → UI 更新五、ref vs reactive
5.1 reactive:对象响应式
import{reactive}from'vue';conststate=reactive({count:0,name:'Tom'});state.count++;// 响应式触发reactive 适合对象/数组。基本类型不能用 reactive(Proxy 只能代理对象)。
5.2 ref:基本类型响应式
import{ref}from'vue';constcount=ref(0);count.value++;// 注意 .valueref 内部用{ value: x }对象包装,再用 reactive 代理。模板里自动解包.value。
5.3 选择建议
| 场景 | 推荐 |
|---|---|
| 基本类型 | ref |
| 对象/数组 | reactive |
| 表单整体 | reactive |
| 单个值 | ref |
六、组合式 API(Composition API)
6.1 Options API 的痛点
Vue2 的 Options API:
exportdefault{data(){return{count:0}},methods:{increment(){this.count++}},computed:{double(){returnthis.count*2}},mounted(){console.log('mounted')}}痛点:一个功能的代码散落在 data/methods/computed/mounted 四个地方,大组件难以维护。
6.2 Composition API:按功能组织
import{ref,computed,onMounted}from'vue';functionuseCounter(initial=0){constcount=ref(initial);constdouble=computed(()=>count.value*2);functionincrement(){count.value++;}onMounted(()=>console.log('counter mounted'));return{count,double,increment};}// 使用const{count,double,increment}=useCounter(10);一个功能的所有逻辑封装在一个函数里,可复用、可测试。
6.3 setup 语法糖
<script setup> import { ref } from 'vue'; const count = ref(0); const increment = () => count.value++; </script> <template> <button @click="increment">{{ count }}</button> </template><script setup>是编译时语法糖,省去了export default和return,顶层变量自动暴露给模板。
七、组件通信
| 方式 | 方向 | 场景 |
|---|---|---|
| props / emit | 父↔子 | 最常用 |
| v-model | 父↔子 | 表单双向绑定 |
| provide / inject | 祖→孙 | 跨层级传值 |
| ref / expose | 父→子 | 父调子方法 |
| 事件总线(mitt) | 任意 | 全局事件(谨慎用) |
| Pinia | 任意 | 全局状态管理(推荐) |
7.1 props / emit 示例
<!-- 父组件 --> <template> <ChildComp :message="msg" @reply="onReply" /> </template> <script setup> import { ref } from 'vue'; import ChildComp from './ChildComp.vue'; const msg = ref('hello'); const onReply = (val) => console.log('子组件回复:', val); </script> <!-- 子组件 ChildComp.vue --> <template> <div> <p>{{ message }}</p> <button @click="emit('reply', '收到')">回复</button> </div> </template> <script setup> const props = defineProps(['message']); const emit = defineEmits(['reply']); </script>八、双向绑定:是语法糖还是机制
<input v-model="text" />v-model是语法糖,等价于:
<input :value="text" @input="text = $event.target.value" />底层机制:
:value="text"→ 把 text 绑定到 input 的 value 属性@input→ 监听 input 事件,用户输入时更新 text- text 变了 → 响应式触发 → value 重新绑定
所以双向绑定 =单向绑定 + 事件监听的语法糖,不是新机制。理解这一点,自定义组件的双向绑定就简单了:
<!-- 自定义组件的双向绑定 --> <MyInput v-model="text" /> <!-- 等价于 --> <MyInput :modelValue="text" @update:modelValue="text = $event" />九、常见陷阱
9.1 解构 reactive 丢失响应式
conststate=reactive({count:0});const{count}=state;// count 不再响应式!解构后 count 是普通值。要用toRefs:
const{count}=toRefs(state);// count 是 ref,保持响应式9.2 修改 props
constprops=defineProps(['count']);props.count++;// 警告:不能直接改 propsprops 是只读的,要改就 emit 事件让父组件改。
9.3 忘记 .value
constcount=ref(0);console.log(count);// 输出 ref 对象,不是值console.log(count.value);// 输出 0模板里自动解包,JS 里要手动.value。
十、小结表
| 概念 | Vue2 | Vue3 |
|---|---|---|
| 响应式 | defineProperty | Proxy |
| 新增属性 | Vue.set | 自动响应 |
| 数组监听 | 重写方法 | 自动响应 |
| 深层代理 | 递归初始化 | 惰性代理 |
| API 风格 | Options | Composition |
| 状态管理 | Vuex | Pinia |