一篇文章带你彻底搞懂VUE响应式原理

2022-10-14,,

这篇文章主要介绍了一篇文章带你彻底搞懂VUE响应式原理,文章围绕主题展开详细的内容介绍,具有一定的参考价值,需要的小伙伴可任意参考一下,需要的朋友可以参考下

目录
  • 响应式原理图
  • 编译
    • 创建compile类
    • 操作fragment
    • 获取元素节点上的信息
    • 获取文本节点信息
    • 操作fragment
  • 响应式
    • 数据劫持
    • 收集依赖
  • 响应式代码完善
    • Dep类
    • 全局watcher用完清空
    • 依赖的update方法
    • 需要注意的一个地方
  • 双剑合璧
    • 总结

      首先上图,下面这张图,即为MVVM响应式原理的整个过程图,我们本篇都是围绕着这张图进行分析,所以这张图是重中之重。

      响应式原理图

       一脸懵逼?没关系,接下来我们将通过创建一个简单的MVVM响应系统来一步步了解这个上图中的全过程。全文分为两大块,首先介绍实例模板的编译过程,然后详细介绍响应式,这里先介绍编译是为了给介绍响应式奠定基础。

      编译

      我们把我们创建的这个微型响应系统命名为miniVue,我们按照平常使用Vue的模式,首先创建一个miniVue的实例。

      <scirpt>
          const vm = new miniVue({
              el: '#app',
              data: {
                  obj: {
                      name: "miniVue",
                      auth: 'xxx'
                  },
                  msg: "this is miniVue",
                  htmlStr: "<h3>this is htmlStr</h3>"
              },
              methods: {
                  handleClick() {
                      console.log(this);
                  }
              }
          });
      </scirpt>
      

      我们根据这个实例,我们可以创建出miniVue的类,这个类中我们肯定要保存该实例所绑定的DOM以及数据对象data。然后我们要开始解析模板,即解析我们所绑定的DOM

      class miniVue {
          constructor(options) {
              this.$el = options.el
              this.$data = options.data
              this.$options = options
          }
          if(this.$el) {
              // 解析模板 to Compile
          }
      }
      

      这里我们来创建一个compile类来进行解析模板的操作

      创建compile类

      Compile类是用来解析模板的,所以肯定要传入要解析的DOM。拿到DOM后直接操作这个DOM会导致页面频繁的回流和重绘,所以我们把这个DOM放到一个文档碎片中,然后操作这个文档碎片。操作这个文档碎片的过程中我们需要获取到数据对象data中的属性来填充一些节点的内容,所以我们还需要传入实例对象。最后将操作好的文档碎片追加到原本的DOM上。

      class Compile {
          constructor(el, vm) {
              // 判断的原因是因为传入的el有可能是DOM,也有可能是选择器例如‘#app'
              this.el = this.isElementNode(el) ? el : document.querySelector(el)
              this.vm = vm
              // 新建文档碎片存储DOM
              const fragment = this.toFragment(this.el)
      
              // 操作文档碎片 to handle fragment
      
              // 将操作好的文档碎片追加到原本的DOM上面
              this.el.appendChild(fragment)
          }
      
          // 判断是否为元素节点
          isElementNode(node) {
              return node.nodeType === 1
          }
          // dom碎片化
          toFragment(el) {
              const f = document.createDocumentFragment()
              f.appendChild(el.clone(true))
          }
      }
      // 上面的miniVue实例相应的改为
      class miniVue {
          constructor(options) {
              this.$el = options.el
              this.$data = options.data
              this.$options = options
          }
          if(this.$el) {
              // 解析模板 to Compile
              new Compile(this.$el, this) // 这里的this就是miniVue实例
          }
      }

      操作fragment

      操作保存好的文档碎片,我们可以专门定义一个函数,然后把文档碎片通过参数传入进来。

      操作文档碎片我们又可以分为两步。因为针对文本节点元素节点,我们需要进行不同的操作,所以我们在遍历所有节点后的第一步应该先判断它是元素节点还是文本节点

      handleFragment(fragment) {
          // 获取文档碎片的子节点
          const childNodes =  fragment.childNodes
          // 遍历所有子节点
          [...childNodes].forEach((child) => {
              if(this.isElementNode(child)) {
                  // 元素节点
                  this.compileElement(child)
              } else {
                  // 文本节点
                  this.compileText(child)
              }
      
              // 递归遍历
              if(child.childNodes && child.childNodes.length) {
                  handleFragment(child)
              }
          })
      }
      
      // 同样的我们需要完善一下compile的构造函数
      constructor(el, vm) {
          this.el = this.isElementNode(el) ? el : document.querySelector(el)
          this.vm = vm
          // 新建文档碎片存储DOM
          const fragment = this.toFragment(this.el)
      
          // 操作文档碎片 to handle fragment
          this.handleFragment(fragment)
      
          // 将操作好的文档碎片追加到原本的DOM上面
          this.el.appendChild(fragment)
      }

      获取元素节点上的信息

      元素节点上的信息主要就是这个元素节点上面的属性,然后拿到绑定在节点上面的vue指令,分离出来vue指令的名称和值(注意:@开的头的指令需要额外处理)。然后还有很重要的一步,那就是去掉这些指令(这些指令updater是不认的)

      compileElement(node) {
          const attrs = node.attributes
          // 遍历节点上的全部属性
          [...attrs].forEach(({name, value}) => {
              // 分类看指令以什么开头
              if(this.headWithV(name)) {
                  // 以v开头
                  const [,directive] = name.split("-") //分离出具体指令
                  const [dir,event] = directive.split(":") // 考虑v-on的情况 例如v-on:click
      
                  // 将指令的名称、值、node节点、整个vm实例、事件名(如果有的话)一起传给最后真正操作的node的函数
                  handleNode[dir](node, value, this.vm, event)
      
              }else if(this.headWithoutV(name)) {
                  // 以@开投
                  const [, event] = name.split("@")
                  // 和上面一样,但是指令名字是确定的,为“on” 因为@是v-on的语法糖
                  handleNode["on"](node, value, this.vm, event)
              }
          })
      }
      ​
      headWithV(name) {
          return name.startsWith("v-");
      }
      headWithoutV(name){
          return name.startsWith("@");
      }

      获取文本节点信息

      文本节点和元素节点类似,只不过文本节点的信息存储在节点的textContent里面,主要用来替换mustache语法,(双大括号插值)需要通过正则识别额外处理。如果是正常的文本节点,则不进行处理(原模原样展示即可)。

      compileText(node) {
          const content = node.textContent
          if(!/{{(.+?)}}/.test(content)) return
          // 识别到是mustache语法 处理方法其实和v-text一样
          handleNode["text"](node, content,this.vm)
      }
      

      操作fragment

      前面铺垫了这么多,终于到了操作文档碎片这一步了。按照上面的思路,handleNode应该是一个对象,里面有多个属性对应不同的指令的处理方法。

      // node--操作的node节点  exp--指令的值(或者是mustache语法内部插入的内容)  vm--vm实例  event--事件名称
      const handleNode = {
          // v-html
          html(node, exp, vm) {
              // 去vm实例中找到这个表达式所对应的值
              const value = this._get(vm, exp)
              // 更新node
              updater.htmlUpdater(node, value)
          },
          // v-model
          model(node, exp, vm) {
              // 同html
              const value = this._get(vm, exp)
              updater.modelUpdater(node, value)
          },
          // v-on
          on(node, exp, vm, event) {
              // v-on特殊一点,我们需要为该node绑定事件监听器
              const listener = vm.$options.methods && vm.$options.methods[exp] // 获取监听器的回调
              // 绑定监听器,注意回调绑定使用bind把this指向vm实例,false代表事件冒泡时触发监听器
              node.addEventListener(event, listener.bind(this), false) 
          },
          // v-text
          text(node, exp, vm) {
              // v-text是最复杂的,需要考虑两种情况,一种是通过v-text指令操作node,另一种则是通过mustache语法操作node,需分类
              let value
              if(exp.indexOf("{{") !== -1) {
                  // mustache语法操作node
                  // 捕捉到所有的mustache语法,将其整个替换为vm实例中属性对应的值
                  // 拿我们最初初始化实例的一个数据举例:{{obj.auth}} -- 'xxx'
                  value = exp.replace(/{{(.+?)}}/g, this._get(vm, exp)) 
              }else {
                  // v-text操作node
                  value = this._get(vm, exp)
              }
              // 更新node
              updater.textUpdater(node, value);
          },
      }
      ​
      // 根据表达式去数据对象里面获取值
      _get(vm, exp) {
          const segments = exp.split('.')
          // 这里使用reduce是为了获取嵌套对象内部属性的值,不熟悉的话去补一补reduce
          // 比如data.a.b.c,那么每次遍历的值为data[a],data[a][b],最终结果是data[a][b][c]
          segments.reduce((pre, key) => {
              return pre[key]
          }, vm.$data)
      }
      
      // 更新node (终于到了更新node这一步)
      const updater = {
          textUpdater(node, value) {
              node.textContent = value;
          },
          htmlUpdater(node, value) {
              node.innerHTML = value;
          },
          modelUpdater(node, value){
              node.value = value;
          }
      }

      至此我们已经实现了vue实例模板编译,并更新了node,其实到现在我们还没有涉及到响应式这三个字。下面我们开始介绍本篇的核心,即vue是如何实现响应式的。

      响应式

      数据劫持

      关键点:Object.defineProperty(具体用法参考MDN)

      主要目的:为data中每个属性添加gettersetter,然后在gettersetter中进行数据劫持

      思路很简单,其实就是从最外层的data层开始遍历属性,通过Object.defineProperty给这些属性都添加上gettersetter,需要注意对象的嵌套,所以需要使用递归来为嵌套的属性添加gettersetter

      function observe(data) {
          if(typeof data !== 'object') return
          Object.keys(data).forEach((key) => {
              defineReactive(data, key, data[key])
          })
      }
      
      function defineReactive(data, key, value) {
          // 递归子属性
          observe(value)
      
          Object.defineProperty(data, key, {
              get() {
                  // 数据劫持 在这个地方进行相关操作
                  return value
              }
              set(newVal) {
                  if(newVal == value) return
                  value = newVal
                  // 为新数据添加getter和setter
                  observe(newVal)
                  // 数据劫持 在这个地方进行相关操作
              }
          })
      }

      收集依赖

      依赖其实说白了,就是数据的依赖,data中的某个属性,可能在DOM中好几个地方进行了使用,那DOM中使用到该属性的地方就都会产生一个对于该属性的依赖,也就是watcher。当该属性的值发生了变化,那么就可以通知watcher来使得页面中使用到这个属性的地方进行视图更新。为每个属性绑定watcher的过程其实就是订阅,反过来,当属性的值发生了变化,通知所有watcher的过程就是发布

      下面我们来将依赖抽象化,即实现watcher

      class Watcher {
          // data--最外层数据对象  exp--表达式  cb--数据更新后需要执行的回调
          // 通过data和exp可以获取watcher所依赖属性的具体值
          constructor(data, exp, cb) {
      	this.data = data
              this.exp = exp
              this.cb = cb
              // 每次初始化watcher实例时,对依赖属性进行订阅
              this.value = this.subscribe()
          }
          // 订阅
          subscribe() {
              // 获取依赖属性的值
      	const value = _get(this.data, this.exp)
              return value
          }
          // 更新
          update() {
              // 获取新值
      	this.value = _get(this.data, this.exp)
              cb()
          }
      }
      
      // 根据表达式去数据对象里面获取值 其实上面已经定义过一个了,功能是一样的,这里重复定义加深一下印象,也方便阅读
      function _get(obj, exp) {
          const segments = exp.split('.')
          // 这里使用reduce是为了获取嵌套对象内部属性的值,不熟悉的话去补一补reduce
          // 比如data.a.b.c,那么每次遍历的值为data[a],data[a][b],最终结果是data[a][b][c]
          segments.reduce((pre, key) => {
      	return pre[key]
          }, obj)
      }

      依赖我们大概清楚了,但是我们上面讲,需要把一个属性全部的依赖(watcher)收集起来,所以我们该如何收集依赖呢?

      首先我们先想第一个问题,一个属性会有一个或者好多个watcher,我们应该如何保存这些watcher呢,这个我们很容易想到,我们可以专门拿一个数组保存一个属性的全部watcher,我们把这个数组命名为dep(dependency)。

      第二个问题,我们应该什么时候进行收集watcher的操作呢。还记得我们上面提到的订阅吗,我们每次初始化watcher时,会为该watcher订阅属性,订阅的过程中我们会首先获取这个属性的值,这时就可以发挥数据劫持的作用了,获取这个属性值的时候,我们就会进到这个属性的getter方法中,所以我们可以在这个时候完成收集watcher的操作。

      第三个问题,我们说watcher的作用其实就是监听到订阅属性的变化(即监听发布),监听到变化后执行其update方法,即执行更新回调,来更新视图。那么我们怎样才能让watcher监听到“发布”呢,这时我们又需要用到数据劫持,即在setter中通知这个属性所有的watcher

      function defineReactive(data, key, value) {
          // 新建用于存储watcher的数据
          const dep = []
      
          // 递归子属性
          observe(value)
      
          Object.defineProperty(data, key, {
              get() {
                  // 数据劫持 在这个地方进行相关操作
                  dep.push(watcher) // 收集依赖
                              return value
              }
              set(newVal) {
                  if(newVal == value) return
                  value = newVal
                  // 为新数据添加getter和setter
                  observe(newVal)
      
                  // 数据劫持 在这个地方进行相关操作
                  dep.notify() // 通知依赖
          	}
          })
      }

      现在我觉得我有必要理一下这个依赖收集的全过程。首先页面初次渲染的时候,会遇到我们在data中定义的属性(注意:此时属性上面已经定义好getter和setter了),遇到属性后会初始化一个watcher实例,在此过程中watcher实例会获取这个属性的值,于是会进入到这个属性的getter中,于是我们通过数据劫持来收集这个watcher。那么又出现了一个问题,我们此时在getter中,如何获取到初始化的watcher实例呢,也就是dep.push的时候,其实我们是没有办法直接拿到这个watcher的。因此,我们需要在初始化watcher的时候,把这个watcher放到全局,比如window.target

      subscribe() {
          // 获取依赖属性的值
          window.target = this // 这里的this即为此时初始化的watcher实例
      	const value = _get(this.data, this.exp)
          return value
      }
      function defineReactive(data, key, value) {
          // 新建用于存储watcher的数据
          const dep = []
          observe(value)
          Object.defineProperty(data, key, {
              get() {
                  dep.push(window.target) // 改为window.target
      
                  return value
              }
              set(newVal) {
                  if(newVal == value) return
                  value = newVal
                  observe(newVal)
      
                  dep.notify()
          	}
          })
      }

      响应式代码完善

      Dep类

      我们可以讲dep数组抽象为一个类

      class Dep {
          constructor() {
              this.subs = []
          }
          // 收集依赖
          addSub(watcher) {
              this.subs.push(watcher)
          }
          // 通知依赖
          notify() {
              [...this.subs].forEach((watcher) => {
                  watcher.update()
              })
          }
      }

      defineReactive也要做出相应的调整

      function defineReactive(data, key, value) {
          // 新建用于存储watcher的数据
          const dep = new Dep()
          observe(value)
          Object.defineProperty(data, key, {
              get() {
                  // 收集依赖
                  dep.addSub(window.target) 
                  return value
              }
              set(newVal) {
                  if(newVal == value) return
                  value = newVal
                  observe(newVal)
                  // 通知依赖
                  dep.notify()
          	}
          })
      }

      全局watcher用完清空

      下面有一个场景,我们在访问到data中的一个属性a后,实例化了一个watcher1,此时在实例化这个watcher1的过程中,会把window.target设置为watcher1,之后我们在没有实例化其他watcher的情况下直接去访问其他的属性,例如属性b,那么属性b中的getter会直接把watcher1推入到它的依赖数组中。这样是不合理的,所以我们每次将watcher推入到依赖数组中后,要将这个watcher从全局中收回。(window.target这里改成Dep.target了,其实都是一样的)

      subscribe() {
          Dep.target = this // 这里的this即为此时初始化的watcher实例
          const value = _get(this.data, this.exp)
          Dep.target = null // 清空暴露在全局中的watcher
          return value
      }
      
      // 同时在收集依赖时添加一层过滤
      addSub(watcher) {
          if(watcher) {
              this.subs.push(watcher)
          }
      }

      依赖的update方法

      上面我们在watcher的update方法中更新了值并且执行了数据更新后的回调,为了让丰富回调中的操作,我们可以将回调的this指向我们的最外层数据对象,这样在回调中就可以通过this任意获取数据对象中的其他属性,并且将更新之前的旧值和新值一起传入到update里面

      update() {
        const oldValue = this.value // 获取旧值
        this.value = parsePath(this.data, this.expression) // 获取新值
        this.cb.call(this.data, this.value, oldValue)
      }
      

      需要注意的一个地方

      下面是watcher中获取所依赖属性值的方法,这里需要说明一下,对于存在对象嵌套的情况,每一层属性的依赖数组中都会添加这个watcher,想不明白的话可以看一下下面的注解。

      // 根据表达式去数据对象里面获取值
      function _get(obj, exp) {
          const segments = exp.split('.')
          /*
          比如data.a.b.c,那么每次遍历的值为data[a],data[a][b],最终结果是data[a][b][c]
          遍历到data[a]、data[a][b]时,肯定会去访问这两个属性的值,于是会进入到这两个属性的getter里面
          所以这个watcher不仅仅会被添加到最内层属性的getter中,中间每一层属性的getter中都会有这个watcher
          即如果data[a]的值发生了变化,也会通知这个watcher去更新视图
          */ 
          segments.reduce((pre, key) => {
              return pre[key]
          }, obj)
      }

      双剑合璧

      怎样将上面的编译响应式整合到一起形成一个完整的具有响应式的miniVue类呢。其实很简单,从我们最上面那张图就可以看出来。总结一下就两点,在我们通过各种指令操作node节点的时候,同时初始化watcher,另一点即为初始化watcher时指定的回调内部需要执行updater里面对应的方法来更新视图

      两点分别对应下图的这两根线:

      这样是不是就清晰多了。至此”双剑合璧“完成,下面贴一下合璧后的代码(只放需要合成的部分,这样更清晰一点)

      // node--操作的node节点  exp--指令的值(或者是mustache语法内部插入的内容)  vm--vm实例  event--事件名称
      const handleNode = {
          // v-html
          html(node, exp, vm) {
              const value = this._get(vm, exp)
              // 新建watcher实例,并绑定更新回调
              new Watcher(vm, exp, (newVal, oldVal) => {
                  // 这里是所依赖数据更新以后更新视图
                  this.updater.htmlUpdater(node, newVal);
              })
              // 这里是编译的时候更新视图
              updater.htmlUpdater(node, value)
          },
          // v-model
          model(node, exp, vm) {
              const value = this._get(vm, exp)
              // 新建watcher实例,并绑定更新回调
              new Watcher(vm, exp, (newVal, oldVal) => {
                  this.updater.modelUpdater(node, newVal);
              });
              updater.modelUpdater(node, value)
          },
          // v-on
          on(node, exp, vm, event) {
              // watcher只针对属性 v-on这里不会生成watcher(方法名也没什么好监听的,一般也不会操作方法名让方法名发生变化)
              const listener = vm.$options.methods && vm.$options.methods[exp] 
              node.addEventListener(event, listener.bind(this), false) 
          },
          // v-text
          text(node, exp, vm) {
              let value
              if(exp.indexOf("{{") !== -1) {
                  // mustache语法操作node
                  value = exp.replace(/{{(.+?)}}/g, this._get(vm, exp)) 
                  
              }else {
                  // v-text操作node
                  value = this._get(vm, exp)
              }
              // 新建watcher实例,并绑定更新回调
              new Watcher(vm, exp, (newVal, oldVal) => {
                  this.updater.textUpdater(node, newVal);
              });
              updater.textUpdater(node, value);
          },
      }
      _get(vm, exp) {
          const segments = exp.split('.')
          segments.reduce((pre, key) => {
              return pre[key]
          }, vm.$data)
      }
      ​
      const updater = {
          textUpdater(node, value) {
              node.textContent = value;
          },
          htmlUpdater(node, value) {
              node.innerHTML = value;
          },
          modelUpdater(node, value){
              node.value = value;
          }
      }

      最后的最后,修改一下我们最开始定义miniVue类的构造函数

      class miniVue {
          constructor(options) {
              this.$el = options.el
              this.$data = options.data
              this.$options = options
          }
          if(this.$el) {
              // 添加数据劫持
              this.observe()
              // 编译
              new Compile(this.$el, this);
          }
      }

      大功告成。

      总结

      如果你是第一次阅读本文,看到最后应该还是会感觉到些许混乱。下面允许我为大家概括一下整体的流程。建议结合我们最上方的中心图。

      • 1.初始化minivue实例 执行其构造函数,首先对实例的数据对象data中全部属性添加数据劫持功能(getterand setter
      • 2.开始编译实例绑定的模板。
      • 3.首先编译做准备,创建compile类,拿到模板的整个DOM对象,遍历其子节点,获取到每个子节点上的信息,这些信息中凡是引用过vm实例data中的属性的,一律都新增一个watcher实例
      • 4.初始化watcher实例的时候,会访问这个属性,然后进入这个属性的getter中,在getter中,将这个watcher添加到这个属性的Dep类中
      • 5.最后更新node,至此初始化编译完成
      • 6.当data中某一个属性的值发生变化,会进入这个属性的setter中,setter会通知该属性的Dep
      • 7.Dep类会通知存储的所有相关watcher进行更新,于是这些watcher分别执行自己update中的回调。回调即会更新node

      到此这篇关于一篇文章带你彻底搞懂VUE响应式原理的文章就介绍到这了,更多相关 VUE响应式原理内容请搜索北冥有鱼以前的文章或继续浏览下面的相关文章希望大家以后多多支持北冥有鱼!

      您可能感兴趣的文章:

      • Vue模拟响应式原理底层代码实现的示例
      • 关于Vue3中的响应式原理
      • 浅析一下Vue3的响应式原理
      • Vue响应式原理及双向数据绑定示例分析
      • 一文详解Vue3响应式原理
      • Vue深入讲解数据响应式原理
      • VUE响应式原理的实现详解
      • Vue响应式原理的示例详解
      • Vue响应式原理模拟实现原理探究

      《一篇文章带你彻底搞懂VUE响应式原理.doc》

      下载本文的Word格式文档,以方便收藏与打印。