乐趣区

关于前端:VUE从源码角度说清楚MVVM实现vmodel真的很简单

前言

大家好,我是HoMeTown,明天聊一聊老成长谈的Vue 之双向数据绑定

What?

首先咱们先看什么是 双向数据绑定

对于不是很理解 设计模式 的敌人,你能够先了解一下 单向数据绑定 ,就是把 数据 绑定到 视图 ,每次触发操作批改了 数据 视图 就会更新,数据 -> 视图,能够了解为 MV 数据驱动视图

举个🌰:

通过点击按钮 set name,触发点击事件,手动更新变量name 的值为 HoMeTown,然而当我扭转input 输入框里的值,变量 name的值却不变,如下图:

那么双向数据绑定就是在单向的根底上,通过操作更新 视图 数据 自动更新,那下面的🌰来讲,就是我输出 Input,变量 name 的值动静扭转。视图 -> 数据,能够了解为 VM 视图驱动数据

How?

Vue中的双向数据绑定由三个重要局部组成:

  • 数据层(Model):利用的数据及业务逻辑
  • 视图层(View):利用的展现成果,各类 UI 组件
  • 业务逻辑层(ViewModel):框架封装的外围,它负责将数据与视图关联起来

这个分层的架构计划,用专业术语来讲就是MVVM

ViewModel

ViewModel 干了两件事儿:

  • 数据变动后更新视图
  • 视图变动后更新数据

它由两个重要局部组成:

  • 监听器(Ovserver):对所有数据的属性进行监听
  • 解析器(Compiler):对每个元素节点的指令进行扫描解析,依据指令模板替换数据,以及绑定相应的更新函数

入手实现

要做什么

Vue 中,双向数据绑定的流程为:

  • new Vue()执行初始化,对 data 执行响应化解决,这个过程产生在 Observe
  • 对模板执行编译,找到其中动静绑定的数据,从 data 中获取并初始化视图,这个过程产生在 Compile
  • 定义一个更新函数和 Watcher,未来对应数据变动时,Watcher 调用更新函数
  • 因为 data 的某个属性在视图中可能呈现 N 次,所以每个属性都须要一个 Dep 来治理多个Watcher
  • data 中的数据一旦发生变化,首先会找到对应的 Dep,而后告诉这个Dep 下所有的 Watcher 执行 更新函数

参考下图:

Do it

首先定义一个 Vue 类,做三件事

  • 数据劫持
  • 属性代理
  • 模板编译

    class Vue {constructor(options) {
          this.$data = options.data
          this.$options = options
          // 数据劫持
          observe(this.$data)
          
          // 属性代理
          proxy(this.$data)
          
          // 模板编译
          compile(el, this)
      }
    }

    接下来开始实现 observe 函数,做三件事

  • 递归 data,劫持每一个
  • getter 的时候收集
  • setter 的时候告诉执行

    function observe(obj) {
      // 递归终止条件
      if(!obj || typeof obj !== 'object') return // 是空的 && 不是一个对象
      
      Object.keys(obj).forEach( key => {
          // 以后 key 对应的 value
          const value = obj[key]
          
          // value 能到这里,有可能是 object,须要递归劫持
          observe(value)
          
          // 为以后的 key 所对应的属性增加 getter & setter
          Object.defineProrerty(obj, key, {
              // 当且仅当该属性的 `enumerable` 键值为 `true` 时,该属性才会呈现在对象的枚举属性中。enumerable: true,
              // 当且仅当该属性的 `configurable` 键值为 `true` 时,该属性的描述符才可能被扭转,同时该属性也能从对应的对象上被删除。configurable: true,
              get() {
                  // 将 new 进去的 Watcher 实例进行收集
                  Dep.target ? dep.addSub(Dep.target) : null
              },
              set(newValue) {if( val !== newValue) dep.notify() // 告诉执行}
          })
      })
    }

接下来实现 Dep 类,做两件事儿:

  • 依赖收集
  • 告诉执行

    // 依赖收集的类
    class Dep {constructor() {
          // 所有 Watcher 实例存在这里
          this.subs = []}
      // 增加 Watcher 实例
      addSub(watcher) {this.subs.push(watcher)
      }
      // 告诉 Watcher 实例执行更新函数
      notify() {this.subs.forEach( w => w.update())
      }
    }

接下来实现订阅者 Watcher 类,做两件事:

  • 提供 Dep.target
  • 提供更新数据的办法

    class Watcher {
      // callback 中,记录了以后 watcher 如何更新本人的文本内容
      // 与此同时,须要拿到最新的数据,所以,在 new Watcher 的时候,须要传递 vm 进来
      // 因为须要晓得在 vm 很多属性中,哪个数据,才是以后本人所须要的数据,所以,new Watcher 的时候,须要指定 key
      constructor(vm, key, callback) {
          this.vm = vm
          this.key = key
          this.callback = callback
          // 把创立的 watcher 实例,在 Dep.addSub 时,存进 Dep 的 subs 里
          Dep.target = this; // 自定义 target 属性
          key.split(".").reduce((newobj, k) => newobj[k], vm);
          Dep.target = null;
      }
      // 发布者告诉 Watcher 更新的办法
      update() {const value = this.key.split(".").reduce((newobj, k) => newobj[key], this.vm);
          
          this.callback(value)
      }
    }
    

最初实现compile,对 HTML 构造进行模板编译的办法:

function compile(el, vm) {
    // 获取 elDom 元素
    vm.$el = document.querySelector(el);
    // 创立文档碎片,进步 Dom 操作性能
    const fragment = document.createDocumentFragment();
    // 取出来
    while ((childNode = vm.$el.firstChild)) {fragment.appendChild(childNode);
    }
    
    // 进行模板编译
    replace(fragment)
    
    // 放进去呀
    vm.$el.appendChild(fragement)
    
    function replace(node) {
      // 定义匹配插值表达式的正则
      const regMustache = /\{\{\s*(\S+)\s*\}\}/;
      // \S 匹配任何非空白字符
      // \s 匹配任何空白字符,包含空格、制表符、换页符等等。// ()非空白字符提取进去,用一个小括号进行分组
      
      // 以后的 node 节点是一个文本子节点,须要进行替换
      if(node.nodeType == 3) {
          const text = node.textContent // 文本子节点的字符串内容
          
          const execResult = regMustache.exec(text) // 为一个数组,索引为 0 的为{{name}}, 为 1 的为 name,exec() 办法用于检索字符串中的正则表达式的匹配。if(execResult) {const value = execResult[1].split(".").reduce((newobj, k) => newobj[k], vm)
              
              node.textContent = text.replace(regMustache, value)
              
              // 此时,就能够创立 Watcher 实例,将这个办法存到 watcher 上,调用 update 就执行
              new Watcher(vm, execResult[1], (newValue) => {node.textContent = text.replace(regMustache, newValue)
              });
              // good good
          }
          
          // 递归完结
          return
      } 
      
      // 判断以后的 node 节点是否为 input 输入框
      if(node.nodeType === 1 && node.tagName.toUpperCase() === 'INPUT' ){
          // 首先要做 v -model,就得先拿到属性节点
          const attrs = Array.from(node.attributes);
          const findResult = attrs.find((x) => x.name === "v-model");
          if(findResult) {
              // 以后有 v -model,获取值
              const expStr = findResult.value;
              const value = expStr.split(".").reduce((newobj, k) => newobj[k], vm);
              node.value = value;
              
              // 创立 Watcher 实例
              new Watcher(vm, expStr, (newValue) => {node.value = newValue})
              
              // 监听 input 事件,拿到文本框最新的值,而后更新到 vm 上
              node.addEventListener("input", e => {const keys = expStr.split(".")
                  const keysLen = keys.length
                  const obj = keys.slice(0, keysLen - 1).reduce((newobj, k) => newobj[k], vm);
                  obj[keys[keysLen - 1]] = e.target.value
              })
          }
      }
      
      // 走到这,证实不是文本节点,递归解决
      node.childNodes.forEach(child => replace(child))
    }
}

测试

还是用最开始咱们的那个🌰,批改如下:

HTML

<div id="app">
  <p>name:<span id="nameBox">{{name}}</span></p>
  <input v-model="name" id="ipt" type="text" />
  <button id="set">Set name</button>
</div>

JS

  const vm = new Vue({
    el: "#app",
    data: {name: "No name yet!",},
  });
  
  const setBtn = document.getElementById("set");
  setBtn.onclick = function () {vm.name = "Is HoMeTown!!";};

点击按钮,批改 Vue 实例 vm 的属性name = 'Is HoMeTown!!'

能够看到曾经胜利了!这是单向,而后咱们试一试,批改 输入框 的内容,上方 name 的值不会不跟着扭转:

SUCCESS!!!!!!!

总结

Vue 中,双向数据绑定的原理总结的来说有几点:

  • observe 进行数据劫持,getter 时增加Watcher,setter 时告诉Watcher.update
  • Dep 类实现 依赖收集与告诉执行
  • Watcher 类实现 订阅者执行更新
  • compile 进行模板编译,解析 v-model,给input 增加事件

完结~

退出移动版