深入了解Vue 2响应式原理,并手写一个简单的Vue

1. Vue 2的响应式原理

Vue.js 一个核心思想是数据驱动。所谓数据驱动是指视图是由数据驱动生成的,对视图的修改,不会直接操作 DOM,而是通过修改数据。vue.js里面只需要改变数据,Vue.js通过Directives指令去对DOM做封装,当数据发生变化,会通知指令去修改对应的DOM,数据驱动DOM的变化,DOM是数据的一种自然的映射。vue.js还会对View操作做一些监听(DOM Listener),当我们修改视图的时候,vue.js监听到这些变化,从而改变数据。这样就形成了数据的双向绑定。
Vue 2官方文档中这样解释其响应式原理:

当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式化并不同,所以建议安装 vue-devtools 来获取对检查数据更加友好的用户界面。
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

深入了解Vue 2响应式原理,并手写一个简单的Vue_第1张图片

2. Object.defineProperty

这里需要说明的是,在Vue 2中Vue基于Object.defineProperty来实现Vue的响应时更新,在Vue 3中,Vue是通过Proxy代理一个对象来实现Vue的响应式更新的。
由于 JavaScript 的限制,Vue 不能检测数组和对象的变化。尽管如此我们还是有一些办法来回避这些限制并保证它们的响应性。

var vm = new Vue({
  data:{
    a:1
  }
})

// `vm.a` 是响应式的

vm.b = 2
// `vm.b` 是非响应式的

我们在初始化Vue实例的时候,会遍历Vue的data,并递归式的为每一个data数据执行Object.defineProperty使其拥有响应性,但是,如果我们为vm添加一个属性(如上文中的vm.b),这是就需要主动调用Object.defineProperty来使该属性具有响应性。

3. 手写实现Vue2 响应式原理

3.1 使用Object.defineProperty实现简单的双向绑定

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>手写实现Vue的响应式更新</title>
</head>
<body>
  <div id="app">
    <input id="input" type="text">
    <span id="content"></span>
  </div>
</body>
<script type="text/javascript">
  let data = {
  }
  Object.defineProperty(data, 'text', {
    set: function (v) {
      this.value = v	//当我们为data.text赋值时,实际上更改的是this.value,
      					//这里的this.value是我们添加的一个变量,用于保存data.text的内容,
      					//也可以是任意的其他变量,比如this.a = v,效果也是一样的。
      console.log('set', this.value)
      document.getElementById('content').innerText = this.value
    },
    get: function () {
      console.log('get', this.value)
      return this.value	//当我们获取data.text时,其实也是获取到this.value的值
    }
  })
  //所以,如果我们通过相同的方法为data再增加一个name属性,执行data.name=3,修改的也是this.value的值
  //所以这时候我们获取data.text的值时,得到的也是3。如果希望data的属性有独立的值,我们可以使用闭包,如下:
  // !function (obj, property, value) {
  //   Object.defineProperty(obj, property, {
  //     set: function (v) {
  //       value = v	//当我们为data.text赋值时,实际上更改的是this.value,
  //       //这里的this.value是我们添加的一个变量,用于保存data.text的内容,
  //       //也可以是任意的其他变量,比如this.a = v,效果也是一样的。
  //       console.log('set', value)
  //       document.getElementById('content').innerText = value
  //     },
  //     get: function () {
  //       console.log('get', value)
  //       return value	//当我们获取data.text时,其实也是获取到this.value的值
  //     }
  //   })
  // } (data, 'text')
  document.getElementById('input').addEventListener('input', function (e) {
    data.text = e.target.value
  })
</script>
</html>

深入了解Vue 2响应式原理,并手写一个简单的Vue_第2张图片

控制台输出:
深入了解Vue 2响应式原理,并手写一个简单的Vue_第3张图片
上面操作直接使用了DOM操作改变了文本节点的值,而且是在知道id的情况下,使用document.getELementById()获取到响应的文本节点,然后修改文本节点的值。

封装成一个框架,肯定不能使用这种做法。所以需要一个可以解析DOM并且能修改DOM中相应变量的模块。

3.2 实现简单Compiler

  1. 首先,获取文本中真实的DOM节点
  2. 然后,分析节点的类型
  3. 最后,根据节点的类型做相应的处理

在实际使用中,多次操作DOM节点,为了提高性能和效率,将进行下面操作:

  1. 将所有的节点转换成文档碎片(fragment)进行编译操作
  2. 解析操作完成后,在将文档碎片(fragment)添加到原来的真实DOM节点
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>手写实现Vue的响应式更新</title>
</head>
<body>
  <div id="app">
    <input id="input" type="text" v-model="input">
    {{input}}
  </div>
</body>
<script type="text/javascript">
  function Compiler (node, vm) {
    let fragment = null
    if (node) {
      fragment = this.nodeToFragment(node, vm) //将node节点转化成文档片段,降低直接操作DOM的频率
      return fragment
    }
  }

  Compiler.prototype.nodeToFragment = function (node, vm) {
    let fragment = document.createDocumentFragment()
    //假设我们的child节点不存在嵌套的情况
    while (node.firstChild) {
      let child = node.firstChild
      this.compileElement(child, vm)  //根据vm的数据,得到节点的实际结构
      fragment.appendChild(child) //DocumentFragment.appendChild会将child从源文档中移出,所以不会造成无线循环
    }
    return fragment
  }

  Compiler.prototype.compileElement = function (node, vm) {
    //在编译节点时,我们需要根据节点的类型来分情况处理
    //{{text}}: nodeType为Text(3),我们需要将文本替换为text的属性值
    //: nodeType为Element(1),我们需要对其添加事件监听,当其value改变时,同时更改vm中的数据
    let regExp = /{{(.*)}}/ //用于查找出形如{{param}}的语句

    //当node是元素类节点时
    if (node.nodeType === 1) {
      let attr = node.attributes
      //v-model实际上就是节点的一个属性,我们需要拿到v-model的属性值,该值就对应了vm中一个属性
      //然后当节点的value改变时,同时改变vm中对应属性的属性值
      for (let i = 0; i < attr.length; i++) {
        if (attr[i].nodeName === 'v-model') {
          //假设该节点存在v-model=text
          let v_model_attr_in_vm = attr[i].nodeValue //此时的v_model_attr_in_vm就等于text
          node.addEventListener('input', function (e) {
            vm.data[v_model_attr_in_vm] = e.target.value
          })
          // 初始化node节点,将vm中的数据赋值给node
          node.value = vm.data[v_model_attr_in_vm]
        }
      }
    }
    //当node是Text节点时
    else if (node.nodeType === 3) {
      //假设该文本节点为{{text}},而不是多个插值表达式的组合以及插值表达式与一半文本的混合
      regExp.test(node.nodeValue)
      let v_model_attr_in_vm = RegExp.$1  //获取到{{text}}中的text,并将text赋值给v_model_attr_in_vm
      node.nodeValue = vm.data[v_model_attr_in_vm]
    }
  }


  function Vue (options) {
    this.data = {}
    for (let property of Object.keys(options.data)) {
      //将options.data中的属性响应式添加到vm.data中
      //注意,此时我们假设options.data不存在字典嵌套的情况
      !function (vm, property, value){
        Object.defineProperty(vm.data, property, {
          get: function () {
            console.log('get,', value)
            return value
          },
          set: function (val) {
            console.log('set,', val)
            value = val
          }
        })
      }(this, property,options.data[property])
    }
    let el = new Compiler(document.getElementById(options.el), this)  //根据this中的数据来编译节点,比如, {{text}} =》 text的属性值
    document.getElementById(options.el).appendChild(el)
  }

  let vue = new Vue({
    el: 'app',
    data: {
      input: '输入'
    }
  })

</script>
</html>

网页:
深入了解Vue 2响应式原理,并手写一个简单的Vue_第4张图片
控制台:
深入了解Vue 2响应式原理,并手写一个简单的Vue_第5张图片
可以发现,当我们改变输入框的值时,能够改变vm.data中对应属性的属性值,但是当vm.data中属性值发生改变时,并不能使视图随之发生变化。但是不用着急,我们现在已经能够使视图层View的变化传递到ViewModel上了。

3.3 实现响应式更新

响应式更新,也就是当vm.data发生改变时,页面也需要随之改变。
Vue通过观察者模式来实现实现响应式更新,记录了每一个节点所依赖的数据,当数据发生改变时,发布者(Dep)会使订阅者(Watcher)触发相应的更新行为。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>手写实现Vue的响应式更新</title>
</head>
<body>
  <div id="app">
    <input id="input" type="text" v-model="input">
    {{input}}
  </div>
</body>
<script type="text/javascript">
  function Compiler (node, vm) {
    let fragment = null
    if (node) {
      fragment = this.nodeToFragment(node, vm) //将node节点转化成文档片段,降低直接操作DOM的频率
      return fragment
    }
  }

  Compiler.prototype.nodeToFragment = function (node, vm) {
    let fragment = document.createDocumentFragment()
    //假设我们的child节点不存在嵌套的情况
    while (node.firstChild) {
      let child = node.firstChild
      this.compileElement(child, vm)  //根据vm的数据,得到节点的实际结构
      fragment.appendChild(child) //DocumentFragment.appendChild会将child从源文档中移出,所以不会造成无线循环
    }
    return fragment
  }

  Compiler.prototype.compileElement = function (node, vm) {
    //在编译节点时,我们需要根据节点的类型来分情况处理
    //{{text}}: nodeType为Text(3),我们需要将文本替换为text的属性值
    //: nodeType为Element(1),我们需要对其添加事件监听,当其value改变时,同时更改vm中的数据
    let regExp = /{{(.*)}}/ //用于查找出形如{{param}}的语句

    //当node是元素类节点时
    if (node.nodeType === 1) {
      let attr = node.attributes
      //v-model实际上就是节点的一个属性,我们需要拿到v-model的属性值,该值就对应了vm中一个属性
      //然后当节点的value改变时,同时改变vm中对应属性的属性值
      for (let i = 0; i < attr.length; i++) {
        if (attr[i].nodeName === 'v-model') {
          //假设该节点存在v-model=text
          let v_model_attr_in_vm = attr[i].nodeValue //此时的v_model_attr_in_vm就等于text
          node.addEventListener('input', function (e) {
            vm.data[v_model_attr_in_vm] = e.target.value
          })
          // 初始化node节点,将vm中的数据赋值给node
          // node.value = vm.data[v_model_attr_in_vm]
          new Watcher(node, vm, v_model_attr_in_vm)
        }
      }
    }
    //当node是Text节点时
    else if (node.nodeType === 3) {
      //假设该文本节点为{{text}},而不是多个插值表达式的组合以及插值表达式与一半文本的混合
      regExp.test(node.nodeValue)
      let v_model_attr_in_vm = RegExp.$1  //获取到{{text}}中的text,并将text赋值给v_model_attr_in_vm
      // node.nodeValue = vm.data[v_model_attr_in_vm]
      new Watcher(node, vm, v_model_attr_in_vm)
    }
  }

  function Watcher (node, vm, property) {
    this.node = node
    this.vm = vm
    this.property = property
    Dep.target = this
    // 在this.update()之前将this赋值给Dep.target,在执行this.update时,需要获取this.vm[this.property]
    // 这就会触发节点依赖数据的get方法,这就能将订阅者this添加到该数据的dep订阅表中了
    this.update()
    Dep.target = null
  }

  Watcher.prototype.update = function () {
    this.value = this.vm.data[this.property]
    if (this.node.nodeType === 1) {
      this.node.value = this.value
    } else if (this.node.nodeType === 3) {
      this.node.nodeValue = this.value
    }
  }

  Watcher.prototype.get = function () {
    return this.value
  }


  function Dep () {
    // 每一个数据都拥有自己的Dep,subs代表依赖该数据的节点
    this.subs = []
  }

  Dep.prototype.addSub = function (sub) {
    this.subs.push(sub)
  }

  Dep.prototype.notify = function () {
    this.subs.forEach(sub => {
      sub.update()
    })
  }

  function observe (vm, data) {
    Object.keys(data).forEach(property => {
      defineReactive(vm, property, data[property])
    })
  }

  function defineReactive (vm, property, value) {
    let dep = new Dep()
    Object.defineProperty(vm.data, property, {
      set: function (val) {
        // 当一个数据更新时,我们需要通知该数据的每一个订阅者
        if (val === value) return
        value = val
        dep.notify()
      },
      get: function () {
        // 当一个节点依赖于某数据时,在获取数据时会触发该数据的get方法,所以我们在get处向dep添加订阅者
        if (Dep.target) {
          dep.addSub(Dep.target)
        }
        return value
      }
    })
  }

  function Vue (options) {
    this.data = {}
    observe(this, options.data)
    let el = new Compiler(document.getElementById(options.el), this)  //根据this中的数据来编译节点,比如, {{text}} =》 text的属性值
    document.getElementById(options.el).appendChild(el)
  }

  let vue = new Vue({
    el: 'app',
    data: {
      input: '输入'
    }
  })

</script>
</html>

3.2相比,3.3增加了两个类,Dep(发布者)Watcher(订阅者),增加了两个方法observe defineReactive

  1. observer方法中调用了defineReactive方法,这两个方法相互配合,使vm.data中的每一条数据添加了get和set方法,并且给每一个数据都赋予了一个dep,dep将用于保存依赖该数据的Watcher
  2. 向dep中增加订阅者,当我们调用new Compiler(document.getElementById(options.el), this),会解析出节点依赖的数据,在3.1中,我们使用node.value = vm.data[v_model_attr_in_vm]虽然可以获取到实际的节点内容,但是不能实现响应式更新,在3.2中,我们使用new Watcher(node, vm, v_model_attr_in_vm),这个Watcher保存了该节点和数据依赖信息,即node依赖于vm.data[v_model_attr_in_vm]new Watcher()会执行this.update,以将节点内容更新为实际内容(例如,{{text}} => text的属性值),同时,由于需要获取vm.data[v_model_attr_in_vm],这会触发该数据的get函数,在该数据的get函数中,我们将此watcher添加到该数据的dep中。
  3. 当数据改变后,这会触发该数据的set,而在每一条数据的set函数中,当value != val时,都会触发dep.notify(),这会使依赖于该数据的watcher调用this.update(),这也就会使该节点的View更新。

这仅仅是一个简单的Vue实现,要实现一个完整的框架,还有很多功能需要实现,此篇文章仅为浅析Vue的响应式原理,可以按下图总结:
深入了解Vue 2响应式原理,并手写一个简单的Vue_第6张图片

你可能感兴趣的:(vue,vue)