从0-1简单实现VUE双向数据绑定

302 阅读6分钟

1. 核心技术

掌握核心技术,事半功倍,关键技术点如下

  • compile : 解析片段,碎片化文档-- documentFragment
  • 观察-订阅者模式,进行数据绑定--数据劫持
  • 动态数据,发布通知

2. 背景代码了解

先附上html关键代码段,Id为app的DOM节点,引入各种关键js文件,具体功能通过文件名可以大致了解

<div id="app">
  <input type="text" v-model="message.a">
  <ul>
    <li>{{message.a}}</li>
  </ul>
  <br>
  <input type="text" v-model="name"> <br>
  我的名字是:{{name}}<br>
  {{number}}元
  <button v-click="increment">增加</button>
</div>

<script src="mvvm.js"></script>
<script src="compile.js"></script>
<script src="observer.js"></script>
<script src="watcher.js"></script>
<script>
new MVVM({
  el: '#app',
  data: {
    message: {
      a: 'hello world'
    },
    name: 'Hi, River'
  },
  methods: {
    increment() {
      console.log(this.name)
      this.number++
    }
  }
})
</script>

new MVVM 实例化代码,开始实例化一个对象

class MVVM {
  constructor(options) {
    this.$el = options.el
    this.$data = options.data
    if (this.$el) {
      new Observer(this.$data) // 1.添加观察者模式
      new Compile(this.$el, this) // 2.编译 解析文档 -- documentFragment
    }
  }
}

3. 解析文档 -- documentFragment

文档解析思路

  • 把template文档片段编译成fragment存在内存中
  • 把fragment片段中的 *{{ }}、v-model 、v-text、v-html、*等替换成data里面中的值
  • 把编译好的 fragment 片段塞入到 dom节点中

3.1 真实DOM移入到内存中fragment

  this.el = document.querySelector(node)
  // 1. 先把这些真实DOM移入到内存中fragment
  let fragment = this.nodeToFragment(this.el)

  nodeToFragment(node) {
    let fragment = document.createDocumentFragment()
    var child
    while (child = node.firstChild) {
      fragment.appendChild(child)
    }
    return fragment
  }

3.2 编译 => 提取想要的元素节点

把fragment片段中的 *{{ }}、v-model 、v-text、v-html、*等替换成data里面中的值

  // 2. 把内存的fragment, 编译 => 提取想要的元素节点
  let fragment = this.compile(fragment)

  compile(fragment) {
    // 当前父节点节点的子节点,包含文本节点,类数组对象
    let childNodes = fragment.childNodes
    Array.from(childNodes).forEach(node => {
      // node 节点有元素节点/文本节点, 需要区分2者进行
      if (this.isElementNode(node)) { // 元素节点
        // 编译元素
        this.compileElement(node)
        // 如果是元素节点,这可能是嵌套内容,所以要在遍历一遍,如 ul > li
        this.compile(node)
      } else { // 文本节点
        this.compileText(node)
      }
    })
  }

编译分为元素编译和文本编译,元素编译的同时,添加元素节点事件监听事件,把实例text值随时更新,元素编译和文本编译编译方法统一放在CompileUtil方法里面,方法如下

  compileElement(node, vm) {
    let attr = node.attributes
    Array.from(attr).forEach(attr => {
      if (attr.name == 'v-model') {
        node.removeAttribute('v-model')
        // node.value = this.getValue(v.value) // 直接赋值

        let [, type] = attr.name.split('-')// 解构赋值[v,model]
        CompileUtil[type](node, this.vm, attr.value)
      } else if (attr.name == 'v-click') {
        let methodName = attr.value
        node.addEventListener('click', function () {
          return vm.$methods[methodName].bind(vm.$data)()
        })
      }
    })
  }

  compileText(node, vm) {
    // let reg = /\{\{(.)*\}\}/g
    let str = node.textContent
    let reg = /\{\{([^}]+)\}\}/g
    if (reg.test(node.textContent)) {
      CompileUtil['text'](node, this.vm, str)
    }
  }

CompileUtil方法统一对文本,元素节点,等进行归一放置

CompileUtil = {

  model(node, vm, key) {
    let updateFn = this.updater['modelUpdater']
    
    // 为每个元素节点添加监听事件,如果值变化,则动态更改data中对应的对象的值。
    node.addEventListener('input', e => {
      let newValue = e.target.value
      this.setValue(vm, key, newValue) // view -> model 即 VM模式
    })
    updateFn && updateFn(node, this.getVal(vm, key))
  },

  text(node, vm, str) {
    // str是一个缓存字符串,保留原始的字符串结构,如 ‘我的名字是:{{name}}’, 后面会针对{{}}内容进行替换
    let updateFn = this.updater['textUpdater']
    let value = this.getTextVal(vm, str)

    updateFn && updateFn(node, value)
  },
  
  updater: {
    // 更新文本
    textUpdater(node, value) {
      node.textContent = value
    },
    // 更新输入框的值
    modelUpdater(node, value) {
      node.value = value
    }
  }

通过方法setValue 动态更改 data 对应的值, 相反获取data对应的值通过 getVal获得。

  setValue(vm, target, newValue) {
    let keys = target.split('.') // 将对象先拆开成数组
    // 收敛
    return keys.reduce((prev, next, currentIndex) => {
      // 如果到对象最后一项时则开始赋值,如message:{a:1}将拆开成message.a = 1
      if (currentIndex === keys.length - 1) {
        return prev[next] = newValue
      }
      return prev[next]
    }, vm.$data)
  }

  getVal(vm, expr) {
    expr = expr.split('.')
    return expr.reduce((prev, next) => { // vm.$data.a.b
      return prev[next]
    }, vm.$data)
  }

3.3 把编译好的 fragment 片段塞入到 dom节点中

this.el.appendChild(fragment)

以上步骤做好之后,会发现input框输入的时候,data中对应的值已经可以动态做变化了,但页面做数据绑定的地方,没有做对应的变化。 MVVM模式中的 view -> model 已经打通了,接下来要实现的就是 model -> view 的动态更新。

4.观察/订阅者模式,进行数据绑定–数据劫持

通过修改vm实例的属性,该改变输入框的内容与文本节点的内容。当我们修改输入框,改变了vm实例的属性,这是1对1的。

但是,我们可能在页面中多处用到 data中的属性,这是1对多的。也就是说,改变1个model的值可以改变多个view中的值。

订阅/发布者模式 订阅发布模式(又称观察者模式)定义了一种一对多的关系,让多个观察者同时监听某一个主题对象,这个主题对象的状态发生改变时就会通知所有观察者对象。 发布者发出通知 => 主题对象收到通知并推送给订阅者 => 订阅者执行相应操作

接下来该如何添加订阅/观察者模式呢?再复习一下原理图

compile 编译 HTML 的过程中,会为每个与数据绑定相关的节点生成一个订阅者 watcher,watcher 会将自己添加到相应属性的 dep 容器中。

  • 修改输入框内容 => 在事件回调函数中修改属性值 => 触发属性的 set 方法。
  • 发出通知 dep.notify() => 触发订阅者的 update 方法 => 更新视图。

这里的关键逻辑是:如何将 watcher 添加到关联属性的 dep 中。 注意: 把所有赋值的操作改为了 添加一个 Watcher 订阅者

CompileUtil = {

  model(node, vm, key) {
    let updateFn = this.updater['modelUpdater']

 +   // 通过观察者进行赋值
 +   new Watcher(vm, key, () => {
 +     updateFn && updateFn(node, this.getVal(vm, key))
 +   })
    
    // 为每个元素节点添加监听事件,如果值变化,则动态更改data中对应的对象的值。
    node.addEventListener('input', e => {
      let newValue = e.target.value
      this.setValue(vm, key, newValue) // view -> model 即 VM模式
    })
    updateFn && updateFn(node, this.getVal(vm, key))
  },

  text(node, vm, str) {
    // str是一个缓存字符串,保留原始的字符串结构,如 ‘我的名字是:{{name}}’, 后面会针对{{}}内容进行替换
    let updateFn = this.updater['textUpdater']
    let value = this.getTextVal(vm, str)

  +  str.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
  +    // 解析时遇到模板中需要替换为数据值的变量时,应添加一个观察者
  +    // 当变量重新赋值时,调用更新值节点到Dom的方法
  +    // new(实例化)后将调用observe.js中get方法
  +    let key = arguments[1]
  +    new Watcher(vm, key, () => {
  +      updateFn && updateFn(node, this.getTextVal(vm, str))
  +    }) // 通过观察者进行赋值
  +  })

    updateFn && updateFn(node, value)
  },
  ...

到目前为止,可能会觉得有点乱,因为observer还没做介绍。

observer -> dep -> watcher 是如何串联起来,他们都做了些什么,咋们一个个过一遍

4.1 observer

observer其实原理很简单,就是把data中的各个对象的值,通过Object.defineProperty进行一一绑定。

observer(data) {
  //将data数据原有属性改成set和get的形式,如果data不为对象,则直接返回
  if (!data || typeof data !== 'object') {
    return
  }
  Object.keys(data).forEach(key => {
    // 数据劫持--数据绑定
    this.defineReactive(data, key, data[key])
    this.observer(data[key]) // 递归
  })
}

数据绑定方法 defineReactive,先大致了解一下关键的代码 Object.defineProperty,dep相关可以暂时跳过, 如下

defineReactive(obj, key, value) {
    let dep = new Dep()
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        if (Dep.target) {
          dep.addSub(Dep.target)
        }
        return value
      },
      set(newValue) {
        if (value !== newValue) {
          console.log('我是值变化了', newValue)
          value = newValue

	  // 一旦数据变化,立马通知订阅者
          dep.notify()
        }
      }
    })
  }

4.2 dep

dep做了什么呢,其实dep就是把每个观察者和observer观察的对象关联起来,建立个一对多的关系。subs保留所有观察者,也就是每一个watcher。

class Dep {
  constructor() {
    // 订阅的数组
    this.subs = []
  }
  // 保存观察者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 广播
  notify() {
    this.subs.forEach(function (sub) {
      sub.update()
    })
  }
}

4.3 watcher

watcher 又做了些什么呢? 编译过程中是不直接把data中的值赋值到 {{ xx }} 中的xx,而是到每个赋值的地方,通过添加一个新的订阅者 new Watcher。创建watcher过过程中,发生以下几件事情。

  • 把 watcher 赋值给全局对象 Dep.target
  • 把 vm, node, name 存在当前对象中
  • 进行数据赋值更新,也就是通过获取data中的值(关键步骤:改方法会触发步骤4.1中 Object.defineProperty的get方法,此时get方法中判断到 Dep.target 不为空,则把该watcher添加到订阅列表dep.subs中 ),多么巧妙的设计。
  • watcher做完以上步骤之后,把Dep.target置空,以免出现订阅者和观察者出现紊乱的情况。
class Watcher {
  constructor(vm, name, cb) {
    Dep.target = this // Dep.target 是一个全局变量
    this.vm = vm
    this.name = name
    this.cb = cb
    this.update()
    Dep.target = null
  }

  update() {
    let newValue = this.getVal(this.vm, this.name)
    this.cb(newValue)
  }

  getVal(vm, key) {
    let keys = key.split('.')
    return keys.reduce((prev, next) => { // vm.$data.a.b
      return prev[next]
    }, vm.$data)
  }

}

通过以上 4.1,4.2,4.3 三个步骤之后,observer -> dep -> watcher 建立起彼此的关联。 实现了双向数据绑定,4.3 watcher 中的update方法混合着2种更新方法,每个对应的订阅者,其实只需要其中的一个,如input输入框值的变化,其实只需要(html->data)进行更改;反过来,data中的值变化,只需要(data->html)的单向传递,对该方法进行优化,更新的方法由创建new Watcher的地方进行传入,作为回调方法,回调方法指向CompileUtil对应的2个方法

  // 更新文本
  textUpdater(node, value) {
    node.textContent = value
  }

  // 更新输入框的值
  modelUpdater(node, value) {
    node.value = value
  }

截止到目前为止,我们实现了 v-model -> data, data-> v-model 的双向数据绑定, 同时实现了v-click方法,但VUE的语法中还存在 v-text, v-html,v-on等指令,如有兴趣,后期读者可以自行实现以上方法。

5.写在最后

截止到目前为止,VUE的双向数据实现原理大体上已经打通了,但还有很多领域知识点还为了解和学习。

如 Object.difineProperty 其实并不是想象中那么美好,是否有可替代的方法进行优化改进呢,答案是肯定的,vue3.0已经对该种方式的数据劫持进行了改进,严格来说应该是选用替代方法,即ES6中的proxy代理。

那么proxy代理如何实现双向数据绑定呢,请移步到 proxy模拟实现双向数据绑定 进行了解。

本文所用到的 源码