基于vue.js的MVVM实现原理

早期的web开发是由后端为主导的MVC开发,前端的任务只是在本地开发好相应的页面,然后交付给后端开发进行嵌套,这种开发模式下前端需要依赖后端环境,导致开发效率并不高效。从web诞生至2005年,一直处于后端重,前端轻的状态。

2004年,Google发布了一个名为Gmailweb应用,里面用到了大量的ajax技术,能够使页面不刷新向服务器发送请求。加上jQuery的封装,ajax技术很快风靡全球,前后端逐渐分离。但是这种架构还是存在一定的问题:前端的开发业务逐渐增多,整体的内容都放到一起,前端的代码会变得越来越难以维护,而此时前端开发者缺少的是一种具有可行性的开发模式和框架。因此,前端的MV*也随之而来。

一、MVC和MVVM

MVC:前端的MVC和后端类似,具有ModelControllerView。其中,Model负责储存应用的数据,提供数据处理逻辑的接口;Controller负责业务逻辑,根据用户行为对调用Model中的接口;View负责试图展示,将Model中的数据可视化。

MVVM: 即ModelViewViewModel。和MVC相似,Model负责数据,View负责视图,不同之处在于MVVM中的ModelView可以向ViewModel发送变化,ViewModel一旦接受改变,会同步数据到ModelView中。

MVVM模型图

二、主流框架的实现原理

单向绑定非常简单,就是在Model数据发生改变时同时改变View,当我们通过JavaScript来对Model的数据进行更改时会自动更新View。在很久之前就已经有框架实现了这种方式,如backbone.jssproutcore.jssammy.js等,而双向绑定也并不复杂,用户修改视图的方式无非就是填写表单,所以只需要监听表单的输入,用户输入的同时通过发送更改到ViewModel中来对Model进行数据更新。

脏值检测(angular.js): 当应用触发了一些特定的条件后,会遍历$watchList中的属性,进行快照对比,来检查这些属性值是否有发生变化,如果有变化,就用一个变量dirty记录为true,再次进行遍历,如此往复,直到某一个遍历完成时,这些属性值都没有变化时,结束遍历执行更新。 这种方式很直观,但是同时存在很多问题: 脏值检测由于需要涉及到对比,所以不得不保存两份变量,而且还要遍历对象比较每一个属性,对内存和运算的要求较高; 脏值检测还需要预知可能会触发状态变更的时机,意味着需要对部分原生接口进行封装,包括setTimeoutrequestAnimationFrame等。

数据劫持(vue.js):在JavaScript中,可以通过Object.defineProperty来实现对象的劫持,该函数接受三个参数(对象,属性,描述),其中描述包括有六个选项(valuewritablegetsetconfigurableenumerable),描述中的setget方法就是当修改或访问对象属性的时候会触发的函数,我们只要在对象属性发生改变时,进行一些额外的操作,就是所谓的数据劫持了。

const user = {}

Object.defineProperty(user, 'name', {
  get () {
  	console.log(`get user\'s name`)
  },
  set (newVal) {
  	console.log(`set user\'s name to ${newVal}`)
  }
})

user.name // get user's name
user.name = 'test' // set user's name to test

要注意该方法每次只能设置一个属性,要配置所有属性,必须通过遍历来完成;如果后续需要扩展对象,则必须手动对新属性进行设置,这就是为什么不在data中声明的属性无法拥有双向绑定的原因;该方法对数组是无效的,需要另外再封装监听数组的方法。在ES6中这些问题都可以通过Proxy对象解决,这也是vue3.0中会做的一处修改。

三、手写MVVM

主要分为三大模块:

  • Observer – 数据监听器:能够监听对象的所有属性,如有变动可以拿到最新值并发送通知
  • Compiler – 指令解析器:将节点中的指令(v-model{{}}等)替换成数据,并添加数据的订阅,一旦数据变动,收到通知,更新视图
  • Watcher – 订阅发布者:作为连接ObserverCompiler的桥梁,接受一个update函数作为参数,在自身实例化时往订阅器(dep)中添加自己,等到数据变动发送通知时,能够调用自身的update方法
流程图

Observer.js:

class Observer {
  constructor (data) {
    this.observer(data)
  }

  /**
   * 数据劫持
   * @param data
   * @returns {*}
   */
  observer (data) {
    if (data && typeof data === 'object') {
      for (const key in data) {
        let value = data[key]
        this.observer(value) // 递归嵌套对象
        const dep = new Dep() // 为该属性创建订阅
        Object.defineProperty(data, key, {
          get () {
            if (Dep.target) dep.addSub(Dep.target) // 如果节点中有引用,则添加订阅
            return value
          },
          set: (newVal) => {
            if (newVal === value) return // 如果新值没有变化就return,以避免不必要的节点渲染
            this.observer(newVal) // 可能新值是对象
            value = newVal
            dep.notify() // 通知所有订阅更新
          }
        })
      }
    }
  }
}

Compiler.js:

class Compiler {
  constructor (el, vm) {
    this.vm = vm
    const root = document.querySelector(el)
    const fragment = this.nodeToFragment(root)
    this.compile(fragment, vm)
    root.appendChild(fragment) // 最后把fragment添加回节点中
  }

  /**
   * 节点转为fragment
   * @param root 根节点
   * @returns {DocumentFragment}
   */
  nodeToFragment (root) {
    const fragment = document.createDocumentFragment() // fragment不是真实DOM树的一部分,它的变化不会触发DOM树的重新渲染,且不会导致性能等问题
    let child
    while (child = root.firstChild) {
      fragment.appendChild(child) // appendChild具有可移动性,root中child会被移动到fragment中,而不是复制
    }
    return fragment
  }

  /**
   * 解析节点
   * @param node 节点
   */
  compile (node) {
    Array.from(node.childNodes).forEach(child => {
      if (child.nodeType === 1) { // nodeType为1是ELEMENT_NODE
        this.compileElement(child)
        this.compile(child) // 递归解析子节点
      } else if (child.nodeType === 3) { // nodeType为3是TEXT_NODE
        this.compileText(child)
      }
    })
  }

  /**
   * 解析ELEMENT_NODE
   * @param node ELEMENT_NODE节点
   */
  compileElement (node) {
    Array.from(node.attributes).forEach(({ name, value: exp }) => {
      if (name === 'v-model') { // 处理v-model
        node.addEventListener('input', e => {
          this.setValue(exp, e.target.value)
        }) // 监听手动输入,修改数据
        new Watcher(() => {
          node.value = this.getValue(exp)
        }) // 订阅数据,动态修改value
      } else if (name.startsWith(':')) { // 处理动态属性
        new Watcher(() => {
          node.setAttribute(name.slice(1), this.getValue(exp))
        }) // 订阅数据,动态修改属性
      }
    })
  }

  /**
   * 解析TEXT_NODE
   * @param node TEXT_NODE节点
   */
  compileText (node) {
    let text = node.textContent
    if (/\{\{(.+?)\}\}/.test(text)) {
      new Watcher(() => {
        node.textContent = text.replace(/\{\{(.+?)\}\}/g, (full, exp) => {
          return this.getValue(exp)
        }) // 替换所有的双大括号语法
      }) // 订阅数据,动态修改文本内容
    }
  }

  /**
   * 获取数据
   * @param exp 表达式
   * @returns {string}
   *
   * @example getValue(' user.name ') -> this.vm['user']['name']
   */
  getValue (exp) {
    return exp.trim().split('.').reduce((data, key) => {
      return data[key]
    }, this.vm)
  }

  /**
   * 设置数据
   * @param exp 表达式
   * @param value 新的值
   *
   * @example setValue(' user.name ', 'new name') -> this.vm['user']['name'] = 'new name'
   */
  setValue (exp, value) {
    exp.split('.').reduce((data, key, index, arr) => {
      if (index === arr.length - 1) {
        return data[key] = value
      }
      return data[key]
    }, this.vm)
  }
}

Watcher.js:

class Watcher {
  constructor (fn) {
    Dep.target = this
    this.update = fn
    this.update() // 初始化时先进行一次数据渲染,当然真正的订阅发布者模式不会这样做,这里执行是为了避免等到数据更新时才渲染数据
    Dep.target = null
  }
}



class Dep {
  subs = []

  addSub (sub) {
    this.subs.push(sub)
  }

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

最后用一个类来整合上面的内容:

class MVVM {
  constructor (options) {
    this.$el = options.el
    this.$data = options.data
    this.$proxyData()
    new Observer(this.$data)
    new Compiler(this.$el, this)
  }

  /**
   * 数据代理
   * @returns {*}
   *
   * @example vm.user -> vm.$data.user
   */
  $proxyData () {
    for (const key in this.$data) {
      Object.defineProperty(this, key, {
        configurable: true,
        enumerable: true,
        get () {
          return this.$data[key]
        },
        set (newVal) {
          this.$data[key] = newVal
        }
      })
    }
  }
}

实例:

  <div id="app">
    <div>
      <input type="text" v-model="user.name">
      <input type="number" v-model="user.age">
    </div>
    <p :title="user.gender">My name is {{ user.name }}, I am {{ user.age }} years old.</p>
  </div>

  <script src="js/Observer.js"></script>
  <script src="js/Watcher.js"></script>
  <script src="js/Compiler.js"></script>
  <script src="js/MVVM.js"></script>
  <script>
    const app = new MVVM({
      el: '#app',
      data: {
        user: {
          name: 'ysm',
          age: 19,
          gender: 'male'
        }
      }
    })
  </script>

效果:

实例化后
修改输入框和控制台改数据后

可以看出无论是通过input或是控制台修改数据,页面的数据都会动态响应。

欢迎评论区讨论( 根本没人看啊 )。

加入对话

2条评论

  1. 哈哈哈还是有人看的 如果你写个硬币系统我还可以每期给你投两个硬币?

留下评论

邮箱地址不会被公开。 必填项已用*标注

给博主打赏

2元 5元 10元