实现类似Vue的 简单双向数据绑定

Vue作为当前国内使用广泛的前端MVVM框架,其中的双向数据绑定大大减少了前端代码维护数值变化的难度,显得高效而神秘,那么今天就来解开其神秘面纱!动手实现简单的双向数据绑定,本项目源码请猛戳这里

1. 实现简单的vue双向数据绑定

animate.gif

1.1 基本原理

  • 首先看原理图如下

    data-binding.png

  • 其中主要部分及其功能(首字母大写为课实例化的类,小写为函数)

  1. MVVM即Vue实例,主要包括datatemplate两部分(其他暂不考虑)
  2. data对象数据模型Model,template对应视图View
  3. observe为数据劫持模块,主要实现数据的gettersetter,并为属性绑定订阅者,在属性值发生变化是通知订阅者
  4. Watcher为订阅者,通过depend将自己添加至订阅者管理模块Dep实例中,主要实现属性值变化时调用回调函数更新视图
  5. Dep为订阅者管理模块,是建立observeWatcher的桥梁.通过notify通知所有订阅者数据发生变化
  6. compile为模板解析模块,解析v-指令以及模板字面量等,并为相应属性添加订阅者Watcher和回调函数

1.2 基本步骤如下

  1. Vue包括datatemplate两部分,分别对应Model与View
  2. 通过observedata的每一个属性和其子属性添加gettersetter
  3. 通过Dep实例来管理订阅者,其中data的每一个属性拥有一个Dep实例(dataDep实例为一对多的关系)
  4. 通过compile解析模板template,分析出那些是data的属性并创建Watcher实例,添加至属性对应Dep实例中
  5. data属性值发生变化时,即调用属性的getter时会触发Dep实例的notify方法,接着出发Watcher实例的update方法,刷新视图(Dep实例与Watcher实例同样是一对多的关系`)
  6. 当视图数据发生变化时,改变data对应属性值,继续步骤5,实现视图刷新

2. 用法

<div id='wu-app'>
       <input type="text" v-model='text'>
       <br>
       <label for="">Input value:{{text}}</label>
       <br>
       <input type="button" v-on:click='btnClick' value='Click Me'>
</div>
<script>
    window.onload = function () {
        var app = new W.Wu({
            el: '#wu-app',
            data: {
                text: 'Hello World!'
            },
            methods: {
                btnClick(e) {
                    this.text = 'You clicked the button!'
                }
            }
        })
    }
</script>

3. 代码分析

3.1 主模块:入口

export function Wu(options) {
  this.$options = options;
  this.$data = options.data || {};
  this.$methods = options.methods || {};
  this.$watched = options.watched;

  // 将data和methods以及computed中的属性方法代理在自己身上
  proxy(this, this.$data);
  proxy(this, this.$methods);
  
  // 初始化数据劫持
  observe(this.$data);
  // 模板解析
  compile(options.el || document.body, this);
}

3.2 数据劫持

function observe(data) {
  if (!data || typeof data !== "object") return;
  Object.keys(data).forEach(function(key) {
    let val = data[key],
      dep = new Dep();
    //观察子属性
    observe(val);
    Object.defineProperty(data, key, {
      configurable: true,
      enumerable: true,
      get: function() {
        // console.log(`i get ${key}:${val}`);
        //添加订阅者
        Dep.target && dep.addSub(Dep.target);
        return val;
      },
      set: function(newVal) {
        // console.log(`i set ${key}:${val}-->${newVal}`);
        val = newVal;
        //通知所有订阅者数据变更
        dep.notify();
      }
    });
  });
}

3.3 模板解析

let compileUtil = {
  elementNodeType: 1,
  textNodeType: 3,
  isDirective(attr) {
    let reg = /v-|:|@/;
    return reg.test(attr);
    return (
      attr.indexof("v-") == 0 ||
      attr.indexof(":") == 0 ||
      attr.indexof("@") == 0
    );
  },
  // 将原生节点拷贝到fragment
  node2Fragment(node) {
    let frag = document.createDocumentFragment();
    [].slice.call(node.childNodes).forEach(child => {
      frag.appendChild(child);
    });
    return frag;
  },
  // 更新回调函数
  update(node, dir, newVal, oldVal) {
    switch (dir) {
      case "model":
        node.value = typeof newVal === "undefined" ? "" : newVal;
        break;
      case "class":
        break;
      case "html":
        break;
      case "text":
        node.textContent = typeof newVal === "undefined" ? "" : newVal;
        break;
    }
  },
  // 获取属性值(当表达式为不只是key,而是一各需要运算的语句是如何处理?)
  getVMVal(vm, exp) {
    let src = vm;
    exp.split(".").forEach(k => {
      src = src[k];
    });
    return src;
  },
  // 设置属性值
  setVMVal(vm, exp, val) {
    let src = vm,
      keys = exp.split(".");
    for (let i = 0; i < keys.length; i++) {
      const k = keys[i];
      if (i < keys.length - 1) {
        src = src[k];
      } else {
        src[k] = val;
      }
    }
    return src;
  },
  // 解析节点
  compileNode(node, vm) {
    [].slice.call(node.childNodes).forEach(child => {
      switch (child.nodeType) {
        // 节点
        case compileUtil.elementNodeType:
          let attrs = child.attributes;
          [].slice.call(attrs).forEach(attr => {
            // 判断是否为内部指令
            let attrName = attr.name;
            if (this.isDirective(attrName)) {
              let dir = attrName.split(/v-|:|@/).join(""),
                exp = attr.value;
              // 事件
              if (dir.substring(0, 2) === "on") {
                child.addEventListener(
                  dir.substring(2),
                  //注意 this 指向
                  this.getVMVal(vm, exp).bind(vm)
                );
              } else {
                // 其他指令model bind text等
                this.update(child, dir, this.getVMVal(vm, exp));
                // 订阅者
                new Watcher(vm, exp, (newVal, oldVal) => {
                  // 更新回调
                  this.update(child, dir, newVal, oldVal);
                });
                // model
                if (dir === "model") {
                  let oldVal = this.getVMVal(vm, exp);
                  // 注册对于表单输入项的input事件
                  child.addEventListener("input", e => {
                    var newVal = e.target.value;
                    if (newVal !== oldVal) {
                      // 更改数值
                      this.setVMVal(vm, exp, newVal);
                    }
                  });
                }
              }
              // 移除指令
              // child.removeAttributes(attr);
            }
          });
          if (child.childNodes && child.childNodes.length > 0) {
            this.compileNode(child, vm);
          }
          break;
        // 文本
        case compileUtil.textNodeType:
          var text = child.textContent,
            reg = /\{\{(.*)\}\}/;
          if (reg.test(text)) {
            var exp = reg.exec(text)[1];
            this.update(child, "text", this.getVMVal(vm, exp));
            new Watcher(vm, exp, (newVal, oldVal) => {
              this.update(child, "text", newVal, oldVal);
            });
          }
          break;
      }
    });
  }
};
// 模板解析
function compile(template, vm) {
  let el =
    template.nodeType == compileUtil.elementNodeType
      ? template
      : document.querySelector(template); // 取出id为el的第一个节点作为容器

  if (el) {
    // 将原始节点存为fragment进行操作 减少页面渲染次数 提升效率
    let fragment = compileUtil.node2Fragment(el);
    compileUtil.compileNode(fragment, vm);
    // 处理完后 重新添加至容器
    el.appendChild(fragment);
  }
}

3.4 订阅者

let _uid = 0;
export function Watcher(vm, exp, cb) {
  // 唯一标识
  this.id = _uid++;
  this.vm = vm;
  this.exp = exp;
  this.cb = cb;
  this.value = this.depend();
}

Watcher.prototype = {
  constructor: Watcher,
  depend() {
    Dep.target = this;
    //通过触发getter,添加自己为订阅者
    var value = this.vm[this.exp];
    Dep.target = null;
    return value;
  },
  // 更新
  update() {
    let oldVal = this.value,
      newVal = this.vm[this.exp];
    if (oldVal !== newVal) {
      this.value = newVal;
      this.cb(newVal, oldVal);
    }
  }
};

3.5 订阅者管理

export function Dep() {
  // 键值对 
  this.subs = new Map();
}
Dep.prototype = {
  constructor: Dep,
  // 添加订阅者
  addSub(watcher) {
    // 通过订阅者id作为唯一标识 避免重复订阅
    this.subs.set(watcher.id, watcher);
  },
  // 通知订阅者
  notify() {
    this.subs.forEach(watcher => {
      watcher.update();
    });
  }
};
Dep.target = null;

参考


如果您感觉有所帮助,或者有问题需要交流,欢迎留言评论,非常感谢!
前端菜鸟,还请多多关照!


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容