了解vue源码,从0到1实现本身的mvvm框架

武学之道,切勿贪多嚼不烂,博儿不精不如 一招鲜吃遍天,编程亦是如此,源码就是内力的修炼。这里咱们根据对vue源码的分析和理解来实现一个自定义的mvvm框架:KVue。html

实现目标:vue

一、数据劫持:defineProperty。
二、依赖收集:Dep && Watcher。
三、编译:插值绑定{{name}},指令绑定(v-text),双向绑定(v-model),事件处理(@click),html解析。node

下面这张图简单介绍了 mvvm实现的各部分细节。 git

主要包括三个部分:响应式原理 --> 依赖收集与追踪 --> 编译complie。
其中依赖收集是与响应式和编译原理相关的,因此咱们能够分两部分来实现一个mvvm框架,即:响应式原理和编译实现。

响应式原理

经过响应式在修改数据的时候更新视图。Vue.js的响应式原理依赖于Object.defineProperty,Vue经过设定对象属性的 setter/getter 方法来监听数据的变化,经过getter进行依赖收集,而每一个setter方法就是一个观察者,在数据变动的时候通知订阅者更新视图。正则表达式

数据劫持

实现流程:实现一个KVue构造器,接收options属性和data属性。遍历options.data里面的属性,经过object.defineProperty属性进行数据劫持。
须要注意的是,在遍历数据属性的过程当中咱们为属性值执行一个代理proxy。这样咱们就把data上面的属性代理到了vm实例上。编程

class KVue {
  constructor(options) {
    this.$options = options;
    this.$data = options.data;
    observe(this.$data);
  }
}
复制代码

实现一个数据观察器Observe()数组

observe(value) {
   if(!value || typeof value !== 'object'){
     return
   }
   // 遍历该对象
   Object.keys(value).forEach(key => {
     this.defineReactive(value, key, value[key])
     // 代理data的中属性到vue实例上
     this.proxyData(key)
   })
  }

  defineReactive(obj, key, val){
    this.observe(val);  // 解决数据嵌套:递归

    const dep = new Dep();

    Object.defineProperty(obj, key, {
     get: function(){
       return val;
     },
     set: function(newVal) {
       if(val === newVal){
         return
       }
       val = newVal;
     }
    })
  }

  proxyData(key) {       //  执行一个代理proxy。这样咱们就把data上面的属性代理到了vm实例上。
    Object.defineProperty(this, key, {
      get(){
        return this.$data[key];
      },
      set(newVal){
        this.$data[key] = newVal
      }
    })
  }

复制代码

这样就实现了对options中的data属性的数据劫持,经过getter劫持到读取属性时的操做以及经过setter劫持到设置属性值的操做。在上文中咱们在时只是简单的读取了值,在set是设置了新值。bash

发布订阅模式

在上面的步骤中咱们只是作了数据劫持,要达到数据响应式的效果,还须要在getter和setter中进行一些操做,咱们须要实现一个依赖收集器Dep()和数据侦听器Watcher()。app

依赖收集

依赖收集也成为发布者。框架

上文中已经对option的date属性进行了实现了数据劫持,在初始化读取值时天然会触发getter事件,因此咱们只要在最开始进行一次render,那么全部被渲染所依赖的data中的数据就会被getter收集到Dep的subs中去。在对data中的数据进行修改的时候setter只会触发Dep的subs的函数。

咱们先来实现一个简单的依赖收集类,dep内部维护了一个deps数组,addDep用来添加数据的依赖, notify函数在数组内部通知依赖更新。

class Dep {
  constructor() {
   this.deps = [];
  }
   addDep(dep) {
     this.deps.push(dep)
   }
   notify() {
     this.deps.forEach(dep => {
       dep.update()
     });
   }
}
复制代码

数据侦听器

数据侦听器也称为订阅者。

当依赖收集的时候会addSub到sub中,在修改data中数据的时候会触发dep对象的notify,通知全部Watcher对象去修改对应视图。 

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

    // 在这里将观察者自己赋值给全局的target,只有被target标记过的才会进行依赖收集
    Dep.target = this;
    // 触发getter,添加依赖
    this.vm[this.key];
    Dep.target = null
  }

  update() {
    //  将回调函数代理到this.vm实例,并传入对应属性的value值
    this.cb.call(this.vm, this.vm[this.key]);
  }
}
复制代码

实现一个数据可被观察(observable)的类

开始依赖收集

实现了发布订阅者模式后,咱们能够将依赖收集器和数据侦听器应用到数据劫持中发挥做用,开始依赖收集。

defineReactive(obj, key, val){
    this.observe(val);  // 解决数据嵌套:递归

    const dep = new Dep();

    Object.defineProperty(obj, key, {
     get: function(){
       /*Watcher对象存在全局的Dep.target中, 只有被target标记过的才会进行依赖收集*/
       Dep.target && dep.addDep(Dep.target)
       return val;
     },
     set: function(newVal){
       if(val === newVal){
         return
       }
       val = newVal;
       /*只有以前addSub中的函数才会触发*/
       dep.notify();
     }
    })
  }
复制代码

第一部分检测目标成果

经过以上代码咱们就实现了mvvm框架最基本的部分,数据响应式。能够运行一下上述代码并模拟watcher的建立过程检测一下目标成果。 编写一个html文件,引入kvue脚本,定义一个KVue实例,传入option选项,以及修改属性值

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app">
    <p id="name">
      <!-- {{test}} -->
    </p>
  </div>
  <script src="kvue.js"></script>
  <script>
    const app = new KVue({
      data: {
        test: 'I am test',
        foo: {
          bar: 'bar'
        }
      }
    })

    app.$data.test = 'hello, vue';
    app.$data.foo.bar = 'oh, my bar';
  </script>
</body>
</html>

复制代码

在KVue构造器内模拟实现watcher的建立中依赖收集和数据侦听的过程;

constructor(options) {
    this.$options = options;

    this.$data = options.data;
    this.observe(this.$data);

    // 模拟一下watcher的建立过程;
    new Watcher(this, 'test', (val)=>{
      console.log(val)
    });
    this.$data.test;
    new Watcher(this.foo, 'bar', (val)=>{
      console.log(val)
    });
    this.$data.foo.bar

    // new Compile(options.el, this);

    // created执行
    // if(options.created){
    //   options.created.call(this);
    // }
  }
复制代码

此时在控制台打开测试用的html文件,能够看到控制台输出“hello, vue”,说明修改test属性值,触发了setter里写的dep.notify函数,调用了Watcher更新的函数,触发了Watcher回调函数。因此在watcher的回调中打印了属性值。

编译器实现

实现目标:

  1. 插值绑定:{{name}},
  2. 指令绑定:k-text,
  3. 双向绑定:k-model,
  4. html解析:k-html。
  5. 事件处理:@click,

Compile构造函数

新建compile.js文件,获取实例渲染的宿主节点,遍历宿主节点,经过document.createDocumentFragment()方法将Dom中的node节点转成文档片断。由于文档片断存在于内存中,并不在DOM树中,因此对文档片断进行插入操做不会引发页面回流。所以,使用文档片断一般会带来更好的性能。以后对转换后的文档片断执行编译函数:遍历根节点的全部自节点,对元素节点和插值节点分别处理。注意若是子节点仍然包含子节点的话,须要递归实现compile函数。

对DOM中的Node节点不熟悉的童鞋能够参考一下MDN 上关于 Node 部分的详解,会对编译过程的理解有帮助。

class Compile {
    constructor(el, vm){
        this.$el = document.querySelector(el); // 拿到要遍历的宿主节点
        this.$vm = vm;  // 存储vm实例

        if(this.$el){
            // 转换内部内容为片断fragment
            this.$fragment = this.node2Fragment(this.$el);
            // 执行编译
            this.compile(this.$fragment);
            // 将编译完的html结果追加至el
            this.$el.appendChild(this.$fragment);
        }
    }
    // 将宿主元素的代码片断拿出来遍历,这样作比较高效。
    node2Fragment(el) {
        const frag = document.createDocumentFragment();
        // 将el中的全部子元素搬家至frag中
        let child;
        while(child = el.firstChild){
            frag.appendChild(child)
        }
        return frag;
    }
    // 编译过程
    compile(el){
        const childNodes = el.childNodes;  // 
        Array.from(childNodes).forEach(node => {
            if(this.isElement(node)){ // 处理元素
                console.log('编译元素'+node.nodeName)
            } else if(this.isInterpolation(node)){ // 插值文本
                onsole.log('编译文本'+node.textContent)
            }
            // 递归子节点
            if(node.childNodes && node.childNodes.length>0){
                this.compile(node)
            }
        }
    }
    isElement(node) { // 判断node是否是元素节点
        return node.nodeType === 1;
    }
    isInterpolation(node) {  // 判断node是否是插值节点
        return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent);
    }
}
复制代码

实现插值替换处理

先实现了一个最简单的插值节点的插值替换处理。在compile 函数中的处理文本分支执行compileText过程。

...
      Array.from(childNodes).forEach(node => {
          if(this.isElement(node)){ // 处理元素
              console.log('编译元素'+node.nodeName)
          } else if(this.isInterpolation(node)){ // 插值文本
              console.log('编译文本'+node.textContent)
              this.compileText(node);
          }
      }
  ...
  compileText(node) {
      /* 捕获正则表达式匹配中的分组1 */
      let groupName = RegExp.$1
      /* 设置节点的textContent属性为data中的属性值,即完成了插值替换 */
      node.textContent = this.$vm.$data[groupName];
  }
复制代码

针对编译过程新建一个测试函数compile-test.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app">
    <p>{{name}}</p>
    <p k-text="name"></p>
    <p>{{age}}</p>
    <p>
      {{doubleAge}}
    </p>
    <input type="text" k-model="name">
    <button @click="changeName">呵呵</button>
    <div k-html="html"></div>
  </div>
  <script src="kvue.js"></script>
  <script src="compile.js"></script>
  <script>
    const app = new KVue({
      el: "#app",
      data: {
        name: 'I am test',
        age: '10',
        doubleAge: '20',
        html: '<button>这是一个按钮</button>'
      },
      created(){
          console.log('开始了');
          setTimeout(() => {
              this.name = 'w我是测试';
          }, 1500)
      },
      methods: {
        changeName() {
            this.name = '事件触发';
            this.age = 1;
        }
      },
    })
  </script>
</body>
</html>
复制代码

对编译过程进行测试,咱们能够在KVue的构造函数中添加created声明周期,调整KVue以下:实现编译器实例化,并执行created声明周期函数。 kvue.js

constructor(options) {
    this.$options = options;

    this.$data = options.data;
    this.observe(this.$data);

    new Compile(options.el, this);

    // created执行
    if(options.created){
      options.created.call(this);
    }
  }
复制代码

运行这个文件,咱们发现关于插值的部分被成功的解析了,可是这个值在修改的时候页面上并不会响应式的变化。这是由于并在编译过程当中插值属性并无被添加到依赖中。 因此的编译器处理节点属性值的时候须要将compiler与数据侦听器结合起来,添加依赖收集。

编译器中的依赖收集

由于对于其余类型的节点也须要进行这一步处理,因此咱们将这个功能提取成一个公共函数update,经过函数参数传入自定义变量。

compileText(node) {
        this.update(node, this.$vm, RegExp.$1, 'text')
    }
    update(node, vm, exp, dir) {  // dir传入节点的操做类型
        const updaterFn = this[dir+'Updater'];
        // 初始化
        updaterFn && updaterFn(node, vm[exp]);
        // 依赖收集
        new Watcher(vm, exp, function(value){
            updaterFn && updaterFn(node, value);
        })
    }
    textUpdater(node, value) {
        node.textContent = value
    }
复制代码

在运行compile-test.html文件,咱们发现页面上的三个插值操做符被正确解析,并created声明周期内修改的属性值也成功的显示到页面上,到这一步,咱们已经实现了能足够响应式处理插值操做符的编译器。

实现指令绑定

实现指令绑定的关键在于须要在编译函数的处理元素的分支上正确处理指令,判断是否以k-开头,例如k-text,dir匹配到text,则判断this.text函数是否存在,存在则执行text函数。text函数内调用公共更新函数update(),在依赖更新的时候触发${dir}Updater(),即textUpdater函数,设置node的textContent属性。

...
  if(this.isElement(node)){ // 处理元素
      // 查找 k-、@、:、
      const nodeAttrs = node.attributes;
      Array.from(nodeAttrs).forEach(attr => {
          const attrName = attr.name;
          const exp = attr.value;
          if(this.isDirective(attrName)) { // 处理指令
              // k-text
              const dir = attrName.substring(2);
              // 执行指令
              this[dir] && this[dir](node, this.$vm, exp);
          }
      })

  }
  ...
  isDirective(attr) {
    return attr.indexOf('k-') === 0;
  }

  text(node, vm, exp){
    this.update(node, vm, exp, 'text')
  }
  textUpdater(node, value) {
    node.textContent = value
  }
复制代码

双向绑定

双向绑定通常经过k-model实现。在上一步代码中已经经过dir匹配到'model',因此只须要在上一步的基础上补充model函数和modelUpdater函数。

// 双向绑定
model(node, vm, exp){
    // 指定input value属性
    this.update(node, vm, exp, 'model');

    // 视图对模型响应
    node.addEventListener('input', e => {
        vm[exp] = e.target.value;
    });
}

modelUpdater(node, value){
    node.value = value;
}
复制代码

html解析

同理,处理v-html指令,只须要添加

html(node, vm, exp){
    this.update(node, vm, exp, 'html');
}

htmlUpdater(node, value){
    node.innerHTML = value;
}
复制代码

事件绑定

在compile函数的处理的元素分子上判断处理之间的指令,dir匹配到事件类型,如:click

...
if(this.isEvent(attrName)){  // 处理事件
  const dir = attrName.substring(1);
  this.eventHandler(node, this.$vm, exp, dir)
}
...
eventHandler(node, vm, exp, dir) {
  // 取事件名
  let fn = vm.$options.methods && vm.$options.methods[exp];
  if(dir && fn) {
      node.addEventListener(dir, fn.bind(vm)); // 绑定事件监听
  }
}
复制代码

至此一个基于vue的基本语法mvvn框架KVue就已经实现,可以实现相似于vue指令的语法解析,以及实时的数据视图的双向绑定。支持插值绑定、指令绑定、双向绑定html解析、事件处理等语法特性。

总结

以上过程整理成脑图以下,也许能有助于理解。

项目源码: 

gitee地址:kvue: 了解vue源码,从0到1实现本身的mvvm框架 若是你以为有帮助能够给个star哦!

相关文章
相关标签/搜索