实现一个简单Vue双向数据绑定


我们在使用vue时,将需要双向绑定数据的输入控件上使用v-model指令,就可以让数据与视图同步更新,那么这到底是怎么做的呢?今天我们自己来实现一个简单MVVM,去探究下数据双向绑定的原理。

页面准备

搭好基础页面,方便随时查看和调试

...
<body>
  <div id="app">
    <h1>Simple V-Model</h1>
    <input type="text" v-model="name" />
    <h2>姓名:{{name}}</h2>
    <hr>
    <input type="number" v-model="age" />
    <h2>年龄:{{age}}</h2>
  </div>
</body>
<script>
  const vm = new Vue({
    el: '#app',
    data: {
      name: '中国',
      age: 72,
    },
  });
</script>

然后随便写几行样式,预览一下

预览

开始编码

观察vue初始化结构可知,new Vue() 时传入了一些基础配置,el为挂载的容器,data为响应式数据,我们将这些都挂载到Vue的构造函数上。因此我们先写出Vue类,此处我用函数写的

function Vue(option){
  this.$el = document.querySelector(option.el);
  this.$data = option.data;
}

下一步需要思考的是如何去处理data中的数据。在Vue中,处理数据用的是Object.defineProperty来劫持对象数据的,对于数组而言,则是重写能改变原数组的一些方法,此例中暂不考虑数组

定义observe方法来劫持数据

function observe(data) {
  // 先判断是否为对象,不是直接跳出
  if (({}).toString.call(data) !== '[object Object]') return;
  let keys = Object.keys(data)
  keys.forEach(key => {
    defineReactive(data, key, data[key])
  })
}

使用defineReactive方法来对数据进行处理,内部是采用Object.defineProperty重写setter 和 getter。源码方法名为defineReactive$$1

function defineReactive(obj, key, value) {
  Object.defineProperty(obj, key, {
    get() {
      return value
    },
    set(newVal) {
      value = newVal
    },
    enumerable: true
  })
}

数据处理完,下一步我们要对模板进行编译,vue中采用的VNode,这里为了简便,我们用文档碎片来代替VNode实现

function Vue(option) {
  this.$el = document.querySelector(option.el)
  this.$data = option.data
  // 数据劫持
  observe(this.$data)
  // dom编译
  nodeToFragment(this.$el)
}

实现nodeToFragment方法。

function nodeToFragment(el) {
  // 创建文档碎片
  let fragment = document.createDocumentFragment()
  let child;
  // 将每一个文档节点进行编译,然后添加到文档碎片中
  while (child = el.firstChild) {
    compile(child)
    fragment.appendChild(child)
  }
  // 将文档碎片加载到el上,挂载
  el.appendChild(fragment)
}

编译时,我们需要根据节点的nodeType来判断是元素还是文本类型

nodeType常用四种类型:1(元素)3(文本)8(注释)9(DOM根节点)

编译时,我们需要取到实例中data中的值,因此需要将this传入

function compile(node, vm) {
  // 获取当前节点的节点类型
  let nodeType = node.nodeType
  if (nodeType == 1) {
    // 如果是元素节点
    // 获取当前元素的所有属性
    let attrs = node.attributes;
    [...attrs].forEach(attr => {
      // 是否是以 v- 开头
      // 此处我们只考虑 v-model
      if (/^v-/.test(attr.nodeName)) {
        // 拿到属性名 即 attr.nodeValue
        let attrName = attr.nodeValue
        // 只考虑 input 情况
        // 将data中对应的值赋给input.value
        let val = vm.$data[attrName]
        node.value = val
      }
    });
    // 以上只是处理了当前元素
    // 对于元素的子元素,进行递归编译
    [...node.childNodes].forEach(childNode => {
      compile(childNode, vm)
    })
  } else {
    // 如果是文本节点
    // 再次重申:我们只考虑了元素和文本类型
    let text = node.textContent
    let reg = /\{\{(\w+)\}\}/
    if (reg.test(text)) {
      // 如果是{{}}的结构,就直接替换为值
      text = text.replace(reg, (a, b) => {
        return vm.$data[b]
      })
      node.textContent = text
    }
  }
}

至此,基本的功能已经完成。但是,我们在控制台改变数据时,视图并没有得到更新。

此时,我们需要一种方法去监视数据的改变。我们使用观察者模式来完成。
先创建监视器,在Dep中存放所有的监视器

class Dep {
  constructor() {
    this.subs = []
  }
  // 添加订阅
  addSub(sub) {
    this.subs.push(sub)
  }
  // 通知更新
  notify() {
    this.subs.forEach(sub => {
      // todo 需要通知后做的事情
    })
  }
}

为了能监视到所有属性的变化,我们在劫持属性时,就给每一个属性新增监视器

function defineReactive(obj, key, value) {
  // 添加监视器
  let dep = new Dep()
  Object.defineProperty(obj, key, {})
}

添加完监视器,我们还需要一个监视者,需要知道监视的对象、属性等

class Watcher {
  constructor(target, key, vm) {
    // 将当前的watcher实例赋值给Dep.target
    Dep.target = this
    this.target = target
    this.key = key
    this.vm = vm
    // 获取值
    this.getValue()
    // 最后将Dep.target设为null是为了防止重复添加watcher
    Dep.target = null
  }
  getValue() {
    this.value = this.vm.$data[this.key]
  }
}

在取值时,我们就收集watcher,当值发生变化时,通知更新

function defineReactive(obj, key, value) {
  let dep = new Dep()
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.addSub(Dep.target)
      }
      return value
    },
    set(newVal) {
      if (newVal !== value) {
        value = newVal
        dep.notify()
      }
    },
    enumerable: true
  })
}

notify需要做什么呢?当然是通知更新视图了。在Watcher中,添加update方法。

...
update() {
  // 更新时取到最新的值
  this.getValue()
  if (this.target.nodeType == 1) {
    // 如果是 input,就将值赋给input.value
    this.target.value = this.value
  } else {
    // 若是文本,直接赋值textContent
    this.target.textContent = this.value
  }
 }

在触发getter的地方,放上我们的观察者

function compile(node, vm) {
  let nodeType = node.nodeType
  if (nodeType == 1) {
    let attrs = node.attributes;
    [...attrs].forEach(attr => {
      if (/^v-/.test(attr.nodeName)) {
        let attrName = attr.nodeValue
        let val = vm.$data[attrName]
        new Watcher(node, attrName, vm)
        node.value = val
      }
    });

    [...node.childNodes].forEach(childNode => {
      compile(childNode, vm)
    })
  } else {
    let text = node.textContent
    let reg = /\{\{(\w+)\}\}/
    if (reg.test(text)) {
      text = text.replace(reg, (a, b) => {
        new Watcher(node, b, vm)
        return vm.$data[b]
      })
      node.textContent = text
    }
  }
}

此时,我们再在控制台修改数据,就会发现页面上数据会同步变化。但还存在一个问题,我们在input中输入的文本并没有同步到text中,此时只需要在编译时给input添加一个事件即可

...
node.addEventListener('input', (e) => {
  vm.$data[attrName] = e.target.value
 })

到此,简单版双向绑定就搞定了。由于本次只是梳理双向数据绑定的基本原理,并没有对其它内容进行代码兼容处理,很多地方都采用了简化处理,想进一步了解原理,建议还是阅读下源码,大有裨益。
最后附上完整版代码,仅供参考,不足之处,望指正。

function Vue(option) {
  this.$el = document.querySelector(option.el)
  this.$data = option.data
  // 数据劫持
  observe(this.$data)
  // dom编译
  nodeToFragment(this.$el, this)
}


function observe(data) {
  if (({}).toString.call(data) !== '[object Object]') return;
  let keys = Object.keys(data)
  keys.forEach(key => {
    defineReactive(data, key, data[key])
  })
}

function defineReactive(obj, key, value) {
  let dep = new Dep()
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.addSub(Dep.target)
      }
      return value
    },
    set(newVal) {
      if (newVal !== value) {
        value = newVal
        dep.notify()
      }
    },
    enumerable: true
  })
}

function nodeToFragment(el, vm) {
  let fragment = document.createDocumentFragment()
  let child;
  while (child = el.firstChild) {
    compile(child, vm)
    fragment.appendChild(child)
  }
  el.appendChild(fragment)
}

function compile(node, vm) {
  let nodeType = node.nodeType
  if (nodeType == 1) {
    let attrs = node.attributes;
    [...attrs].forEach(attr => {
      if (/^v-/.test(attr.nodeName)) {
        let attrName = attr.nodeValue
        let val = vm.$data[attrName]
        new Watcher(node, attrName, vm)
        node.value = val
        node.addEventListener('input', (e) => {
          vm.$data[attrName] = e.target.value
        })
      }
    });

    [...node.childNodes].forEach(childNode => {
      compile(childNode, vm)
    })
  } else {
    let text = node.textContent
    let reg = /\{\{(\w+)\}\}/
    if (reg.test(text)) {
      text = text.replace(reg, (a, b) => {
        new Watcher(node, b, vm)
        return vm.$data[b]
      })
      node.textContent = text
    }
  }
}

class Dep {
  constructor() {
    this.subs = []
  }
  addSub(sub) {
    this.subs.push(sub)
  }
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}

class Watcher {
  constructor(target, key, vm) {
    Dep.target = this
    this.target = target
    this.key = key
    this.vm = vm
    this.getValue()
    Dep.target = null
  }
  getValue() {
    this.value = this.vm.$data[this.key]
  }
  update() {
    this.getValue()
    if (this.target.nodeType == 1) {
      this.target.value = this.value
    } else {
      this.target.textContent = this.value
    }
  }
}

文章作者: 塵影
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 塵影学习笔记 !
评论
  目录