vue中mvvm模式实现

1,024 阅读2分钟

vue框架是典型的MVVM(Model-View-ViewModel)模式的前端框架,它最大的特点就是,View和ViewModel之间做了双向数据绑定,当Model发生变化的时候,绑定Model数据的View会随之发生变化,当View发生变化时,对应的Model也会随之变化。

我们现在就来实现一个小巧简单的mvvm框架吧~ 初步的文件结构设计如下: undefined

index.html

页面框架,创建MVVM实例,挂载页面元素和数据。分别引入watcher.jsobserver.jscompile.jsmvvm.js文件,后面会介绍具体的作用。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>My MVVM</title>
</head>

<body>
  <div id="app">
    <!-- 双向数据绑定 -->
    <input type="text" v-model='msg'> {{msg}}
  </div>
</body>
<script src="./watcher.js"></script>
<script src="./observer.js"></script>
<script src="./compile.js"></script>
<script src="./mvvm.js"></script>
<script>
  // mvvm如何实现?
  // vue中实现双向绑定 1.模板编译 2.数据劫持 3.Watcher
  let vm = new MVVM({
    el: '#app', //el:document.getElementById('app')
    data: {
      msg: 'hello'
    }
  })
</script>

</html>

以下js代码带有详细的注释,边阅读文章边动手实践效果更佳哦~

mvvm.js

mvvm.js定义了MVVM类,首先把可用的属性全部挂载到实例上。如果有要编译的模板就开始编译,涉及到数据劫持、数据代理、模板编译三个阶段。其中,数据劫持是把对象的所有属性改为get、set;代理数据阶段让this.$data下的数据都代理到this(MVVM)中,能让用户方便地从this.xx进行取值,而不是需要从this.$data.xx进行取值;模板编译阶段是使用数据和元素进行编译,返回含有完整数据内容的页面。

undefined

class MVVM {
  constructor(options) {
    // 先把可用的东西挂载到实例上
    this.$el = options.el
    this.$data = options.data

    // 如果有要编译的模板就开始编译
    if (this.$el) {
      new Observer(this.$data)  // 数据劫持,把对象的所有属性改为get、set
      this.proxyData(this.$data)
      new Compile(this.$el, this)  // 用数据和元素进行编译
    }
  }

  // 代理数据,因为用户可能要通过this.msg取值,而不是this.$data.msg取值
  proxyData(data) {
    Object.keys(data).forEach(key => {
      Object.defineProperty(this, key, {
        get() {
          return data[key]
        },
        set(newVal) {
          data[key] = newVal
        }
      })
    })
  }
}

compile.js

compile.js是将数据和页面元素组合起来,返回含有对应数据内容的完整页面,用于浏览器渲染。如果存在模板,则需要进行以下几步:

  1. 把真实的dom移入到内存,放到fragment(node2Fragment(el)函数)
  2. 进行编译(compile(fragment)函数),提取元素节点和文本节点,针对元素节点和文本节点实行不同的编译方式:
    • 如果是元素节点,则需要先进行元素节点编译(compileElement(node)函数),再进行递归
    • 如果是文本节点,则直接编译文本节点(compileText(node)函数),提取{{}}中的内容进行数据填充
  3. 把编译好的element放回页面

undefined

class Compile {
  constructor(el, vm) {
    // 判断el是否是元素节点,如果是html的元素节点则直接返回,否则使用document.querySelector找到el节点并返回
    this.el = this.isElementNode(el) ? el : document.querySelector(el)
    this.vm = vm
    if (this.el) {
      // 1. 先把真实的dom移入到内存,放到fragment
      let fragment = this.node2Fragment(this.el)
      // 2. 编译——提取想要的元素节点和文本节点 v-model {{}}
      this.compile(fragment)
      // 3. 把编译好的element放回页面
      this.el.appendChild(fragment)
    }
  }

  // 辅助方法

  isElementNode(node) {
    return node.nodeType === 1
  }
  isDirective(name) {
    return name.includes('v-')
  }

  // 核心方法

  // 将el元素内容全部放入内存
  node2Fragment(el) {
    let fragment = document.createDocumentFragment() //文档碎片
    let firstChild
    while (firstChild = el.firstChild) {
      fragment.appendChild(firstChild)
    }
    return fragment
  }

  // 编译
  compile(fragment) {
    // childNodes拿不到嵌套子节点,需要使用递归
    let childNodes = fragment.childNodes
    Array.from(childNodes).forEach(node => {
      // 元素节点
      if (this.isElementNode(node)) {
        this.compileElement(node)
        this.compile(node) // 需要深入检查, 使用递归
      } else {
        // 文本节点
        this.compileText(node)
      }
    })
  }

  // 编译元素 v-model、v-text等
  compileElement(node) {
    let attrs = node.attributes
    Array.from(attrs).forEach(attr => {
      // 判断属性名字是否包含v-
      let attrName = attr.name
      if (this.isDirective(attrName)) {
        let expr = attr.value //expr是指令的值
        // node this.vm.$data expr
        //取到v-后面的名称,如v-model的model,v-text的text等等
        // let type = attrName.slice(2)
        let [, type] = attrName.split('-')
        Util[type](node, this.vm, expr)
      }
    })
  }

  // 编译文本,{{}}
  compileText(node) {
    let expr = node.textContent
    let reg = /\{\{([^}]+)\}\}/g //匹配{{}}
    if (reg.test(expr)) {
      const type = 'text'
      Util[type](node, this.vm, expr)
    }
  }
}

Util = {
  // 获取实例上对应的数据,如msg.a.b=>'hello'
  // msg.a.b=>this.$data.msg=>this.$data.msg.a=>this.$data.msg.a.b
  getVal(vm, expr) {
    expr = expr.split('.')
    return expr.reduce((prev, next) => {
      return prev[next]
    }, vm.$data)
  },
  // 获取编译文本后的结果,如{{msg}}=>'hello'
  getTextVal(vm, expr) {
    return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
      // arguments[1]是正则匹配括号内容,如{{msg}}的msg
      return this.getVal(vm, arguments[1])
    })
  },
  // 赋值
  // 例如给msg.a.b赋新值,则取到最后再赋value值
  setVal(vm, expr, value) {
    expr = expr.split('.')

    return expr.reduce((prev, next, curIndex) => {
      if (curIndex === expr.length - 1) {
        return prev[next] = value
      }
    }, vm.$data)
  },
  // 文本处理
  text(node, vm, expr) {
    let updateFn = this.update['textUpdater']

    // 拿到{{a}}{{b}}的a、b
    expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
      new Watcher(vm, arguments[1], newVal => {
        // 如果数据变化了, 文本节点需要重新获取依赖的数据来更新文本节点
        updateFn && updateFn(node, this.getTextVal(vm, expr))
      })
    })

    let value = this.getTextVal(vm, expr)
    updateFn && updateFn(node, value)
  },
  // 输入框处理 
  model(node, vm, expr) {
    let updateFn = this.update['modelUpdater']
    // 这里应该加一个监控,数据变化时,应该调用watcher的callback,将新值传递过来
    new Watcher(vm, expr, newVal => {
      updateFn && updateFn(node, this.getVal(vm, expr))
    })
    updateFn && updateFn(node, this.getVal(vm, expr))
    node.addEventListener('input', e => {
      let newVal = e.target.value
      this.setVal(vm, expr, newVal)
    })
  },
  update: {
    // 文本更新
    textUpdater(node, value) {
      node.textContent = value
    },
    // 输入框更新
    modelUpdater(node, value) {
      node.value = value
    }
  }
}

observer.js

observer.js是将页面中绑定的数据全部变为响应式,即将data数据原有的属性改为get和set的形式,使用defineReactive函数进行数据劫持(这里要注意,如果劫持的是对象,还要对对象内的属性继续劫持)。在defineReactive函数中,我们针对每个数据都新建了Dep的实例,Dep是典型的用来发布订阅的类(见下文的watcher.js),可以用来添加订阅者信息和触发数据更新。在这个函数中,使用Object.defineProperty进行了数据劫持,在数据发生变化时(对应set),通知所有该数据的订阅者数据变化了,会让对应的订阅者进行更新操作。

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

  // 将data数据原有的属性改为get和set的形式
  observe(data) {
    if (!data || typeof data !== 'object') return
    Object.keys(data).forEach(key => {
      // 开始劫持
      this.defineReactive(data, key, data[key])
      // 如果劫持的是对象,还要对对象内的属性继续劫持
      this.observe(data[key])
    })
  }

  // 定义响应式
  defineReactive(data, key, value) {
    let _this = this
    let dep = new Dep() //每个变化的数据都会对应一个数组,这个数组是存放所有更新的操作
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      get() {
        Dep.target && dep.addSub(Dep.target)
        return value
      },
      set(newValue) {
        if (newValue !== value) {
          // 设置新值时,如果是对象仍然需要劫持
          _this.observe(newValue)
          value = newValue
          dep.notify() //通知所有订阅者数据变化了
        }
      }
    })
  }
}

watcher.js

watcher.js定义了观察者类,用来给需要变化的dom元素增加观察者。使用新值和旧值进行比对,如果发生变化,执行对应的方法(如更新页面)。

class Watcher {
  constructor(vm, expr, cb) {
    this.vm = vm
    this.expr = expr
    this.cb = cb
    this.value = this.get()
  }

  // 获取实例上对应的数据,如msg.a.b=>'hello'
  getVal(vm, expr) {
    expr = expr.split('.')
    return expr.reduce((prev, next) => {
      return prev[next]
    }, vm.$data)
  }

  get() {
    Dep.target = this
    let value = this.getVal(this.vm, this.expr)
    Dep.target = null
    return value
  }

  // 对外暴露的方法
  update() {
    let newVal = this.getVal(this.vm, this.expr)
    let oldVal = this.value
    if (newVal !== oldVal) {
      this.cb(newVal)
    }
  }
}

// 发布订阅
class Dep {
  constructor() {
    // 订阅数组
    this.subs = []
  }

  // 添加订阅
  addSub(watcher) {
    this.subs.push(watcher)
  }

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

测试html页面,可以成功实现mvvm,view和model已经完成了双向绑定。大功告成~!