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

Vue2里的Observer是怎么让数据变响应式的?

terry 2个月前 (06-07) 阅读数 77 #Vue
文章标签 Vue2Observer

在Vue2开发里,你肯定遇到过“数据变了页面自动更新”这种神奇效果,背后离不开响应式系统的核心——Observer,可Observer到底是干啥的?它怎么让普通数据变成“响应式”的?数组和对象的处理为啥不一样?这些问题弄懂了,才能更顺溜地写Vue代码、排查 Bug,今天就用问答的方式,把Vue2 Observer的门道扒清楚~

Observer到底是什么?在Vue2响应式里扮演啥角色?

要理解Observer,得先想“数据劫持”这件事,Vue2要让数据变化时自动更新视图,得先把普通对象、数组“盯”起来——Observer就是负责把数据变成“被观测状态”的工具类,它的核心逻辑是用 Object.defineProperty 遍历对象的属性,给每个属性加上 getter 和 setter;这样每次访问(get)或修改(set)属性时,Vue就能感知到,进而触发后续的更新操作。

举个例子:你定义了 data() { return { name: '小明' } },Observer会把 name 这个属性“劫持”,当你在代码里改 this.name = '小红' 时,setter会被触发,Vue就知道要更新对应的视图了,要是没有Observer,数据改了Vue根本没法察觉,自然也做不到“响应式”。

而且Observer不光处理第一层对象,还会递归遍历对象的子属性data 里有 user: { info: { age: 18 } },Observer会从 user 开始,逐层把 infoage 都变成响应式的——确保嵌套对象的修改也能被检测到。

Observer是怎么“劫持”数据的?核心流程有哪些步骤?

这得拆成几个关键动作来讲:

初始化观测:当Vue实例创建时,会把 data 里的对象传给Observer,Observer内部会先判断数据类型——如果是对象(非null),就进入遍历;如果是数组,走特殊的数组观测逻辑(后面单独讲数组)。

然后是遍历对象属性:对普通对象,Observer会用 Object.keys 拿到所有自身可枚举属性,逐个处理,处理每个属性时,会调用 defineReactive 方法。

重点看 defineReactive:这个方法是“数据劫持”的直接实现,它用 Object.defineProperty 重写属性的getter和setter:

  • getter:当代码中访问这个属性({{ name }} 渲染到页面),getter会触发依赖收集(后面讲Dep和Watcher时详细说),把当前的Watcher(观察者)收集到Dep(依赖容器)里。
  • setter:当属性被修改时(this.name = '新值'),setter会通知Dep里的所有Watcher:“我变了!你们该更新了~”,Watcher收到通知后就会触发视图更新或者执行用户定义的回调。

还要递归观测子对象:如果当前属性的值还是对象(user: { info: {} } 里的 info),Observer会递归创建新的Observer实例,把 info 也变成响应式的,这样层层嵌套的对象,每个属性修改都能被捕获。

举个简化的代码逻辑帮你理解(非Vue源码,只看核心思路):

function Observer(data) {
  this.walk(data); // 遍历对象,给每个属性做响应式
}
Observer.prototype.walk = function(data) {
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key]);
  });
};
function defineReactive(obj, key, val) {
  // 递归观测子对象,val 是 { info: {} } 这种结构
  new Observer(val); 
  Object.defineProperty(obj, key, {
    get() {
      // 收集依赖:把当前Watcher和Dep关联起来
      dep.depend();
      return val;
    },
    set(newVal) {
      if (newVal === val) return; // 值没变化,不做操作
      val = newVal;
      // 新值如果是对象,继续递归观测
      new Observer(newVal); 
      // 通知所有依赖该属性的Watcher更新
      dep.notify();
    }
  });
}

Vue源码里的逻辑更复杂(要处理Dep、Watcher的关联,避免循环引用等),但核心思路就是通过递归 + Object.defineProperty 实现属性的 getter/setter 拦截,让数据的读写都“暴露”给Vue监控。

数组的观测为啥和对象不一样?Vue2是怎么处理数组的?

这是很多人踩坑的点!因为数组不能像对象一样,给每个索引都加getter/setter(性能太差,而且数组长度可能很大),所以Vue2对数组做了“特殊处理”:

Vue2拦截了数组的七个变异方法push、pop、shift、unshift、splice、sort、reverse,这些方法的特点是会改变原数组,Vue会重写这些方法的逻辑,在执行原数组操作的同时,触发响应式更新。

具体怎么做的?Vue在原型链上做了手脚:创建一个 arrayMethods 对象,把这七个方法重新定义,然后让响应式数组的 __proto__ 指向 arrayMethods,这样当调用数组的 push 等方法时,实际执行的是重写后的逻辑——在方法内部,除了执行数组本身的操作,还会触发 dep.notify() 来通知Watcher更新。

举个例子:你有个响应式数组 this.list = [1,2,3],当执行 this.list.push(4) 时,调用的是重写后的 push 方法,这个方法里,先执行原生的push逻辑(把4加到数组里),然后找到这个数组对应的Dep,触发通知,让依赖这个数组的Watcher去更新视图。

但要注意:数组的索引修改、长度修改是监听不到的this.list[0] = 10 或者 this.list.length = 0,这种操作不会触发响应式更新,因为给每个索引加getter/setter性能太低,Vue2没这么做,这也是为什么官方推荐用 $set 来处理数组元素修改(后面讲开发坑的时候细说)。

数组里的对象元素还是会被Observer观测的。list: [{name: 'a'}, {name: 'b'}],数组本身是被特殊处理的,但里面的每个对象元素,依然会被递归劫持属性——所以如果修改 this.list[0].name = 'newA',是能触发更新的,因为对象的 name 属性的setter被劫持了。

Observer和Dep、Watcher有啥关系?三者怎么配合实现响应式?

这得把Vue响应式的“依赖收集 - 触发更新”流程讲清楚,Observer负责劫持数据,Dep负责管理依赖,Watcher负责执行更新,三者是协作关系:

  • Dep(依赖容器):每个响应式属性都有自己的Dep实例,可以理解为“这个属性的所有观察者都存在我这儿”,Dep里有 depend()(收集Watcher)和 notify()(通知Watcher更新)两个核心方法。
  • Watcher(观察者):可以理解为“谁关心这个数据的变化,谁就是Watcher”,比如组件渲染时,会创建一个渲染Watcher;用户写的 watch 选项,会创建用户Watcher,Watcher内部有个 update() 方法,负责当数据变化时执行更新(比如重新渲染组件、执行watch回调)。
  • Observer:通过劫持属性的getter/setter,在getter里调用 dep.depend() 把当前Watcher加到Dep里(依赖收集);在setter里调用 dep.notify() 通知所有Watcher执行update(触发更新)。

举个流程例子,帮你更直观理解:

  1. 组件首次渲染时,会创建渲染Watcher,这个Watcher执行过程中,会访问到响应式数据(比如模板里的 {{ name }}),触发 name 属性的getter。
  2. getter里调用 dep.depend(),把当前渲染Watcher加入到 name 对应的Dep里(依赖收集完成)。
  3. 当代码中修改 this.name = '新值' 时,触发 name 属性的setter,setter里调用 dep.notify()
  4. Dep里的所有Watcher(这里是渲染Watcher)收到通知,执行 update() 方法,触发组件重新渲染,页面上的 name 就更新了。

再延伸到用户Watcher(watch: { name(newVal) { ... } }):当创建这个watch时,会生成一个用户Watcher,它会主动去访问 name 属性(触发getter,把自己加入Dep),当 name 变化时,Dep通知这个用户Watcher,执行对应的回调函数。

实际开发中,Observer相关的坑有哪些?怎么解决?

理解了Observer的原理,就能明白为啥会遇到这些“数据改了页面没反应”的坑,以及怎么绕开:

坑1:对象新增属性不响应

data() { return { user: { name: '小明' } } },之后执行 this.user.age = 18——页面上如果用了 age,不会更新,因为Observer是在初始化时劫持已有的属性,新增的属性没有被defineReactive处理,没有getter/setter

解决方法:用 this.$set(目标对象, 新增属性, 值)$set 内部会手动给新增属性做响应式处理(调用defineReactive),同时触发更新。

坑2:数组直接改索引/长度不响应

this.list[0] = 10 或者 this.list.length = 0——这些操作不会触发响应式更新,因为数组的索引和length没有被劫持。

解决方法:

  • 改索引用 this.$set(this.list, 0, 10)
  • 改长度可以用 splice(因为splice是被重写的变异方法),this.list.splice(0) 清空数组。

坑3:深层次对象嵌套,初始化时没处理到?

data 里有个空对象,后续赋值成嵌套结构:data() { return { info: {} } }this.info = { a: { b: 1 } }——这时候 ab 会不会被观测?

答案是:会!因为给 info 赋值新对象时,setter会被触发,Observer会递归观测新对象的所有属性,但如果是 this.info.a = { b: 1 }(给已存在的对象新增子属性),这时候 a 是新增属性,需要用 $set 处理 a,否则 a 的子属性 b 虽然会被观测,但 a 本身的修改不会触发更新(因为 a 没有getter/setter)。

Vue3为啥不用Observer,换成Proxy了?Object.defineProperty和Proxy有啥区别?

Vue3响应式改用Proxy,正是因为Observer基于的 Object.defineProperty 有先天不足,而Proxy能解决这些问题:

区别1:对象新增属性的处理

Object.defineProperty 只能劫持已存在的属性,新增属性必须手动用 $set,但Proxy可以拦截 obj.newKey = value 这样的操作(通过 set(target, key, value, receiver) 钩子),天然支持对象新增属性的响应式。

区别2:数组的处理

Object.defineProperty 对数组索引劫持性能差,所以Vue2只能特殊处理七个变异方法;Proxy可以直接拦截数组的 push、索引修改 等操作(通过 set 钩子监测数组索引变化),不需要像Vue2那样重写数组方法,逻辑更简洁,也能监听更多数组操作。

区别3:性能和灵活性

Object.defineProperty 需要遍历对象的每个属性(包括递归子对象),Proxy是代理整个对象,不需要提前遍历(访问时才处理),对于大对象性能更友好,而且Proxy能拦截更多操作(indeleteProperty 等),功能更强大。

所以Vue3用Proxy重构响应式系统后,不仅解决了Vue2里的诸多“坑”(比如对象新增属性自动响应),还提升了性能和开发体验,但Vue2的Observer作为经典实现,理解它的原理,能帮我们更深入掌握响应式的本质~

Vue2的Observer是响应式系统的“侦察兵”,通过Object.defineProperty把数据层层劫持,配合Dep和Watcher完成“依赖收集 - 触发更新”的闭环,虽然现在Vue3换了Proxy,但Observer的设计思路(数据劫持 + 依赖管理)依然是理解前端响应式的关键,实际开发里遇到的“数据不响应”问题,大多能从Observer的原理里找到答案——搞懂它,写Vue代码时才能更有底气,排查问题也更顺手~

版权声明

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

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门