源码上分析Vue2和Vue3的响应式原理

前言

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的过程中其实就已经完成了数据劫持和依赖收集,

JS 复制代码
/* 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。 注意:对象会以递归的形式去添加响应式

JS 复制代码
/**
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去派发更新

JS 复制代码
/**
每个响应式属性都会创建这样一个 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,接着触发depnotify方法,最后触发watcher的update方法。

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

JS 复制代码
// 数据更新后 收到通知之后 调用 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方法派发更新。

JS 复制代码
// 判断是否为对象 ,注意 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函数
JS 复制代码
// 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更新。

JS 复制代码
// 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实现响应式

JS 复制代码
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 存取器实现的响应式

JS 复制代码
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不会触发响应式


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

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax