Vue学习-学习源码手撸简易Vue

Caleb ... 2020-12-10
  • Vue
大约 11 分钟

提示

本期内容是带着大家熟悉 Vue 的基本组成逻辑,并手把手的帮助大家完成一个简易版本的 Vue。

内容篇幅较长,请耐心观看。

# 演示

"miniVue"

# 准备工作

创建好文件夹,起名叫做 Mini_Vue。再在文件夹中分别创建好 index.htmljs 文件夹。在 js 文件下创建如下内容:

Mini_Vue
--------
├─ js
│  ├─vue.js
│  ├─observer.js
│  ├─compiler.js
│  ├─dep.js
│  └─watcher.js
└─ index.html
1
2
3
4
5
6
7
8
9

需要在index.html中写入数据。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Mini Vue</title>
  </head>

  <body>
    <div id="app">
      <h1>差值表达式</h1>
      <h3>{{msg}}</h3>
      <h3>{{count}}</h3>
      <h1>v-text</h1>
      <div v-text="msg"></div>
      <h1>v-model</h1>
      <input type="text" v-model="msg" />
      <input type="text" v-model="count" />
    </div>

    <script src="./js/dep.js"></script>
    <script src="./js/watcher.js"></script>
    <script src="./js/compiler.js"></script>
    <script src="./js/observer.js"></script>
    <script src="./js/vue.js"></script>

    <script>
      let vm = new Vue({
        el: "#app",
        data: {
          msg: "Hello Mini Vue",
          count: 12,
          person: {
            name: "xiao",
          },
        },
      });
      console.log(vm);
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

准备好文件后,我们开始逐一分析。

# 整体分析

Vue

  • 把 data 中的成员注入到 Vue 实例,并且把 data 中的成员转成 getter/setter

Observer

  • 能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知 Dep

Compiler

  • 解析每个元素中的指令/插值表达式,并替换成相应的数据

Dep

  • 添加观察者(watcher),当数据变化通知所有观察者

Watcher

  • 数据变化更新视图

# Vue

功能

  • 负责接收初始化的参数(选项)
  • 负责把 data 中的属性注入到 Vue 实例,转换成 getter/setter
  • 负责调用 observer 监听 data 中所有属性的变化
  • 负责调用 compiler 解析指令/插值表达式

结构 大致内容

Vue
|
├─ $options
├─ $el
├─ $data
├─ _proxyData()
├─ ..... 等等属性
1
2
3
4
5
6
7

梳理

先行解读 Vue 模块中的参数:

  • $options
    此属性代指初始化 Vue 时(new Vue())传入的自定义属性数据。例如传入 router、store、render()、i18n。

  • $el
    Vue 实例绑定的 DOM 节点

  • $data
    读取数据属性对象

  • _proxyData()
    我们理解为对 data 进行数据劫持。

简单的了解参数后,实现功能:

  • 接收初始化的参数。首先应该将需要的属性进行声明,而属性的值都来自于传入的 option

  • 通过Object.defineProperty将 data 转换成 getter/setter

代码

首先打开 vue.js 文件,我们开始 Vue 类的建立。

  1. 实现 Vue 属性的加载
class Vue {
  constructor(option) {
    // 1.通过属性保存选项的数据
    this.$option = option || {};
    this.$data = option.data || {};
    this.$el =
      typeof option.el === "string"
        ? document.querySelector(option.el)
        : option.el;
    // 2.把data中的成员转换成getter和setter,并注入到Vue实例中
    this._proxyData(this.$data);
    // 3.调用observer对象,监听数据变化
    // 4.调用compiler对象,解析指令和差值表达式
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在如上代码中可以看到,我们在第二步调用了 this._proxyData()函数,目的是为了将 data 中的成员转换成 getter 和 setter。现在开始实现。

  1. 实现 this._proxyData()
class Vue {
  // 此处与第一步中的一致,不再复制
  constructor(option) {
    //   xxxxxxx
  }

  _proxyData(data) {
    // 遍历data中的所有属性,把data的所有属性注入到实例中
    Object.keys(data).forEach((key) => {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get() {
          return data[key];
        },
        set(newValue) {
          if (newValue === data[key]) {
            return;
          }
          data[key] = newValue;
        },
      });
    });
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

如果对此方法不熟悉的,可以先去看看这篇文章。

📘 Vue 学习-数据响应式原理

此时我们对 Vue 类进行了简单的处理。但是还有两个功能并没有实现,我们先放着,接着往下走。

# Observer

功能

  • 负责把 data 选项中的属性转换成响应式数据
  • data 中的某个属性也是对象,把该属性转换成响应式数据
  • 数据变化发送通知

结构 大致内容

Observer
|
├─ walk(data)
├─ defineReactive(data,key,value)
├─ .....
1
2
3
4
5

梳理

  • walk()
    用来判断传入的值是否为对象。如果不是对象就返回,是对象的话遍历对象的所有属性调用defineReactive()来转换为 getter/setter

  • defineReactive()
    将传入的对象进行转换,并进行递归操作。

代码

打开 observe.js。

// 负责数据劫持
// 把 $data 中的成员转换成 getter/setter
class Observer {
  constructor(data) {
    this.walk(data);
  }

  // 1.负责把 data 选项中的属性转换成响应式数据
  walk(data) {
    // a.判断data是否是对象
    if (!data || typeof data !== "object") {
      return;
    }
    // b.遍历data对象的所有属性
    Object.keys(data).forEach((key) => {
      this.defindReactive(data, key, data[key]);
    });
  }

  defindReactive(obj, key, val) {
    var this_ = this;
    // 2.如果val是对象,把对象中的属性也转换成响应式数据
    this.walk(val);
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        Dep.target && dep.addSub(Dep.target);
        return val;
      },
      set(newValue) {
        if (newValue === val) {
          return;
        }
        val = newValue;
        this_.walk(newValue);
      },
    });
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

此时完成了前两个功能,已经拥有了简单的处理能力。数据变化发送通知需要在后面进行处理。

我们需要把 Observer 类实例化,此时需要在 Vue 类的第三个功能下 new Observer()并传入 this.$data.

打开 vue.js 文件,在第三步中调用。













 




class Vue {
  constructor(option) {
    // 1.通过属性保存选项的数据
    this.$option = option || {};
    this.$data = option.data || {};
    this.$el =
      typeof option.el === "string"
        ? document.querySelector(option.el)
        : option.el;
    // 2.把data中的成员转换成getter和setter,并注入到Vue实例中
    this._proxyData(this.$data);
    // 3.调用observer对象,监听数据变化
    new Observer(this.$data);
    // 4.调用compiler对象,解析指令和差值表达式
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# Compiler

功能

  • 负责编译模板,解析指令/插值表达式
  • 负责页面的首次渲染
  • 当数据变化后重新渲染视图

结构 大致内容

Compiler
|
├─ el
├─ vm
├─ compile(el)
├─ compilerElement(node)
├─ compilerText(node)
├─ isDirective(attrName)
├─ isTextNode(node)
├─ isElementNode(node)
├─ .....
1
2
3
4
5
6
7
8
9
10
11

梳理

属性介绍:

  • el
    Vue 实例化的初始 DOM 对象

  • vm
    vue 实例

  • compile(el)
    顾名思义编译 DOM 节点,判断 el 下的内容是元素节点还是文本节点,进行对应的操作

  • compilerElement(node)
    解析元素节点的内容

  • compilerText(node)
    解析文本节点的内容

  • isDirective(attrName)
    判断元素节点里的属性是否为 Vue 属性,以"v-"开头

  • isTextNode(node)
    判断元素是否为文本节点

  • isElementNode(node)
    判断元素是否为元素节点

代码

打开 compiler.js 文件。

class Compiler {
  constructor(vm) {
    this.el = vm.$el;
    this.vm = vm;
    this.compiler(this.el);
  }
  // 编译模板,出来文本节点和元素节点
  compiler(el) {
    let childNodes = el.childNodes;
    Array.from(childNodes).forEach((node) => {
      if (this.isTextNode(node)) {
        // 处理文本节点
        this.compilerText(node);
      } else if (this.isElementNode(node)) {
        // 处理元素节点
        this.compilerElement(node);
      }

      // 判断node节点,是否有子节点 如果有子节点 要递归处理compliler
      if (node.childNodes && node.childNodes.length) {
        this.compiler(node);
      }
    });
  }

  // 编译文本节点
  compileText(node) {}

  // 编译属性节点
  compileElement(node) {}

  // 判断元素属性的名字是否是指令
  isDirective(attrName) {
    return attrName.startsWith("v-");
  }
  // 判断元素是否为文本节点
  isTextNode(node) {
    return node.nodeType === 3;
  }
  // 判断元素是否为元素节点
  isElementNode(node) {
    return node.nodeType === 1;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

分步实现两个未定义的函数。

compileText()

  • 负责编译插值表达式

此步骤是用来提取页面中被 {{}} 包裹的参数。

// 编译文本节点
compileText(node) {
  const reg = /\{\{(.+)\}\}/;
  // 获取文本节点的内容
  const value = node.textContent;
  if (reg.test(value)) {
    // 插值表达式中的值就是我们要的属性名称
    const key = RegExp.$1.trim();
    // 把插值表达式替换成具体的值
    node.textContent = value.replace(reg, this.vm[key]);

    // 未来实现数据响应式。。。。。
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

compileElement()

  • 负责编译元素的指令
  • 处理 v-text 的首次渲染
  • 处理 v-model 的首次渲染

我们在这里先实现 v-text v-model, 可以理解在初始化时,如果元素节点中绑定了指令,那么在解析它时必须先对 attr 进行遍历,拿到带有指令的属性。然后建立对应的指令方法,将指令功能实现。

compileElement(node) {
  // 遍历元素节点中的所有属性,找到指令
  Array.from(node.attributes).forEach((attr) => {
    // 获取元素属性的名称
    let attrName = attr.name;
    // 判断当前的属性名称是否是指令
    if (this.isDirective(attrName)) {
      // attrName 的形式 v-text  v-model
      // 截取属性的名称,获取 text model
      attrName = attrName.substr(2);
      // 获取属性的名称,属性的名称就是我们数据对象的属性 v-text="name",获取的是name;
      const key = attr.value;
      // 处理不同的指令
      this.update(node, key, attrName);
    }
  });
}
// 负责更新 DOM
update(node, key, attrName) {
  // node 节点,key 数据的属性名称,dir 指令的后半部分
  const updaterFn = this[attrName + "Updater"];
  updaterFn && updaterFn(node, this.vm[key]);
}
// v-text 指令的更新方法
textUpdater(node, value) {
  node.textContent = value;
}
// v-model 指令的更新方法
modelUpdater(node, value) {
  node.value = value;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

此时我们需要将 Compiler 类实例化到 Vue 类的第四个功能当中。

打开 vue.js,在第四步中实例化 Compiler。















 



class Vue {
  constructor(option) {
    // 1.通过属性保存选项的数据
    this.$option = option || {};
    this.$data = option.data || {};
    this.$el =
      typeof option.el === "string"
        ? document.querySelector(option.el)
        : option.el;
    // 2.把data中的成员转换成getter和setter,并注入到Vue实例中
    this._proxyData(this.$data);
    // 3.调用observer对象,监听数据变化
    new Observer(this.$data);
    // 4.调用compiler对象,解析指令和差值表达式
    new Compiler(this);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

提示

下面这部分可能会有点绕,此处使用了观察者模式。对设计模式陌生的同学,请先阅读第二章内容。

📘 Vue 学习-设计模式探索

# Dep(Dependency)

"dep"

功能

  • 收集依赖,添加观察者(watcher)
  • 通知所有观察者

结构 大致内容

Dep
|
├─ subs
├─ addSub(sub)
├─ notify()
├─ .....
1
2
3
4
5
6

梳理

解读属性:

  • subs 存放所有的观察者

  • addSub(sub) 添加观察者

  • notify() 通知所有的观察者

代码

打开 dep.js 文件。

class Dep {
  constructor() {
    // 存储所有的观察者
    this.subs = [];
  }

  // 添加观察者
  addSub(sub) {
    if (sub && sub.update) {
      this.subs.push(sub);
    }
  }

  // 通知所有观察者
  notify() {
    this.subs.forEach((sub) => {
      sub.update();
    });
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

简单的 Dep 订阅者已经建立好了,这时候需要在数据监听的地方将订阅者激活,也就是数据劫持 Observe 类。

所以需要将原本在 Observe 中的defindReactive进行部分改写。

defindReactive(obj, key, val) {
  var this_ = this;
  // 负责收集依赖,并发送通知
  let dep = new Dep();
  // 2. 如果val是对象,把对象中的属性也转换成响应式数据
  this.walk(val);
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      // 获取数据的时候将观察者添加进来
      Dep.target && dep.addSub(Dep.target);
      return val;
    },
    set(newValue) {
      if (newValue === val) {
        return;
      }
      val = newValue;
      this_.walk(newValue);

      // 发送通知
      dep.notify();
    },
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

需要将 Dep 的方法注册在数据劫持函数内,已达到全局的订阅中心。

# Watcher

"Watcher"

功能

  • 当数据变化触发依赖,dep 通知所有的 Watcher 实例更新视图
  • 自身实例化的时候往 dep 对象中添加自己

结构 大致内容

Watcher
|
├─ vm
├─ key
├─ cb
├─ oldValue
├─ update()
├─ .....
1
2
3
4
5
6
7
8

梳理

  • vm vue 实例

  • key data 中属性名称

  • cb 回调函数,负责更新视图

  • oldValue 用来触发 Observe 中定义的 get 方法,调用 Dep 的 addSub 方法。

  • update() 当数据发生变化的时候更新视图

可以大致理解,Watcher 干的事情就是负责处理视图变化,由 Dep 在数据更新的时候告诉它,调用它的 update 方法,然后通过回调函数来更新视图。

代码

打开 watcher.js 文件。

class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    // data中的属性名称
    this.key = key;
    // 回调函数 负责更新视图
    this.cb = cb;

    // 把watcher对象记录到Dep类的静态属性target
    Dep.target = this;
    // 触发get方法,在get方法中会调用addSub
    this.oldValue = vm[key];
    // 添加后清除当前Watcher
    Dep.target = null;
  }
  // 当数据发生变化的时候更新视图
  update() {
    let newValue = this.vm[this.key];
    if (this.oldValue === newValue) {
      return;
    }
    this.cb(newValue);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

那么需要在哪里调用 Watcher 类呢? 当然是在 Compiler 类中解析元素数据的时候,比如指令的对应函数,解析文本节点时。所以就需要对原本的函数进行处理升级。

打开 compiler.js 文件。

compilerText()

compilerText(node) {
    // console.dir(node);
    // {{ msg }}
    let reg = /\{\{(.+?)\}\}/;
    let val = node.textContent;
    if (reg.test(val)) {
        let key = RegExp.$1.trim()
        node.textContent = val.replace(reg, this.vm[key])

        // 添加 观察者
        new Watcher(this.vm, key, (newValue) => {
            node.textContent = newValue
        })
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

textUpdater()

// 处理v-text
textUpdater(node, value, key) {
    node.textContent = value;

    // 添加 观察者
    new Watcher(this.vm, key, (newValue) => {
        node.textContent = newValue
    })
}
1
2
3
4
5
6
7
8
9

modelUpdater()

我们假设目前只给 input 做双向数据绑定,那个监听它的 input 事件,更新数据就可以触发一圈内容。

// 处理v-model
modelUpdater(node, value, key) {
    node.value = value;

    // 添加 观察者
    new Watcher(this.vm, key, (newValue) => {
        node.value = newValue
    })
    // 双向绑定
    node.addEventListener('input', () => {
        this.vm[key] = node.value
    })
}
1
2
3
4
5
6
7
8
9
10
11
12
13

至此简单的 Vue 就封装完毕了,这时候我们就需要调试了,但是作为文章显示,不是特别好做调试演示,就将这块省略了。

感谢能阅读到这里。如果有什么问题,可以自行调试排查或者在文字底部留言。

打赏