VUE双向数据绑定原理及简单实现

Reactive Programming是一种编程形式,在很多场景都会见到,最近正在学习的RxJS是一个例子,当然Vue同样是一种Reactive Programming,就是当变量发生改变的时候,相关的变量和视图也会跟着改变,而我们开发者不需要自己去写代码来实现这个过程,我们只需要关心变量改变之后应该进行什么操作,更加关注于业务流程。

Vue的双向数据绑定是基于ES5的Object.defineProperty()gettersetter,每当数据发生变化,就会执行getter/setter,结合发布者/订阅者的模式,通知订阅者这些变化,进而执行相应的回调函数。

今天我们来分析一下vue双向数据绑定的原理,同时我们自己用js来实现一个简单的双向数据绑定,首先看一下原理图:

原理图

MVVM就是我们要实现的vue实例,简单讲述一下流程:

  1. 首先通过一个Observer(监听器或者劫持器)去劫持data对象中的所有属性,方法就是使用Object.defineProperty()中的getter/setter,在属性set的时候通知Dependency(订阅器/容器)发布变化;

  2. 实现一个Watcher(订阅者),这个Watcher就是说我收到数据变化的通知后,应该去执行什么操作(重新填充列表,填充值等等,即更新视图),一个data.message数据可能对应多个使用场景,比如v-model="message"v-text="message"{{message}}等等,所以Watcher不止一个;

  3. 上面说到Watcher不止一个,所以我们可以实现一个容器Dependency,里面存放data.message对应的所有Watcher,这样当ObserverSetter改变时,调用Dependencynotify方法,逐条去通知所有的Watcher

  4. 实现一个编译器Complier,编译器的作用是扫描和解析每一个节点node,先将节点转换为fragment(性能优化,一次性append所有节点至目标element内),再根据不同的节点类型nodeType,针对v-modelv-text{{message}}做不同的处理,完成第一次的数据message填充(即初始化视图);同时编译器还担当着初始化Watcher的任务,将Watcher添加到Dependency中去;

有了以上的思路,接下来就是编写代码时间,使用了ES6的class,首先我们来实现Observer:

class Observer {
  constructor(data) {
    this.observer(data);
  }
  observer(data) {
    if (!data || typeof data !== 'object') {
      return false;
    } else {
      Object.keys(data).forEach((key) => {
        // 劫持data对象中的每一条数据
        this.defineReactive(data, key, data[key]);
      })
    }
  }
  defineReactive(obj, key, value) {
    let dep = new Dependency();
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: false,
      get() {
        if (Dependency.target) {
          dep.addSub(Dependency.target);    // 添加订阅者watcher,应该是整个实例Watcher
        }
        return value;
      },
      set(newValue) {
        // 值未变化return回去
        if (newValue === value) { return false; }
        value = newValue;
        // 数据变化,通知dep里所有的watcher
        dep.notify();
      }
    })
  }
}
 // 第一次get值的时候不会添加Watcher到Dependency,实例化(调用)watcher时再添加
Dependency.target = null; 

接下来实现Watcher:

class Watcher {
  constructor(vm, expr, callback) {
    this.vm = vm;
    this.expr = expr;           // data中的key值
    this.callback = callback;   // 值变化的时候执行什么回调
    this.value = this.get();    // 实例化watcher的时候将自己添加到Dependency
  }
  get() {
    Dependency.target = this;   // 缓存自己,就是这个Watcher实例
    let value = this.vm.$data[this.expr];  // 触发执行Observer中的get函数,将自己添加到Dep
    Dependency.target = null;   // 释放自己
    return value;
  }
  update() {
    // 值更新后,Observer的setter就会触发,就会执行dep.notify(),即通过Dep容器通知watcher根据callback去更新视图
    let newValue = this.vm.$data[this.expr];
    let oldValue = this.value;
    if (newValue !== oldValue) {
      // 新老值不一致,执行回调
      this.callback(newValue);
    }
  }
}

然后我们需要一个容器Dependency去储存data.message对应的所有Watcher:

class Dependency {
  constructor() {
    this.subs = [];      // 容器数据,放watcher用
  }
  addSub(watch) {
    this.subs.push(watch);   // 将watcher添加到subs内
  }
  notify() {
    // 通知subs内的所有watcher更新回调
    this.subs.forEach((watch) => {
      watch.update();
    })
  }
}

下面是编译器Complier,编译器涉及的东西比较杂,判断的情况比较多,所以这里只考虑到了v-modelv-text{{message}}这3种情况的实现:

class Complier {
  constructor(el, vm) {
    this.vm = vm;
    this.el = document.querySelector(el);
    if (this.el) {
      // 使用fragment储存元素,这时候#app内就没有节点了,因为已经被frag删除完了
      let fragment = this.nodeToFragment(this.el);    
      this.complie(fragment);                         // 编译fragment
      this.el.appendChild(fragment);                  // 将fragment放回#app内
    }
  }
  complie(node) {
    // 使用Array.from将类数组node.childNodes转换为真正的数组
    let nodeList = Array.from(node.childNodes);    
    nodeList.forEach((item) => {
      //根据nodeType判读节点类型,执行不同的编译
      switch (item.nodeType) {
        case 1:
          this.elementComplier(item);break;
        case 3:
          this.textComplier(item);break;
      }
    })
  }
  elementComplier(node) {
    // 元素节点编译器,处理属性v-model,v-text等
    let attrs = Array.from(node.attributes);
    attrs.forEach((attr) => {
      if (attr.name.indexOf('v-') > -1) {
        let type = attr.name.split('-')[1];    // 取到'model',即指令的类型
        complierUnits[type] && complierUnits[type](node, this.vm, attr.value);
      }
    })
  }
  textComplier(node) {
    // 文本节点编译器{{message}},跟v-text共用一个编译方法
    if ((/\{\{(.+)\}\}/).test(node.textContent)) {
      complierUnits.text(node, this.vm, RegExp.$1);
    }
  }
  nodeToFragment(node) {
    // 将node转换为fragment
    let frag = document.createDocumentFragment();
    let child;
    while (child = node.firstChild) {
      // fragment调用appendChild方法会删除node.firstChild节点
      frag.appendChild(child);
    }
    return frag;
  }
}

// 编译器工具箱
const complierUnits = {
  model (node, vm, expr) {
    let updateFn = this.updater.modelUpdater;
    // 初始化的时候取一次值填充,渲染页面数据
    updateFn && updateFn(node, vm.$data[expr]);
    // 实例化watcher(调用watcher),将watcher添加到Dep中,同时定义好回调函数(数据变化后干什么)
    new Watcher(vm, expr, function(newValue){
      updateFn && updateFn(node, newValue);
    });
    // 监听input值的变化,从视图到data
    node.addEventListener('input', (event) => {
      vm.$data[expr] = event.target.value;
    })
  },
  text (node, vm, expr) {
    let updateFn = this.updater.textUpdater;
    updateFn && updateFn(node, vm.$data[expr]);
    new Watcher(vm, expr, function(newValue){
      updateFn && updateFn(node, newValue);
    });
  },
  updater: {
    modelUpdater(node, value) {
      node.value = value;
    },
    textUpdater(node, value) {
      node.textContent = value;
    }
  }
};

还有入口main.js:

class MVVM {
  constructor(options) {
    this.$el = options.el;
    this.$data = options.data;
    // 当视图存在时
    if (this.$el) {
      // 将属性添加进Observer,劫持数据
      new Observer(this.$data);
      // 编译页面
      new Complier(this.$el, this);
    }
  }
}

最后就是html调用了:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>vue原理简单实现</title>
  <script src="js/dependency.js"></script>
  <script src="js/observer.js"></script>
  <script src="js/watcher.js"></script>
  <script src="js/complier.js"></script>
  <script src="js/main.js"></script>
</head>
<body>
<div id="app">
  <span v-text="message"></span>
  <input type="text" v-model="message" />
  {{message}}
</div>

<script>
  let vm = new MVVM({
    el: '#app',
    data: {
      message: 'hello Vue!'
    }
  })
</script>
</body>
</html>

总结:实例化MVVM时,先使用Object.defineProperty劫持每一个data数据,为每一个属性实例化一个Dependency;在编译页面的时候为每一个需要更新message的地方添加一个Watcher,即v-model="message"v-text="message"{{message}},有一个算一个,将这些Watcher添加到Dependency中进行统一管理;在编译的时候我们还要为input添加一个事件监听addEventListener,这样input的输入值变化时,触发setter,在setter内调用Depnotify()方法,循环调用每一个Watcherupdate更新我们的视图(执行回调函数)。

以上代码都放到了我的github仓库vue-principle,欢迎查阅。推销一下我的博客

参考文章:vue双向绑定原理分析vue的双向绑定原理及实现

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

推荐阅读更多精彩内容