深入浅出Vue响应式原理,MVVM实现过程

本文详细介绍了Vue中响应式系统的实现原理,从2.x版本的Object.defineProperty到3.x版本的Proxy。通过实例解析了如何监听和响应数据变化,以及如何通过操作DOM实现数据绑定。文章还探讨了发布订阅模式在优化响应式处理中的应用,最后总结了MVVM模式的理解要点。
摘要由CSDN通过智能技术生成

   相信第一次接触Vue响应式的时候,不少同学会联想到之前学习css的时候响应式原理,即根据不同的设备、不同的宽度、不同的特性、对页面上内容的样式做出相应的调整,而Vue中的响应式并非是基于图像样式层面上的意义上的响应式,Vue中的响应式是一旦数据发生改变我们就立马通过监听器拦截知道,然后可以从中添加一些我们想要做的事,这些操作包括但是不限于发送一个网络请求,修改dom等等一系列操作。

在Vue当中分别使用了两种js方案去实现了数据响应式,分别是:

1.Vue(2.x版本)对象属性拦截 

Object.defineProperty

2.Vue(3.x版本)对象整体代理

Proxy

下面将主要以Object.defineProperty 实现来解析原理 3.0相对于2.0区别在于解决了响应式处理的性能无端消耗,采用了劫持对象整体方法+惰性处理(有点类似于懒加载用到了才处理)

常规的定义属性方法是无法触发响应式处理的 例如我们常用的字面量定义方式

let data = {
      name: '陈奕迅'
    }

我们在后台对数据进行了处理但是这样的定义方式,我们无法拦截到修改处理,也无法拦截到访问处理,因为我们无法通过字面量定义的方式拦截到属性的修改和访问,所以也无法基于拦截到的操作对其进行响应式处理。

这里我们就需要js的对象属性拦截方法拦截到我们的属性的访问和修改操作,从而再其中插入我们想要实现的功能

		Object.defineProperty(data, 'name', {
			// 当我们访问data的name属性的时候自动调用get方法
			// get方法的返回值就是你data.name拿到的值
			get() {
				console.log('你访问了data的name属性')
				return '陈奕迅'
			},
			// 当我们修改name属性的时候自动调用set方法
			// 并且属性最新的值会被当成实参即newValue传入进来
			set(newValue) {
				console.log('你修改了data的name属性最新的值为', newValue)
				// 这个位置 只要你修改了name属性就会得到执行
				// 所以如果你想要在name变化的时候 完成一些自己的事情
				// 都可以放到这里来执行
				// 1. ajax()
				// 2. 操作一块dom区域
			}
        })

 

通过Object.defineProperty初步实现了Vue的响应式机制原理基础,当然这样写还有不少问题

例如:get直接返回一个固定值,set拿到新的值后并没有进行操作

 修改了后访问name属性依旧没有发生改变 ,当然还有就是如果我们要拦截一个对象里面的多个属性值得时候又该怎么写呢?难道要每个属性都要写那么一大段来添加拦截吗?

我们之前是学习过对象的forEach方法可以遍历出对象的每一个属性的key以及value,key代表属性的属性名,value代表属性的属性值。通过遍历我们可以拿到一个对象下的多个属性的属性名和属性值, 上面我们用到的Object.defineProperty方法中的data则是对象名,name对应了遍历出来的key,get与set函数中可以去修改访问遍历出来的value的值,通过这种方法我们可以制作一个通用的拦截方案,来自动遍历获取key与value值来给对象的属性添加拦截,代码如下

  // 正常情况下我们定义的对象都会有多个属性  
  let data = {
      name: '张三',
      nickName: '法外狂徒',
    }
  // 遍历添加拦截的方法
    Object.keys(data).forEach((key) => {
      console.log(key, data[key])
      // key代表data对象的每一个属性名
      // 只要知道了data和key,所有的属性值我们都可以通过对象访问属性的方法知道
	  // 遍历中添加给对象绑定拦截的方法将对应的属性名属性值传过去
      defineReactive(data, key, data[key])
    })
    // 通用的属性拦截方法,通过遍历多次调用下列方法
    function defineReactive(data, key, value) {
      Object.defineProperty(data, key, {
        get() {
          console.log('您访问了属性', key)
          return value
        },
        set(newValue) {
          console.log('您修改了属性', key)
          value = newValue
        }
      })
    }

我们通过上面的方法完成了数据的变化的劫持,每当数据发生修改或查看数据的时候,我们都能从中添加方法,则我们可以实现M => V的一个过程,这便是数据绑定的基础,即数据的变化引起视图的变化,有上面的方法想要实现这一功能并不困难

实现v-text组件的过程

想要实现数据的变化引起视图的变化,并通过v-text的标识来识别并渲染,我们可以分为三步完成

第一步 监听数据变化我们已经实现

第二步 在数据变化后通过操作Dom的方式将数据变化响应到视图中

  上面的set方法是当每次数据发生修改的时候就会自动调用,要实现以上步奏我们只需要在set方法中添加一个操作Dom的方法将获取到的新的值实时渲染到Dom中即可

 document.querySelector('这里放要操作的dom元素').innerText = newValue

 第三步 识别指定的v-text标识将渲染结果渲染到指定标识的元素内

·  这一步我们需要用到一个Dom的属性childNodes 这个属性可以获得元素下的所有子节点,每一个子节点都有一个nodeType属性用来区别子节点到底是什么,为元素节点的时候为1,属性节点为2,文本节点为3,attributes可以获取到节点的所有属性即可以识别v-text标识,通过这个方法从而识别v-text标识来获取需要指定渲染的元素节点这里我们可以封装一个新的方法来专门查找获取指定标识下的元素节点并且识别其中的标识

	<div id="app">
		<p v-text="name"></p>
		<div v-text="nickName"></div>
	</div>
    //假设我们要获取以上Dom节点并渲染其中的v-text
<script>
       let data = {
          name: '张三',
          nickName: '法外狂徒',
        }
        Object.keys(data).forEach((key) => {
			defineReactive(data, key, data[key])
		})
		function defineReactive(data, key, value) {
			// 进行转换操作
			Object.defineProperty(data, key, {
				get() {
					console.log('您访问了属性', key)
					return value
				},
				set(newValue) {
					// set函数的执行 不会自动判断俩次修改的值是否相等
					// 显然如果相等 不应该执行变化的逻辑
					if (newValue === value) {
						return
					}
					console.log('您修改了属性', key)
					value = newValue
					// 这里我们把最新的值 反映到视图中  这里是关键的位置
					// 核心:操作dom  就是通过操作dom api 把最新的值设置上去
					findIdentification()
				}
			})
		}
		function findIdentification() {
			let app = document.getElementById('app')
			// 拿到所有子节点
			const childNodes = app.childNodes
			childNodes.forEach(node => {
				if (node.nodeType === 1) {
					const attrs = node.attributes
					console.log(attrs)
					Array.from(attrs).forEach(attr => {
						const nodeName = attr.nodeName
						const nodeValue = attr.nodeValue
						console.log(nodeName, nodeValue)
						// nodeName  =》 v-text  就是我们需要查找的标识
						// nodeValue =》 name    data中对应数据的标识的值
						// 把data中的数据 放到满足标识的dom上
						if (nodeName === 'v-text') {
							console.log('设置值', node)
							node.innerText = data[nodeValue]
						}
					})
				}
			})
		}
findIdentification()
</script>

并且在之前的数据监听访问中调用该方法即可实现v-text的编写,就可以实现M =》V的过程 视图绑定了数据的变化,每一次修改数据视图都会响应数据的变化而变化,同理v-html也是相同的方法,不过是将innerText,更改为innerHtml就可以实现了

Vue的最特殊常用的还要属v-model 即双向绑定 也是MVVM编程思想的基础原理 我们已经可以实现M=》V了 只需要再实现一个V =》 M那么数据的双向绑定就可以实现,在v-text的基础上要实现这看起来并不困难,只要实现Dom元素的变化监听拦截同时反映给数据就可以了

V-model的实现

我们在html中创建一个input输入框并在其中添加一个v-model的标识

<div id="app">
  <input v-model="name" />
</div>
		function findIdentification() {
			let app = document.getElementById('app')
			// 拿到所有节点
			const childNodes = app.childNodes // 所有类型的节点包括文本节点和标签节点
			childNodes.forEach(node => {
				if (node.nodeType === 1) {
					const attrs = node.attributes
					Array.from(attrs).forEach(attr => {
						const nodeName = attr.nodeName
						const nodeValue = attr.nodeValue
						// 实现v-text
						if (nodeName === 'v-text') {
							console.log(`当前您修改了属性${nodeValue}`)
							node.innerText = data[nodeValue]
						}
						// 实现v-model
						if (nodeName === 'v-model') {
							// input的value相当于 p等标签的innerText 这一步是将数据的值绑定给视图 即 M=》V
							node.value = data[nodeValue]
							// 监听input事件 在事件回调中 拿到最新的输入值 赋值给绑定的属性
							node.addEventListener('input', (e) => {
								let newValue = e.target.value
								// 反向赋值
								data[nodeValue] = newValue
							})
						}
					})
				}
			})
		}

这样做确实实现了数据的双向绑定,但是这里面有个浪费内存的问题,因为不论我们修改的那一个属性其他的属性也会跟着更新一遍,正常的逻辑应该是我们修改那一个属性就更新那一个属性,对于这里我们还有优化的空间 对其进行跟懒加载比较类似的优化节约内存

优化

我们思考一下,每次数据更新后我们实际上需要执行的代码到底是什么?

node.innerText = data[dataProp]

为了缓存node和data[dataProp]我们需要通过一个闭包函数将每一次执行时候的数据缓存下来,之前是每一次用的时候会重新创建,事件结束后会清除缓存的局部变量,这就好像开车的方向盘一样,直线的时候方向盘应该是一个全局变量,而不是像之前那样直线的时候就把方向盘下下来了,弯道的时候又吧方向盘上上去然后转弯,对于遍历的更新方法我们应该使用闭包的方式缓存起来,代码如下:

() => {
  node.innerText = data[dataProp]
}

我们可以将更新方法闭包了,当我们数据发生变化的时候就会调用闭包好的方法来实现更新,但是一个响应式数据可能会有多个视图部分都需要依赖,也就是响应式数据发生变化后,可能有多个视图需要发生改变,就像下图一个name属性有两个div使用了它,所以一个属性通常对应多个更新函数

<div id="app">
   <div v-text="name"></div>
   <div v-text="name"></div>
   <p v-text="nickName"></p>
   <p v-text="nickName"></p>
</div>

这里我们对于这种一对多得形式有一个新的方法理念需要理解即 发布订阅模式 这种模式其实就是一种一个对象一对多的依赖关系,当对象状态发生改变的时候,所有依赖与它的对象都会收到消息

我们可以通过浏览器事件来理解这个概念,正常的一个click事件是只能绑定到一个时间处理函数来执行的

    btn.onclick = function () {
      console.log('btn被点击了')
    }
	这种写法只能绑定一个回调函数
    btn.onclick = function(){
      console.log('我又被点击了')
    }

只会执行其中一个方法的回调,当我们需要一对多的时候则需要这个方法

 btn.addEventListener('click', () => {
      console.log('我被点击了')
    })
    btn.addEventListener('click', () => {
      console.log('我又被点击了')
    })

 利用addEventListener可以为一个触发方式调用多个回调函数来处理,我们的利用好这种方式见可以实现我们的优化效果,即修改了那个属性字段只修改绑定该属性字段表示的子元素的视图效果

完整代码如下

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div id="app">
    <p v-text="name"></p>
    <p v-text="name"></p>
    <span v-text="nickName"></span>
    <input type="text" v-model="nickName">
  </div>

  <script>
    // 引入发布订阅模式
    const Dep = {
      map: {},
      // 收集事件的方法
      collect(eventName, fn) {
        // 如果当前map中已经初始化好了 click:[]  
        // 就直接往里面push  如果没有初始化首次添加  就先进行初始化
        if (!this.map[eventName]) {
          this.map[eventName] = []
        }
        this.map[eventName].push(fn)
      },
      // 触发事件的方法
      trigger(eventName) {
        this.map[eventName].forEach(fn => fn())
      }
    }

    let data = {
      name: '张三',
      nickName: '法外狂徒'
    }
    // 把data中的属性变成响应式的
    Object.keys(data).forEach((key) => {
      defineReactive(data, key, data[key])
    })
    function defineReactive(data, key, value) {
      // 进行转换操作
      Object.defineProperty(data, key, {
        get() {

          return value
        },
        set(newValue) {
          // set函数的执行 不会自动判断俩次修改的值是否相等
          // 显然如果相等 不应该执行变化的逻辑
          if (newValue === value) {
            return
          }
          value = newValue
          // 这里我们把最新的值 反映到视图中  这里是关键的位置
          // 核心:操作dom  就是通过操作dom api 把最新的值设置上去
          // 在这里进行精准更新 -> 通过data中的属性名找到对应的更新函数依次执行
          Dep.trigger(key)
        }
      })
    }
    // 1.通过标识查找把数据放到对应的dom上显示出来
    function findIdentification() {
      let app = document.getElementById('app')
      // 拿到所有节点
      const childNodes = app.childNodes // 所有类型的节点包括文本节点和标签节点
      childNodes.forEach(node => {
        if (node.nodeType === 1) {
          const attrs = node.attributes
          Array.from(attrs).forEach(attr => {
            const nodeName = attr.nodeName
            const nodeValue = attr.nodeValue
            // 实现v-text
            if (nodeName === 'v-text') {
              node.innerText = data[nodeValue]
              // 收集更新函数
              Dep.collect(nodeValue, () => {
                console.log(`当前您修改了属性${nodeValue}`)
                node.innerText = data[nodeValue]
              })
            }
            // 实现v-model
            if (nodeName === 'v-model') {
              // 调用dom操作给input标签绑定数据
              node.value = data[nodeValue]
              // 收集更新函数
              Dep.collect(nodeValue,()=>{
                node.value = data[nodeValue]
              })
              // 监听input事件 在事件回调中 拿到最新的输入值 赋值给绑定的属性
              node.addEventListener('input', (e) => {
                let newValue = e.target.value
                // 反向赋值
                data[nodeValue] = newValue
              })
            }
          })
        }
      })
    }
    findIdentification()
    console.log(Dep)
  </script>

总结

  1.在Vue2.x的版本中,放置于data中的数据无论我们是否使用都会做响应式处理,所以在data中定义数据要尽可能的精简

  2.不论是插值表达式或者是标识,其实本质上都是需要将数据变化响应渲染到Dom中的标记罢了,通过标记再将对应的数据变化返回到对应的Dom中

  3.发布订阅的本质就是解决一对多的问题,可以精准的通过类似于key的名称确定到对应的该使用的更新函数从而减少不必要的更新操作

  4.MVVM 理解起来其实就是拆分为 M(模型)V(视图)M=》V 视图响应模型数据变化 V=》M 数据响应视图数据变化
 
笔者能力有限若有不对之处望指正,你的点赞评论就是我最大的更新动力

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值