Vue.js 双向数据绑定原理剖析

  在理解Object.defineProperty(),我提到了Vue.js 通过Object.defineProperty 方法进行数据劫持从而实现双向数据绑定的,并在Object.defineProperty() 实现双向数据绑定小案例中手动实现了一个简单的双向数据绑定。
  其实只通过Object.defineProperty()已经可以实现数据绑定了,只是结合发布订阅者模式可以做到‘一对多’的模式,效率更高,Vue.js就是通过Object.defineProperty() + 发布订阅者模式 来实现双向数据绑定的。

  要实现mvvm的双向数据绑定,需要实现以下几点:

  1. 实现一个观察者Observer,能够对data数据对象的所有的属性都分别创建一个发布者Dep, 并通过Object.defineProperty() 对所有属性进行监听。
  2. 实现一个发布者Dep,建立一个数组subs保存该data属性的所有订阅者(Watcher),并定义增加订阅者(Watcher) 和更新subs数组中所有订阅者(Watcher)的方法。
  3. 实现一个订阅者Watcher,记录每个使用data数据对象的属性的地方,并定义了更新视图的update方法。
  4. 实现一个编译器Compiler,对Vue管理入口元素'#app'中的所有子元素节点的指令进行扫描和解析,根据指令模板替换数据,添加订阅者(Watcher)并更新视图

  流程图如下(PS:建议看后面代码的时候都结合这张图看):

双向数据绑定流程图


接下来就自己来实现双向数据绑定吧,先来看效果:


自己实现双向数据绑定效果.gif



  代码开始了,先准备入口页面和入口文件

index.html





  
  
  Document


  
{{message}}

{{message}}

{{message}}


main.js

// 入口文件 
import Vue from './vue'
const vue = new Vue({
  el: '#app',
  data: {
    message: '自己实现双向数据绑定'
  }
})
window.vue = vue // 绑定到window全局


vue.js

  自己写一个vue.js文件,并在index.html 中导入。

// 自己写的vue文件
import Obeserver from './Obeserver'
import Compiler from './Compiler'
class Vue {
  constructor(options) {
    // 1.保存创建Vue 实例传入的Vue管理入口元素'#app'、data数据
    this.$options = options
    this.$el = this.$options.el
    this.data = this.$options.data
    // 2.将创建Vue 实例传入的data数据进行遍历,使用this.message返回this.data.message的值
    Object.keys(this.data).forEach(item => {
      this._proxy(item)
    })
    // 3.使用观察者Obeserver监听data中数据的改变
    new Obeserver(this.data)
    // 4.使用编译器Compiler编译模板
    new Compiler(this.$el, this)
  }

  _proxy(key){
    let that = this
    Object.defineProperty(this, key, {
      get() {
        return that.data[key]
      },
      set(newVal) {
        that.data[key] = newVal
      }
    })
  }
}
export default Vue


观察者Observer

  实现一个观察者Observer,通过Object.defineProperty()对所有属性的存取进行监听,当监听到数据的变化之后就需要通知订阅者了,因此可以实现一个发布者Dep,在发布者Dep里面维护一个数组subs,用来收集订阅者,数据变动触发notify就可以通知到订阅者了,订阅者再调用update方法更新视图,data中属性的存取如下:
(1) 当属性值变化时自动调用Object.defineProperty()set方法,拿到最新值并通知发布者Dep,发布者Dep再遍历所有订阅者Watcher修改对应订阅者Watcher对应的节点值,更新视图;
(2) 当获取属性值时自动调用Object.defineProperty()get方法,调用发布者Dep的addSub方法(判断是否是新的订阅者Watcher,是则添加到发布者Dep的subs数组中)

// 观察者-Obeserver
import Dep from './Dep'
class Obeserver {
  constructor(data) {
    this.data = data // vue中的data
    // 使用Object.defineProperty监听data数据对象的所有属性
    Object.keys(this.data).forEach(key => {
      let value = this.data[key]
      let dep = new Dep(key) // 创建dep对象
      Object.defineProperty(this.data, key, {
        set(newValue) {
          if (value === newValue) return
          console.log(`监听到data中${key}改变,新值为:${newValue}`)
          value = newValue
          dep.notify()
        },
        //获取this.data中key的值都会触发 get函数, 该方法将Watcher存放到subs数组中
        get() {
          console.log(`获取到${key}对应的值为:${value}`)
          if (Dep.target) {
            dep.addSub(Dep.target)
            console.log('添加watcher')
          }
          return value
        }
      })
    })
  }
  
}
export default Obeserver


Dep 发布者

  实现一个发布者Dep,建立一个数组subs保存该data属性的所有订阅者(Watcher),并定义了增加订阅者(Watcher) 的addSub方法和更新subs数组中所有Watcher中对应节点的值的notify方法。

// Dep-发布者
import Watcher from './Watcher'
class Dep {
  constructor(data, value) {
    this.subs = []
    this.data = data
    this.value = value
  }
  addSub(watcher) {
    this.subs.push(watcher)
  }
  // 遍历subs中存放的所有Watcher,调用update方法,更新视图
  notify(value) {
    console.log(this.subs)
    this.subs.forEach(item => {
      item.update(value)
    })
  }
}
Dep.prototype.target = null
export default Dep


Watcher订阅者

  实现一个订阅者Watcher,记录每个使用data数据对象的属性的地方,并定义了更新视图的update方法。

// Watcher-订阅者
import Dep from "./Dep"

class Watcher {
  constructor(node, name, vm, type) {
    // 编译器传过来的
    this.node = node
    this.name = name
    this.vm = vm
    this.type = type
    Dep.target = this
    this.update()
    Dep.target = null
  }
  // 更新视图
  update(){
    /* 执行this.vm[this.name],会调用Observer中Object.defineProperty()的get方法,get方法会判断是否是新的订阅者Watcher,是则将订阅者Watcher 存放到subs数组中
      再将this.vm[this.name]的值赋给该节点的值,完成页面渲染
    */
    if (this.type === 'twoEle') {
      this.node.value = this.vm[this.name] // 元素节点包含v-model更新视图
    }else if(this.type === 'ele'){
      this.node.innerText = this.vm[this.name] // 元素节点包含插值更新视图
    }
    else if(this.type === 'text'){
      this.node.nodeValue = this.vm[this.name] // 文本节点更新视图
    }
  }
}
export default Watcher


编译器Compiler

  实现一个编译器Compiler,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。

import Watcher from './Watcher'
const reg = /\{\{(.*)\}\}/
class Compiler {
  constructor(el, vm) {
    this.el = document.querySelector(el)
    this.vm = vm
    this.frag = this._createFragment()
    this.el.appendChild(this.frag)
  }
  // appendChild() 方法的作用是从一个元素向另一个元素中移动元素, 因此通过while可以遍历到this.el(#app)中的所有子元素,添加到frag中
  _createFragment() {
    // createDocumentFragment适合处理有大量dom元素,效率比createElement高
    let frag = document.createDocumentFragment() 
    while(this.el.firstChild) {
      this._compile(this.el.firstChild)
      frag.appendChild(this.el.firstChild)
    }
    return frag
  }
  // 判断节点类型,填加订阅者Watcher并更新视图
  _compile(node) {
    // node.nodeType: 1元素节点  3文本节点
    let type = null
    if (node.nodeType === 1) {
      console.log(node.attributes)
      let attr = node.attributes
      /* 元素节点中有v-model: view --> model  model --> view
        (1) 此处以input输入框为例,监听input 事件,更新data中数据
        (2) 设置input输入框的值为data中对应的数据
      */
      if(attr.hasOwnProperty('v-model')) {
        let name = attr['v-model'].nodeValue
        node.addEventListener('input', (e) => {
          this.vm[name] = e.target.value
        })
        type = 'twoEle'
        new Watcher(node, name, this.vm, type)
      }
      // 元素节点中有插值 {{}}
      if (reg.test(node.innerText)) {
        // 获取元素节点{{}}中的数据
        let name = RegExp.$1
        name = name.trim()
        type = 'ele'
        // 将获取到的数据用Watcher订阅者记录并更新视图
        new Watcher(node, name, this.vm, type)
      }
    }else if (node.nodeType === 3) {
      if (reg.test(node.nodeValue)) {
        // 测试文本节点是否有插值{{}},则获取{{}}中的数据
        let name = RegExp.$1
        name = name.trim()
        type = 'text'
        // 将获取到{{}}中的数据用Watcher订阅者记录
        new Watcher(node, name, this.vm, type)
      }
    }
  }
}
export default Compiler

  好了,这就实现了一个简单的双向数据绑定了,其思想和原理大都来自vue源码,最后和官方的Vue.js 做一个效果对比。




  
  
  Document


  
{{message}}

{{message}}

{{message}}

{{message}}
Vue官方的双向数据绑定效果.gif

  通过对比可以知道,自己实现的vue.js 和官方的vue.js 双向数据绑定效果一致,收工 。



文中有不足或者读者有疑问或更好的见解,欢迎留言讨论。
如果觉得该篇文章对您有帮助,别忘了留下您的足迹,点个赞❤噢

你可能感兴趣的:(Vue.js 双向数据绑定原理剖析)