手动实现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点击事件,双向绑定的关键点在于数据是如何改变视图。

那么难点就在于数据改变视图了,所以为了实现双向绑定,我们需要引入一些概念:

  1. Observer:监听器,用来监听所有属性,如果属性发生了变化,则告诉订阅者Watcher。
  2. Watcher:订阅者,收到属性变化并执行对应的函数,从而更新视图。
  3. Dep:消息订阅器,因为订阅者是多个,消息订阅器就专门用来收集这些订阅者。
  4. 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'了。

change

到这里,总算大功告成一半了。

但是还有一个细节问题,我们在赋值的时候是这样的形式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主要做下面两件事情:

  1. 解析模板指令,并替换模板数据,初始化视图。(即Vue中用{{}}包裹起来的部分)
  2. 将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器。(完成绑定)

先给出完整的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 的双向绑定原理及实现,该文章中的讲解更为详细,本文章仅仅做了一些精简。