源码上分析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不会触发响应式


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

相关推荐
一条晒干的咸魚几秒前
【Web前端】创建我的第一个 Web 表单
服务器·前端·javascript·json·对象·表单
Amd79415 分钟前
Nuxt.js 应用中的 webpack:compiled 事件钩子
前端·webpack·开发·编译·nuxt.js·事件·钩子
生椰拿铁You24 分钟前
09 —— Webpack搭建开发环境
前端·webpack·node.js
狸克先生35 分钟前
如何用AI写小说(二):Gradio 超简单的网页前端交互
前端·人工智能·chatgpt·交互
baiduopenmap1 小时前
百度世界2024精选公开课:基于地图智能体的导航出行AI应用创新实践
前端·人工智能·百度地图
loooseFish1 小时前
小程序webview我爱死你了 小程序webview和H5通讯
前端
菜牙买菜1 小时前
让安卓也能玩出Element-Plus的表格效果
前端
请叫我欧皇i1 小时前
html本地离线引入vant和vue2(详细步骤)
开发语言·前端·javascript
533_1 小时前
[vue] 深拷贝 lodash cloneDeep
前端·javascript·vue.js
guokanglun1 小时前
空间数据存储格式GeoJSON
前端