Vue源码研究之数据驱动(二)

本文最后更新于:2 个月前

对虚拟dom有一种猜想,那就是将节点用json表示,然后再写一个render函数进行渲染,感觉vue的源码就是这样的。
我研究vue源码主要的目的就是想要制作一个符合我自己想法的低代码的设计器,可以使我的项目变得更加适合更多人玩,让他们也体验到前端开发的乐趣。
本次将实现把之前的内容使用构造函数来实现,使用的时候就和vue一样只要创建一个对象,调用构造函数就可以实现数据模板替换的功能。

定义函数

写个名字叫LikeVue的函数,接收一个options,用来传入接收的参数,这里应当知道一个原则

  • 内部数据用_开头,只读数据用$开头
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function LikeVue(options) {
    // 内部数据用_开头,只读数据用$开头
    this._data = options.data;
    this.$el = document.querySelector(options.el);
    this._el = options.el;
    this._parent = this.$el.parentNode;
    this._methods = options.methods;
    // this.compiler(this.$el, this._data);
    // this.update()
    this.render();
    }
    这样就把options取到了,并且放到了_data里面。其中使用_parent主要是用来保存父节点的,这样在更新替换节点的时候就可以直接用this._parent来操作了。这里还使用_methods来保存写的method,用来保存写的方法,当然现在是用不到的,在后面才会用到。
    接下来就是写三个函数,分别是compiler,update,render,其中compiler是用来把模板替换成真实的dom,update是用来更新dom,render是用来渲染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
    // 编译
    LikeVue.prototype.compiler = function (tmpNode, data) {
    let rPattern = /\{\{(.*?)\}\}/g;
    let childNodes = tmpNode.childNodes;
    for (let index = 0; index < childNodes.length; index++) {
    /**
    * nodeType 1:元素节点 3:文本节点
    */
    if (childNodes[index].nodeType == 1) {
    this.compiler(childNodes[index], data);
    }else if (childNodes[index].nodeType == 3) {
    let txt = childNodes[index].nodeValue;
    txt = txt.replace(rPattern, function(match, key) {
    key = key.trim();
    let value = getPropByPath(data, key);
    return value;
    });
    childNodes[index].nodeValue = txt;
    }
    }
    }
    // 渲染
    LikeVue.prototype.render = function () {
    let realDom = document.querySelector(this._el).cloneNode(true);
    this.compiler(realDom, this._data);
    this.update(realDom);
    }
    // 更新
    LikeVue.prototype.update = function (realDom) {
    this._parent.replaceChild(realDom, this.$el);
    }
    这样就把整个流程分为了三个阶段,编译,渲染,更新。

读取多级属性

现在来解决一个上次尚未解决的问题,就是在读取prop的时候,就简单的用了*data[key]*的方式,这个方式其实是个简单的方式,太简单了,以至于无法读取多级属性,比如这么一个对象,

1
2
3
4
5
6
7
8
var obj = {
name: '张三',
age: 18,
address: {
city: '北京',
street: '朝阳'
}
};

我如果要读取obj.address.street就不能读取到对应的数据了,因此我们需要一个能够读取多级属性的方法。
要想做到这件事,最基本的想法应该是将这个字符串拆分成数组,然后逐级读取,这样就可以读取到多级属性了。这应该就是一般能够想到的办法,不过还有其他想法,就比如用栈来实现读取多级属性。我这里的话给出三种实现,用栈实现的方法后面再给出,这三种是用js的方法做的,也有用到函数柯里化的方法做的。因此很有学习的必要。

利用es6的reduce实现

1
2
3
function getPropByPath(obj, path) {
return path.split('.').reduce((acc, cur) => acc[cur], obj);
}

reduce() 方法接收一个函数作为累加器,数组中的每个值(从左到右)开始缩减,最终计算为一个值。

1
2
3
arr.reduce(function(prev,cur,index,arr){
...
}, init);

arr 表示原数组;
prev 表示上一次调用回调时的返回值,或者初始值 init;
cur 表示当前正在处理的数组元素;
index 表示当前正在处理的数组元素的索引,若提供 init 值,则索引为0,否则索引为1;
init 表示初始值。
方法很快,很容易就能解决这个问题,推荐使用。

利用函数柯里化实现

1
2
3
4
5
6
7
8
9
10
11
function createGetValueByPath(path) {
let paths = path.split('.');
return function getPropByPath (obj) {
let res = obj;
let prop;
while(prop = paths.shift()) {
res = res[prop];
}
return res;
}
}

这是一种从视频里面学到的方法,减少了函数调用次数,提高了性能。主要用在做优化的地方,vue的源码中有多处使用该方法来优化,也正是因为这样,所以vue的性能从来不拉跨,因此也是要学会的一种方法。
这种方法调用的时候与上面的方法是稍微有点区别的,

1
2
let getPropByPath = createGetValueByPath('address.city');
let prop = getPropByPath(obj);

函数柯里化的简化版

最后是函数柯里化的简化版,省去了创建函数的过程,因此可以直接调用

1
2
3
4
5
6
7
8
9
function getPropByPath (obj) {
let paths = path.split('.');
let res = obj;
let prop;
while(prop = paths.shift()) {
res = res[prop];
}
return res;
}

这里的话纯纯属于一种笨办法了,如果我不知道*reduce()的话我第一个想到的就会是这个办法,相比之下,还是reduce()*更加简洁明了。

虚拟DOM

虚拟DOM主要是考虑到两个问题

  1. 如何把一个虚拟DOM转换成真实DOM
  2. 如何把一个真实DOM转换成虚拟DOM
    对于一个学过后端技术的人来说,理解这些会比较方便,在Java中,如果你想把一个对象转成json或者把json转成对象,如果你有经验的话,一定知道有一种技术叫序列化,在这里的话我觉得应该叫这种技术“序列化”会很贴切。一个对象,很容易抽象成json数据,再转成字符串,这样就非常有利于存储和操作了。
    当然,这种直接转成json的效果是不存在的,因此只能是我们自己写,不然也不会有Vue的发明了。
    对象能转json 但是dom节点却不可以

    虚拟DOM的实现

    首先是将DOM节点转化成json
    例如,
    1
    <div title="Hello" />
    将该节点转化成json以后就成了
    1
    2
    3
    4
    5
    {
    type:"1", // 1 元素 , 3 文本节点 对应nodeType
    tag:"div",
    title:"hello"
    }
    如果是文本节点的话
    1
    <p>this is a paragraph</p>
    将这个标签转化为json
    1
    2
    3
    4
    5
    {
    type:"3", // 1 元素 , 3 文本节点 对应nodeType
    tag:"p",
    content:"this is a paragraph"
    }
    当然,这目前来说只是我的一个想法,如果有多级节点,例如
    1
    2
    3
    <div>
    <div>hello 1</div>
    </div>
    将其转为json
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    type:"1",
    tag:"div",
    children:[
    {
    type:"1",
    content:"hello 1"
    }
    ]
    }
    这样基本上就可以实现虚拟DOM了,如果是虚拟DOM要转成真实DOM的话,那就将这个过程反过来就好了。

    虚拟DOM的实现

    为了实现这个虚拟DOM,我先是抽象了一个节点类——VNode
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class VNode{
    /***
    * 构造函数
    * @param tag 标签名
    * @param children 子节点
    * @param text 文本
    * @param type 类型
    * @param data 数据
    */
    constructor(tag, data, children, text, type) {
    this.tag = tag && tag.toLowerCase();
    this.data = data;
    this.children = [];
    this.text = text;
    this.type = type;
    }
    appendChild(vnode) {
    this.children.push(vnode);
    }
    }
    该类的type就是元素的nodeType,根据他判断节点类型,children是子节点的数组,text是文本节点的内容,tag是标签名,data是保存元素属性的一个数组。
    然后写了个根据节点来生成VNode的函数——createVNode,用递归来实现将真实DOM转换成虚拟的VNode,也就是虚拟DOM。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /***
    * 使用递归遍历DOM元素,生成虚拟DOM,
    * Vue使用的栈结构,后面学习到的时候写
    * */
    function createVNode(node) {
    let nodeType = node.nodeType;
    let _vnode = null;
    if(nodeType === 1){
    // node.attributes是个伪数组,因此需要将其转为数组
    let _attrObj = {};
    for(let i = 0; i < node.attributes.length; i++){
    _attrObj[node.attributes[i].name] = node.attributes[i].value;
    }
    _vnode = new VNode(node.tagName, _attrObj, [], undefined, nodeType);
    for(let i = 0; i < node.childNodes.length; i++){
    _vnode.appendChild(createVNode(node.childNodes[i]));
    }
    }else if(nodeType === 3){
    _vnode = new VNode(null, null, null, node.textContent, nodeType);
    }
    return _vnode;
    }
    然后写了个demo来测试一下效果。

html部分

1
2
3
4
5
6
7
8
9
<div id="root">
<ul>
<li class="ul li">1</li>
<li>2</li>
<li>3</li>
</ul>
<div title="hello">hello 1</div>
<div>hello 2</div>
</div>

js部分

1
2
3
let root = document.querySelector("#root");
let vroot = createVNode(root);
console.log(vroot);

结果
输出id为root的节点的虚拟DOM
可以看到确实是成功了,但是有个小问题,有的节点中间夹杂着\n节点,这个问题后面在解决他,暂时先不管。
然后写了个将虚拟DOM转成真实DOM的函数——parseVNode,用递归来实现将虚拟DOM转换成真实DOM,也就是真实DOM。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function parseVNode(vnode) {
let nodeType = vnode.type;
let _node = null;
if(nodeType === 1){
_node = document.createElement(vnode.tag);
for(let key in vnode.data){
_node.setAttribute(key, vnode.data[key]);
}
for(let i = 0; i < vnode.children.length; i++){
_node.appendChild(parseVNode(vnode.children[i]));
}
}else if(nodeType === 3){
_node = document.createTextNode(vnode.text);
}
return _node;
}

传入一个VNode类型的对象,通过遍历虚拟DOM来创建元素,并返回一个真实DOM元素。
同样,也是写个demo测试一下效果,就用上面的例子。
html部分

1
2
3
4
5
6
7
8
9
<div id="root">
<ul>
<li class="ul li">1</li>
<li>2</li>
<li>3</li>
</ul>
<div title="hello">hello 1</div>
<div>hello 2</div>
</div>

js部分

1
2
3
let root = document.querySelector("#root");
let vroot = createVNode(root);
console.log(vroot);

结果
将获取到的root的虚拟DOM转化成真实的DOM,并输出到控制台
可以看出,输出的节点与root节点一模一样,这个parseVNode就算完成了。