vue3使用proxy实现数据双向绑定基本原理

253 阅读5分钟

vue3.x使用了Proxy来对数据进行监听了,因此我们来简单的来学习下使用Proxy来实现一个简单的vue双向绑定。 我们都知道实现数据双向绑定,需要实现如下几点:

  1. 需要实现一个数据监听器 Observer, 能够对所有数据进行监听,如果有数据变动的话,拿到最新的值并通知订阅者Watcher.
  2. 需要实现一个指令解析器Compile,它能够对每个元素的指令进行扫描和解析,根据指令模板替换数据,以及绑定相对应的函数。
  3. 需要实现一个Watcher, 它是链接Observer和Compile的桥梁,它能够订阅并收到每个属性变动的通知,然后会执行指令绑定的相对应 的回调函数,从而更新视图。

938664-20170522225458132-1434604303.png 下面是一个简单的demo源码如下(我们可以参考下,理解下原理):

<!DOCTYPE html>
 <html>
    <head>
      <meta charset="utf-8">
      <title>标题</title>
    </head>
    <body>
      <div id="app">
        <input type="text" v-model='count' />
        <input type="button" value="增加" @click="add" />
        <input type="button" value="减少" @click="reduce" />
        <div v-bind="count"></div>
      </div>
      <script type="text/javascript">   
        class Vue {
          constructor(options) {
            this.$el = document.querySelector(options.el);
            this.$methods = options.methods;
            this._binding = {};
            this._observer(options.data);
            this._compile(this.$el);
          }
          _pushWatcher(watcher) {
            if (!this._binding[watcher.key]) {
              this._binding[watcher.key] = [];
            }
            this._binding[watcher.key].push(watcher);
          }
          /*
           observer的作用是能够对所有的数据进行监听操作,通过使用Proxy对象
           中的set方法来监听,如有发生变动就会拿到最新值通知订阅者。
          */
          _observer(datas) {
            const me = this;
            const handler = {
              set(target, key, value) {
                const rets = Reflect.set(target, key, value);
                me._binding[key].map(item => {
                  item.update();
                });
                return rets;
              }
            };
            this.$data = new Proxy(datas, handler);
          }
          /*
           指令解析器,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相对应的更新函数
          */
          _compile(root) {
            const nodes = Array.prototype.slice.call(root.children);
            const data = this.$data;
            nodes.map(node => {
              if (node.children && node.children.length) {
                this._compile(node.children);
              }
              const $input = node.tagName.toLocaleUpperCase() === "INPUT";
              const $textarea = node.tagName.toLocaleUpperCase() === "TEXTAREA";
              const $vmodel = node.hasAttribute('v-model');
              // 如果是input框 或 textarea 的话,并且带有 v-model 属性的
              if (($vmodel && $input) || ($vmodel && $textarea)) {
                const key = node.getAttribute('v-model');
                this._pushWatcher(new Watcher(node, 'value', data, key));
                node.addEventListener('input', () => {
                  data[key] = node.value;
                });
              }
              if (node.hasAttribute('v-bind')) {
                const key = node.getAttribute('v-bind');
                this._pushWatcher(new Watcher(node, 'innerHTML', data, key));
              }
              if (node.hasAttribute('@click')) {
                const methodName = node.getAttribute('@click');
                const method = this.$methods[methodName].bind(data);
                node.addEventListener('click', method);
              }
            });
          }
        }
        /*
         watcher的作用是 链接Observer 和 Compile的桥梁,能够订阅并收到每个属性变动的通知,
         执行指令绑定的响应的回调函数,从而更新视图。
        */
        class Watcher {
          constructor(node, attr, data, key) {
            this.node = node;
            this.attr = attr;
            this.data = data;
            this.key = key;
          }
          update() {
            this.node[this.attr] = this.data[this.key];
          }
        }
      </script>
      <script type="text/javascript">
        new Vue({
          el: '#app',
          data: {
            count: 0
          },
          methods: {
            add() {
              this.count++;
            },
            reduce() {
              this.count--;
            }
          }
        });
      </script>
    </body>
</html>
        class Vue {
          constructor(options) {
            this.$el = document.querySelector(options.el);
            this.$methods = options.methods;
            this._binding = {};
            this._observer(options.data);
            this._compile(this.$el);
          }
        }

Vue类使用new创建一个实例化的时候,就会执行 constructor方法代码,因此options是vue传入的一个对象,它有 el,data, methods等属性。 如上代码先执行 this._observer(options.data); 该 observer 函数就是监听所有数据的变动函数。

  1. 实现Observer对所有的数据进行监听。
        _observer(datas) {
          const me = this;
          const handler = {
            set(target, key, value) {
              const rets = Reflect.set(target, key, value);
              me._binding[key].map(item => {
                item.update();
              });
              return rets;
            }
          };
          this.$data = new Proxy(datas, handler);
        }

使用了我们上面介绍的Proxy中的set方法对所有的数据进行监听,只要我们Vue实列属性data中有任何数据发生改变的话,都会自动调用Proxy中的set方法,我们上面的代码使用了 const rets = Reflect.set(target, key, value); return rets; 这样的代码,就是对我们的data中的任何数据发生改变后,使用该方法重新设置新值,然后返回给 this.$data保存到这个全局里面。

        me._binding[key].map(item => {
          item.update();
        });

如上this._binding 是一个对象,对象里面保存了所有的指令及对应函数,如果发生改变,拿到最新值通知订阅者,因此通知Watcher类中的update方法,如下Watcher类代码如下:

        /*
         watcher的作用是 链接Observer 和 Compile的桥梁,能够订阅并收到每个属性变动的通知,
         执行指令绑定的响应的回调函数,从而更新视图。
        */
        class Watcher {
          constructor(node, attr, data, key) {
            this.node = node;
            this.attr = attr;
            this.data = data;
            this.key = key;
          }
          update() {
            this.node[this.attr] = this.data[this.key];
          }
        }

  1. 实现Compile _compile 函数的作用就是对页面中每个元素节点的指令进行解析和扫描的,根据指令模板替换数据,以及绑定相应的更新函数。
_compile(root) {
    const nodes = Array.prototype.slice.call(root.children);
    const data = this.$data;
    nodes.map(node => {
      if (node.children && node.children.length) {
        this._compile(node.children);
      }
      const $input = node.tagName.toLocaleUpperCase() === "INPUT";
      const $textarea = node.tagName.toLocaleUpperCase() === "TEXTAREA";
      const $vmodel = node.hasAttribute('v-model');
      // 如果是input框 或 textarea 的话,并且带有 v-model 属性的
      if (($vmodel && $input) || ($vmodel && $textarea)) {
        const key = node.getAttribute('v-model');
        this._pushWatcher(new Watcher(node, 'value', data, key));
        node.addEventListener('input', () => {
          data[key] = node.value;
        });
      }
      if (node.hasAttribute('v-bind')) {
        const key = node.getAttribute('v-bind');
        this._pushWatcher(new Watcher(node, 'innerHTML', data, key));
      }
      if (node.hasAttribute('@click')) {
        const methodName = node.getAttribute('@click');
        const method = this.$methods[methodName].bind(data);
        node.addEventListener('click', method);
      }
    });
  }
}

如上代码,

  1. 拿到根元素的子节点,然后让子元素变成数组的形式,如代码: const nodes = Array.prototype.slice.call(root.children);

  2. 保存变动后的 this.data,如下代码:constdata=this.data, 如下代码: const data = this.data;

  3. nodes子节点进行遍历,如果改子节点还有子节点的话,就会递归调用 _compile方法,如下代码:

nodes.map(node => {
  if (node.children && node.children.length) {
    this._compile(node.children);
  }
});
  1. 对子节点进行判断,如果子节点是input元素或textarea元素的话,并且有 v-model这样的指令的话,如下代码:
nodes.map(node => {
  const $input = node.tagName.toLocaleUpperCase() === "INPUT";
  const $textarea = node.tagName.toLocaleUpperCase() === "TEXTAREA";
  const $vmodel = node.hasAttribute('v-model');
  // 如果是input框 或 textarea 的话,并且带有 v-model 属性的
  if (($vmodel && $input) || ($vmodel && $textarea)) {
    const key = node.getAttribute('v-model');
    this._pushWatcher(new Watcher(node, 'value', data, key));
    node.addEventListener('input', () => {
      data[key] = node.value;
    });
  }
});

如上代码,如果有 v-model,就获取v-model该属性值,如代码: const key = node.getAttribute('v-model'); 然后把该指令通知订阅者 Watcher; 如下代码: this._pushWatcher(new Watcher(node, 'value', data, key));

就会调用 Watcher类的constructor的方法,如下代码:

class Watcher {
  constructor(node, attr, data, key) {
    this.node = node;
    this.attr = attr;
    this.data = data;
    this.key = key;
  }
}

把 node节点,attr属性,data数据,v-model指令key保存到this对象中了。然后调用 this._pushWatcher(watcher); 这样方法。

if (!this._binding[watcher.key]) {
  this._binding[watcher.key] = [];
}
this._binding[watcher.key].push(watcher);

如上代码,先判断 this._binding 有没有 v-model指令中的key, 如果没有的话,就把该 this._binding[key] = []; 设置成空数组。然后就把它存入 this._binding[key] 数组里面去。 5. 对于 input 或 textarea 这样的 v-model 会绑定相对应的函数,如下代码:

node.addEventListener('input', () => {
  data[key] = node.value;
});

当input或textarea有值发生改变的话,那么就把最新的值存入 Vue类中的data对象里面去,因此data中的数据会发生改变,因此会自动触发执行 _observer 函数中的Proxy中的set方法函数,还是一样,首先更新最新值,使用代码: const rets = Reflect.set(target, key, value); 然后遍历 保存到 this._binding 对象中对应的键;如下代码:

me._binding[key].map(item => {
  console.log(item);
  item.update();
});

我们在input输入框输入1的时候,然后执行 item.update()方法,update方法如下

class Watcher {
  update() {
    this.node[this.attr] = this.data[this.key];
  }
}

就会更新值到视图里面去,比如input或textarea, 那么 attr = 'value', node 是该元素的节点,key 就是 v-model中的属性值,因此 this.node['value'] = this.data[key];

然后同时代码中如果有 v-bind这样的指令的话,也会和上面的逻辑一样判断和执行;如下 v-bind指令代码如下:

if (node.hasAttribute('v-bind')) {
  const key = node.getAttribute('v-bind');
  this._pushWatcher(new Watcher(node, 'innerHTML', data, key));
}

然后也会更新到视图里面去,那么 attr = 'innerHTML', node 是该元素的节点,key 也是 v-model中的属性值了,因此 this.node.innerHTML = thid.data['key'];

比如页面中html代码如下:

<div id="app">
  <input type="text" v-model='count' />
  <input type="button" value="增加" @click="add" />
  <input type="button" value="减少" @click="reduce" />
  <div v-bind="count"></div>
</div>

实例化代码如下

new Vue({
  el: '#app',
  data: {
    count: 0
  },
  methods: {
    add() {
      this.count++;
    },
    reduce() {
      this.count--;
    }
  }
});

因此上面的 node 是 input中的node节点了,因此 node.value = this.data['count']; 因此 input框的值就更新了,同时

该节点通过 node.innerHTML = this.data['count'] 这样的话,值也得到了更新了。

  1. 对于页面中元素节点带有 @click这样的方法,也有判断,如下代码:
if (node.hasAttribute('@click')) {
  const methodName = node.getAttribute('@click');
  const method = this.$methods[methodName].bind(data);
  node.addEventListener('click', method);
}

如上代码先判断该node是否有该属性,然后获取该属性的值,比如html页面中有 @click="add" 和 @click="reduce" 这样的,当点击的时候,也会调用 this.𝑚𝑒𝑡ℎ𝑜𝑑𝑠[𝑚𝑒𝑡ℎ𝑜𝑑𝑁𝑎𝑚𝑒].𝑏𝑖𝑛𝑑(𝑑𝑎𝑡𝑎)中对应𝑣𝑢𝑒实列中对应的函数的。因此也会执行函数的,其中𝑑𝑎𝑡𝑎就是𝑡ℎ𝑖𝑠.data,监听该对象的值发生改变的话,同样会调用 Proxy中的set函数,最后也是一样执行函数去更新视图的。如上就是使用proxy实现数据双向绑定的基本原理的。