前言

Vue2和Vue3的响应式原理一直是前端面试中的高频考点,如果你还只知道Vue2通过defineProperty方式实现,Vue3通过代理的方式实现,是不是就太浅显了。那本文带大家从源码去解读他们的实现,响应式实现主要分为三步:数据劫持、收集依赖、派发更新。

Vue2响应式原理

本小节涉及的完整代码github源码链接,这是简化过的源码,添加了注释方便阅读。


整个过程就像上面这张图一样,浏览器会触发’Touch’,这是浏览器在编译文件的过程中完成对所有的HTML中的{{}}、v-text、v-model等涉及响应式的依赖,对每个依赖new Watcher,作为后面的订阅者,因为响应式的目的就是自动完成更新这些订阅者。

  • 数据劫持:在数据劫持阶段将data中的数据添加响应式(对象会以递归的形式去添加)
  • 收集依赖:针对data中每个变量new Dep,在getter中 且 watcher 中依赖这个变量的时候去收集依赖,变量更改的时候触发setter去派发更新
  • 派发更新:watcher对象在创建过程会传入updata用到的cb方法,该方法会去更改Dom上

new Vue的过程中其实就已经完成了数据劫持和依赖收集,

/* vue.js */
class Vue {
constructor(options) {
// 获取到传入的对象 没有默认为空对象
this.$options = options || {}
// 获取 el
this.$el =
typeof options.el === 'string'
? document.querySelector(options.el)
: options.el
// 获取 data
this.$data = options.data || {}
// 调用 _proxyData 处理 data中的属性
this._proxyData(this.$data)
// 使用 Obsever 把data中的数据转为响应式 数据劫持、和收集依赖
new Observer(this.$data)
// 编译模板 `{{}}`、v-text、v-model等涉及响应式的依赖,对每个依赖`new Watcher`,作为后面的订阅者
new Compiler(this)
}
// 把data 中的属性注册到 Vue
_proxyData(data) {
Object.keys(data).forEach((key) => {
// 进行数据劫持
// 把每个data的属性 到添加到 Vue 转化为 getter setter方法
Object.defineProperty(this, key, {
// 设置可以枚举
enumerable: true,
// 设置可以配置
configurable: true,
// 获取数据
get() {
return data[key]
},
// 设置数据
set(newValue) {
// 判断新值和旧值是否相等
if (newValue === data[key]) return
// 设置新值
data[key] = newValue
},
})
})
}
}

数据劫持

当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 完成数据劫持: 把这些 property 全部转为 getter/setter。并且在getter时调用dep.js收集依赖,在setter中调用dep.js的notify方法更新所有依赖的watcher。
注意:对象会以递归的形式去添加响应式

/**
obsever.js 中是把 data 的所有属性 加到 data 自身 变为响应式 转成 getter setter方式
**/
/* observer.js */

class Observer {
constructor(data) {
// 用来遍历 data
this.walk(data)
}
// 遍历 data 转为响应式
walk(data) {
// 判断 data是否为空 和 对象
if (!data || typeof data !== 'object') return
// 遍历 data
Object.keys(data).forEach((key) => {
// 转为响应式
this.defineReactive(data, key, data[key])
})
}
// 转为响应式
// 要注意的 和vue.js 写的不同的是
// vue.js中是将 属性给了 Vue 转为 getter setter
// 这里是 将data中的属性转为getter setter
defineReactive(obj, key, value) {
// 如果是对象类型的 也调用walk 变成响应式,不是对象类型的直接在walk会被return
this.walk(value)
// 保存一下 this
const self = this
// 创建 Dep 对象
let dep = new Dep()
Object.defineProperty(obj, key, {
// 设置可枚举
enumerable: true,
// 设置可配置
configurable: true,

// 获取值
get() {
// 在这里添加观察者对象 Dep.target 表示观察者
Dep.target && dep.addSub(Dep.target)
return value
},
// 设置值
set(newValue) {
// 判断旧值和新值是否相等
if (newValue === value) return
// 设置新值
value = newValue
// 赋值的话如果是newValue是对象,对象里面的属性也应该设置为响应式的
self.walk(newValue)
// 触发通知 更新视图
dep.notify()
},
})
}
}

收集依赖

data中每个响应式属性都会new Dep,在getter中 且 watcher 中依赖这个变量的时候去收集依赖,变量更改的时候触发setter去派发更新

/**
每个响应式属性都会创建这样一个 Dep 对象 ,负责收集该依赖属性的Watcher对象
(是在使用响应式数据的时候做的操作)
**/
/* dep.js */
class Dep {
constructor() {
// 存储观察者
this.subs = []
}
// 添加观察者
addSub(sub) {
// 判断观察者是否存在 和 是否拥有update方法
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 通知方法
notify() {
// 触发每个观察者的更新方法
this.subs.forEach((sub) => {
sub.update()
})
}
}

派发更新

组件在解析 {{}}、v-text、v-model等和依赖相关的内容,每个需要响应式的位置都会创建watcher 实例。派发更新:数据更新过后首先触发setter,接着触发dep的notify方法,最后触发watcher的update方法。

Vue 在更新 DOM 时是异步执行的,只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。在下一个的事件循环“tick”中,Vue 在内部对异步队列尝试使用原生的 Promise.then setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。
如果要立即访问更新后的数据,直接访问显示是未更新的数据,因为异步任务挂着呢,没执行到
Vue.nextTick(callback),这样回调函数将在 DOM 更新完成后被调用

// 数据更新后 收到通知之后 调用 update 进行更新
// watcher实例化在compiler文件中 就是编译器中 比如{{}}和v-text和v-model中
/* watcher.js */

class Watcher {
constructor(vm, key, cb) {
// vm 是 Vue 实例
this.vm = vm
// key 是 data 中的属性
this.key = key
// cb 回调函数 更新视图的具体方法
this.cb = cb
// 把观察者的存放在 Dep.target
Dep.target = this
// 旧数据 更新视图的时候要进行比较
// 还有一点就是 vm[key] 这个时候就触发了 get 方法
// 之前在 get 把 观察者 通过dep.addSub(Dep.target) 添加到了 dep.subs中
this.oldValue = vm[key]
// Dep.target 就不用存在了 因为上面的操作已经存好了
Dep.target = null
}
// 观察者中的必备方法 用来更新视图
update() {
// 获取新值
let newValue = this.vm[this.key]
// 比较旧值和新值
if (newValue === this.oldValue) return
// 调用具体的更新方法
this.cb(newValue)
}
}

问题

对象增删属性检测不到

只能检查到data函数中声明的对象中的所有property,无法检测到添加或移除property;
解决办法:

  1. 实例前在data中声明,为null也行。
  2. 使用this.$delete或Vue.delete进行删除属性。

数组检测问题

Vue2响应式不能检测以下数组的变动:

  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength
    解决办法:1.使用this.$set()或Vue.set();2.使用splice

为什么对象能检测到属性变化,而数组检测不到

本质上Object.defineProperty 也能监听数组变化,但是Vue没采用这个去检测数组,因为要监听数组中的每个元素性能开销大,且使用场景太少。

为什么数组的push和pop等方法会产生响应式?

本来数组的一些方法比如push,pop是不会触发getter/setter的。不会触发的原因是因为这是Array原型上的方法,并没有在Array本身上面。但是vue重写了数组原型上的7个方法,就有了响应式。重写的过程使用拦截器实现,就是和 Array.prototype 一样的对象。

Vue2响应式设计模式的体现

观察者模式:dep.js就是观察者模式,监听到改变就notify所有的观察者
发布订阅模式:dep.js扮演消息中心的角色,observer.js扮演观察者(发布者),watcher.js扮演订阅者

Vue3响应式原理

本小节涉及的完整代码github源码链接,这是简化过的源码,添加了注释方便阅读。

vue3没有vue2那些问题,对象中增删改都可以检测到

Vue3的响应式实现可分为两种:
ref:以ref为代表的基础数据类型的响应式,使用 get/set 存取器实现
reactive:以reactive为代表的引用数据类型的响应式,使用Proxy配合Reflect实现的响应式
其实还以分为更多比如toReftoRefsshallowReactiveshallowRef本文就不展开讨论了

下面主要讲reactive响应式的实现,ref响应式的实现见第四小节 get/set 存取器相关内容


这张图用于辅助理解下面的数据劫持、收集依赖、派发更新理解。

数据劫持

使用Proxy代理的方式实现数据劫持,与Vue2中一样,属性存在引用数据类型会触发递归,在getter中调用track方法收集依赖,trigger方法派发更新。

// 判断是否为对象 ,注意 null 也是对象
const isObject = val => val !== null && typeof val === 'object'
// 判断key是否存在
const hasOwn = (target, key) => Object.prototype.hasOwnProperty.call(target, key)

function reactive(target) {
// 首先先判断是否为对象
if (!isObject(target)) return target

const handler = {
get(target, key, receiver) {
console.log(`获取对象属性${key}值`)
// 收集依赖
track(target, key)

const result = Reflect.get(target, key, receiver)
// 递归判断的关键, 如果发现子元素存在引用类型,递归处理。
if (isObject(result)) {
return reactive(result)
}
return result
},

set(target, key, value, receiver) {
console.log(`设置对象属性${key}值`)

// 首先先获取旧值
const oldValue = Reflect.get(target, key, reactive)

// set 是需要返回 布尔值的
let result = true
// 判断新值和旧值是否一样来决定是否更新setter
if (oldValue !== value) {
result = Reflect.set(target, key, value, receiver)
// 派发更新
trigger(target, key)
}
return result
},

deleteProperty(target, key) {
console.log(`删除对象属性${key}值`)

// 先判断是否有key
const hadKey = hasOwn(target, key)
const result = Reflect.deleteProperty(target, key)

if (hadKey && result) {
// 派发更新
target(target, key)
}
return result
},
}
return new Proxy(target, handler)
}

收集依赖

结合下图不难理解,在track中完成依赖的收集的过程是,先找targetMap,再找depsMap,最后找actieEffect,没有则创建Map或者添加effect。

  • targetMap:key为响应式对象的引用,value为depsMap
  • depsMap:key响应式对象的属性,value为set类型表示该属性的所有依赖
  • actieEffect:表示触发更新的回调就像Vue2的dp函数

// activeEffect 表示当前正在走的 effect
let actieEffect = null
function effect(callback) {
actieEffect = callback
callback()
actieEffect = null
}

// targetMap 表里每个key都是一个普通对象 对应他们的 depsMap
let targetMap = new WeakMap()

function track(target, key) {
// 如果当前没有effect就不执行追踪
if (!actieEffect) return
// 获取当前对象的依赖图
let depsMap = targetMap.get(target)
// 不存在就新建
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
// 根据key 从 依赖图 里获取到到 effect 集合
let dep = depsMap.get(key)
// 不存在就新建
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
// 如果当前effectc 不存在,才注册到 dep里
if (!dep.has(actieEffect)) {
dep.add(actieEffect)
}
}

派发更新

派发更新就是去调用触发更新的所有依赖。通过target找到是哪个对象需要更新,再通过key找到是哪个属性需要更新,最后调用该属性的所有依赖的effect更新。

// trigger 响应式触发
function trigger(target, key) {
// 拿到 依赖图
const depsMap = targetMap.get(target)
if (!depsMap) {
// 没有被追踪,直接 return
return
}
// 拿到了 视图渲染effect 就可以进行排队更新 effect 了
const dep = depsMap.get(key)

// 遍历 dep 集合执行里面 effect 副作用方法
if (dep) {
dep.forEach(effect => {
effect()
})
}
}

问题

直通过索引设置可能隐式导致length问题

当通过索引设置响应式数组的时候,有可能会隐式修改数组的 length 属性,例如设置的索引值大于数组当前的长度时,那么就要更新数组的 length 属性,因此在触发当前的修改属性的响应之外,也需要触发与 length 属性相关依赖进行重新执行。
所以我们要尽量避免这个问题:

  1. 不要去直接修改响应式数组的length属性
  2. 通过数组索引修改响应式数组时,不要将数组索引大于数组的length属性

ref和reactive的响应式区别

  • ref:把一个基础类型包装成一个有value响应式对象(使用get/set 存取器,来进行追踪和触发),如果是普通对象就调用 reactive 来创建响应式对象。
  • reactive:返回proxy对象,这个reactive可以深层次递归,如果发现子元素存在引用类型,递归reactive处理

Object.definePropertyget/set 存取器的区别

Object.defineProperty 是一个较低级别的操作,它只能用于单个属性,并且需要显式地定义每个属性的描述符。这在大量属性定义时可能会显得冗长和繁琐。因此,在 ES6 之后,通常更推荐使用get/set 存取器来创建访问器属性

Object.defineProperty实现响应式

const obj = {};
let _value = 0;

Object.defineProperty(obj, 'value', {
get() {
return _value;
},
set(newValue) {
_value = newValue;
},
enumerable: true,
configurable: true
});

console.log(obj.value); // 调用 get 方法,输出: 0
obj.value = 10; // 调用 set 方法,将 _value 设置为 10
console.log(obj.value); // 调用 get 方法,输出: 10

get/set 存取器实现的响应式

const obj = {
_value: 0,
get value() {
return this._value;
},
set value(newValue) {
this._value = newValue;
}
};

console.log(obj.value); // 调用 get 方法,输出: 0
obj.value = 10; // 调用 set 方法,将 _value 设置为 10
console.log(obj.value); // 调用 get 方法,输出: 10

为什么要使用Reflect(反射)

Reflect是ES6出现的新特性,代码运行期间用来设置或获取对象成员,代替原始的操作,更加安全、语义化;
Object.getPrototypeOf => Reflect.getPrototypeOf
target[propName] => Reflect.get(target,propName)
target[propName] = value => Reflect.set(target,propName,value) 返回true和false
delete target[propName] => Reflect.deleteProperty(target,propName) 返回true和false 表示执行成功还是失败

总结

Vue2的响应式实现可分为三步:

  • 数据劫持:在数据劫持阶段将data中的数据添加响应式(对象会以递归的形式去添加)
  • 收集依赖:针对data中每个变量new Dep,在getter中 且 watcher 中依赖这个变量的时候去收集依赖,变量更改的时候触发setter去派发更新
  • 派发更新:watcher对象在创建过程会传入updata用到的cb方法,该方法会去更改Dom上

Vue2响应式还存在问题:对象增删属性检、数组使用index修改和直接修改length不会触发响应式


Vue3的响应式实现分为两种:

  • 基础数据类型的响应式,使用 get/set 存取器实现
  • 引用数据类型的响应式,使用Proxy配合Reflect实现的响应式,实现过程中也可分为数据劫持、收集依赖、派发更新三步去实现

Vue3响应式还存在问题:直接修改length、将数组索引大于数组的length不会触发响应式


感谢小伙伴们的耐心观看,本文为笔者个人学习记录,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力!