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

news2024/11/16 15:43:16

本文节选自我的博客:源码上分析Vue2和Vue3的响应式原理

  • 💖 作者简介:大家好,我是MilesChen,偏前端的全栈开发者。
  • 📝 CSDN主页:爱吃糖的猫🔥
  • 📣 我的博客:爱吃糖的猫
  • 📚 Github主页: MilesChen
  • 🎉 支持我:点赞👍+收藏⭐️+留言📝
  • 💬介绍:The mixture of WEB+DeepLearning+Iot+anything🍁

前言

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,接着触发depnotify方法,最后触发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不会触发响应式


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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1063602.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

基于帝国主义竞争优化的BP神经网络(分类应用) - 附代码

基于帝国主义竞争优化的BP神经网络(分类应用) - 附代码 文章目录 基于帝国主义竞争优化的BP神经网络(分类应用) - 附代码1.鸢尾花iris数据介绍2.数据集整理3.帝国主义竞争优化BP神经网络3.1 BP神经网络参数设置3.2 帝国主义竞争算…

【BI看板】Superset2.0+图表二次开发初探

Superset图表功能也很丰富了,但一些个性化的定制需求就需要二次开发了。网上二开的superset版本大多是0.xxx版本的或1.5xxx版本,本次用的是2.xxx。 源码相关说明 源码目录 superset-2.0\superset-frontend\plugins\plugin-chart-echarts 插件相关资料 官…

图片批量编辑器,轻松拼接多张图片,创意无限!

你是否曾经遇到这样的问题:需要将多张图片拼接成一张完整的画面,却缺乏专业的图片编辑技能?现在,我们为你带来一款强大的图片批量编辑器——让你轻松实现多张图片拼接,创意无限! 这款图片批量编辑器可以帮助…

IP地址划分知识点总结

目录 1.IP数据报头 2.IP地址 3.IP地址分类 4.特殊IP地址 1.IP数据报头 网络之间的互连协议(Internet Protocol,IP)是方便计算机网络系统之间相互通信的协议,是各大厂家遵循的计算机网络相互通信的规则。 IP数据报报头如下图所示: (1)版…

计算机竞赛 题目:基于python的验证码识别 - 机器视觉 验证码识别

文章目录 0 前言1 项目简介2 验证码识别步骤2.1 灰度处理&二值化2.2 去除边框2.3 图像降噪2.4 字符切割2.5 识别 3 基于tensorflow的验证码识别3.1 数据集3.2 基于tf的神经网络训练代码 4 最后 0 前言 🔥 优质竞赛项目系列,今天要分享的是 基于pyt…

蓝桥杯---第二讲---二分与前缀和

文章目录 前言Ⅰ. 数的范围0x00 算法思路0x00 代码书写 Ⅱ. 数的三次方根0x00 算法思路0x01代码书写 Ⅲ. 前缀和0x00 算法思路0x01 代码书写 Ⅳ. 子矩阵的和0x00 算法思路0x01 代码书写 Ⅴ. 机器人跳跃问题0x00 算法思路0x01 代码书写 Ⅵ. 四平方和0x00 算法思路0x01 代码书写 …

10.3 C++运算符重载实现的过程,代码

目录 运算符重载背景(operator) 定义 重载的方法 不能重载的运算符 运算符重载注意事项 代码实现 运行结果 运算符重载背景(operator) 自定义的类中,系统默认只提供两个运算符供用户使用,分别是赋值…

1.3.OpenCV技能树--第一单元--图像的基础操作(进阶篇)

目录 1.文章内容来源 2.图像的进阶操作 2.1.边界填充 2.2.数值计算 2.3.图像融合 2.4.图像保存 2.5.视频读取 3.课后习题代码复现 3.1.问题一图像像素颜色 3.2.问题二图片黑客帝国化 3.3.问题三梅西的足球轨迹 4.易错点总结与反思 1.文章内容来源 1.题目来源:https://edu.c…

netcore MediatR

一、安装包 <PackageReference Include"MediatR" Version"12.1.1" /> 二、编写示例 using MediatR;namespace WebApplication7 {public class TestCommand : IRequest<bool>{}public class TestCommandHandler : IRequestHandler<TestCo…

Python为Excel中每一个单元格计算其在多个文件中的平均值

本文介绍基于Python语言&#xff0c;对大量不同的Excel文件加以跨文件、逐单元格平均值计算的方法。 首先&#xff0c;我们来明确一下本文的具体需求。现有一个文件夹&#xff0c;其中有如下所示的大量Excel文件&#xff0c;我们这里就以.csv文件为例来介绍。其中&#xff0c;每…

Linux友人帐之账号用户管理

一、账号管理 1.1简介 Linux系统是一个多用户多任务的分时操作系统&#xff0c;任何一个要使用系统资源的用户&#xff0c;都必须首先向系统管理员申请一个账号&#xff0c;然后以这个账号的身份进入系统。 用户的账号一方面可以帮助系统管理员对使用系统的用户进行跟踪&#…

不死马的利用与克制(基于条件竞争)及变种不死马

不死马即内存马&#xff0c;它会写进进程里&#xff0c;并且无限地在指定目录中生成木马文件 这里以PHP不死马为例 测试代码&#xff1a; <?phpignore_user_abort(true);set_time_limit(0);unlink(__FILE__);$file .test.php;$code <?php if(md5($_GET["pass…

理解自动驾驶感知技术

理解自动驾驶感知技术 文章目录 什么是自动驾驶感知技术&#xff1f;自动驾驶感知技术的关键组成部分1. 雷达&#xff08;Radar&#xff09;2. 摄像头&#xff08;Camera&#xff09;3. 激光雷达&#xff08;Lidar&#xff09;4. 超声波传感器&#xff08;Ultrasonic Sensors&a…

一文搞懂APT攻击

APT攻击 1. 基本概念2. APT的攻击阶段3. APT的典型案例参考 1. 基本概念 高级持续性威胁&#xff08;APT&#xff0c;Advanced Persistent Threat&#xff09;&#xff0c;又叫高级长期威胁&#xff0c;是一种复杂的、持续的网络攻击&#xff0c;包含高级、长期、威胁三个要素…

法国乐天下单支付流程,自养号测评技术环境揭秘。

Rakuten的前身是PriceMinister一家法国公司&#xff0c;经营电子商务网站PriceMinister&#xff0c;按访问量计算&#xff0c;该网站是法国第五大电子商务网站。2010年&#xff0c;它被乐天公司收购&#xff0c;2018年&#xff0c;它更名为Rakuten。乐天法国Rakuten France&…

蓝桥等考Python组别十四级002

第一部分&#xff1a;选择题 1、Python L14 &#xff08;15分&#xff09; 运行下面程序&#xff0c;输出的结果是&#xff08; &#xff09;。 d {A: 11, B: 12, C: 13, D: 14} print(d[B]) 11121314 正确答案&#xff1a;B 2、Python L14 &#xff08;15分&#x…

蓝桥等考Python组别十四级003

第一部分&#xff1a;选择题 1、Python L14 &#xff08;15分&#xff09; 运行下面程序&#xff0c;输出的结果是&#xff08; &#xff09;。 d {A: 1, B: 2, C: 3, D: 4} print(d[B]) 1234 正确答案&#xff1a;B 2、Python L14 &#xff08;15分&#xff09; 运…

idea多项目复合启动Compound

1、配置多项目同时启动 2、给每个项目分配最大使用内存&#xff08;非必要&#xff0c;内存大的可以不设置&#xff09;

SpringBoot自带模板引擎Thymeleaf使用详解①

目录 前言 一、SpringBoot静态资源相关目录 二、变量输出 2.1 在templates目录下创建视图index.html 2.2 创建对应的Controller 2.3 在视图展示model中的值 三、操作字符串和时间 3.1 操作字符串 3.2 操作时间 前言 Thymeleaf是一款用于渲染XML/HTML5内容的模板引擎&am…

基于Java的企业人事管理系统设计与实现(源码+lw+ppt+部署文档+视频讲解等)

文章目录 前言具体实现截图论文参考详细视频演示为什么选择我自己的网站自己的小程序&#xff08;小蔡coding&#xff09;有保障的售后福利 代码参考源码获取 前言 &#x1f497;博主介绍&#xff1a;✌全网粉丝10W,CSDN特邀作者、博客专家、CSDN新星计划导师、全栈领域优质创作…