初探Vue源码(一)

前言

        这段时间因为忙着秋招,好像很久都没有更新过了,现在秋招也没个着落,花了几天的时间看了一下Vue的源码,当然是跟着视频教程走的,但是视频有几节没有,所以还是挺遗憾的,知识点还是比较多的,这里做个总结,希望能够帮助到后面的面试。

        一上手就直接翻Vue源码的话,还是比较痛苦的,这里采用的是另一种方式,先对后面出现的相关知识点进行整理,最后再来看源代码部分,这样会好很多,视频也是按照这样的流程走的,还是比较易于理解的。(Vue版本:2.6.12)

第一部分 如何将模板与数据相结合

首先我们回顾一下Vue的使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 1. 写模板 -->
<div id="root">
<p>`{{name}}`</p>
<p>`{{message}}`</p>
</div>
<script>
console.log(root);
// 2. 创建实例
let app = new Vue({
el: '#root',
data: {
name: 'handsome',
message: 'call...'
}
})
// 3.挂载 :以上这种用法的挂载在vue.js中帮我们实现了
console.log(root);
</script>

那么Vue帮我们做了什么事呢?简单来说它做了4件事:拿到模板、拿到数据、将模板和数据结合、放到页面中,下面我们分别来实现一下这几个步骤

1. 找到模板

1
let template = document.querySelector('#root');

2. 拿到数据

这里我们模拟一下data的数据

1
2
3
4
let data = {
name: 'jyq',
message: 'missing you...'
}

3. 将模板和数据结合(难点)

这一部分是今天这部分的难点,我们如何将模板和数据进行结合呢?

首先要明白几个知识点,我们的dom节点是分为元素节点和文本节点的,像我们的被 双大括号 包裹的就是在文本节点中,所以这里的思路是:

  • 遍历页面上的节点
    • 元素节点
      • 元素节点下可能还有其它节点,所以我们进行递归
    • 文本节点
      • 利用正则判断是否有 双大括号 ,有的话用数据进行替换
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
45
46
47
48
49
50
51
// 一般都是使用递归
// 在现在这个案例中 template 是 DOM 元素
// 在真正的 Vue 源码中是 DOM -> 字符串模板 -> vNode -> 真正的DOM
function compiler(template, data) {
const reg = /\{\{(.+?)\}\}/g
// 获取模板元素下的子元素
let nodeList = template.childNodes;
// 遍历子元素 判断哪些是元素节点 哪些是文本节点
for (let i = 0; i < nodeList.length; i++) {
// 1: 代表元素节点
// 3: 代表文本节点
let type = nodeList[i].nodeType;
// 若为元素节点 则继续递归 这里只考虑在元素节点下的情况 没考虑在标签中的情况
if (type === 1) {
// 递归调用
compiler(nodeList[i], data)
} else if (type === 3) {
// 文本节点 判断里面是否有 `{{}}` 插值
let str = nodeList[i].nodeValue; // nodeValue 属性只在文本节点中有效
// 利用正则判断有没有 `{{}}` 有的话就进行替换
// replace 函数在正则匹配成功一次就调用一次
// 后面的函数中 第一个参数代表匹配到的内容 `{{name}}`
// 之后的参数代表正则中的 第 n 组我们在正则中用()分组 name
str = str.replace(reg, (_, g) => {
let key = g.trim();
// 将 data 中的值 取出给变量
let value = data[key];
// 将变量返回出去 如果不返回 会是 undefined
// 返回的value相当于拿到匹配到的对应的数据返回出去 为下一步填充数据做准备
return value
});
// 注意: str现在和 DOM 元素是没有关系的
// 这里做的就是将 str 对 DOM 上的元素进行填充
nodeList[i].nodeValue = str;
}
}
}

// 仅仅做完上面还不够 你会发现如果我们此时调用compiler的话 相当于是把原来埋下的坑给补上
// 但是不要忘了 vue 是响应式的 如果你之后再去修改 data 中的数据那么原来的坑已经没有了 更何谈补呢
// 所以这里将template进行了一份拷贝
// cloneNode参数如果为 true 的话他将递归赋值当前节点的所有子孙节点 否则只复制当前节点
// 此时如果在调用compiler前后打印template的值会发现两个都是可以在页面上进行选择的
// 这说明 我们此时是没有生成新的 template 所以这里看到的 是直接在页面中就更新的数据
// 因为 DOM 是引用类型
let generateNode = template.cloneNode(true);
// 调用 compiler 函数进行编译 将数据与模板进行绑定
compiler(generateNode, data);
// 4 放到页面中
// 将拷贝后的内容放到页面中 注意这里的两个方法 平时自己没怎么使用
root.parentNode.replaceChild(generateNode, root);

4. 放到页面中

1
root.parentNode.replaceChild(generateNode, root);

一些问题

当我们做完上面的步骤后,仔细想一下,真的没有问题了吗,答案很明显,问题有很多。

  1. 首先Vue中使用的是虚拟DOM,这一点我相信大多数人都听过,虚拟DOM的引入使得我们在任何情况下,都能以一个相对较少的修改去操作我们的dom元素;
  2. 是这里我们只考虑了单属性的情况,也就是类似于{{name}}这种情况,对于多层级的我们这里是不能处理的,比如说{{name.obj.age}}这种情况,很明显不符合我们的实际开发;
  3. 是我们这里的代码还没有进行整合,会感觉很乱。

所以接下来我们就围绕这三个方面来进行展开。

这里我们先解决第三个问题 代码的整合

在整合之前呢,我们先做一点约定(Vue中的约定),就是我们约定 _ 开头的代表的是内部的私有属性,可读可写; $ 开头的只能读,不能写,这里我把整体结构搭在这里,具体实现你可以查看这里具体实现

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

function myVue(options){
// 习惯: _ 开头的都是内部的私有属性 可读可写
// $ 开头的只能读
this._data = options.data;
this._el = options.el;

// 准备工作( 准备模板 )
this._templateDOM = document.querySelector(this._el);
this._parent = this._templateDOM.parentNode;
// 渲染工作
this.render();
}

/** 将模板结合数据 得到 HTML 加载到页面中 */
myVue.prototype.render = function () {
this.compiler();
}
/** 编译将模板结合数据 得到真正的 DOM 元素 */
myVue.prototype.compiler = function () {
let reaHTMLlDOM = this._templateDOM.cloneNode(true);
compiler(reaHTMLlDOM, this._data);
this.update(reaHTMLlDOM)
}
/** 将真正的 DOM 元素 加到页面中 */
myVue.prototype.update = function (reaHTMLlDOM) {
this._parent.replaceChild(reaHTMLlDOM, document.querySelector('#root'));
}

function compiler(){};

let app = new myVue({
el: '#root',
data: {
name: 'jquery yes queen',
message: 'miss'
}
})
}

接下来是第二个问题 针对单属性的情况

这个问题我们的思路是,首先找到这个属性,然后将属性转为数组的形式并用 . 进行分割,然后使用循环对其处理。这里我们的函数名叫做 getValueByPath

1
2
3
4
5
6
7
8
9
10
11
12
function getValueByPath(obj, path) {
// 得到的是数组 [xxx,yyy,zzz]
let paths = path.split('.');
let res = obj;
let prop;
// 这里使用的是 while 循环 很巧妙的一种方式
// 通过每次来弹出首个数组中的元素来控制循环次数
while(prop = paths.shift()){
res = res[prop];
}
return res
}

在Vue中我们采用了大量的函数柯里化的处理,目的就是对我们进场用到的一些数据进行缓存,虽说函数柯里化的概念自己已经清楚了,但是仅仅凭那几个例子(我想大多数人了解函数柯里化的时候都看到过相加的那个例子吧),我并不能知道这个技巧拿给我们带来什么,通过在Vue源码中的这种方式,是我对函数柯里化有了全新的认识。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ------------------ 优化 ----------------------------
// Vue中把上面的函数柯里化了 原因是因为 我们的模板其实是不变的 变得是数据
// 柯里化之后我们能减少调用函数的次数 能够提高一点点的性能
function createGetValueByPath(path) {
// 得到的是数组 [xxx,yyy,zzz]
let paths = path.split('.');
return function getValueByPath(obj){
let res = obj;
let prop;
// 这里使用的是 while 循环 很巧妙的一种方式
// 通过每次来弹出首个数组中的元素来控制循环次数
while(prop = paths.shift()){
res = res[prop];
}
return res
}
}

最后一个问题 也就是虚拟DOM的问题

虚拟DOM在这里我就不多赘述了,这里我们要做的就是要将真实DOM转换为虚拟DOM,同时也要提供虚拟DOM到真实DOM的转换

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// 下面我们需要做的就是 如何将真实的DOM转换为虚拟的DOM
// 为什么要转换呢? -性能
// 真实的 DOM 的分类
// <div /> => { tag: 'div' }
// 文本节点 => { tag: undefined,value:'文本节点'}
// <div title='1' class='c' /> => { tag: 'div' , data: { title: '1', class: 'c'} }
// <div> <div /> </div> => { tag:'div' , children: [{ tag: 'div'}] };

class vNode {
constructor(tag, data, value, type) {
// tag 表示属性的名称 比如 div ul li...
this.tag = tag && tag.toLowerCase();
// data 表示的是具有的属性 比如说class...
this.data = data;
// value 只有在元素为文本节点时才有效 指的是对应的值 如果是文本节点则上面的两个属性为 undefined
this.value = value;
// type 表示类型 这里只有两种类型 文本节点和元素节点
this.type = type;
// children 表示该节点下面的子节点
this.children = [];
}
// 增加节点
appendChild(vNode) {
this.children.push(vNode);
}
}

/** 使用递归来遍历 dom 元素来生成虚拟 dom*/

// Vue 源码中使用的是栈结构 , 使用栈存储 父元素 来实现递归生成;
function getVNode(node){
// 首先拿到元素的节点类型
let nodeType = node.nodeType;
// 创建一个变量用来表示一个虚拟dom
let _vNode = null;
// 判断节点类型
if(nodeType === 1){
// 为 1 代表是元素节点
let nodeName = node.nodeName;
// 返回的是 伪数组包含所有属性 得包装成一个对象
let attrs = node.attributes;
let _attrObj = {};
for(let i=0; i<attrs.length; i++){
_attrObj[attrs[i].nodeName] = attrs[i].nodeValue;
}
_vNode = new vNode(nodeName,_attrObj,undefined,nodeType);
// 判断有没有子节点 有的话加到 children 中
let nodeChild = node.childNodes;
// 遍历子节点
for(let i=0; i<nodeChild.length; i++){
_vNode.appendChild(getVNode(nodeChild[i]));
}
}else if(nodeType === 3){
_vNode = new vNode(undefined,undefined,node.nodeValue,nodeType);
}
return _vNode;
}

function parseVNode(vNode){
// 拿到虚拟 DOM 的节点类型
let nodeType = vNode.type;
// 创建的真实 DOM
let realNode = null;
// 判断类型
if(nodeType === 1){
// 元素节点
// 根据 vNode 的 tag 值建立几点
realNode = document.createElement(vNode.tag);
let data = vNode.data;
// 遍历 data 给节点添加属性 foreach如果循环的是空数组 它不会执行
// for(let key in data){
// realNode.setAttribute(key,data[key])
// }
Object.keys(data).forEach(value => {
realNode.setAttribute(value,data[value])
})
// 子元素
let childNode = vNode.children;
// for(let i=0; i<childNode.length; i++){
// realNode.appendChild(parseVNode(childNode[i]));
// }
// item 是一个虚拟 dom
childNode.forEach(item => {
realNode.appendChild(parseVNode(item))
})
}else if(nodeType === 3){
realNode = document.createTextNode(vNode.value);
}
return realNode
}

// 在真正的Vue中也是使用 递归 + 栈 的数据类型
let temp = getVNode(document.querySelector('#root'));
console.log(temp);
console.log(parseVNode(temp));

总结

其实在这之前,自己也曾经打开过Vue的源代码,只不过看到这么多代码有点无从下手,经过这几天的学习之后,发现其实阅读源代码带来的收获还是挺大的,就比如说一下平时没注意的知识点,或是知道这个知识点,但是并不清楚为什么要有这个东西,就像函数柯里化一样,虽然知道它的形式,但是平时开发中真的很少会去应用这个代码,在Vue中算是看到一次实践了吧,第一部分内容大概就这么多,秋招快要结束了,也没能拿到一个offer,挺失败的,继续努力吧!