一起学习、手写MVVM框架

作者: 你看到我的小熊了吗 | 来源:发表于2019-07-17 11:16 被阅读2次

    vue中的数据双向绑定,其实一句话就可以说清楚了:利用 Object.defineProperty(),并且把内部解耦为 Observer, Dep, 并使用 Watcher 相连。
    那根据这句话我们可以把整一个简单的MVVM框架粗分为以下四个模块:
    1.模板编译(Compile)
    2.数据劫持(Observer)
    3.订阅发布(Dep)
    4.观察者(Watcher)
    我们就根据这四个模块来分析、手写一个MVVM框架。
    想看源码的,请直接下滑到最后。

    MVVM类

    和Vue类似,我们构建一个MVVM类,通过new指令创建一个MVVM实例,并传入一个类型为对象的参数option,包含当前实例的作用域el和模板绑定的数据data

    class MVVM {
      constructor(options) {
        // 挂载实例
        this.$el = options.el;
        this.$data = options.data;
    
        // 编译模板
        if(this.$el) {
          // 数据劫持 把对象的所有属性 改成带set 和 get 方法的
          new Observer(this.$data)
          
          // 将数据代理到实例上,直接操作实例即可,不需要通过vm.$data来进行操作
          this.proxyData(this.$data)
          // 用数据和元素进行编译
          new Compile(this.$el, this)
        }
      }
    
      proxyData(data) {
        Object.keys(data).forEach(key => {
          Object.defineProperty(this, key, {
            get() {
              return data[key]
            },
            set(newValue) {
              data[key] = newValue
            }
          })
        })
      }
    } 
    

    MVVM类整合了所有的模块,作为连接CompileObserver的桥梁。

    模板编译(Compile)

    Compile

    compile在编译模板的时候,其实是从指令和文本两个方面来处理的。

    class Compile {
      constructor(el, vm) {
        // 判断是否为DOM,若不是,自己获取
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        if (this.el) {
          // 1. 将真实DOM放进内存中
          let fragment = this.node2fragment(this.el);
          // 2. 开始编译 提取想要的元素节点 v-model 和 文本节点 {{}}
          this.compile(fragment);
          // 3. 将编译好的 fragment 重新放回页面
          this.el.appendChild(fragment);
        }
      }
    
      /**
       * 辅助方法
       * 是否为元素节点
       * @isElementNode
       * 是否为指令
       * @isDirective
       */
      isElementNode(node) {
        return node.nodeType === 1;
      }
      isDirective(name) {
        return name.includes("v-");
      }
    
      /**
       * 核心方法
       */
      compileElement(node) {
        // v-model  v-text
        let attrs = node.attributes; // 取出当前节点的属性
        Array.from(attrs).forEach(attr => {
          let attrName = attr.name;
          if (this.isDirective(attrName)) {
            // 判断属性名是否包含 v-model
    
            // 取到对应的值,放到节点中
            let expr = attr.value;
            let [, type] = attrName.split("-");  //解构赋值v-model-->model
    
            // 调用对应的编译方法, 编译哪个节点,用数据替换掉表达式
            CompileUtil[type](node, this.vm, expr);
          }
        });
      }
    
      compileText(node) {
        let expr = node.textContent; // 取出文本中的内容
        let reg = /\{\{([^]+)\}\}/g; // {{a}} {{b}} {{c}}
        if (reg.test(expr)) {
          // 调用编译文本的方法,编辑哪个节点,用数据替换掉表达式
          CompileUtil["text"](node, this.vm, expr);
        }
      }
    
      // 递归
      compile(fragment) {
        let childNodes = fragment.childNodes;
        Array.from(childNodes).forEach(node => {
          if (this.isElementNode(node)) {
            // 如果是元素的节点,则继续深入检查
            // 编译元素
            this.compileElement(node);
            this.compile(node);
          } else {
            // 文本节点
            // 编译文本
            this.compileText(node);
          }
        });
    
        // Array.from()方法是将一个类数组对象或者可遍历对象转换成一个真正的数组
      }
    
      // 将el中的内容全部放进内存中
      node2fragment(el) {
        // 文档碎片 内存中的 dom 节点
        let fragment = document.createDocumentFragment();
        let firstChild;
        // 把值赋给变量 取不到后返回null,null作为条件
        while ((firstChild = el.firstChild)) {
          // 使用appendChild() 方法从一个元素向另一个元素中移动
          fragment.appendChild(firstChild);
        }
    
        return fragment; // 内存中的节点
      }
    }
    

    CompileUtil

    CompileUtil是一个对象工具,配合Copmpile使用。

    let CompileUtil = {
      model(node, vm, expr) {
        let updateFn = this.updater["modelUpdater"];
    
        /**
         *
         * 这里应该加一个监控,数据变化了 应该调用watch的callback
         * (这里只是记录原始的值 watcher的update没有执行,只有属性的set执行的时候,才会执行cb回调,重新进行真实数据绑定)
         *
         */
        new Watcher(vm, expr, newValue => {
          // 当值变化后会调用cb 将新的值传递过来
          updateFn && updateFn(node, this.getVal(vm, expr));
        });
    
        node.addEventListener("input", e => {
          let newValue = e.target.value;
    
          //监听输入事件,将输入的内容设置到对应数据上
          this.setVal(vm, expr, newValue);
        });
        updateFn && updateFn(node, this.getVal(vm, expr));
      },
      text(node, vm, expr) {
        // 文本处理
        let updateFn = this.updater["textUpdater"];
        let value = this.getTextVal(vm, expr);
    
        expr.replace(/\{\{((?:.|\r?\n)+?)\}\}/g, (...args) => {
          new Watcher(vm, args[1], newValue => {
            // 如果数据变化了,文本节点需要重新获取依赖的属性,更新文本中的内容
            updateFn && updateFn(node, this.getTextVal(vm, expr));
          });
        });
        updateFn && updateFn(node, value);
      },
      getTextVal(vm, expr) {
        // 获取编译文本后的结果
        let value = this.parseText(expr);
        let result = '';
        value.tokens.forEach((item) => {
          if(item.hasOwnProperty('@binding')) {
            result += this.getVal(vm, item['@binding'])
          } else {
            result += item
          }
        })
        return result
      },
      parseText(text) {
        const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
        if (!tagRE.test(text)) {
          return;
        }
        const tokens = [];
        const rawTokens = [];
        let lastIndex = (tagRE.lastIndex = 0);
        let match, index, tokenValue;
        while ((match = tagRE.exec(text))) {
          index = match.index;
          // push text token
          if (index > lastIndex) {
            rawTokens.push((tokenValue = text.slice(lastIndex, index)));
            tokens.push(JSON.stringify(tokenValue));
          }
          // tag token 
          const exp = match[1].trim();
          tokens.push(`_s(${exp})`);
          rawTokens.push({ "@binding": exp });
          lastIndex = index + match[0].length;
        }
    
        if (lastIndex < text.length) {
          rawTokens.push((tokenValue = text.slice(lastIndex)));
          tokens.push(JSON.stringify(tokenValue));
        }
        return {
          expression: tokens.join("+"),
          tokens: rawTokens
        };
      },
      setVal(vm, expr, value) {
        expr = expr.split(".");
        return expr.reduce((prev, next, currentIndex) => {
          if (currentIndex === expr.length - 1) {
            return (prev[next] = value);
          }
          return prev[next];
        }, vm.$data);
      },
      getVal(vm, expr) {
        // 获取实例上对应的数据
        expr = expr.split("."); // {{message.a}} [message, a]
    
        // vm.$data.message => vm.$data.message.a
        return expr.reduce((prev, next) => {
          return prev[next.trim()];
        }, vm.$data);
    
        /**
         *  关于 reduce:
         * arr.reduce(callback,[initialValue])
         */
      },
      updater: {
        // 文本更新
        textUpdater(node, value) {
          node.textContent = value;
        },
        // 输入框更新
        modelUpdater(node, value) {
          node.value = value;
        }
      }
    };
    

    再次认识到正则表达式的重要性。
    在处理{{}}模板引擎的时候,遇到一个bug,在一个DOM节点里,如果有个有多个{{}}{{}}会显示为undefined,后来仔细阅读了vueJs的源码,借鉴其中parseText()方法,进行处理,得以解决。

    数据劫持(Observer)

    什么是数据劫持?

    在访问或修改对象的某个属性时,通过一段代码拦截这个行为,进行额外的操作,或者修改返回的结果。

    数据劫持的作用是什么?

    它是双向数据绑定的核心方法,通过劫持对象属性的settergetter操作,监听数据的变化,同时也是后期ES6中很多语法糖底层实现的核心方法。

    使用Object.defineProperty()做数据劫持,有什么弊端?

    1、不能监听数组的变化
    2、必须遍历对象的每个属性
    3、必须深层遍历嵌套的对象

    MVVM中的数据劫持

    class Observer {
      constructor(data) {
        this.observe(data)
      }
    
      observe(data) {
        // 要对这个data数据,将原有的属性改成set和get的形式
    
        // defineProperty针对的是对象
        if(!data || typeof data !== 'object') {
          return
        }
    
        // 将数据一一劫持,先获取到data的key和value
        Object.keys(data).forEach(key => {
          // 定义响应式变化
          this.defineReactive(data, key, data[key])
          this.observe(data[key]) //深度递归劫持
        })
    
        // 关于Object.keys() 返回一个包含对象的属性名称的数组
      }
    
      // 定义响应式
      defineReactive(obj, key, value) {
        let that = this;
        let dep = new Dep(); // 每个变化的数据 都会对应一个数组,这个数组是存放所有更新的操作
    
        Object.defineProperty(obj, key, {
          enumerable: true,     // 是否能在for...in循环中遍历出来或在Object.keys中列举出来
          configurable: true,   // false,不可修改、删除目标属性或修改属性性以下特性
          get() {
            Dep.target && dep.addSub(Dep.target)
            return value;
          },
          set(newValue) {
            if(newValue != value) {
              that.observe(newValue);  // 如果设置的是对象,继续劫持
              value = newValue;
              dep.notify(); //通知所有人 数据更新了
            }
          }
        })
      }
    }
    

    订阅发布(Dep)

    其实发布订阅说白了就是把要执行的函数统一存储在一个数组subs中管理,当达到某个执行条件时,循环这个数组并执行每一个成员。

    class Dep {
      constructor() {
        // 订阅数组
        this.subs = [];
      }
    
      // 添加订阅
      addSub(watcher) {
        this.subs.push(watcher);
      }
    
      // 将消息通知给所有人
      notify() {
        this.subs.forEach(watcher => watcher.update());
      }
    }
    

    观察者(Watcher)

    Watcher 类的作用是,获取更改前的值存储起来,并创建一个 update 实例方法,当值被更改时,执行实例的 callback 以达到视图的更新。

    class Watcher{  // 因为要获取 oldValue,所以需要“数据”和“表达式”
      constructor(vm, expr, cb) {
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
    
        // 先获取 oldValue 保存下来
        this.value = this.get();
      }
    
      getVal(vm, expr) {
        expr = expr.split('.');
    
        return expr.reduce((prev, next) => {
          return prev[next.trim()]
        }, vm.$data);
      }
    
      get() {
        // 在取值之前先将 watcher 保存到 Dep 上
        Dep.target = this;
        let value = this.getVal(this.vm, this.expr);
        Dep.target = null;
        return value;
      }
    
      // 对外暴露的方法,如果值改变就可以调用这个方法来更新
      update() {
        let newValue = this.getVal(this.vm, this.expr);
        let oldValue = this.value;
        if (newValue != oldValue) {
          this.cb(newValue);
        }
      }
    }
    

    最后

    最后当然是要检测一下,我们的写的代码是不是能正常运行。

    <!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>Document</title>
      </head>
      <body>
        <div id="app">
          <!-- 双向数据绑定 靠的是表单 -->
          <input type="text" v-model="message.a" />
          <div>{{ message.a }} 啦啦啦</div>
          {{ message.a }}
          {{ b }}
        </div>
      </body>
    </html>
    <script src="observer.js"></script>
    <script src="watcher.js"></script>
    <script src="compile.js"></script>
    <script src="dep.js"></script>
    <script src="mvvm.js"></script>
    <script>
      let vm = new MVVM({
        el: "#app",
        data: {
          message: { a: "wlf" },
          b: "biubiubiu"
        }
      });
    </script>
    

    总结

    我们根据下图(参考《深入浅出vue.js》),将整个流程再梳理一遍:


    流程图.jpg

    new MVVM() 后, MVVM 会进行初始化即实例化MVVM,在这个过程中,模板绑定的数据data通过Observer数据劫持,转换成了getter/setter的形式,来监听数据的变化,当被设置的对象被读取的时候会执行getter函数,当它被赋值的时候会执行setter函数。

    当页面渲染的时候,会读取所需对象的值,这个时候会触发getter函数从而将Watcher添加到Dep中进行依赖收集,添加订阅。

    当对象的值发生变化时,会触发对应的setter函数,setter会调用dep.notify()通知之前依赖收集得到的 Dep 中的每一个 Watcher,也就是遍历subs这个数组,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher就会开始调用 update() 来更新视图。

    源码地址:
    https://github.com/lostimever/MVVM

    相关文章

      网友评论

        本文标题:一起学习、手写MVVM框架

        本文链接:https://www.haomeiwen.com/subject/bgwflctx.html