最近答辩需要选择一个专业选题,于是在小导师的建议下选了这个题目,博客的内容大多是对大佬们精华的整理,学会了就是我的啦~

数据双向绑定

双向绑定:在单向绑定的基础上给可输入元素(input、textarea等)添加 change、input事件,来动态修改model和view。

MVVM框架实现数据绑定的主流做法:

  • 发布者-订阅者模式 backbone.js
  • 脏值检查 anglular.js
  • 数据劫持 vue.js

VUE采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter、getter,在数据改变时发布消息给订阅者,触发相应的监听回调。

数据单向绑定

单向绑定:将 Model数据 绑定到 View。

当我们用JavaScript代码更新Model时,View就会自动更新。而View 变化,不会自动影响到 Model 状态。

VUE中的单向绑定:

  • {{ data}} Mustache语法
  • v-bind 指令

微信小程序的单向绑定:

  • {{ data }} Mustache语法
  • {{ flag ? '男' : '女' }} 三元运算
  • {{ a + b }} 算术运算
  • {{ “Hello" + world }} 字符串运算
  • {{ [zero, 1, 2, 3 ] }} 数组
  • {{ a: 1, b: 2 }} 对象
  • {{ ...obj1, ...obj2, c: 3 }} 扩展运算符

实现单向绑定

1、通过Object.defineProperty

在 Javascript 中,如果改变一个属性的值,那么对应的 setter 函数会被执行。而Object.defineProperty()方法可以在对象上定义或修改属性的数据描述符或存取描述符。因此,我们可以通过Object.defineProperty()为一个新对象的属性定义get、set存取描述符,在getter函数中返回原data对象的属性值,在setter函数中修改data属性的值通知html页面刷新。

下面维护一个obj的属性:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title></title>
</head>

<body>
  <div id="app">
    姓名:{{name}}<br>
  </div>
  <script>
    var obj = {};
    Object.defineProperty(obj, 'name', {
      enumerable: true,
      configurable: true,
      get: function () { return data.name; },
      set: function (val) {
        data.name = val;
        render();
      }
    });
    let data = {
      name: '阿中',
    }
    let el = document.getElementById('app');
    let template = el.innerHTML;
    render();

    function render() {
      el.innerHTML = template.replace(/\{\{(.+?)}\}/g, (...args) => {
        return obj[args[1]];
      });
    }
  </script>

</body>
</html>

在新的对象obj的set()操作中,完成数据修改操作的同时,更新视图内容。

2、通过代理对象

Proxy对象用于包装目标对象,可以拦截对代理对象的读取/写入属性和其他操作。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title></title>
</head>
<body>
  <div id="app">
    姓名:{{name}}<br> 
    年龄:{{age}}
  </div>
</body>
<script>
  let el = document.getElementById('app');
  let template = el.innerHTML;
  let _data = {
    name: '阿信',
    age: 25
  };

  let data = new Proxy(_data, {
    set(obj, name, value) {
      obj[name] = value;    //或者使用反射完成
      render();
    }
  });
  
  render();

  function render() {
    el.innerHTML = template.replace(/\{\{\w+\}\}/g, str => {
      str = str.substring(2, str.length - 2);
      return _data[str];
    });
  }
</script>
</html>![图片来源大佬的博客](/img/bVcIwOj)

实现思路和Object.defineProperty基本一致。

3、结合发布/订阅者模式

由于一个属性的值可能关联到多个DOM节点,一个DOM节点也可能依赖到多个属性的值,我们使用发布-订阅模式来维护属性、节点间一对多的关系。

发布-订阅者模式可以类比公众号与读者的关系,读者订阅公众号后,每当公众号的内容发生更新,微信就会主动向读者发送推送消息。类似地,data对象中的属性就是发布者,页面中的dom节点就是订阅者,一旦属性的值发生改变,订阅器就通知dom节点以判断是否更新视图内容。

当然,我们刚才是固定一个div节点进行文本替换,实际我们需要通过文档对象模型Dom,将整个页面内容表示为可以修改的节点对象,解析模板替换数据。

综上所述,实现数据的单向绑定,我们需要:

1、监听器Observer,用来劫持并监听所有属性,属性变动时通知订阅器。

2、订阅者Watcher,可以收到属性的变化通知并调用更新视图。

3、订阅器 Dep, 在发布者和订阅者之间进行统一管理。一方面添加订阅者,一方面将发布者Observer的变化消息传达给订阅者Wachter。

4、解析器Compile,扫描解析每个节点的相关指令,初始化模板数据和更新页面。

图片来源于大佬的博客

实现Observer

对data对象中属性进行递归遍历,为每个属性都加上getter、setter劫持,以便完成对属性赋值操作的监听。

// 发布者,实现数据劫持和属性监听

// 发布者,实现数据劫持和属性监听
class Observer {
  constructor(data) {
    this.observer(data);
  }
  observer(data) {
    if (data && typeof data == 'object') {    //仅针对对象
      for (let key in data) {
        this.defineReactive(data, key, data[key]);
      }
    }
  }
  defineReactive(obj, key, value) {
    this.observer(value); // 监听子属性

    Object.defineProperty(obj, key, {
      enumerable: true, // 可枚举
      configurable: false, // 不可配置
      get() {
        return value;
      },
      set: (newVal) => {
        if (newVal !== value) {
          this.observer(newVal); //改变后的值可能是一个对象
          value = newVal;
        }
      }
    })
  }
}

实现Dep

Dep需具有发布、订阅功能。

// 订阅器,维护发布者与订阅者之间的管理
class Dep {
  constructor() {
    this.subs = [];  //存放所有的watcher
  }
  // 订阅
  addSub(watcher) {  // 添加 watcher
    this.subs.push(watcher);
  }
  // 发布
  notify() {
    this.subs.forEach(watcher => watcher.update())    //调用更新函数
  }
}

为每个被监听的属性都创建一个Dep:

defineReactive(obj, key, value) {
    this.observer(value); // 监听子属性
    let dep = new Dep() //给每一个属性 都加上一个具有发布订阅功能的dep
    Object.defineProperty(obj, key, {
      enumerable: true, // 可枚举
      configurable: false, // 不可配置
      get() {
        return value;
      },
       set: (newVal) => {
        if (newVal !== value) {
          this.observer(newVal); //改变后的值可能是一个对象
          value = newVal;
          dep.notify(); //将属性变化的消息发布给所有订阅者
        }
      }
    }

实现Watcher

观察者Watcher,应该具备在所依赖的属性改变时触发更新页面的函数。

// 观察者
class Watcher {
  constructor(vm, expr, cb) {  
    //vm viewModel 保存data; cb callback 回调函数
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;
    //默认先存放一个原来的值
    this.oldValue = this.get();
  }
  get() {     //初始化时获取所依赖的属性原本的值
    Dep.target = this;    //将当前订阅者指向自己
    let value = CompileUtil.getVal(this.vm, this.expr);//触发get()
    Dep.target = null;    //添加完毕
    return value;
  }
  update() { //更新操作,数据变化后会调用观察者的update方法
    //获取更新后的值
    let newVal = CompileUtil.getVal(this.vm, this.expr);
    if (newVal !== this.oldValue) {
      this.cb(newVal);
    }
  }
}

在Watcher实例化的同时,应该往属性订阅器dep中添加自己;因此在Observer的get()操作中添加:

defineReactive(obj, key, value) {
    this.observer(value);
    let dep = new Dep() //给每一个属性 都加上一个具有发布订阅功能的dep
    Object.defineProperty(obj, key, {
      get() {
        // 将当前订阅者添加到dep中
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
        ...
}

实现Compiler

编译器,解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图;并且将每个指令对应的节点绑定更新函数,添加监听数据的订阅者。

class Compiler {
  constructor(el, vm) {
    // 判断 el属性 是不是一个元素;若不是,则获取元素
    this.el = this.isElementNode(el) ? el : document.querySelector(el);
    this.vm = vm;
    // 将当前节点元素 获取到内存中 以提高效率
    let fragment = this.node2fragment(this.el);
    //编译模板
    this.compile(fragment);
    // 把内容塞回页面中
    this.el.appendChild(fragment);
  }
...

把节点获取进内存中的方法:创建文档碎片fragment

  //把节点移动到内存中
  node2fragment(node) {
    //创建一个文档碎片
    let fragment = document.createDocumentFragment();
    let firstChild;
    while (firstChild = node.firstChild) {
      //appendChild具有移动性,会把原本的节点移动而不是复制到文档碎片中
      fragment.appendChild(firstChild);
    }
    return fragment;
  }

接着,完成Compiler的核心编译功能。

//核心编译方法 编译内存中 DOM节点
  compile(node) {
    let childNodes = node.childNodes;
    [...childNodes].forEach(child => {
      if (this.isElementNode(child)) {    //编译元素节点
        this.compileElement(child);
        // 递归编译子节点
        this.compile(child);
      } else {
        this.compileText(child);    //编译文本节点
      }
    });
  }

对VUE进行分析可知,{{ }}插值表达式只能出现在文本节点中。这里由于仅讨论单向绑定中的插值表达式,故省略元素节点中的v-bind指令,仅讨论文本节点:

 //编译文本
  compileText(node) {  //判断当前节点是否含有{{}},若无则无需处理
    let content = node.textContent;
    if (/\{\{(.+?)\}\}/.test(content)) {
      CompileUtil['text'](node, content, this.vm);
    }
  }

这里使用了一个编译工具类CompileUtil,text方法主要用于为{{}}表达式创建Watcher,并且替换模板数据。

CompileUtil = {
  text(node, expr, vm) { // exp = > {{a}} {{b}} {{c}} => a b c
    let fn = this.updater['textUpdater'];
    let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {    //将{{}}替换为值
      // 给表达式每个{{}}里的表达式都加上观察者
      new Watcher(vm, args[1], (newVal) => {
        fn(node, this.getContentValue(vm, expr));    //回调函数是文本结点更新函数
      })
      return this.getVal(vm, args[1]);    // 获取文本节点的值
    });
    fn(node, content);
  },
  // 根据表达式获取对应的数据
  // 不能直接用vm[expr]取,expr是一个字符串,无法取出嵌套对象的属性
  getVal(vm, expr) { // vm.$data 'school.name'
    return expr.split('.').reduce((data, current) => {
      return data[current];
    }, vm.$data);
  },
  setValue(vm, expr, value){
    expr.split('.').reduce((data,current,index,arr)=>{
      if(index === arr.length-1){
         return data[current] = value;
      }
      return data[current];
    }, vm.$data);
  },
  getContentValue(vm, expr) {
    // 遍历表达式,将内容重新替换成一个完整的内容
    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      return this.getVal(vm, args[1]);
    })
  },
  updater: {
    // 把数据插入到节点里
    textUpdater(node, value) {
      node.textContent = value;
    }
  }
}

主要内容基本都完成了,实现MVVM构造器来作为数据绑定的入口吧:

class Vue {
  constructor(options) {
    this.$el = options.el;
    this.$data = options.data;
    // 若根元素存在 编译模板
    if (this.$el) {
      new Observer(this.$data);   
      this.proxyVm(this.$data);
      new Compiler(this.$el, this);
    }
  }
}

目前只能使用vm.$data来对属性进行操作。
为了符合大众直接操作data的习惯,我们直接把对vm取值的操作都代理到 vm.$data上:

proxyVm(data){
    for(let key in data){
      Object.defineProperty(this, key, {
        get(){
          return data[key];
        }
      })
    }  
  }

至此,插值文本表达式的单向绑定基本实现了。完整代码见:xxxxxxxx(还没上传,稍后更新地址)

实现双向绑定

单向绑定+UI事件


这是一只野指针
1 声望0 粉丝