手动实现Vue双向绑定
1. 原理
Vue数据双向绑定是通过数据劫持结合发布者-订阅者模式的方式来实现的。
Vue的双向绑定是前端面试中一个高频问题,不排除会让你手动实现Vue双向绑定这种问题,毕竟面试造火箭嘛。
下面的代码都通过ES6语法进行书写,如果对ES6不是太熟悉,可以直接参考原文章:vue 的双向绑定原理及实现,原作者是使用的ES5语法。
因为ES6引入了类的概念,所以代码会显得更清晰。
2. 数据劫持
vue是通过Object.defineProperty()来实现数据劫持,Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象,有点类似于TypeScript中的装饰器。
比如说:
let Book = {  name: "Vue入门",};
console.log(Book.name); // Vue入门如果想要在执行console.log(Book.name)时,用书名号将结果包含起来,这个时候就需要使用Object.defineProperty()。
let Book = {};let name = "";Object.defineProperty(Book, "name", {  set(value) {    name = value;    console.log("你取了一个书名叫做" + value);  },  get() {    return "《" + name + "》";  },});
Book.name = "Vue入门"; // 你取了一个书名叫做Vue入门console.log(Book.name); // 《Vue入门》上面的代码通过Object.defineProperty( )对Book的属性name的get和set进行了重写操作,所以如果读取name属性时,就会触发get函数。如果设置name属性时,那么同样也会触发set函数。
这个时候我们已经明白了Vue数据劫持的方式,那么我们下面就可以写出一个简易版的双向绑定代码。
3. 简易双向绑定
使用过Vue后应该都清楚,data中的数据改变时,视图就会改变,而视图发生改变时,数据也会改变。
视图改变数据是一个非常好实现的功能,可以通过事件监听来实现,比如说onclick点击事件,双向绑定的关键点在于数据是如何改变视图。
那么难点就在于数据改变视图了,所以为了实现双向绑定,我们需要引入一些概念:
- Observer:监听器,用来监听所有属性,如果属性发生了变化,则告诉订阅者Watcher。
- Watcher:订阅者,收到属性变化并执行对应的函数,从而更新视图。
- Dep:消息订阅器,因为订阅者是多个,消息订阅器就专门用来收集这些订阅者。
- Compile:解析器,扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。
清楚了上面的概念后,我们就可以逐步进行实现一个简易的双向绑定Demo,整个双向绑定实现的核心方法即为上面介绍的Object.defineProperty( )。
3.1 实现Observer
前面我们实现了一个简易的监听器,仅仅是针对Book对象下的name属性,如果我们要对对象下的每一个属性进行监听,那么我们就需要使用递归来遍历所有属性值,并对其进行Object.defineProperty( )处理。
/*传入的参数和上面的简单例子一样data:对象 key:属性名 val:剩余的对象*/function defineReactive(data, key, val) {  observe(val); // 递归遍历所有子属性  Object.defineProperty(data, key, {    enumerable: true, // 该属性会出现在对象的枚举属性中    configurable: true, // 该属性的描述能被改变,同时该属性也能从对象上删除    get() {      return val;    },    set(newVal) {      val = newVal;      console.log(        "属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”"      );    },  });}
let library = {  book1: {    name: "",  },  book2: "",};observe(library);library.book1.name = "Vue入门"; // 属性name已经被监听了,现在值为:“Vue入门”library.book2 = "没有此书籍"; // 属性book2已经被监听了,现在值为:“没有此书籍”3.2 添加Dep
在实现订阅者之前,还需要个消息订阅器,用来收集这些订阅者。
/* data:对象 key:属性名 val:剩余的对象 */function defineReactive(data, key, val) {  observe(val); // 递归遍历所有子属性  let dep = new Dep();  Object.defineProperty(data, key, {    enumerable: true,    configurable: true,    get() {      if (是否需要添加订阅者) {        dep.addSub(watcher); // 在这里添加一个订阅者      }      return val;    },    set(newVal) {      if (val === newVal) {        return;      }      val = newVal;      console.log(        "属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”"      );      dep.notify(); // 如果数据变化,通知所有订阅者    },  });}
class Dep {  constructor() {    this.subs = [];  }
  addSub(sub) {    this.subs.push(sub);  }
  notify() {    this.subs.forEach((sub) => {      sub.update();    });  }}
Dep.target = null;在get方法中,判断是否添加一个订阅者到订阅器Dep中,而在set方法中,如果数据变化,就去通知所有订阅者,执行对应的函数。
3.3 实现Watcher
我们需要在Watcher初始化的时候触发对应的get方法去执行添加订阅者的操作。
class Watcher {  /* vm:绑定的对象,exp:属性名称,cb:回调函数 */  constructor(vm, exp, cb) {    this.cb = cb;    this.vm = vm;    this.exp = exp;    this.value = this.get(); // 将自己添加到订阅器的操作  }
  update() {    this.run();  }
  run() {    let value = this.vm.data[this.exp];    let oldVal = this.value;    if (value !== oldVal) {      this.value = value;      this.cb.call(this.vm, value, oldVal);    }  }
  get() {    Dep.target = this; // 缓存自己    let value = this.vm.data[this.exp]; // 强制执行监听器里的get函数    Dep.target = null; // 释放自己    return value;  }}调整Observer中的defineReactive函数:
/* data:对象 key:属性名 val:剩余的对象 */defineReactive(data, key, val) {  observe(val); // 递归遍历所有子属性  let dep = new Dep();  Object.defineProperty(data, key, {    enumerable: true,    configurable: true,    get() {      if (Dep.target) {  // 判断是否需要添加订阅者        dep.addSub(Dep.target); // 在这里添加一个订阅者      }      return val;    },    set(newVal) {      if (val === newVal) {        return;      }      val = newVal;      console.log("属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”");      dep.notify(); // 如果数据变化,通知所有订阅者    }  });}到这里,我们就已经实现了一个简易的双向绑定,但是这个时候我们还没有Compile解析器,所以不能直接解析dom,为了演示,暂且模拟一下Vue中双向绑定的dom写法:
<body>    <h1 id="name">{{name}}</h1></body>将Observer和Watcher关联起来,而SelfVue类,就是将Observer,Watcher,Compile进行整合,也正是我们在使用Vue时的new Vue,只不过这里变成了new SelfVue,顺带一提,new Vue做的事情远远不止双向绑定这么简单。:
/* data:数据,el:dom节点,exp:属性名称 */function SelfVue(data, el, exp) {  this.data = data;  observe(data);  el.innerHTML = this.data[exp]; // 初始化模板数据的值  new Watcher(this, exp, function (value) {    el.innerHTML = value;  });  return this;}然后在页面上new一下SelfVue类,就可以实现数据的双向绑定了:
<body>    <h1 id="name">{{name}}</h1></body><script src="js/observer.js"></script><script src="js/watcher.js"></script><script src="js/index.js"></script><script type="text/javascript">    let ele = document.querySelector('#name');    let selfVue = new SelfVue({        name: 'hello world'    }, ele, 'name');
    window.setTimeout(function () {        console.log('name值改变了');        selfVue.data.name = 'canfoo';    }, 2000);
</script>这时候打开页面,可以看到页面刚开始显示了是'hello world',过了2s后就变成'canfoo'了。

到这里,总算大功告成一半了。
但是还有一个细节问题,我们在赋值的时候是这样的形式selfVue.data.name = 'canfoo'而我们理想的形式是selfVue.name = 'canfoo'。
我们在new SelfVue的时候做一个代理处理,让访问selfVue的属性代理为访问selfVue.data的属性,实现原理还是使用Object.defineProperty( )对属性值再包一层:
class SelfVue {  constructor(data, el, exp) {    let self = this;    this.data = data;
    Object.keys(data).forEach(function (key) {      self.proxyKeys(key); // 绑定代理属性    });
    observe(data);    el.innerHTML = this.data[exp]; // 初始化模板数据的值    new Watcher(this, exp, function (value) {      el.innerHTML = value;    });    return this;  }
  proxyKeys(key) {    let self = this;    Object.defineProperty(this, key, {      enumerable: false,      configurable: true,      get: function proxyGetter() {        return self.data[key];      },      set: function proxySetter(newVal) {        self.data[key] = newVal;      },    });  }}到目前为止,完整的Observer:
class Observer {  constructor(data) {    this.data = data;    this.walk(data);  }
  walk(data) {    let self = this;    Object.keys(data).forEach((key) => {      self.defineReactive(data, key, data[key]);    });  }
  /* data:对象 key:属性名 val:剩余的对象 */  defineReactive(data, key, val) {    observe(val); // 递归遍历所有子属性    let dep = new Dep();    Object.defineProperty(data, key, {      enumerable: true,      configurable: true,      get() {        if (Dep.target) {  // 判断是否需要添加订阅者          dep.addSub(Dep.target); // 在这里添加一个订阅者        }        return val;      },      set(newVal) {        if (val === newVal) {          return;        }        val = newVal;        console.log("属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”");        dep.notify(); // 如果数据变化,通知所有订阅者      }    });  }}
function observe(value, vm) {  if (!value || typeof value !== "object") {    return;  }  return new Observer(value);}
class Dep {  constructor() {    this.subs = [];  }
  addSub(sub) {    this.subs.push(sub);  }
  notify() {    this.subs.forEach((sub) => {      sub.update();    });  }}
Dep.target = null;3.4 实现Compile
上面已经实现了一个简单的双向绑定操作,但是整个过程中并没有解析dom节点,而是直接固定某个节点进行替换数据。
而Compile就是为了解决这个问题。而Compile主要做下面两件事情:
- 解析模板指令,并替换模板数据,初始化视图。(即Vue中用{{}}包裹起来的部分)
- 将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器。(完成绑定)
先给出完整的compile,接下来我们再一步一步的进行讲解:
/* el:dom节点,vm:绑定的对象 */class Compile {  constructor(el, vm) {    this.vm = vm;    this.el = document.querySelector(el);    this.fragment = null;    this.init();  }
  init() {    if (this.el) {      this.fragment = this.nodeToFragment(this.el); // 将需要解析的dom节点存入fragment片段里再进行处理      this.compileElement(this.fragment); // 对特殊节点进行处理,比如{{}},V-      this.el.appendChild(this.fragment);    } else {      console.log("Dom元素不存在");    }  }
  nodeToFragment(el) {    let fragment = document.createDocumentFragment();    let child = el.firstChild;    while (child) {      // 将Dom元素移入fragment中      fragment.appendChild(child);      child = el.firstChild;    }    return fragment;  }
  compileElement(el) {    let childNodes = el.childNodes;    let self = this;    [].slice.call(childNodes).forEach(function (node) {      let reg = /\{\{(.*)\}\}/;      let text = node.textContent;
      if (self.isElementNode(node)) {        self.compile(node);      } else if (self.isTextNode(node) && reg.test(text)) {        self.compiletext(node, reg.exec(text)[1]);      }
      if (node.childNodes && node.childNodes.length) {        self.compileElement(node);      }    });  }
  compile(node) {    let nodeAttrs = node.attributes;    let self = this;    Array.prototype.forEach.call(nodeAttrs, function (attr) {      let attrName = attr.name;      if (self.isDirective(attrName)) {        let exp = attr.value;        let dir = attrName.substring(2);        if (self.isEventDirective(dir)) {  // 事件指令          self.compileEvent(node, self.vm, exp, dir);        } else {  // v-model 指令          self.compileModel(node, self.vm, exp, dir);        }        node.removeAttribute(attrName);      }    });  }
  compiletext(node, exp) {    let self = this;    let initText = this.vm[exp];    this.updateText(node, initText);    new Watcher(this.vm, exp, function (value) {      self.updateText(node, value);    });  }
  compileEvent(node, vm, exp, dir) {    let eventType = dir.split(":")[1];    let cb = vm.methods && vm.methods[exp];
    if (eventType && cb) {      node.addEventListener(eventType, cb.bind(vm), false);    }  }
  compileModel(node, vm, exp, dir) {    let self = this;    let val = this.vm[exp];    this.modelUpdater(node, val);    new Watcher(this.vm, exp, function (value) {      self.modelUpdater(node, value);    });
    node.addEventListener("input", function (e) {      let newValue = e.target.value;      if (val === newValue) {        return;      }      self.vm[exp] = newValue;      val = newValue;    });  }
  updateText(node, value) {    node.textContent = typeof value == "undefined" ? "" : value;  }
  modelUpdater(node, value, oldValue) {    node.value = typeof value == "undefined" ? "" : value;  }
  isDirective(attr) {    return attr.indexOf("v-") == 0;  }
  isEventDirective(dir) {    return dir.indexOf("on:") === 0;  }
  isElementNode(node) {    return node.nodeType == 1;  }
  isTextNode(node) {    return node.nodeType == 3;  }}那么知道我们该做什么后,第一件事就是获取到含有相关指令的dom元素,这个环节对dom操作比较繁琐,可以先建一个fragment片段,将需要解析的dom节点存入fragment片段里再进行处理:
function nodeToFragment(el) {  let fragment = document.createDocumentFragment();  let child = el.firstChild;  while (child) {    // 将Dom元素移入fragment中    fragment.appendChild(child);    child = el.firstChild;  }  return fragment;}接下来对含有{{}}这种形式的指令进行处理:
compileElement(el) {  let childNodes = el.childNodes;  let self = this;  [].slice.call(childNodes).forEach(function (node) {    let reg = /\{\{(.*)\}\}/;    let text = node.textContent;
    if (self.isTextNode(node) && reg.test(text)) {  // 判断是否是符合这种形式{{}}的指令      self.compiletext(node, reg.exec(text)[1]);    }
    if (node.childNodes && node.childNodes.length) {      self.compileElement(node);  // 继续递归遍历子节点    }  });}
compiletext(node, exp) {  let self = this;  let initText = this.vm[exp];  this.updateText(node, initText);  // 将初始化的数据初始化到视图中  new Watcher(this.vm, exp, function (value) {  // 生成订阅器并绑定更新函数    self.updateText(node, value);  });}
updateText(node, value) {  node.textContent = typeof value == "undefined" ? "" : value;}获取到最外层节点后,调用compileElement函数,对所有子节点进行判断,如果节点是文本节点且匹配{{}}这种形式指令的节点就开始进行编译处理,编译处理首先需要初始化视图数据,对应上面所说的步骤1。
接下来需要生成一个并绑定更新函数的订阅器,对应上面所说的步骤2。
接下来需要将解析器Compile与监听器Observer和订阅者Watcher关联起来,这个时候就需要修改一下SelfVue整合类,因为之前的整合类没有关联Compile:
class SelfVue {  constructor(data, el, exp) {    let self = this;    this.vm = this;    this.data = options;
    Object.keys(this.data).forEach(function (key) {      self.proxyKeys(key);    });
    observe(this.data);    new Compile(options, this.vm);    return this;  }
  proxyKeys(key) {    let self = this;    Object.defineProperty(this, key, {      enumerable: false,      configurable: true,      get: function proxyGetter() {        return self.data[key];      },      set: function proxySetter(newVal) {        self.data[key] = newVal;      },    });  }}更改后,我们就不要像之前通过传入固定的元素值进行双向绑定了,可以随便命名各种变量进行双向绑定了:
<body>    <div id="app">        <h2>{{title}}</h2>        <h1>{{name}}</h1>    </div></body><script src="js/observer.js"></script><script src="js/watcher.js"></script><script src="js/compile.js"></script><script src="js/index.js"></script><script type="text/javascript">
    let selfVue = new SelfVue({        el: '#app',        data: {            title: 'hello world',            name: ''        }    });
    window.setTimeout(function () {        selfVue.title = '你好';    }, 2000);
    window.setTimeout(function () {        selfVue.name = 'canfoo';    }, 2500);
</script>如上代码,在页面上可观察到,刚开始titile和name分别被初始化为hello world和空,2s后title被替换成你好3s后name被替换成canfoo。
如果需要完善更多指令的解析编译,只要在上文说的compileElement函数加上对其他指令节点进行判断,然后遍历其所有属性,看是否有匹配的指令的属性,如果有的话,就对其进行解析编译。
这里我们再添加一个v-model指令和事件指令的解析编译,对于这些节点我们使用函数compile进行解析处理:
compile(node) {  let nodeAttrs = node.attributes;  let self = this;  Array.prototype.forEach.call(nodeAttrs, function (attr) {    let attrName = attr.name;    if (self.isDirective(attrName)) {      let exp = attr.value;      let dir = attrName.substring(2);      if (self.isEventDirective(dir)) {  // 事件指令        self.compileEvent(node, self.vm, exp, dir);      } else {  // v-model 指令        self.compileModel(node, self.vm, exp, dir);      }      node.removeAttribute(attrName);    }  });}最后我们在稍微改造下类SelfVue,使它更像Vue的用法:
class SelfVue {  constructor(data, el, exp) {    let self = this;    this.data = options.data;    this.methods = options.methods;
    Object.keys(this.data).forEach(function (key) {      self.proxyKeys(key);    });
    observe(this.data);    new Compile(options.el, this);    options.mounted.call(this); // 所有事情处理好后执行mounted函数  }
  proxyKeys(key) {    let self = this;    Object.defineProperty(this, key, {      enumerable: false,      configurable: true,      get: function proxyGetter() {        return self.data[key];      },      set: function proxySetter(newVal) {        self.data[key] = newVal;      },    });  }}到这里,Vue的双向绑定就已经实现了,代码这个东西,第一遍看可能并不理解,但是多写多看,Vue双向绑定也并不是一个太难实现的功能,其实代码不一定非要像作者这么写,主要是明白其原理,如果能自行实现代码那是更好的。
4. 参考文章
本篇文章的代码大部分都来源于:vue 的双向绑定原理及实现,该文章中的讲解更为详细,本文章仅仅做了一些精简。