本身动手写一个 SimpleVue

最近看到一句话颇有感触 —— 有人问 35 岁以后你还会在写代码吗?各类中年程序员的言论充斥的耳朵,好像中年就不应写代码了,可是我想说,若干年之后,有人问你闲来无事你会干什么,我想我会说,写代码,我想这个答案就够了,年龄不是你不爱的理由。javascript

理论基础

双向绑定是 MVVM 框架最核心之处,那么双向绑定的核心是什么呢?核心就是 Object.defineProperty 这个 API,关于这个 API 的具体内容,请移步 MDN - Object.defineProperty ,里面有更详细的说明。html

接下来咱们来看一下 Vue 是怎么设计的:vue

vue

图中有几个重要的模块:java

  • 监听者(Observer): 这个模块的主要功能是给 data 中的数据增长 gettersetter,以及往观察者列表中增长观察者,当数据变更时去通知观察者列表。
  • 观察者列表(Dep): 这个模块的主要做用是维护一个属性的观察者列表,当这个属性触发 getter 时将观察者添加到列表中,当属性触发 setter 形成数据变化时通知全部观察者。
  • 观察者(Watcher): 这个模块的主要功能是对数据进行观察,一旦收到数据变化的通知就去改变视图。

咱们简化一下 Vue 里的各类代码,只关注咱们刚刚说的那些东西,实现一个简单版的 Vue。node

Coding Time

咱们就拿 Vue 的一个例子来检验成果。git

<body>
  <div id="app">
    <p>{{ message }}</p>
    <button v-on:click="reverseMessage">逆转消息</button>
  </div>
</body>
<script src="vue/index.js"></script>
<script src="vue/observer.js"></script>
<script src="vue/compile.js"></script>
<script src="vue/watcher.js"></script>
<script src="vue/dep.js"></script>
<script> const vm = new Vue({ el: '#app', data: { message: 'Hello Vue.js!' }, methods: { reverseMessage: function () { this.message = this.message.split('').reverse().join('') } }, mounted: function() { setTimeout(() => { this.message = 'I am changed after mounte'; }, 2000); }, }); </script>
复制代码

new Vue()

首先,看 Vue 的源码咱们就能知道,在 Vue 的构造函数中咱们完成了一系列的初始化工做,以及生命周期钩子函数的设置。那咱们的简易版 Vue 该怎么写呢?咱们在使用 Vue 的时候是经过一个构造函数来开始使用,因此咱们的简易代码也从构造函数开始。程序员

class Vue {
  constructor(options) {
    this.data = options.data;
    this.methods = options.methods;
    this.mounted = options.mounted;
    this.el = options.el;

    this.init();
  }

  init() {
    // 代理 data
    Object.keys(this.data).forEach(key => {
      this.proxy(key);
    });
    // 监听 data
    observe(this.data, this);
    // 编译模板
    const compile = new Compile(this.el, this);
    // 生命周期其实就是在完成一些操做后调用的函数,
    // 因此有些属性或者实例在一些 hook 里其实尚未初始化,
    // 也就拿不到相应的值
    this.callHook('mounted');
  }

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

  callHook(lifecycle) {
    this[lifecycle]();
  }
}
复制代码

能够看到咱们在构造函数中实例化了 Vue,而且对 data 进行代理,为何咱们要进行代理呢?缘由是经过代理咱们就可以直接经过 this.message 操做 message,而不须要 this.data.message,代理的关键也是咱们上面所说的 Object.defineProperty。而生命周期其实在代码中也是在特定时间点调用的函数,因此咱们作一些操做的时候也要去想一想,它初始化完成没有,新手常常犯的错误就是在没有完成初始化的时候去进行操做,因此对生命周期的理解是很是重要的。github

好了,完成了初始化,下面咱们就要开始写如何监听这些数据的变化了。算法

Observer

经过上面的认识,咱们知道,Observer 主要是给 data 的每一个属性都加上 gettersetter,以及在触发相应的 getset 的时候执行的功能。app

class Observer {
  constructor(data) {
    this.data = data;
    this.init();
  }

  init() {
    this.walk();
  }

  walk() {
    Object.keys(this.data).forEach(key => {
      this.defineReactive(key, this.data[key]);
    });
  }
  
  defineReactive(key, val) {
    const dep = new Dep();
    const observeChild = observe(val);
    Object.defineProperty(this.data, key, {
      enumerable: true,
      configurable: true,
      get() {
        if(Dep.target) {
          dep.addSub(Dep.target);
        }
        return val;
      },
      set(newVal) {
        if(newVal === val) {
          return;
        }
        val = newVal;
        dep.notify();
        observe(newVal);
      }
    });
  }
}

function observe(value, vm) {
  if(!value || typeof value !== 'object') {
    return;
  }
  return new Observer(value);
}
复制代码

在上面,咱们完成了对 data 的监听,经过递归调用实现了对每一个属性值的监听,给每一个数据都添加了 setter 和 getter,在咱们对数据进行取值或者是赋值操做的时候都会触发这两个方法,基于这两个方法,咱们就可以作更多的事了。

如今咱们知道了怎么监听数据,那么咱们如何去维护观察者列表呢?我相信有些朋友和我同样,看到 get 中的 Dep.target 有点懵逼,这究竟是个啥,怎么用的,带着这个疑问,咱们来看看观察者列表是如何实现的。

Dep

class Dep {
  constructor() {
    this.subs = [];
  }

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

  notify() {
    this.subs.forEach(sub => sub.update());
  }
}

Dep.target = null;
复制代码

在 Dep 中咱们维护一个观察者列表(subs),有两个基础的方法,一个是往列表中添加观察者,一个是通知列表中全部的观察者。能够看到咱们最后一行的 Dep.target = null;,可能你们会好奇,这东西是干什么用的,其实很好理解,咱们定义了一个全局的变量 Dep.target,又由于 JavaScript 是单线程的,同一时间只可能有一个地方对其进行操做,那么咱们就可以在观察者触发 getter 的时候,将本身赋值给 Dep.target,而后添加到对应的观察者列表中,这也就是上面的 Observergetter 中有个对 Dep.target 的判断的缘由,而后当 Watcher 被添加到列表中,这个全局变量又会被设置成 null。固然了这里面有些东西还须要在 Watcher 中实现,咱们接下来就来看看 Watcher 如何实现。

Watcher

在写代码以前咱们先分析一下,Watcher 须要一些什么基础功能,Watcher 须要订阅 Dep,同时须要更新 View,那么在代码中咱们实现两个函数,一个订阅,一个更新。那么咱们如何作到订阅呢?看了上面的代码咱们应该有个初步的认识,咱们须要在 getter 中去将 Watcher 添加到 Dep 中,也就是依靠咱们上面说的 Dep.target,而更新咱们使用回调就能作到,咱们看代码。

class Watcher {
  constructor(vm, exp, cb) {
    this.vm = vm;
    this.exp = exp;
    this.cb = cb;
    this.value = this.get();
  }

  get() {
    Dep.target = this;
    const value = this.vm.data[this.exp.trim()];
    Dep.target = null;
    return value;
  }

  update() {
    const newVal = this.vm.data[this.exp.trim()];
    if(this.value !== newVal) {
      this.value = newVal;
      this.cb.call(this.vm, newVal);
    }
  }
}
复制代码

那么,咱们有了 Watcher 以后要在什么地方去调用它呢?想这个问题以前,咱们要思考一下,咱们如何拿到你在 template 中写的各类 {{message}}v-text等等指令以及变量。对,咱们还有一个模版编译的过程,那么咱们是否是能够在编译的时候去触发 getter,而后咱们就完成了对这个变量的观察者的添加,好了说了那么多,咱们来看下下面的模块如何去作。

Compile

Compile 主要要完成的工做就是把 template 中的模板编译成 HTML,在编译的时候拿到变量的过程也就触发了这个数据的 getter,这时候就会把观察者添加到观察者列表中,同时也会在数据变更的时候,触发回调去更新视图。咱们下面就来看看关于 Compile 这个模块该怎么去完成。

// 判断节点类型
const nodeType = {
  isElement(node) {
    return node.nodeType === 1;
  },
  isText(node) {
    return node.nodeType === 3;
  },
};

// 更新视图
const updater = {
  text(node, val) {
    node.textContent = val;
  },
  // 还有 model 啥的,但实际都差很少
};

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);
      this.compileElement(this.fragment);
      this.el.appendChild(this.fragment);
    }
  }

  nodeToFragment(el) {
    // 使用 document.createDocumentFragment 的目的就是减小 Dom 操做
    const fragment = document.createDocumentFragment();
    let child = el.firstChild;

    // 将原生节点转移到 fragment
    while(child) {
      fragment.appendChild(child);
      child = el.firstChild;
    }
    return fragment;
  }

  // 根据节点类型不一样进行不一样的编译
  compileElement(el) {
    const childNodes = el.childNodes;
    
    [].slice.call(childNodes).forEach((node) => {
      const reg = /\{\{(.*)\}\}/;
      const text = node.textContent;

      // 根据不一样的 node 类型,进行编译,分别编译指令以及文本节点
      if(nodeType.isElement(node)) {
        this.compileEl(node);
      } else if(nodeType.isText(node) && reg.test(text)) {
        this.compileText(node, reg.exec(text)[1]);
      }

      // 递归的对元素节点进行深层编译
      if(node.childNodes && node.childNodes.length) {
        this.compileElement(node);
      }
    });
  }

  // 在这里咱们就完成了对 Watcher 的添加
  compileText(node, exp) {
    const value = this.vm[exp.trim()];
    updater.text(node, value);
    new Watcher(this.vm, exp, (val) => {
      updater.text(node, val);
    });
  }

  compileEl(node) {
    const attrs = node.attributes;
    Object.values(attrs).forEach(attr => {
      var name = attr.name;
      if(name.indexOf('v-') >= 0) {
        const exp = attr.value;
        // 只作事件绑定
        const eventDir = name.substring(2);
        if(eventDir.indexOf('on') >= 0) {
          this.compileEvent(node, eventDir, exp);
        }
      }
    });
  }

  compileEvent(node, dir, exp) {
    const eventType = dir.split(':')[1];
    const cb = this.vm.methods[exp];

    if(eventType && cb) {
      node.addEventListener(eventType, cb.bind(this.vm));
    }
  }
}
复制代码

这就是 Compile 完成的部分工做,固然了这个模块不会这么简单,这里只是简单的实现了一点功能,现在 Vue 2.0 引入了 Virtual DOM,对元素的操做也不像这么简单了。

最后实现的功能因为我比较懒,你们能够本身写一写或者在个人 GitHub 仓库里能够看到。

总结

上面的代码也借鉴了前人的想法,但因为时间比较久了,因此我也没找到,感谢大佬提供思路。

Vue 的设计颇有意思,在学习之中也能有不少不同的感觉,同时,在读源码的过程当中,不要过多的追求读懂每个变量,每个句子。第一遍代码,先读懂程序是怎么跑起来的,大概是怎么走的,通读一遍,第二遍再去深究,扣一扣当时不清楚的东西,这是我看源码的一些心得,可能每一个人的方法不同,但愿你能有所收获。

最后,由于 Vue 2.0 已经出来一段时间了,源码也有不少的变更,生命周期的变化、Virtual DOM 等等,还有比较感兴趣的 diff 算法,这些后续会继续研究的,谢谢。

相关文章
相关标签/搜索