Vue2里的Observer是怎么让数据变响应式的?
在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
开始,逐层把 info
、age
都变成响应式的——确保嵌套对象的修改也能被检测到。
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(触发更新)。
举个流程例子,帮你更直观理解:
- 组件首次渲染时,会创建渲染Watcher,这个Watcher执行过程中,会访问到响应式数据(比如模板里的
{{ name }}
),触发name
属性的getter。 - getter里调用
dep.depend()
,把当前渲染Watcher加入到name
对应的Dep里(依赖收集完成)。 - 当代码中修改
this.name = '新值'
时,触发name
属性的setter,setter里调用dep.notify()
。 - 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 } }
——这时候 a
和 b
会不会被观测?
答案是:会!因为给 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能拦截更多操作(in
、deleteProperty
等),功能更强大。
所以Vue3用Proxy重构响应式系统后,不仅解决了Vue2里的诸多“坑”(比如对象新增属性自动响应),还提升了性能和开发体验,但Vue2的Observer作为经典实现,理解它的原理,能帮我们更深入掌握响应式的本质~
Vue2的Observer是响应式系统的“侦察兵”,通过Object.defineProperty把数据层层劫持,配合Dep和Watcher完成“依赖收集 - 触发更新”的闭环,虽然现在Vue3换了Proxy,但Observer的设计思路(数据劫持 + 依赖管理)依然是理解前端响应式的关键,实际开发里遇到的“数据不响应”问题,大多能从Observer的原理里找到答案——搞懂它,写Vue代码时才能更有底气,排查问题也更顺手~
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。