只需六步,写一个属于自己的 vue!

Vue 是国内目前最火的前端框架,它功能强大而又上手简单,基本成为前端工程师们的标配,但很多同学都只是停留在如何使用上,知其然不知所以然,对它内部的实现原理一知半解,今天就带领大家动手写一个类Vue的迷你库,进一步加深对Vue的理解,在前端进阶的道路上如虎添翼!

内容摘要

  • MVVM 简介和流程分析
  • 核心入口-MyVue类的实现
  • 观察者 - Watcher 类的实现
  • 发布订阅 - Dep 类的实现
  • 数据劫持 - Observer 类的实现
  • 大功告成,运行 MyVue

MVVM 简介和流程分析

作为前端最火的框架之一,Vue是MVVM设计模式实现的典型代表,什么是MVVM呢?MVVM是Model-View-ViewModel的简写,M - 数据模型(Model),V - 视图层(View),VM - 视图模型(ViewModel),它本质上就是MVC 的改进版。

MVVM 就是将其中的View 的状态和行为抽象化,让我们将视图 UI 和业务逻辑分开。

mvvm 实现原理的可以用下图简略表示·

mvvm.png

mvvm 实现流程分析

process.png

本案例我们通过实现 vue 中的插值表达式解析和指令 v-model 功能来探究vue的基本运行原理。

1. 核心入口-MyVue类的实现

实现数据代理

先了解些必备知识,Object.defineProperty(obj, prop, descriptor)该方法可以定义或修改对象的属性描述符

MyVue 的创建

class MyVue {
    constructor(option) {
        this.$el = document.querySelector(option.el);
        this.$data = option.data;

        if (this.$el) {
            // 1, 代理数据
            this.proxyData();

            // 2, 数据劫持
            // new Observer(this.$data);

            // 3, 编译数据
            // new Compile(this);
        }
    }
    // 代理数据,用于监听对 data 数据的访问和修改
    proxyData() {
        for (const key in this.$data) {
            Object.defineProperty(this, key, {
                enumerable: true, // 设为false后,该属性无法被删除。
                configurable: false, // 设为true后,该属性可以被 for...in或Object.keys 枚举到。
                get() {
                    return this.$data[key]
                },
                set(newVal) {
                    this.$data[key] = newVal
                }
            })
        }
    }
}

2. 编译模板 - Compile 类的实现

class Compile {
  constructor(vm) {
    // 要编译的容器
    this.el = vm.$el

    // 挂载实例对象,方便其他实例方法访问
    this.vm = vm

    // 通过文档片段来编译模板
    // 1,createDocumentFragment()方法,是用来创建一个虚拟的节点对象
    // 2,DocumentFragment(以下简称DF)节点不属于文档树,它有如下特点:
    //  2-1 当把该节点插入文档树时,插入的不是该节点自身,而是它所有的子孙节点
    //  2-2 当添加多个dom元素时,如果先将这些元素添加到DF中,再统一将DF添加到页面,会减少
    // 页面的重排和重绘,进而提升页面渲染性能。
    //  2-3 使用 appendChild 方法将dom树中的节点添加到 DF 中时,会删除原来的节点

    // 1,获取文档片段
    const fragment = this.nodeToFragment(this.el)

    // 2,编译模板
    this.compile(fragment) 

    // 3,将编译好的子元素重新追加到模板容器中
    this.el.appendChild(fragment)
    // console.log(fragment, fragment.nodeType, this.el.nodeType)
  }

  // dom元素转为文档片段
  nodeToFragment(element) {
    // 1,创建文档片段
    const f = document.createDocumentFragment()

    // 2, 迁移子元素
    while(element.firstChild) {
      f.appendChild(element.firstChild)
    }
    
    // 3,返回文档片段 
    return f
  }

  // 编译方法
  compile(fragment) {
    // 1,获取所有的子节点
    const childNodes = fragment.childNodes;

    // 2,遍历子节点数组
    childNodes.forEach(node => {
      // 分别处理元素节点(nodeType: 1)和文档节点(nodeType:3)
      const ntype = node.nodeType
      
      if (ntype === 1) {
        // 如果是元素节点,解析指令
        this.compileElement(node)
      } else if (ntype === 3) {
        // 如果是文档节点,解析双花括号
        this.compileText(node)
      }

      // 如果存在子节点则递归调用 compile
      if (node.childNodes && node.childNodes.length > 0) {
        this.compile(node)
      }
    })
  }

  // 编译元素节点
  compileElement(node){
    // 获取元素的所有属性
    const attrs = node.attributes;
    
    Array.from(attrs).forEach(atr => {
      const {name, value} = atr

      if (name.startsWith('v-')) {
        // 对指令做处理
        // name == v-model
        const [, b] = name.split('-')
        if (b === 'model') {
          node.value = this.vm[value]
        }
      }
    })
  }
  // 编译文档节点
  compileText(node) {
    const con = node.textContent;
    const reg = /\{\{(.+?)\}\}/g;

    if (reg.test(con)) {
      const value = con.replace(reg, (...args) => {
        // console.log(args)
        return this.vm[args[1]]
      })

      // 更新文档节点内容
      node.textContent = value
    }
  }
}

3. 观察者 - Watcher 类的实现

class Watcher {
  // 当观察者对应的数据发生变化时,使其可以更新视图
  constructor(vm, key, cb) {
    this.vm = vm;
    this.key = key;
    this.cb = cb;

    // 保存旧值
    this.oldVal = this.getOldVal()
  }

  getOldVal() {
    Dep.target = this
    const oldVal = this.vm[this.key]
    Dep.target = null

    return oldVal
  }

  // 更新视图
  update() {
    this.cb()
  }
}
// 接下来处理:1,谁来通知观察者去更新视图;2,在什么时机更新视图

4. 发布订阅 - Dep 类的实现

// 收集依赖
class Dep {
  constructor() {
    // 初始化观察者列表
    this.subs = []
  }

  // 收集观察者
  addSub(watcher) {
    this.subs.push(watcher)
  }

  // 通知观察者去更新视图
  notify() {
    this.subs.forEach(w => w.update())
  }
}

5. 数据劫持 - Observer 类的实现

class Observer {
  constructor(data) {
    this.observer(data)
  }

  observer(data) {
    // 实例化依赖收集器,专门收集所有的观察者对象
    const dep = new Dep();
      
    for (const key in data) {
      let val = data[key]
      Object.defineProperty(data, key, {
        enumerable: true,
        configurable: false,
        get() {
          // 当观察者实例化的时候会访问对应属性,进而触发get函数,然后添加订阅者
          // 之后,数据的变化就会触发 set 函数,而 set 函数触发就会执行 dep.notify()
          // 从而实现,数据变化驱动视图更新。
          Dep.target && dep.addSub(Dep.target);
          return val
        },
        set(newVal) {
          val = newVal
          // 既然数据更新,视图也应该随之更新
          dep.notify()
        }
      })
    }
  }
}

6. 大功告成,运行 MyVue

对之前代码进行调整

  • 首先是在 MyVue 中
class MyVue {
    constructor(option) {
        this.$el = document.querySelector(option.el);
        this.$data = option.data;

        if (this.$el) {
            // 1, 代理数据
            this.proxyData();

            // 2, 数据劫持
-           // new Observer(this.$data);
+           new Observer(this.$data);

            // 3, 编译数据
-           // new Compile(this);
+           new Compile(this);
        }
    }
}
  • 然后是在 Compile 中
class Compile {
  ...
  // 编译元素节点
  compileElement(node){
    // 获取元素的所有属性
    const attrs = node.attributes;
    
    Array.from(attrs).forEach(atr => {
      const {name, value} = atr

      if (name.startsWith('v-')) {
        // 对指令做处理
        // name == v-model
        const [, b] = name.split('-')
        if (b === 'model') {
          // 将v-model 的绑定的数据解析到输入框中
          node.value = this.vm[value]
                   
+         // 输入框内容变化,则修改数据(这一步是从视图到数据的变化,需要手动添加)
+         node.addEventListener('input', (e) => {
+          this.vm.$data[value] = e.target.value;
+         });

+         // 通过添加一个观察者实例对象,当数据发生任何变化则自动更新视图
+         new Watcher(this.vm, value, () => {
+           node.value = this.vm.$data[value];
+         });
        }
      }
    })
  }
  // 编译文档节点
  compileText(node) {
    const con = node.textContent;
    const reg = /\{\{(.+?)\}\}/g;

    if (reg.test(con)) {
      const value = con.replace(reg, (...args) => {
        // console.log(args)
+       // 通过添加一个观察者实例对象,当数据发生任何变化则自动更新视图
+       new Watcher(this.vm, args[1], () => {
+         node.textContent = con.replace(reg, (...args) => {
+           return this.vm[args[1]]
+         })
+       })
        return this.vm[args[1]]
      })

      // 更新文档节点内容
      node.textContent = value
    }
  }
}

引入前面创建的五个文件,就可以 new 一个自己的vue实例对象了,赶紧去试试吧!

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>MyVue</title>
</head>
<body>
  <div id="app">
    <!-- v-model 指令功能的实现 -->
    <input type="text" v-model="msg">
    <div>
      <!-- 插值表达式 -->
      <p>{{msg}} --- {{info}}</p>
    </div>
  </div>
  <script src="js/Dep.js"></script>
  <script src="js/Observer.js"></script>
  <script src="js/Watcher.js"></script>
  <script src="js/Compile.js"></script>
  <script src="js/MyVue.js"></script>
  <script>
    var mv = new MyVue({
      el: "#app",
      data: {
        msg: "Hello",
        info: "World"
      }
    })
  </script>
</body>
</html>