• 【VUE】从源码角度说清楚MVVM!实现v-model!真的很简单!


    前言

    大家好,我是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)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    接下来开始实现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() // 通知执行
                }
            })
        })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

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

    • 依赖收集
    • 通知执行
    //依赖收集的类
    class Dep {
        constructor() {
            // 所有Watcher实例存在这里
            this.subs = []
        }
        // 添加Watcher实例
        addSub(watcher) {
            this.subs.push(watcher)
        }
        // 通知Watcher实例执行更新函数
        notify() {
            this.subs.forEach( w => w.update())
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    接下来实现订阅者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)
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    最后实现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))
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75

    测试

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

    HTML

    <div id="app">
      <p>name:<span id="nameBox">{{name}}span>p>
      <input v-model="name" id="ipt" type="text" />
      <button id="set">Set namebutton>
    div>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    JS

      const vm = new Vue({
        el: "#app",
        data: {
          name: "No name yet!",
        },
      });
      
      const setBtn = document.getElementById("set");
      setBtn.onclick = function () {
        vm.name = "Is HoMeTown!!";
      };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

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

    在这里插入图片描述

    可以看到已经成功了!这是单向,然后我们试一试,修改输入框的内容,上方name的值不会不跟着改变:

    在这里插入图片描述

    SUCCESS!!!

    总结

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

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

    完结~

  • 相关阅读:
    如何获取大数据平台 CDH 中 hive metastore db 的用户名和密码?
    微信小程序|基于小程序+C#实现聊天功能
    二百零一、Flink——Flink配置状态后端运行后报错:Can not create a Path from an empty string
    Cell和RefCell
    Java面试宝典(超级详细)
    【OCPP】ocpp1.6协议第4.8章节Start Transaction的介绍及翻译
    LeetCode 47 全排列 II - Java 实现
    Java核心知识1:泛型机制详解
    MFC 鼠标悬停提示框
    “益路同行”栏目人物专访第0001期—笨爸爸工房创始人张旭健先生
  • 原文地址:https://blog.csdn.net/isHoMeTown/article/details/127629499