Vue 的 computed、watch 的实现

1,898 阅读3分钟

前言

本文章纯为学习中的经验总结,并非教程,若有疑问欢迎讨论。


准备代码

之前有总结 Vue 双向绑定的实现,建议先去看看再看这篇文章,这篇文章是在其基础之上进行拓展的,所以先去瞅瞅吧,如果已经看过的话,把代码拷过来,后面要用。

文章地址:Vue 双向绑定的剖析

对于双向绑定的回顾

之前说过,双向绑定是对某个属性进行了劫持,在其get的时候进行订阅,然后在set的时候通知更新。

两者的概念

想想 computed 和 watch 的实现过程,我个人的理解为:

  • computed 是一个订阅者订阅多个属性
  • watch 是一个订阅者订阅一个属性,在其更新到时执行特定的方法

所以代码也从此入手。

实现

computed

首先改造一下 html 文件:

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="app">
      <input v-model="name" />
      <h1>{{ name }}</h1>
      <h1>{{ age }}</h1>
      <h1>{{ hello }}</h1>
      <button v-on:click="addAge">过年了,大一岁</button>
      <button v-on:click="changeName">我叫李四</button>
    </div>

    <script src="./myvue.js"></script>
    <script>
      var vm = new MyVue({
        el: '#app',

        data: {
          name: '张三',
          age: 20
        },

        computed: {
          hello() {
            return `你好我是${this.name}, 今年${this.age}岁。`;
          }
        },

        methods: {
          addAge() {
            this.age++;
          },
          changeName() {
            this.name = '李四';
          }
        }
      });
    </script>
  </body>
</html>

和之前对比,就是加了一个 computed 的属性,然后页面上渲染出来,没什么特殊的含义,来解决一下JS部分吧。

首先在 MyVue 类的构造函数中,把 computed 挂载上去:

class MyVue {
  constructor({ el, data, computed, methods }) {
    let obs = new Observer(data);

    this.$el = el;
    this.$data = obs.data;
    this.$computed = computed; // add
    this.$methods = methods;

    Object.keys(this.$data).forEach(i => {
      this.proxyKeys(i);
    });

    new Compile(this);
  },
  
  // code ... 
}

然后在 Compile 中,渲染节点的地方,应该是 node.nodeType === 3 的部分,进行一下判断,修改如下:

if (node.nodeType === 3) {
  let reg = /\{\{(.*)\}\}/;
  if (reg.test(node.textContent)) {
    // 这里文本里也许会有多个 {{}} ,{{}} 内或许会有表达式,这里简单处理,就取一个值
    let exp = reg.exec(node.textContent)[1].trim();

    // old code
    // let value = this.vm.$data[exp];

    // node.textContent = typeof value === 'undefined' ? '' : value;

    // new Watcher(this.vm.$data, exp, newVal => {
    //   node.textContent = typeof newVal === 'undefined' ? '' : newVal;
    // });

    // new code
    if (this.vm.$data[exp]) {
      let value = this.vm.$data[exp];
      node.textContent = typeof value === 'undefined' ? '' : value;

      new Watcher(this.vm.$data, exp, newVal => {
        node.textContent = typeof newVal === 'undefined' ? '' : newVal;
      });
    } else if (this.vm.$computed[exp]) {
      let computed = new ComputedWatcher(this.vm, exp, newVal => {
          node.textContent = typeof newVal === 'undefined' ? '' : newVal;
        }
      );
      node.textContent = computed.value;
    }
  }
}

其实就是在取值的时候,看有 data 没有,没有的话就看 computed 中有没有,有的话就用计算属性订阅者生成器来生成计算属性的订阅者。

其结构和 Watcher 差不多,传入 vm 、计算属性的键和回调函数,在更新时去更新节点的内容。

所以现在写一下 ComputedWatcher 类吧,它和 Watcher 特别像:

class ComputedWatcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    this.key = key;
    this.cb = cb;

    Dep.target = this;
    this.value = this.vm.$computed[key].call(this.vm);
    Dep.target = null;
  }

  update() {
    let newVal = this.vm.$computed[this.key].call(this.vm);
    let oldVal = this.value;
    if (newVal !== oldVal) {
      this.value = newVal;
      this.cb(newVal, oldVal);
    }
  }
}

实际上,它就是 Watcher 复制过来,在第一次取值的时候,使用 computed 定义的方法,这样的话,在方法中使用的每一个属性都会把这个计算属性的订阅者加入其订阅器中,所以只要其中的一个更改了,就会通知这个计算属性。

而 update 方法则执行方法比较旧值判断是否更新节点,这样就完成了 computed 的实现。

watch

watch 就很简单了,它是监听某个属性,更新和执行相应的方法,其实和解析DOM节点一样,更新数据后,节点的回调方法的操作是更新节点文本内容,而 watch 的回调方法就是自己写的一些业务。

所以先改造一下 html

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="app">
      <input v-model="name" />
      <h1>{{ name }}</h1>
      <h1>{{ age }}</h1>
      <h1>{{ hello }}</h1>
      <button v-on:click="addAge">过年了,大一岁</button>
      <button v-on:click="changeName">我叫李四</button>
    </div>

    <script src="./myvue.js"></script>
    <script>
      var vm = new MyVue({
        el: '#app',

        data: {
          name: '张三',
          age: 20
        },

        computed: {
          hello() {
            return `你好我是${this.name}, 今年${this.age}岁。`;
          }
        },

        watch: {
          name() {
            alert(`你好我是${this.name}!`);
          }
        },

        methods: {
          addAge() {
            this.age++;
          },
          changeName() {
            this.name = '李四';
          }
        }
      });
    </script>
  </body>
</html>

更改时就 alert 一下。

因为 watch 和页面无关,所以仅需要更改一下 MyVue,代码如下:

class MyVue {
    constructor({ el, data, computed, watch, methods }) {
    let obs = new Observer(data);

    this.$el = el;
    this.$data = obs.data;
    this.$computed = computed;
    this.$watch = watch; // add1
    this.$methods = methods;

    Object.keys(this.$data).forEach(i => {
      this.proxyKeys(i);
    });

    new Compile(this);
    
    // add2
    Object.keys(this.$watch).forEach(key => {
      new Watcher(this.$data, key, () => {
        this.$watch[key].call(this);
      });
    });
  },
  
  // code ... 
}
  • add1: 挂载一下 watch
  • add2:将每一个 watch 生成为订阅者,回调函数里执行相应的方法。

如此,就ok了。


所有代码:

  • index.html
<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="app">
      <input v-model="name" />
      <h1>{{ name }}</h1>
      <h1>{{ age }}</h1>
      <h1>{{ hello }}</h1>
      <button v-on:click="addAge">过年了,大一岁</button>
      <button v-on:click="changeName">我叫李四</button>
    </div>

    <script src="./myvue.js"></script>
    <script>
      var vm = new MyVue({
        el: '#app',

        data: {
          name: '张三',
          age: 20
        },

        computed: {
          hello() {
            return `你好我是${this.name}, 今年${this.age}岁。`;
          }
        },

        watch: {
          name() {
            alert(`你好我是${this.name}!`);
          }
        },

        methods: {
          addAge() {
            this.age++;
          },
          changeName() {
            this.name = '李四';
          }
        }
      });
    </script>
  </body>
</html>
  • myvue.js
// 订阅者生成器
class Observer {
  constructor(data) {
    this.data = data;
    Object.keys(data).forEach(key => {
      let value = data[key];
      let dep = new Dep();

      Object.defineProperty(data, key, {
        get() {
          Dep.target && dep.add(Dep.target);
          return value;
        },
        set(newVal) {
          if (newVal !== value) {
            value = newVal;
            dep.notify(newVal);
          }
        }
      });
    });
  }
}

// 订阅者生成器
class Watcher {
  constructor(data, key, cb) {
    this.data = data;
    this.cb = cb;
    this.key = key;

    Dep.target = this;
    this.value = data[key];
    Dep.target = null;
  }

  update(newVal) {
    let oldVal = this.value;
    if (newVal !== oldVal) {
      this.value = newVal;
      this.cb(newVal, oldVal);
    }
  }
}

// 计算属性订阅者生成器
class ComputedWatcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    this.key = key;
    this.cb = cb;

    Dep.target = this;
    this.value = this.vm.$computed[key].call(this.vm);
    Dep.target = null;
  }

  update() {
    let newVal = this.vm.$computed[this.key].call(this.vm);
    let oldVal = this.value;
    if (newVal !== oldVal) {
      this.value = newVal;
      this.cb(newVal, oldVal);
    }
  }
}

// 订阅库生成器
class Dep {
  constructor() {
    this.subs = [];
  }

  add(sub) {
    this.subs.push(sub);
  }

  notify(newVal) {
    this.subs.forEach(sub => {
      sub.update(newVal);
    });
  }
}
Dep.target = null;

// 片段解析器
class Compile {
  constructor(vm) {
    this.vm = vm;

    let el = document.querySelector(this.vm.$el);
    let fragment = document.createDocumentFragment();

    if (el) {
      while (el.firstChild) {
        fragment.appendChild(el.firstChild);
      }

      // 编译片段
      this.compileElement(fragment);

      el.appendChild(fragment);
    } else {
      console.log('挂载元素不存在!');
    }
  }

  compileElement(el) {
    for (let node of el.childNodes) {
      /*
        node.nodeType
        1:元素节点
        3:文本节点
      */
      if (node.nodeType === 1) {
        for (let attr of node.attributes) {
          let { name: attrName, value: exp } = attr;

          // v- 代表存在指令
          if (attrName.indexOf('v-') === 0) {
            /*
              <div v-xxx=""> 元素上,可以用很多指令,这里仅做学习,所以不判断太多了
              on    事件绑定
              model 表单绑定
            */
            let [dir, value] = attrName.substring(2).split(':');
            if (dir === 'on') {
              // 取 vm.methods 相应的含税,进行绑定
              let fn = this.vm.$methods[exp];
              fn && node.addEventListener(value, fn.bind(this.vm), false);
            } else if (dir === 'model') {
              // 取 vm.data 进行 input 的赋值,并且在 input 的时候更新 vm.data 上的值
              let value = this.vm.$data[exp];
              node.value = typeof value === 'undefined' ? '' : value;

              node.addEventListener('input', e => {
                if (e.target.value !== value) {
                  this.vm.$data[exp] = e.target.value;
                }
              });

              new Watcher(this.vm.$data, exp, newVal => {
                node.value = typeof newVal === 'undefined' ? '' : newVal;
              });
            }
          }
        }
      } else if (node.nodeType === 3) {
        let reg = /\{\{(.*)\}\}/;
        if (reg.test(node.textContent)) {
          // 这里文本里也许会有多个 {{}} ,{{}} 内或许会有表达式,这里简单处理,就取一个值
          let exp = reg.exec(node.textContent)[1].trim();

          // old code
          // let value = this.vm.$data[exp];

          // node.textContent = typeof value === 'undefined' ? '' : value;

          // new Watcher(this.vm.$data, exp, newVal => {
          //   node.textContent = typeof newVal === 'undefined' ? '' : newVal;
          // });

          // new code
          if (this.vm.$data[exp]) {
            let value = this.vm.$data[exp];
            node.textContent = typeof value === 'undefined' ? '' : value;

            new Watcher(this.vm.$data, exp, newVal => {
              node.textContent = typeof newVal === 'undefined' ? '' : newVal;
            });
          } else if (this.vm.$computed[exp]) {
            let computed = new ComputedWatcher(this.vm, exp, newVal => {
              node.textContent = typeof newVal === 'undefined' ? '' : newVal;
            });
            node.textContent = computed.value;

            // 将 computed 做一个代理,以便 this.xxx
            Object.defineProperty(this.vm, exp, {
              enumerable: false,
              configurable: true,
              get() {
                return computed.value;
              }
            });
          }
        }
      }

      if (node.childNodes && node.childNodes.length) {
        this.compileElement(node);
      }
    }
  }
}

class MyVue {
  constructor({ el, data, computed, watch, methods }) {
    let obs = new Observer(data);

    this.$el = el;
    this.$data = obs.data;
    this.$computed = computed;
    this.$watch = watch;
    this.$methods = methods;

    Object.keys(this.$data).forEach(i => {
      this.proxyKeys(i);
    });

    new Compile(this);

    Object.keys(this.$watch).forEach(key => {
      new Watcher(this.$data, key, () => {
        this.$watch[key].call(this);
      });
    });
  }

  proxyKeys(key) {
    let _this = this;
    Object.defineProperty(_this, key, {
      enumerable: false,
      configurable: true,
      get() {
        return _this.$data[key];
      },
      set(newVal) {
        _this.$data[key] = newVal;
      }
    });
  }
}

PS:在 new ComputedWatcher 后,加了一个代理的方法,将计算属性代理到 this 上面,以便以 this.xxx 调用。