Vue3的双向绑定和Vue2比到底强在哪?新手怎么快速搞懂Proxy怎么用?
最近翻社区留言,发现好多刚学Vue的朋友还在纠结双向绑定的原理:有的学过Vue2的Object.defineProperty,总觉得绕得慌;有的刚接触Vue3,知道换成了Proxy但没太明白换的好处,也不知道实际写代码时会不会不一样,今天就把这些问题揉碎了讲,用大白话加一点小例子,争取让大家看完就能明白核心区别,还能自己写个极简版的双向绑定体验下Proxy。
先搞懂:双向绑定到底是什么?为什么要存在?
很多教程一开始就讲原理,我觉得得先解决“为什么要这么干”,不然就是死记硬背,遇到实际需求还是不会举一反三。
我们做前端页面,本质上是数据和视图的交互:数据变了,页面上显示的内容要自动更新;用户在页面上操作(比如填表单、点按钮、滑动滑块),数据也要跟着变,要是没有双向绑定,你就得手动写代码做这两件事——比如填个输入框,得先监听input事件拿到值,再赋值给对应的变量;要是这个变量还在别的地方用,比如表格、标题,还得再手动更新DOM。
举个简单的例子,做个年龄输入框,下面显示“你今年X岁啦”,没有双向绑定的原生JS写法大概是这样:
<input type="number" id="ageInput">
<p id="ageText">你今年0岁啦</p>
<script>
let age = 0;
const input = document.getElementById('ageInput');
const text = document.getElementById('ageText');
// 视图→数据:监听输入事件
input.addEventListener('input', (e) => {
age = Number(e.target.value);
// 数据→视图:手动更新DOM
text.textContent = `你今年${age}岁啦`;
});
</script>
要是这个age还要传给兄弟组件、孙子组件用?那手动操作DOM和传递事件的代码会堆成山,维护起来特别麻烦,双向绑定就是帮你把这两件事自动化的工具。
回头看:Vue2的双向绑定有什么“硬伤”?
刚才说过Vue2用的是Object.defineProperty,很多教程会说这个方法“只能监听属性的读写,不能监听新增、删除属性”“不能监听数组下标和长度的直接修改”,没错,但新手可能没太有概念,换个小例子就能直观感受到。
还是刚才的年龄场景,再加个需求:用户输入姓名后,页面上显示“你叫X,今年Y岁啦”;还有个兴趣爱好数组,用户点“加个爱好”按钮,页面上要新增一个爱好项。
// Vue2的data模拟,用Object.defineProperty实现基础监听
const data = {
age: 0,
hobbies: ['吃饭', '睡觉']
};
// 监听age
let tempAge = data.age;
Object.defineProperty(data, 'age', {
get() {
console.log('读取age了');
return tempAge;
},
set(newVal) {
console.log('修改age了');
tempAge = newVal;
// 这里简化了,没写真正的DOM更新逻辑
}
});
// 先试试修改hobbies的长度和下标:data.hobbies.length = 1; data.hobbies[0] = '打游戏'
// 再试试新增name属性:data.name = '小明'
你在浏览器控制台试一下就会发现,修改hobbies的长度、直接用下标修改值,还有新增name属性,控制台都不会打印任何东西——也就是说,Vue2根本不知道这些数据变了,视图自然不会更新,后来Vue2官方虽然给了$set、$delete这些API,数组的七个方法也做了重写,但总归是“补丁”,不是原生的解决办法,新手用的时候还容易忘,踩不少坑。
看现在:Vue3用Proxy做双向绑定,到底“香”在哪里?
香点1:能监听对象的所有变化,包括新增、删除、数组操作
Proxy是ES6新增的一个特性,它可以在目标对象之前“拦截”所有对它的操作,比如读取属性、修改属性、新增属性、删除属性、遍历对象、修改数组下标和长度等等,几乎覆盖了所有能想到的数据操作场景。
还是刚才的Vue2模拟例子,换成Proxy试试:
const data = {
age: 0,
hobbies: ['吃饭', '睡觉']
};
// 用Proxy拦截data的所有操作
const proxyData = new Proxy(data, {
// get拦截读取操作
get(target, key, receiver) {
console.log(`读取了${key}`);
// 这里的Reflect.get是ES6新增的,用来替代原来的target[key],更规范
return Reflect.get(target, key, receiver);
},
// set拦截修改、新增操作
set(target, key, newVal, receiver) {
console.log(`修改/新增了${key},新值是${newVal}`);
const res = Reflect.set(target, key, newVal, receiver);
// 这里可以加DOM更新逻辑
return res;
},
// deleteProperty拦截删除操作
deleteProperty(target, key) {
console.log(`删除了${key}`);
const res = Reflect.deleteProperty(target, key);
// 这里可以加DOM更新逻辑
return res;
}
});
// 现在试试各种操作
proxyData.age = 18; // 打印“修改/新增了age,新值是18”
proxyData.name = '小明'; // 打印“修改/新增了name,新值是小明”
proxyData.hobbies.push('打游戏'); // 注意!这里会先打印“读取了hobbies”(因为要拿到push方法),再打印“读取了push”(因为是从数组原型上拿的),再打印“修改/新增了2,新值是打游戏”(因为push会新增下标为2的元素),最后打印“修改/新增了length,新值是3”(因为push会自动更新长度)
proxyData.hobbies[0] = '打篮球'; // 打印“修改/新增了0,新值是打篮球”
delete proxyData.age; // 打印“删除了age”
哇,是不是所有操作都被监听到了?完全不需要额外的补丁API,新手再也不用纠结“为什么加了个新属性页面没更新”“为什么直接改数组下标没用”这种问题了,这就是Proxy的核心优势之一。
香点2:性能更好,初始化时不需要遍历所有属性做defineProperty
Vue2用Object.defineProperty实现双向绑定的时候,初始化阶段会对data里的所有属性(包括嵌套很深的对象)做深度遍历,给每个属性都加上getter和setter,不然嵌套对象的属性变化就监听不到,如果data里的对象特别大、嵌套特别深,这一步初始化就会消耗不少性能,页面打开速度可能会变慢。
Vue3用Proxy就不一样了,初始化的时候只需要给整个data对象加一层Proxy拦截就行,嵌套对象的属性什么时候被读取,什么时候再给它加内层的Proxy——也就是“懒监听”,比如data里有个userInfo对象,里面又嵌套了address对象,address里又有city属性,如果你在页面初始化的时候只用到了userInfo.name,那userInfo.address和userInfo.address.city都不会被加Proxy,只有当你第一次读取这两个属性的时候,Vue3才会给它们加上内层的拦截,这样就大大减少了初始化阶段的性能消耗,页面打开速度会更快。
香点3:支持监听更多数据类型,比如Map、Set、WeakMap、WeakSet
Object.defineProperty只能监听普通对象的属性,对于ES6新增的Map、Set这些集合类型,根本没办法直接监听它们的变化(比如添加元素、删除元素、清空集合),Vue2虽然也提供了一些间接的处理方式,但用起来特别麻烦。
Proxy就不一样了,它可以直接监听Map、Set这些对象的所有操作,比如get拦截size、add、delete、set这些方法的调用,set拦截内部数据的变化,Vue3的双向绑定已经完全支持这些类型了,新手可以直接放心用,不用再找第三方库或者写复杂的逻辑。
上手试:写一个极简版的Vue3双向绑定,理解核心流程
光说不练假把式,现在我们来写一个只有核心功能的双向绑定,理解下Vue3是怎么把数据、视图、用户操作连起来的,这个极简版只会实现文本插值和input输入框的双向绑定,但核心逻辑和Vue3是一样的。
第一步:理解Vue3双向绑定的核心流程
Vue3的双向绑定核心是响应式系统+编译器+运行时渲染,不过我们的极简版可以简化一下,只保留响应式系统和简单的渲染逻辑,大概流程是这样的:
- 用Proxy创建响应式数据:拦截数据的所有操作。
- 收集依赖(订阅):当页面读取响应式数据的时候,把对应的DOM更新函数存起来,存到一个“小本本”里。
- 触发更新(发布):当响应式数据变化的时候,遍历“小本本”里的所有DOM更新函数,依次执行,更新视图。
- 监听用户操作(视图→数据):给input输入框加input事件监听,拿到值后直接修改响应式数据,触发上面的发布流程。
第二步:写代码实现这个流程
我们先写个HTML结构:
<div id="app">
<h2>{{title}}</h2>
<p>你叫:{{name}}</p>
<input type="text" v-model="name" placeholder="请输入你的姓名">
<p>你今年:{{age}}岁</p>
<input type="number" v-model="age" placeholder="请输入你的年龄">
</div>
然后写JavaScript代码,实现极简版的双向绑定:
// 1. 写一个Dep类,用来存依赖和触发依赖
class Dep {
constructor() {
// 用Set存依赖,避免重复添加
this.subscribers = new Set();
}
// 收集依赖:把当前的更新函数加进去
depend() {
if (currentUpdateFn) {
this.subscribers.add(currentUpdateFn);
}
}
// 触发依赖:遍历执行所有更新函数
notify() {
this.subscribers.forEach(fn => fn());
}
}
// 2. 全局变量,用来存当前正在执行的更新函数
let currentUpdateFn = null;
// 3. 写一个watchEffect函数,用来创建更新函数并立即执行一次(执行的时候会收集依赖)
function watchEffect(fn) {
currentUpdateFn = fn;
fn(); // 立即执行,读取响应式数据的时候会触发dep.depend()
currentUpdateFn = null;
}
// 4. 写一个reactive函数,用Proxy创建响应式数据
const targetMap = new WeakMap(); // 全局的Map,用来存所有响应式对象的Dep,WeakMap的key是响应式对象,value是Map(key是属性名,value是对应的Dep)
function reactive(target) {
// 如果不是对象或者已经是响应式的,直接返回
if (typeof target !== 'object' || target === null) {
return target;
}
// 先看看这个对象有没有被加过Proxy,加过的话直接返回之前的Proxy
if (targetMap.has(target)) {
return targetMap.get(target);
}
// 创建一个Dep的Map,用来存当前对象的所有属性对应的Dep
const depsMap = new Map();
// 创建Proxy
const proxy = new Proxy(target, {
get(target, key, receiver) {
// 先看看depsMap里有没有当前属性对应的Dep,没有的话就创建一个
let dep = depsMap.get(key);
if (!dep) {
dep = new Dep();
depsMap.set(key, dep);
}
// 收集依赖
dep.depend();
// 递归处理嵌套对象:读取嵌套对象的时候,给它也加Proxy
const res = Reflect.get(target, key, receiver);
return reactive(res);
},
set(target, key, newVal, receiver) {
// 只有当新值和旧值不一样的时候才触发更新
const oldVal = Reflect.get(target, key, receiver);
if (oldVal !== newVal) {
const res = Reflect.set(target, key, newVal, receiver);
// 先看看depsMap里有没有当前属性对应的Dep,有的话就触发更新
const dep = depsMap.get(key);
if (dep) {
dep.notify();
}
return res;
}
return true;
}
});
// 把当前的Proxy和target存到全局的targetMap里
targetMap.set(target, proxy);
return proxy;
}
// 5. 写一个简单的编译器,用来处理模板里的{{}}和v-model
function compile(node, vm) {
// 处理文本节点
if (node.nodeType === 3) {
const text = node.textContent;
// 匹配{{xxx}}
const reg = /\{\{(.*?)\}\}/g;
if (reg.test(text)) {
const updateText = () => {
// 替换{{xxx}}为响应式数据的值
node.textContent = text.replace(reg, (match, key) => {
// 处理嵌套属性,比如user.name
const keys = key.trim().split('.');
let value = vm;
for (const k of keys) {
value = value[k];
}
return value;
});
};
// 用watchEffect监听数据变化,更新文本
watchEffect(updateText);
}
return;
}
// 处理元素节点
if (node.nodeType === 1) {
// 先处理属性,比如v-model
const attrs = node.attributes;
for (const attr of attrs) {
if (attr.name.startsWith('v-model')) {
const key = attr.value.trim();
// 监听input事件,视图→数据
node.addEventListener('input', (e) => {
// 处理嵌套属性,比如user.name
const keys = key.split('.');
let target = vm;
// 找到最后一个属性的父对象
for (let i = 0; i < keys.length - 1; i++) {
target = target[keys[i]];
}
// 修改最后一个属性的值
target[keys[keys.length - 1]] = e.target.value;
});
// 处理输入框的初始值,用watchEffect监听数据变化,更新输入框的值
const updateInput = () => {
// 处理嵌套属性,比如user.name
const keys = key.split('.');
let value = vm;
for (const k of keys) {
value = value[k];
}
node.value = value;
};
watchEffect(updateInput);
// 移除v-model属性,不然浏览器会显示出来
node.removeAttribute(attr.name);
}
}
// 递归处理子节点
const childNodes = node.childNodes;
for (const child of childNodes) {
compile(child, vm);
}
}
}
// 6. 初始化极简版的Vue
const app = document.getElementById('app');
const vm = reactive({ '极简版Vue3双向绑定',
name: '',
age: 0
});
compile(app, vm);
现在把这些代码放到浏览器里运行试试,是不是可以实现文本插值和input输入框的双向绑定了?你可以试试修改name和age,页面上的内容会自动更新;你还可以在控制台里试一下新增属性、修改数组(虽然我们的极简版没写数组的处理,但reactive里已经有递归逻辑了,你可以自己加个hobbies数组试试),是不是都没问题?
今天我们学到了什么?
- 双向绑定的作用:自动化实现数据和视图的交互,减少手动操作DOM和传递事件的代码。
- Vue2的硬伤:只能监听普通对象属性的读写,不能监听新增、删除属性和数组下标、长度的直接修改,需要补丁API;初始化时需要深度遍历所有属性,性能消耗大;不支持直接监听Map、Set等集合类型。
- Vue3的优势:用Proxy拦截所有数据操作,不需要补丁API;懒监听,初始化性能更好;支持监听更多数据类型。
- 核心流程:响应式系统(reactive+Dep+watchEffect)+ 编译器 + 运行时渲染,极简版的代码可以帮你理解核心逻辑。
其实Vue3的双向绑定还有很多细节,比如ref和reactive的区别、computed和watch的实现、依赖收集的优化等等,今天先讲这么多,感兴趣的朋友可以自己去查资料深入学习,如果这篇文章对你有帮助,别忘了点赞收藏转发哦!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



