Code前端首页关于Code前端联系我们

Vue3的双向绑定和Vue2比到底强在哪?新手怎么快速搞懂Proxy怎么用?

terry 21小时前 阅读数 308 #Vue
文章标签 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的双向绑定核心是响应式系统+编译器+运行时渲染,不过我们的极简版可以简化一下,只保留响应式系统和简单的渲染逻辑,大概流程是这样的:

  1. 用Proxy创建响应式数据:拦截数据的所有操作。
  2. 收集依赖(订阅):当页面读取响应式数据的时候,把对应的DOM更新函数存起来,存到一个“小本本”里。
  3. 触发更新(发布):当响应式数据变化的时候,遍历“小本本”里的所有DOM更新函数,依次执行,更新视图。
  4. 监听用户操作(视图→数据):给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数组试试),是不是都没问题?

今天我们学到了什么?

  1. 双向绑定的作用:自动化实现数据和视图的交互,减少手动操作DOM和传递事件的代码。
  2. Vue2的硬伤:只能监听普通对象属性的读写,不能监听新增、删除属性和数组下标、长度的直接修改,需要补丁API;初始化时需要深度遍历所有属性,性能消耗大;不支持直接监听Map、Set等集合类型。
  3. Vue3的优势:用Proxy拦截所有数据操作,不需要补丁API;懒监听,初始化性能更好;支持监听更多数据类型。
  4. 核心流程:响应式系统(reactive+Dep+watchEffect)+ 编译器 + 运行时渲染,极简版的代码可以帮你理解核心逻辑。

其实Vue3的双向绑定还有很多细节,比如ref和reactive的区别、computed和watch的实现、依赖收集的优化等等,今天先讲这么多,感兴趣的朋友可以自己去查资料深入学习,如果这篇文章对你有帮助,别忘了点赞收藏转发哦!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门