Skip to content

实现双向绑定

参考自 https://juejin.im/post/5acc17cb51882555745a03f8

原理

Vue的双向绑定通过 Object对象的defineProperty属性,重写data的set和get函数来实现。

<img src="https://user-gold-cdn.xitu.io/2018/4/10/162ad3d5be3e5105?imageView2/0/w/1280/h/960/format/webp/ignore-error/1">

简单实现

html
&lt;div id="app">
  &lt;form>
    &lt;input type="text"  v-model="number">
    &lt;button type="button" v-click="increment">增加&lt;/button>
  &lt;/form>
  &lt;h3 v-bind="number">&lt;/h3>
&lt;/div>
  1. 一个input,使用v-model指令
  2. 一个button,使用v-click指令
  3. 一个h3,使用v-bind指令。

通过类似于vue的方式来使用我们的双向数据绑定,结合我们的数据结构添加注释

js
var app = new MyVue({
  el: '#app',
  data: {
    number: 0
  },
  methods: {
    increment() {
      this.number++
    }
  }
})

首先定义一个MyVue构造函数

js
function MyVue(options) {}

为了初始化这个构造函数,给它添加一 个_init属性

js
function MyVue(options) {
  this._init(options)
}
MyVue.prototype._init = function(options) {
  this.$options = options // options为上面使用时传入的结构体,包括el data methods
  this.$el = document.querySelector(options.el) // this.$el 即 id为app的 dom元素
  this.$data = options.data // this.$data = {number: 0}
  this.$methods = options.methods // { increment() {this.number++} }
}

接下来实现_observe函数,对data进行处理,重写data的set和get函数,并改造 _init函数

js
MyVue.prototype._observe = function(obj) { // 将要实现监听的对象 obj = {number: 0}
  var value
  for (key in obj) {
    if (obj.hasOwnProperty(key)) {
      value = obj[key]
      if (typeof value === 'object') { // 若值还是 object类型,则继续遍历
        this._observe(value)
      }
      Object.defineProperty(this.$data, key, { // 关键
        enumerable: true,
        configurable: true,
        get: function() {
          console.log(`获取${value}`)
          return value
        },
        set: function(newVal) {
          console.log(`更新${newVal}`)
          if (value !== newValue) {
            value = newVal
          }
        }
      })
    }
  }
}

MyVue.prototype._init = function(options) {
  this.$options = options
  this.$el = document.querySelector(options.el)
  this.$data = options.data
  this.$methods = options.methods
  
  this._observe(this.$data)
}

接下来我们写一个指令类 Watcher 用来绑定更新函数,实现对DOM元素的更新

js
function Watcher(name, el, vm, exp, attr) {
  this.name = name  // 指令名称,例如文件节点,该值设为 text
  this.el = el      // 指令对应的DOM元素
  this.vm = vm      // 指令所属的MyVue实例
  this.exp = exp    // 指令对应的值,本例为 number
  this.attr = attr  // 绑定的属性值,本例为 innerHTML

  this._update()
}

Watcher.prototype._update = function() {
  this.el[this.attr] = this.vm.$data[this.exp]
  // 比如 h3.innerHTML = this.data.number
  // 当number改变时,会触发_update函数,保证对应的DOM进行更新
}

更新_init函数以及observe函数

js
MyVue.ptototype._init = function(options) {
  /* 省略 */
  this._binding = {}
  // _binding保存着model与view的映射关系, 也就是我们前面定义的Watcher的实例
  // 当model改变时,我们会触发其中的指令类更新,保证view也能实时更新
  /* ... */ 
}

MyVue.prototype._observe = function(obj) {
  /* 省略 */
  if (obj.hasOwnProperty(key)) {
    this._binding[key] = {
      _directives: []
    }
    // ...
    var binding = this._binding[key]
    Object.defineProperty(this.$data, key, {
      // ...
      set: function(newVal) {
        console.log(`更新${newVal}`)
        if (value !== newVal) {
          value = newVal
          binding._directives.forEach(function(item) {
            // 当number改变时,触发_binding[number]._directives 中绑定的Watcher类的更新
            item._update()
          })
        }
      }
    })
  }
  /* ... */
}

那么如何将 view 与 model进行绑定呢? 接下来我们定义一个_compiler函数,用来解析我们的指令(v-bind, v-model, v-click)等,并在这个过程中与view与model进行绑定

js
MyVue.prototype._init = function(options) {
  // 省略
  this._compile(this.$el)
}

MyVue.prototype._compile = function(root) { // root为 id为app的element,即根元素
  var _this = this
  var nodes = root.children
  for (var i = 0; i &lt; nodes.length; i++) {
    var node = nodes[i]
    if (node.chilren.length) { // 若存在子元素则进行递归处理
      this._compile(node)
    }
    // 如果有v-click属性,我们监听它的onclick事件,触发increment事件,即number++
    if (node.hasAttribute('v-click')) {
      node.onclick = (function() {
        var attrVal = nodes[i].getAttribute('v-click')
        return _this.$methods[attrVal].bind(_this.$data)
        // bind是使data的作用域与method函数的作用域保持一致
      })()
    }
    // 如果有v-model属性,且元素是input或textarea,就监听它的input事件
    if (node.hasAttribute('v-model') && (node.tagName === 'input' || node.target === 'textarea')) {
      node.addEventListener('input', (function(key) {
        var attrVal = node.getAttribute('v-model')
        // _this._binding[number]._directives = [一个Watcher的实例]
        // 其中Watcher.prototype.update = functoin() {
        //   node['value'] = _this.$data['number']
        //   这就将node的值保持与number一致了
        // }
        _this._bniding[attrVal]._directives.push(new Watcher(
          'input',  // 结合上面的 Watcher构造函数来看 
          node,     // 分别传参 指令名, dom
          _this,    // Vue绑定的实例
          attrVal,  // 指令对应值
          'value'   // 绑定的对应属性
        ))

        return function() {
          _this.$data[attrVal] = nodes[key].value
          // 使number的值与node的value保持一致,这就实现了双向绑定
        }
      })(i))
    }
    // 如果有v-bind属性,我们只要使node的值及时更新为data中的number值即可
    if (node.hasAttribute('v-bind')) {
      var attrVal = node.getAttribute('v-bind')
      _this._binding[attrVal]._directives.push(new Watcher(
        'text',
        node,
        _this,
        attrVal,
        'innerHTML'
      ))
    }
  }
}

完整实现

最后来梳理一下逻辑:

  1. 写一个构造函数 MyVue 可传入一个options参数,实例化时执行其 _init方法
  2. _init 其核心是将 options的各种属性 放到其实例上,并执行 _observe 和 _compile 方法
  3. _observe 作为一个代理方法,监听传入的options.data属性的改变,(这里会将data里的每个key放入 _bingding 对象中,然后用Object.defineProperty对每一个key进行监听, 若属性值改变了,就实时改变)
  4. 上面说的实时改变就是 update 方法,那观察的对象从哪来? 指令上对应的值? 这一过程其实就是 _compile做的. 让 MyVue能知道 el 上的指令代表什么,要做怎样的处理

附上完整代码:

html
&lt;div id="app">
  &lt;form>
    &lt;input type="text"  v-model="number">
    &lt;button type="button" v-click="increment">增加&lt;/button>
  &lt;/form>
  &lt;h3 v-bind="number">&lt;/h3>
&lt;/div>
&lt;script>
  function MyVue(options) {
    this._init(options)
  }

  MyVue.prototype._init = function(options) {
    this.$options = options
    this.$el = document.querySelector(options.el)
    this.$data = options.data
    this.$methods = options.methods

    this._binding = {}
    this._observe(this.$data)
    this._compile(this.$el)
  }

  MyVue.prototype._observe = function(obj) {
    var value
    for (key in obj) {
      if (obj.hasOwnproperty(key)) {
        this._binding[key] = {
          _directives: []
        }
        value = obj[key]
        if (typeof value === 'object') {
          this._observe(value)
        }
        var binding = this._binding[key]
        Object.defineProperty(this.$data, key, {
          enumerable: true,
          configurable: true,
          get() {
            console.log(`获取${value}`)
            return value
          },
          set(newVal) {
            console.log(`更新${newVal}`)
            if (value !== newVal) {
              value = newVal
              binding._directives.forEach(function(item) {
                item.update()
              })
            }
          }
        })
      }
    }
  }

  MyVue.prototype._compile = function(root) {
    var _this = this
    var nodes = root.children
    for (var i = 0; i &lt; nodes.length; i++) {
      var node = nodes[i]
      if (node.children.length) {
        this._compile(node)
      }

      if (node.hasAttribute('v-click')) {
        node.onclick = (function() {
          var attrVal = nodes[i].getAttribute('v-click')
          return _this.$methods[attrVal].bind(_this.$data)
        })()
      }

      if (node.hasAttribute('v-model') && (node.tagName === 'input' || node.tagName === 'textarea')) {
        node.addEventListener('input', (function(key) {
          var attrVal = node.getAttribute('v-model')
          _this._binding[attrVal]._directives.push(new Watcher(
            'input',
            node,
            _this,
            attrVal,
            'value'
          ))

          return function() {
            _this.$data[attrVal] = nodes[key].value
          }
        })(i))
      }

      if (node.hasAttribute('v-bind')) {
        var attrVal = node.getAttribute('v-bind')
        _this._binding[attrVal]._directives.push(new Watcher(
          'text',
          node,
          _this,
          attrVal,
          'innerHTML'
        ))
      }
    }
  }

  function Watcher(name, el, vm, exp, attr) {
    this.name = name    //指令名称,例如文本节点,该值设为"text"
    this.el = el        //指令对应的DOM元素
    this.vm = vm        //指令所属myVue实例
    this.exp = exp      //指令对应的值,本例如"number"
    this.attr = attr    //绑定的属性值,本例为"innerHTML"

    this.update()
  }

  Wathcer.prototype.update = function() {
    this.el[this.attr] = this.vm.$data[this.exp]
  }

  window.onload = function() {
    var app = new MyVue({
      el: '#app',
      data: {
        number: 0
      },
      methods: {
        increment: function() {
          this.number++
        }
      }
    })
  }
&lt;/script>

以上就实现了文本与input的双向绑定

共 20 个模块,1301 篇 Markdown 文档。