VUE源码解析

164 阅读14分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情

vue源码解析

双向绑定数据

1.原理

时时监听数据变化, 一旦数据发生变化就更新界面 在vue模板的写法

<!-- 选择vue区域 -->
<div id="app">
    <input type="text" v-model="name">
    <p>{{ name }}</p>
</div>
<script>
// 2.创建一个Vue的实例对象
    let vue = new Vue({
        // 3.告诉Vue的实例对象, 将来需要控制界面上的哪个区域
        el: '#app',
        // 4.告诉Vue的实例对象, 被控制区域的数据是什么
        data: {
            name: "yyy"
        }
    });
  console.log(vue.$el);
  console.log(vue.$data);
</script>

当你在input 标签内进行输入时候,随后p标签的内容也会随之改变.

2.如何实现

  • 通过原生JS的defineProperty 方法去帮助vue实现实时监听数据变化的

那么下面就是介绍此方法:

2.1defineProperty

注:以下介绍一些常见的属性,更多属性去官网上看 官网介绍

  • Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
  • 语法Object.defineProperty(obj, prop, descriptor)
    • 参数解析:
      • obj: 需要操作的对象
      • prop: 需要操作的属性
      • descriptor: 属性描述符 (简单说就是对属性赋予操作能力)
    • descriptor 的值
  • value: 'yyy' ----> 该属性对应的值。默认为 undefined
  • writable: true ------> 当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值运算符改变。默认为 false
  • configurable

当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。 默认为 > false

  • enumerable

当且仅当该属性的 enumerable  键值为 true 时,该属性才会出现在对象的枚举属性中。 默认为 false

  • get 属性的 getter 函数,该函数的返回值会被用作属性的值。默认为 undefined

  • set 属性的 setter 函数,该函数的返回值会被用作属性的值。默认为 undefined。 | | configurable | enumerable | value | writable | get | set | | --- | --- | --- | --- | --- | --- | --- | | 数据描述符 | 可以 | 可以 | 可以 | 可以 | 不可以 | 不可以 | | 存取描述符 | 可以 | 可以 | 不可以 | 不可以 | 可以 | 可以 |

    • 注意:如果设置了get/set方法, 那么就不能通过value直接赋值, 也不能编写writable:true 换句话说数据描述符和存取描述符不能同时存在

以上都是准备知识,

代码解析

为了方便管理代码,Vue 使用了一个 Observe 类专门来处理对象的监听,在初始化类时将需要监听的对象传入即可。

  • 我们就先构造一个Observer 对象
  • 利用constructor 对所创建的Observer 进行初始化(都添加defineProperty )
  • 对结构体objectdefineProperty 添加对应的描述符
  • 从简单的结构体到复杂的结构体
    class Observer{//第一步
        // 只要将需要监听的那个对象传递给Observer这个类
        // 这个类就可以快速的给传入的对象的所有属性都添加get/set方法
        constructor(data){//第二步
            this.observer(data);
        }
        observer(obj){
            if(obj && typeof obj === 'object'){
                // 遍历取出传入对象的所有属性, 给遍历到的属性都增加get/set方法
                for(let key in obj){
                    this.defineRecative(obj, key, obj[key])
                }
            }
        }
        // obj: 需要操作的对象
        // attr: 需要新增get/set方法的属性
        // value: 需要新增get/set方法属性的取值
        defineRecative(obj, attr, value){//第三步
            // 如果属性的取值又是一个对象, 那么也需要给这个对象的所有属性添加get/set方法
            this.observer(value);//第四步
            Object.defineProperty(obj, attr, {
                get(){
                    return value;
                },
                set:(newValue)=>{
                    if(value !== newValue){
                        // 如果给属性赋值的新值又是一个对象, 那么也需要给这个对象的所有属性添加get/set方法
                        this.observer(newValue);
                        value = newValue;
                        console.log('监听到数据的变化, 需要去更新UI');
                    }
                }
            })
        }
    }
    new Observer(obj);

以上就是利用原生js实现数据双向绑定

vue实例

    let vue = new Nue({
        // 3.告诉Vue的实例对象, 将来需要控制界面上的哪个区域
        el: '#app',
        // el: document.querySelector('#app'),
        // 4.告诉Vue的实例对象, 被控制区域的数据是什么
        data: {
            name: "yyy",
            age: 33
        }
    });
	console.log(vue.$el);
    console.log(vue.$data);

回到vue实例可以分析出

  1. 要想使用Vue必须先创建Vue的实例, 创建Vue的实例通过new来创建, 所以说明Vue是一个类
  2. Vue就会根据指定的区域el和数据el和数据data, 去编译渲染这个区域
  3. el 可以使 id 名,也可以是 dom 元素。
  4. vue 实例会将传递的控制区域和数据都绑定到创建出来的实例对象上也就是 dom 与 data 分别绑定到elel 和data 上

创建nue实例

上面是对实例进行分析,接下来我们可以开始写简单的属于自己的实例

步骤:

  • 先创建类似于vue的类nue
  • 保存创建时候的elel 和data
  • 根据elel 和data 去进行渲染页面

nue.js

class Nue {
    constructor(options){
        // 1.保存创建时候传递过来的数据
        if(this.isElement(options.el)){
            this.$el = options.el;
        }else{
            this.$el = document.querySelector(options.el);
        }
        this.$data = options.data;
        // 2.根据指定的区域和数据去编译渲染界面
        if(this.$el){
            new Compiler(this)//这一块可能一开始看到不太懂什么意思? ---> 主要是用于数据更新后,重新渲染页面
            console.log('this: ', this);
        }
    }
    // 判断是否是一个元素
    isElement(node){
        return node.nodeType === 1;
    }
}
class Compiler {
    constructor(vm){
        this.vm = vm;
        console.log('this.vm: ', this.vm);

    }
}

至于index.html 中的new vue 换成 new nue 即可

此时你也经写出了一个简单的vue实例,但是也有问题就是没有渲染页面,(没有将name 渲染到他的p 标签中)

接下来我们看如何渲染页面

  1. 获取此时绑定的区域,将元素放在内存中-----------------------(为什么要放在内存中?为什么不可以直接遍历元素)
  2. 需要找到哪里使用了v-model ,
  3. 找到之后,我们还要去找那个标签有这样{{}}的符号
  4. 将数据渲染到对应的标签

1.获取元素放入内存

我们开始第一步:

先介绍准备知识

DocumentFragment,文档片段接口,一个没有父对象的最小文档对象文档

我们会用到一些方法:

  • Document.createDocumentFragment() ---->创建一个新的空白的文档片段( DocumentFragment)。
  • appendChild 将元素添加到文档碎片中
  • firstChild 获取此时第一个元素
class Compiler {
    constructor(vm){
        this.vm = vm;
        // 1.将网页上的元素放到内存中
        let fragment = this.node2fragment(this.vm.$el);
        console.log(fragment);
        // 2.利用指定的数据编译内存中的元素
        // 3.将编译好的内容重新渲染会网页上
    }
    node2fragment(app){
        // 1.创建一个空的文档碎片对象
        let fragment = document.createDocumentFragment();
        // 2.编译循环取到每一个元素
        let node = app.firstChild;
        while (node){
            // 注意点: 只要将元素添加到了文档碎片对象中, 那么这个元素就会自动从网页上消失
            fragment.appendChild(node);
            node = app.firstChild;
        }
        // 3.返回存储了所有元素的文档碎片对象
        return fragment;
    }
}

回答上面一个问题:

如果你使用遍历循环去找具有v-model的元素和{{name}} 的元素,找到了然后去渲染对应的值name,但是每次你的值name 进行改变时候,那么就会重新渲染那一个区域,多次改变就会多次重新渲染,就会导致浏览器性能降低,而我们将控制区域存在内存中,在内存中找到,在内存中将数据替换好,之后再去渲染,那么此时只需要渲染一次. 其实有这样一个案例,你需要利用appendchild去添加子元素,此时你需要添加10000000个,如果你仅仅不断的用appendchild,会导致我加一个元素,我需要将全部重新渲染一遍,其实是不需要的,你可以利用string去将那么多的子元素连在一起,只需要用一次appendchild 其实这个想法和之前学的在dom树中增加元素是一个道理.(利用string,在此基础上增加多个元素,然后再放在浏览器中渲染)

2.寻找指令和模板

我们开始第二步甚至第三步(一起进行):

  • 我们既然把控制区域的元素都存在文档碎片中,那么需要取出来,列为一个数组
  • 此时我们需要判断取出来的是一个元素还是一个文本?
    • 如果是一个元素, 我们需要判断有没有v-model 属性(眼睛也不要只盯着v-model 例如v-text v-html 都是一个道理只是多了一段代码罢了)
    • 如果是一个文本, 我们需要判断有没有**{{}}**的内容
class Compiler {
    constructor(vm){
        this.vm = vm;
        // 1.将网页上的元素放到内存中
        let fragment = this.node2fragment(this.vm.$el);
        // 2.利用指定的数据编译内存中的元素
        this.buildTemplate(fragment);
        // 3.将编译好的内容重新渲染会网页上
    }
    node2fragment(app){
        // 1.创建一个空的文档碎片对象
        let fragment = document.createDocumentFragment();
        // 2.编译循环取到每一个元素
        let node = app.firstChild;
        while (node){
            // 注意点: 只要将元素添加到了文档碎片对象中, 那么这个元素就会自动从网页上消失
            fragment.appendChild(node);
            node = app.firstChild;
        }
        // 3.返回存储了所有元素的文档碎片对象
        return fragment;
    }
    buildTemplate(fragment){
        let nodeList = [...fragment.childNodes];
        nodeList.forEach(node=>{
            // 需要判断当前遍历到的节点是一个元素还是一个文本
            // 如果是一个元素, 我们需要判断有没有v-model属性
            // 如果是一个文本, 我们需要判断有没有{{}}的内容
            if(this.vm.isElement(node)){
                // 是一个元素
                this.buildElement(node);
                console.log('node: ', node);
                // 处理子元素(处理后代)
                this.buildTemplate(node);
            }else{
                // 不是一个元素
                this.buildText(node);
                console.log('node1: ', node.textContent);
            }
        })
    }
    buildElement(node){
        let attrs = [...node.attributes];
        attrs.forEach(attr => {
            let {name, value} = attr;
            if(name.startsWith('v-')){
                console.log('是Vue的指令, 需要我们处理', name);
            }
        })
    }
    buildText(node){
        let content = node.textContent;
        let reg = /\{\{.+?\}\}/gi;
        if(reg.test(content)){
            console.log('是{{}}的文本, 需要我们处理', content);
        }
    }
}

说明:

当你去把从文档碎片获取的元素打印出来时候你可以发现其实空格也是元素之一(当作文本)

如果你这样写<div id="app"><input type="text" v-model="name"><p>{{name}}</p></div>

看下面输出:

是不是得到了上面的结论!!

如果你没有上诉的结论,你肯定觉得文本就是元素之间的你写的那些文本,而这样恰恰相反.因为你写完了你会得到这样的结果

就只看到了vue的指令,没有vue的文本,你所获取到的元素则是<p>{{name}}</p>这样的整体!,为了获取内部的文本.

这个时候你就需要在元素里面再进行一次寻找(不断递归).而v-model这样的指令在元素的属性上,所以不需要去进行递归!,当然如果元素嵌套,你肯定是需要递归的,所以综上所诉,当你碰到了元素,你需要进一步递归!

此时你就看到了你想要看到的结果

3.渲染指令

首先我们需要考虑的是指令,不仅仅我们才讨论到的v-model 还有v-html v-text ,所以鉴于指令较多,我们可以在前面定义一个类,对对应的指令进行相关的操作

let CompilerUtil = {
    model: function (node, value, vm) { 
    },
    html: function (node, value, vm) {
       
    },
    text: function (node, value, vm) {
       
    }
}

此时,我们在编译指令的地方进行处理

  1. 获取指令的有效值  例如v-model 中的model
  2. 确定传递的参数
    1. 此时的元素节点
    2. 指令的值
    3. data值--->此时的this
    buildElement(node){
        let attrs = [...node.attributes];
        attrs.forEach(attr => {
            let {name, value} = attr; // v-model="name" / name:v-model / value:name
            if(name.startsWith('v-')){ // v-model / v-html / v-text / v-xxx
                let [_, directive] = name.split('-'); // v-model -> [v, model]
                CompilerUtil[directive](node, value, this.vm);
            }
        })
    }

为什么会需要三个参数呢?

  1. 元素节点(document.getElementById("demo"))
  2. v-model的值
  3. this

那我反问一句,那如何去编译指令呢.脑袋反应的就是,先找到,然后将值,替换掉,不就好了!

所以,你需要位置(node),找到需要被替换的人(value),在找到进行替换掉人(this.data)

调用函数处理好之后,我们可以写之前定义的类中的函数.

可能到这里,会有一点疑惑,就是为什么要编译指令.我拿v-model举例,编译指令,是将v-model的值渲染到你写的input上,然而对于v-html也是如此

在这里就一段代码的事情

node.value = vm.$data[value];

将指令的值渲染到对应的元素上

此时,依旧看不到页面,因为元素全部在内存中,以上的操作都是对内存中的元素进行操作的,所以当你编译好元素后,需要将编译好的内容重新放到html上,所以在编译网页后加一段代码

this.vm.$el.appendChild(fragment);

将内容重新加入到html

你可能觉得写完了,但是其实存在一个问题,先看看问题所在!!

你定义的data肯定不只是name:"yyy" 这样子的,肯定也会用json 进行嵌套

其实此时的input 是不会显示time 中的属性的

为什么会出现undefined 这个情况呢?

你的v-model写法与上图无异,这就导致buildElement这个函数中获取到的v-model的值不再是name这样简单的了,获取的是time.h,所以你又怎么可能通过vm.$data[time.h]找到呢,并且这样写法也是错的.

既然出现这个问题,那么我们应该怎么解决呢?

其实也不难,就先获取time的值然后在获取time.h的值,可能描述不是很清晰. data[time]->data[time].h这不就拿到了time.h值了吗!!

一些视频可能会推荐你去使用reduce去实现这个.

但是在此我发表一下自己的浅显的看法:

当初学习reduce时候,我就觉得这个函数很傻,用reduce基本上那些可以用foreach实现,或者for感觉不知道哪一个场景使用他更好,而且对我来说代码很不清晰.可能我不是大佬吧!!!所以一般别人用reduce我就会用foreach去进行替换

reduce实现

    getValue(vm, value) {
        //time.h --> [time, h]
        let x = value.split('.').reduce((data, currentKey) => {
            // 第一次执行: data=$data, currentKey=time
            // 第二次执行: data=time, currentKey=h
            console.log('data[currentKey]: ', data[currentKey]);
            return data[currentKey];

        }, vm.$data);
        console.log('x: ', x);
        return x;
    }

foreach实现

		data = vm.$data;
        let data2;
        value.split('.').forEach((ele, item) => {
            data2 = data[ele];
            console.log('data2: ', data2);
            data = data[ele];    
        });
        return data2;

那么对应的model的函数可能就不能那么写的了

let val = this.getValue(vm, value);
node.value = val;

htmltext就是一样的走法

4.渲染模板

其实渲染模板,和渲染指令都是一个方法走出来的!

所以

在这里添加一样的函数

CompilerUtil['content'](node, content, this.vm);至于为什么是三个参数我就不多介绍了

同时肯定要在CompilerUtil添加content的函数.

步骤:

  1. 找到对应元素节点(已完成)
  2. 从data获取对应的值(其实这个也完成,之前写的getValue就是)
  3. 在元素中进行替换

所以到现在就是第三步.

    content: function (node, value, vm) {
        console.log('node: ', node);
        // console.log(value); // {{ name }} -> name -> $data[name]
        let val = this.getContent(vm, value);
        console.log('val: ', val);
         node.textContent = val;
    }

getContent 作用就是通过正则查找{{}} 的内容然后替换将值返回回来!

    getContent(vm, value){
        // console.log(value); //  {{name}}-{{age}} -> yay-{{age}}  -> 李yay-33
        let reg = /\{\{(.+?)\}\}/gi;
        let val = value.replace(reg, (...args) => {
            console.log('args: ', args);
            // 第一次执行 args[1] = name
            // 第二次执行 args[1] = age
            // console.log(args);
            return this.getValue(vm, args[1]); // yay, 33
        });
         console.log(val);
        return val;
    },

其实到这里模板,就已经渲染好了!

插一个小插曲

当时在给content替换数据时候 就是这里,我在考虑为什么不能用innertext,就发现他们两是有一定的区别的! 区别: innertext

  1. 获取指定节点的文本及其后代节点中文本内容,但不能获取<script><style> 元素中的内容。
  2. innerText的返回的文本就是你所看到的在浏览器上
  3. innerText 受 CSS 样式的影响,它会触发重排(reflow)

textcontent:

  1. 获取指定节点的文本及其后代节点中文本内容
  2. 返回节点内全部文本
  3. 不会

推荐替换文本用textcontent

博客 博客2

5.监听数据变化

搞完之后,以上的步骤都是仅仅实现了v-model 的静态的功能,还有一个地方需要处理,监听数据变化 实现真正意义上的双向绑定

但是监听数据变化,我们上面就已经实现了这个问题了.

那就是把上面写的放到nue.js里面

将外部的数据放入Observer中就完成了.

6.实时驱动数据改变

我们将使用订阅发布模式去实现监控数据,更新界面!

6.1订阅发布模式

发布-订阅模式里面包含了三个模块,发布者,订阅者和处理中心。这里处理中心相当于报刊办事大厅。发布者相当与某个杂志负责人,他来中心这注册一个的杂志,而订阅者相当于用户,随后在中心订阅了这份杂志。每当发布者发布了一期杂志,办事大厅就会通知订阅者来拿新杂志。

在后续,我们将发布者去监听属性的数据的改变,如果改变了,对调度中心进行发布,同时订阅者(元素节点)需要相对应的获取新的数据,然后重新渲染.

6.2.为什么使用订阅发布模式

如果不使用,我们将在每一个属性内写一个方法,去监听数据,然后传值给元素节点,最后重新渲染页面.这种方式耦合度就比较高了,对于代码的维护,和易读就不太友好了.

采用订阅发布模式,能降低这种耦合度,将监听属性,与元素节点渲染,进行分离,我只需要发布者监听到数据改变之后直接告诉调度中心,发布者不需要关心调度中心是怎么做的!订阅者收到通知后,进行改变.对于代码的维护度,易读性都很友好

6.3.解析代码

首先我定义一个发布者(观察者)

class Watcher {    constructor(vm, attr, cb){        this.vm = vm;        this.attr = attr;        this.cb = cb;        // 在创建观察者对象的时候就去获取当前的旧值        this.oldValue = this.getOldValue();    }    getOldValue(){        let oldValue = CompilerUtil.getValue(vm, attr);        return oldValue;    }    // 定义一个更新的方法, 用于判断新值和旧值是否相同    update(){        let newValue = CompilerUtil.getValue(vm, attr);        if(this.oldValue !== newValue){            this.cb(newValue, this.oldValue);        }    }}

发布者的有三个参数,分别是

  • vm-----当前this
  • attr-----对应的属性
  • cb------回调函数

因为你需要在这里监听数据的变化,所以需要新的数据和老的数据进行对比判断出数据是否发生了改变!

定义一个调度中心类,方便管理发布者以及订阅的事件

class Dep {    constructor(){        // 这个数组就是专门用于管理某个属性所有的观察者对象的        this.subs = [];    }    // 订阅观察的方法    addSub(watcher){        this.subs.push(watcher);    }    // 发布订阅的方法    notify(){        this.subs.forEach(watcher=>watcher.update());    }}

上面就完成基本的订阅发布模式

接下来结合订阅者进一步完善.

  • 你需要先对所有的属性添加观察者,监听属性数据的变化
  • 同时你需要在Observer 这个类中加入订阅中心,方便管理观察者.
  • 最后进行界面重新渲染

CompilerUtilmodel 中添加

 new Watcher(vm, value, (newValue, oldValue)=>{            node.value = newValue;            // debugger;        });

达到为属性添加观察者,以及当数据发生变化时进行改变

Observer 定义一个订阅中心,进行管理观察者,当数据发生变化时候,发布通知.

        let dep = new Dep(); // 创建了属于当前属性的发布订阅对象        Object.defineProperty(obj, attr, {            get(){                Dep.target && dep.addSub(Dep.target);                // debugger;                return value;            },            set:(newValue)=>{                if(value !== newValue){                    // 如果给属性赋值的新值又是一个对象, 那么也需要给这个对象的所有属性添加get/set方法                    this.observer(newValue);                    value = newValue;                    dep.notify();                    console.log('监听到数据的变化, 需要去更新UI');                }            }        })

中间有个小小的问题需要解决

订阅中心需要添加观察者对象,但是在Observer其实没有观察者对象,所以在观察者查询老的数据时候利用target 将实例传出来,进行添加观察者

参考文件1

参考文件2