vue源码分析(一) -----双向数据绑定__Vue.js
发布于 4 年前 作者 banyungong 1434 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

前言

自己之前看过一些Vue2源码解析的文章,视频,博客等,之前面试也经常会被问到双向数据绑定甚至被要求手写代码(身为一个学习前端一年半的垃圾练习生,被问原理我可以跟你将博客上一些高大上的解释跟你吹上半天,但是你要我写,哼,那我只好理直气壮的告诉你:我不会!)。因此这次想要从一个简单的角度逐渐切入,写一篇Vue双向数据绑定相关的内容。

Vue双向数据绑定

Vue中,双向数据绑定总是第一个被提起的话题,在本文尝试编写一个简单版本的双向数据绑定,然后看一下源码的实现,并对其进行分析。

1. vue的效果预览

在最开头,先写一个Vue基本的功能代码,查看Vue的功能:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<body>
    <div id="app">
        <div>i am {{name}}</div>
        <div v-text="name"></div>
        <div v-html="age"></div>
        <div>computed: {{doubleAge}}</div>
        <input type="text" v-model="input">
        <button @click="handleClick">clickMe</button>
    </div>

<script>
    const vm = new Vue({
        el: '#app',
        data: {
            name: 'Evan',
            age: 18,
            input: 'test',
        },
        methods:{
            handleClick(){
                console.log("i am be cliked, i am ", this.age)
            },
        },
        computed: {
            doubleAge(){
                return this.age * 2;
            }
        }
    })
</script>
</body>
</html>

我们直接打开这个html,展示的内容如下图 直接打开页面 然后F12打开开发者工具 对控制台进行一顿操作: 对控制台进行操作 这里我依次进行了修改Vue实例vm上的agenameinput,页面的内容对应也发生了改变,然后点击按钮,控制台输出相应内容。最后去输入框里面修改输入框的内容,再去控制台打印出来vm.input的值,发现其对应的值也改变了。哇,真的好神奇呢,居然在控制台改变了数据的值,html上的内容也发生了变化(真是浮夸的表演)。

2. 手写Vue双向数据绑定

在查看了上面引入Vue文件实现的效果后,我们也来实现一下类似的功能。在上面的html中,script引入了vue.js后,创建了一个script,在其中做了如下的操作:

  1. 创建一个Vue的实例。并传入了一个对象参数,里面包含dataelcomputed等(后续将传入构造函数的对象称之为options)。
  2. options.el对应的元素节点(上面例子中即为<div id='#app'>,后续将options.el对应的元素节点称之为根节点)以及该节点对应的孩子节点里面编写的Vue相关的内容,解析成真正的Dom节点,展示在页面上。
  3. 创建Vue的实例后,根节点以及其子节点中使用option.data相关属性的地方,会因为options.data中数据的变化,视图随之更新。

那么如何实现类似于Vue这样数据改变相应视图的功能呢?

在提及Vue时,总能听到MVVM设计模式,简单介绍一下MVVM:

MVVM由三部分组成: Model(数据层), View(视图层), ViewModel。其中当Model层发生变化时,ViewModel收到Model的相关变化,进而去操作View层,更新相关的视图。而当View层有用户的输入或者交互时,我们也可以通过viewModel去改变Model层的状态(即修改数据内容)。 mvvm 在张图片中,可以将View当作我们在创建Vue实例时选择的根节点,viewModel当作Vue实例,而Model就是Vue构造函数传入对象参数中的data属性,当Model中的数据改变时,我们通过Vue实例来修改DOM节点的相关内容,进而当我们在修改的Model时,我们无需操作Dom便可以更新视图。由此,可以猜测实现Vue功能需要做如下的内容:

  1. 当数据发生改变时,通知到Vue实例,并且Vue实例可以找到根节点中我们使用相关的数据的地方,对其进行DOM操作,更新视图。
  2. Vue能够解析在<div id="#app">该节点下我们编写的Vue指令,例如 {{name}}v-text 等操作,并且将其转化为我们浏览器能够识别的,正确的DOM内容将其展示在视图层,并且对有指令存在的DOM节点做额外的处理:当数据改变的时候,可以对其进行视图的更新。

接下来,将script标签的src属性改为引入本地的mvvm.js开始我们的编写:

1. Vue类的编写

根据上面的分析,mvvm.js中应该要有一个Vue这么一个构造函数,我们尝试编写如下的代码:

class Vue{
	constructor(options){
    	this.$data = options.data;
        this.$el = options.el;
        this.$option = options;
        if(this.$el){
          // 将数据变为响应式
          new Observer(this.$data);
          // 解析模板
          new Compile(this.$el, this);
        }
    }
}

在上面代码中,利用Observer类来实现数据的相应式处理,利用Compile去解析我们编写在<div id='app'>中的相关内容。

下面将从Compile开始,从解析options.el开始慢慢过渡到双向数据绑定。

2. Compile 类

在上面的内容中,我们知道Compile类是用来解析options.el中的Dom 节点的,具体要做的操作如下:

  1. 解析Vue根节点中的节点内容,检查vue相关的指令。
  2. Vue指令转化为Dom节点的相关内容,在视图上显示我们预期的内容。
  3. 对某些使用Vue数据和指令的节点做一些额外的处理,使Vue$data内部某个属性改变时,视图也能自动改变。

因此我们尝试编写如下的代码:

class Compile{
    constructor(el, vm){
        this.$el = this.isElementNode(el) ? el: document.querySelector(el);
        this.$vm = vm;
        // 在内存中创建一个和$el相同的元素节点,
        // 并且将$el的孩子加入到内存中
        let fragment = this.node2fragment(this.$el);
        // 解析模板($el节点)
        this.compile(fragment);
        // 将解析后的节点重新挂载到DOM树上
        this.$el.appendChild(fragment);
    }
    // 判断node是否为元素节点
    isElementNode(node) {
        return node.nodeType === 1;
    }
    // 判断是否为v-开头的Vue指令
    isDirective(attr) {
        return attr.startsWith('v-');
    }
    compile(fragment){ 
        // 遍历根节点中的子节点
        let childNodes  = fragment.childNodes;
        [...childNodes].forEach(child =>{
            if(this.isElementNode(child)){
                // 解析元素节点的属性,查看是否存在Vue指令
                this.compileElement(child);
                // 如果子节点也是元素节点,则递归执行该函数
                this.compile(child);
            }else{
                // 解析文本节点,查看是否存在"{{}}"
                this.compileText(child);
            }
        })
    }
    // 解析元素
    compileElement(node){
        // 获取元素节点的所有属性
        let attrs = node.attributes;
        // 遍历所有属性,查找是否存在Vue指令
        [...attrs].forEach(attr =>{ 
            // name: 属性名, expr: 属性值
            let {name, value:expr} = attr; 
            // 判断是不是指令
            if(this.isDirective(name)){
                let [,directive] = name.split('-');
                // 如果为指令则去设置该节点的响应式函数 
                compileUtil[directive](node, expr, this.$vm);
            }
        })
    }
    // 解析文本
    compileText(node){
        let content = node.textContent;
        // 匹配 {{xxx}}
        if(/\{\{(.+?)\}\}/.test(content)){
            compileUtil['contentText'](node, content, this.$vm);
        }
    }
    // 把节点移动到内存中
    node2fragment(node){
        // 创建文档碎片
        let fragment = document.createDocumentFragment();
        let firstChild;
        while(firstChild = node.firstChild){
            fragment.appendChild(firstChild);
        }
        return fragment;
    }
}

简单介绍一下Compile这个类构造函数进行的操作:

  1. 创建内存中的Dom对象(创建虚拟Dom)。首先在构造函数中,将vue的根节点和vue实例保存到Compile实例的属性中,然后创建了一个存在于内存中的Dom对象,内容和Vue根节点中的DOM节点相同。当我们直接去操作Dom节点的时候,对节点的一些操作会导致浏览器的重新渲染,因此这里将关于Dom节点的所有内容保存一份到内存中,当对内存中的Dom对象完成编译(Vue相关指令,语法的解析)后,再将其直接挂载到Dom上,这样可以减少浏览器的渲染次数,提高性能。(在本文中,后续将对内存中的Dom对象称之为虚拟Dom)。
  2. 执行compile函数Compile构造函数将虚拟Dom的根节点传入这个函数,然后对其子节点进行遍历。
  • 当子节点是元素节点的时候,我们首先调用compileElement查看该节点的属性是否存在例如v-textv-html这样的Vue指令,如果存在则对该节点的属性进行检查,查看元素节点的属性是否存在v-开头的指令,然后将该元素节点传入compile函数进行递归操作。
  • 如果子节点不是元素节点则将其当为文本节点,直接调用compileText方法,对文本节点的内容进行{{}}的匹配。如果文本内容存在{{}},则对文本内容进行相应的替换,将{{xxx}}替换为真正的值。

3. compileUtil 对象

在上面内容中,Compile类已经有了解析了Vue指令的功能。解析到的元素节点如果存在Vue指令,或者解析的文本节点存在形如{{}}的内容时,在Compile实例中会调用compileUtil对象的函数处理解析出来的Vue指令。由于在compile我们还没将节点转换为真实视图的Dom节点,也没有完成当Vue数据改变,视图随之更新的功能。由此我们可以推断出compileUtils是一个工具函数的集合,用于帮我们处理Vue相关的指令。

那么可以尝试编写compileUtil工具对象:

const compileUtil = {
    getValue(expr, vm){
        return expr.split('.').reduce((totalValue, key) =>{
            if(!totalValue[key]) return '';
            return totalValue[key];
        }, vm.$data)
    },
    setValue(expr, vm, value){
        return expr.split('.').reduce((totalValue, key, index, arr) =>{
            if(index === arr.length - 1) totalValue[key] = value;
            return totalValue[key];
        }, vm.$data)
    },
    getContentValue(content, vm){
        return content.replace(/\{\{(.+?)\}\}/g, (...args) =>{
            return this.getValue(args[1], vm); 
         })
    },
    contentText(node, content, vm){
        let fn = () =>{
            this.textUpdater(node, this.getContentValue(content, vm));
        }
        let resText = content.replace(/\{\{(.+?)\}\}/g, (...args) =>{
            // args[1] 为{{xxx}}中的xxx
            new Watcher(vm, args[1], fn);
            return this.getValue(args[1], vm);
        });
        // 首次解析直接替换文本内容
        this.textUpdater(node, resText);
    },
    text(node, expr, vm){
        let value = this.getValue(expr, vm);
        // 调用函数更新dom内容
        this.textUpdater(node, value);
        let fn = () =>this.textUpdater(node, this.getValue(expr, vm));
        new Watcher(vm, expr, fn);
    },
    textUpdater(node, value){
        node.textContent = value;
    },
    html(node, expr, vm){
        let value = this.getValue(expr, vm);
        // 调用函数更新dom内容
        this.htmlUpdater(node, value);
        let fn = () =>this.htmlUpdater(node, this.getValue(expr, vm));
        new Watcher(vm, expr, fn);
    },
    htmlUpdater(node, value){
        node.innerHTML = value;
    },
    model(node, expr, vm){
        let value = this.getValue(expr, vm);
        // 初始化表单中的值
        this.modelUpdater(node, value);
        let fn = () => this.modelUpdater(node, this.getValue(expr, vm));
        node.addEventListener('input', ()=>{
            this.setValue(expr, vm, node.value);
        })
        new Watcher(vm, expr, fn);
    },
    modelUpdater(node, value){
        node.value = value;
    }
}

在此工具类中,实现了v-textv-htmlv-model以及文本内容中{{}}的处理。下面简单介绍各个函数的作用:

  • getValue(expr, vm):从Vue实例的$data属性中获取obj.name这样的字符串表达式在$data中对应的属性值。(形如obj.name这样的字符串表示Vue实例中$data的某个属性值,下文称之为字符串表达式,函数中一般用参数expr表示)。
  • setValue(expr, vm, value): 将value赋值给字符串表达式对应的Vue实例$data上的属性。
  • text(node, expr, vm) 对元素节点的v-text指令进行处理。
  • textUpdater(node, value) 对元素的文本内容进行更新。
  • html(node, expr, vm)text逻辑类似, 该函数对元素节点的v-html指令进行处理
  • htmlUpdater(node, value)textUpdater类似,只不过该函数是对元素里面的html内容进行更新。
  • model(node, expr, vm)的逻辑与text逻辑也是类似的,不同的是在表单元素使用v-model指令后,用户在视图层修改表单元素内容时,用户输入内容需要同步更新到vm.$data中的相关属性中。因此我们在编写与text类似的逻辑时,还需要在表单元素上添加一个input事件监听,当表单元素发生变化时,直接更新vm.$data上面的相关属性的值。
  • contentText(node, content, vm) 对存在{{}}的文本节点进行处理。
  • getContentValue(node, vm) 解析形如i am {{name}}字符串,返回为解析后的文本。

在上面的代码中,我们发现在处理v-text, v-html, v-model等指令的函数时,已经将节点中相应的内容替换成需要在视图中展示的内容。但在每个指令对应的处理函数中都创建了一个Watcher对象,这个对象是做什么的呢?

compileVue的指令解析已经完成,Dom节点内容的转化我们在CompileUtile也已经实现,但是还差一个核心功能没有实现:Vue实例$data数据改变时,我们如何在Dom中更新视图?

我们在compileUtil编写了很多形如xxxUpdater的函数,每次调用该方法时,就可以改变真实Dom的视图内容。这时候我们不禁想到如果每次Vue$data一更新,就有一个工具人帮我们调用这些更新函数改变视图就好了。想到这里我不禁要大喊一句:~~就决定是你了,皮卡丘。~~就决定是你了,Watcher。因此,我们需要new出来一个工具人watcher,帮助我们更新视图。

tips: 后续使用watcher表示Watcher实例对象,dep表示Dep实例对象,vm表示Vue实例。

4. Watcher类

class Watcher{
    constructor(vm, expr, cb){
        this.$vm = vm;
        this.expr = expr;
        this.cb = cb;
        this.getter();
    }
    update(){
        let newVal = compileUtil.getValue(this.expr, this.$vm);
        if(this.value === newVal) return; 
        this.value = newVal;
        this.cb();
    }
    getter(){
        window.target = this;
        this.value = compileUtil.getValue(this.expr, this.$vm);
        window.target = null;
    }
}

上面就是一个简单的Watcher类,可以看到构造函数中我们传入了Vue实例,字符串表达式,以及更新节点的函数。在构造函数中,我们将传入的参数保存到实例对象的属性上,然后调用getter方法。从代码上看,getter方法将Watcher实例赋值给了window这个全局对象的target上,然后获取了一下expr表达式在Vue实例中对应$data属性中对应的值。然后将window.target属性置为空。

这是什么操作??我是谁,我在哪,我在干嘛??目前先不解释getter函数的意义,在讲完下面的Observer以及Dep后在回头看这个神奇的黑魔法-v-。

Watcher中还有一个update函数,当数据更新时,调用该方法更新视图内容。

到目前为止, new Compile()做的工作就结束了。总结一下干了哪些事情:

  1. 找到Vue的根节点,将其所有子节点加入到虚拟Dom中。
  2. 解析Vue根节点中的内容,将包含Vue指令的节点进行处理,得到真实Dom中的内容。
  3. 创建工具人Watcher实例,可以调用update方法更新视图。
  4. 将虚拟Dom重新挂载到真实Dom节点上,在视图上显示内容。

我们知道,我们在编写Vue中的template模板时(即<div id="app">以及其子节点的相关内容)时,可能会多次使用某个属性。在最开始我们写的例子中,多次使用了name这个属性。节点每次使用同一个属性时,我们都会创建一个工具人Watcher,当工具人们关注的女神name属性变化时,我们需要一个经纪人通知所有的工具人,女神有事情需要你们帮忙,快去干活。这个经纪人就是Dep实例。

5. Dep类

class Dep{
    constructor(){
        this.subs = [];
    }
    addSubs(){
        this.subs.push(window.target);
    }
    notify(){
        this.subs.forEach(watcher => watcher.update());
    }
}

Dep类的构造函数:创建一个数组,用于保存Watcher实例。Dep中存在两个方法:

  • addSubs 用于添加watchersubs数组中。
  • notify 遍历自己实例中存储的subs数组的每一个watcher,执行update函数更新相关的视图。

根据上面编写的代码,我们已经将问题转化成这样: 当数据发生变化的时候,如何去通知所有使用该数据的watcher调用响应的update方法呢?在这里其实是两个问题需要解决:

  1. 如何收集所有使用某个相同数据的watcher,对其进行统一的更新处理。即我们如何实现数据的依赖收集
  2. 如何在数据发生改变时,将收集到watcher依赖全部更新。

第二个问题,经纪人Dep实例已经解决了。我们使用Dep类收集相关的watcher,然后对其进行统一的更新操作。还剩问题一待解决。

6. Observer类

在上一节中,我们现在还差最后一步,就可以完成数据的响应式功能:在何处,何时创建Dep实例去收集watcher

在此之前,我们首先要了解一下Object.defineProperty()这个核心方法,MDN文档对该方法的介绍是这样的:

Object.defineProperty(obj, prop, descriptor)方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

参数:

  • obj: 要定义属性的对象
  • prop: 要定义或修改的属性的名称或Symbol
  • descriptor: 要定义或者修改的属性描述符

前两个属性很好理解,就是填入某个对象和它的属性。重点就是descriptor,它是一个对象,包含:configurable, enumerable, value, writable, get, set 六个描述符。我们今天主要使用其中的getset,其余的属性如果有兴趣可以去MDN上面查看文档学习 :)

写一个简单Demo了解getset描述符的作用:

let obj = { name: 'Evan', age: 22};
Object.defineProperty(obj, 'age', {
    get(){
        return "don't ask my age";
    }
})
console.log(obj, obj.name, obj.age); 
// { name: 'Evan', age: [Getter] } 'Evan' 'don\'t ask my age'
obj.age = 18;
console.log(obj, obj.name, obj.age); 
// { name: 'Evan', age: [Getter] } 'Evan' 'don\'t ask my age'

let obj2 = { name: 'lucy', age: 30};
let frozenAge = 18;
Object.defineProperty(obj2, 'age', {
    get(){
        return frozenAge
    },
    set(newVal){
        if(newVal > 18) return 
        frozenAge = newVal;
    }
})
console.log(obj2, obj2.name, obj2.age); 
// { name: 'lucy', age: [Getter/Setter] } 'lucy' 18
obj2.age = 30; 
console.log(obj2, obj2.name, obj2.age)
// { name: 'lucy', age: [Getter/Setter] } 'lucy' 18
obj2.age = 10;
console.log(obj2, obj2.name, obj2.age) 
// { name: 'lucy', age: [Getter/Setter] } 'lucy' 10

在上面的代码中,我们能够了解到getset的作用:

  • get: 获取obj.key时,调用get函数,函数返回值即为该属性的值,这个值会取代原有对象的值。
  • set: 当设置obj.key时,调用set函数,set函数的参数则为想要设置的值。

看到getset时,有没有眼前一亮:

  • set触发时,我们可以调用depnotify方法,更新视图。
  • get触发时,我们可以让dep去收集watcher

这不就是我们想要的,收集依赖的地方和触发更新的地方吗? 由此可以尝试编写如下函数:

function defineReactive(obj, key){
  let dep = new Dep();
  let value = obj[key];
  Object.defineProperty(obj, key, {
      get(){
          if(window.target){
              dep.addSubs();
          }
          return value;
      },
      set: (newVal) =>{
          // 当新值和老值相同时
          if(value === newVal) return;
          value = newVal;
          dep.notify();
      }
  })
}

看到这里我们终于再次看到了window.target这个对象,大家还记得在哪里使用了它吗,没错!就是Watcher类里面的getter方法,当时说后面在讲它的作用,就是在这里QAQ。简单来说,window.target就是触发依赖收集的条件,如果在某个未知的地方获取到了Object.defineProperty中设置过get的某个object的属性的值,那我们就有了Dep收集依赖的第一个条件:触发get,其次我们还需要第二条件进行筛选,就是Window.target的值不为空,不为空那它是什么呢,当然是我们的watcher :-D。为什么我们需要第二个条件呢?在之前的分析过程中,我们知道dep中的subs需要存储的是watcher,用于更新视图,但是在实际过程中,有多个地方需要获取vm.$data中的某个属性(例如methods中的方法会获取data多个属性的值),因此会触发set函数但是我们没有必要去收集watcher

由于js是单线程的,不存在当创建一个watcher的时候,其他的地方刚好触发了相关的set从而依赖错误收集,所以我们在new一个watcher的时候 调用getter方法, 先将window.target指向wathcer, 然后去获取一下expr对应的vm.$data中的相关值,触发Object.defineProperty中的get方法,让depwatcher收集到subs属性中,最后将window.target置空,完成数据依赖的收集。这样在对应的数据发生变化时,dep.subs中就存在所有的watcher,可以调用depnotify方法,更新所有依赖该数据的watcher,完成视图的更新。

总结:和女神打交道的人不是全都是女神的舔狗,如果window.target有值,才标志着你是舔狗,那好兄弟dep就会记住这个工具人。

这样,我们还差一步,就是递归遍历vm中的$data将其中的所有的属性,利用Object.defineProperty方法将其变为响应式。因此我们回到最开始Vue的构造函数,其中执行了了new Observer

class Observer{
    constructor(data){
        this.$data = data;
        this.observer(this.$data);
    }
    observer(obj){
        if(typeof obj !== 'object') return;
        Object.keys(obj).forEach(key =>{
            this.defineReactive(obj, key, obj[key]);
        })
    }
    defineReactive(obj, key, value){
        if(typeof value === 'object') this.observer(value);
        let dep = new Dep();
        Object.defineProperty(obj, key, {
            get(){
                if(window.target) {
                    dep.addSubs();
                }
                return value;
            },
            set: (newVal) =>{
                if(value === newVal) return;
                // 防止 newVal为对象的情况,需要重新将对象中的属性变为响应式
                this.observer(newVal);
                value = newVal;
                dep.notify();
            }
        })
    }
}

到此为止,我们的双向数据绑定的简单实现已经完成了,还剩下computed,v-on(或者说@xxx),以及一些细节还未实现。

3. 对简化版vue进行完善

在上述的章节中,我们实现了一个简单版本的Vue双向数据绑定,接下来我们对其功能进行一些简单的扩展。

1. 对于Vue$data属性值的获取

Vue中,我们写JS代码调用Vue中$data都是直接使用形如this.name的方式,而不是类型this.$data.name的形式,因此,我们可以在Vue的构造函数中做一个简单的代理,this.name代理到this.$data.name上,在构造函数中添加proxyVm方法

// 把数据 全部用Object.defineProperty
new Observe(this.$data);

// 把数据获取操作,vm上的取值操作 都代理到vm.$data;
this.proxyVm(this.$data);

new Compiler(this.$el, this);

proxyVm的具体实现如下:

 proxyVm(data){
      for(let key in data){
          Object.defineProperty(this, key, {
              get(){
              	return data[key];
              },
              set(newVal){
              	data[key] = newVal;
              }
          })
      }
  }

2. Vuecomputed属性的实现

我们知道,在computed中,我们可以编写相关方法,方法中所有使用到的数据任意一个发生变化时,我们都会重新计算相关的值,并且更新视图。 这个时候,我们想一下,我们所实现的Watchergetter方法正是收集依赖的地方,如果我们在getter方法中,将window.target指向为自己后,执行一个函数,那么是不是会将自己加入到getter方法中该函数所使用的所有数据的相关Depsubs中,当其中某个数据更新了,我们调用Dep实例的notify方法都可以调用到Watcher实例自己的update方法更新视图。想到这里,我们貌似知道了computed的实现方法,我们需要将Watcher稍微改造一下

class Watcher{
    constructor(vm, expr, cb){
        this.$vm = vm;
        this.expr = expr;
        this.cb = cb;
        this.get = typeof expr === 'function' ? expr : () => compileUtil.getValue(this.expr, this.$vm);
        this.getter();
    }
    update(){
        let newVal = this.get();
        if(this.value === newVal) return; 
        this.value = newVal;
        this.cb();
    }
    getter(){
        window.target = this;
        this.value = this.get();
        window.target = null;
    }
}

然后需要将vm.$options.computed中的所有属性通过代理,让我们和data一样 可以直接在vue实例上访问。因此在Vue构造函数中调用proxyVm(this.$options.computed)将其代理,然后我们通过代理让datacomputed的属性通过实例可以直接访问,那么模板中遇到{{this.count}}时可能为computed也可能为data上的属性,因此我们将compileUtil.getValue中的reduce方法中的参数从vm.$data换成vm.

3. 简单实现@指令绑定方法

在本次内容中,我们实现一个@方法,看到@,我们知道这时v-on的缩写,在解析时,我们要去解析特殊字符 首先在Compile类中修改compileElement方法,然后在类中添加compileSpecificDirective方法。

compileElement(node){
    // 获取元素节点的所有属性
    let attrs = node.attributes;
    // 遍历所有属性,查找是否存在Vue指令
    [...attrs].forEach(attr =>{ 
        // name: 属性名, expr: 属性值
        let {name, value:expr} = attr; 
        // 判断是不是指令
        if(this.isDirective(name)){
            let [,directive] = name.split('-');
            // 如果为指令则去设置该节点的响应式函数 
            compileUtil[directive](node, expr, this.$vm);
        }
        // 解析特殊指令,比如"@", ":"
        this.compileSpecificDirective(node, name, expr);
    })
}
 compileSpecificDirective(node, name, expr){
    if(name.trim().startsWith('@')){
        compileUtil['on'](node, name.trim().substr(1), this.$vm, expr);
    }
}

然后再compileUtil添加一个on方法

on(node, eventName, vm, expr){
  let fn = vm.$options.methods[expr].bind(vm);
  node.addEventListener(eventName, fn);
}

这样一个简单的@指令就被我们实现了(没有实现带参数的情况的,实在是想偷个懒… ^_^ )

在这里我们可以理一理实现这个简单版vue.js都干了什么 实现的简单版本的Vue 到现在为止,最开始在html中编写的内容,我们使用mvvm.js也可以实现。完整代码内容点这里

4. 源码阅读

上面,我们手写了一个炒鸡简单的Vue,甚至实现的功能都没有写全。这个时候,当然要去正版Vue看下Vue的具体实现,git的地址如下:Vue源码地址。我们直接查看src目录,文件目录结构如下: 文件目录结构

本次主要是查看和双向数据绑定相关的observer模块,以及Vue构造函数相关的instance模块。首先我们找到src/core/instance/index.js。该文件内容如下: Vue构造函数 在Vue构造函数中,我们执行了this._init该方法来自initMixin(Vue),因此我们继续深入查看initMixin(Vue),该函数在src/core/instance/init.js中。

Vue.prototype._init的核心代码: _init方法

由于这次我们主要关注的核心点在数据的初始化,因此我们继续深入到initState函数查看Vue初始化进行的操作,该方法来自src/core/instance/state.js: initState方法 我们可以看到在个函数中,对Vue实例中传入的propsmethodsdatacomputedwatch都做了相应的初始化处理。依次查看它们的初始化:

1. initProps函数

initProps函数核心代码 在该函数中,最核心的部分就是defineReactive,将props中每个key遍历成响应式。defineReactiveinitData中会讲述,因此这里不过多分析。

2. initMethods

initMethods方法 初始化vm中的方法则更为简单,将optionsmethods的所有属性方法放一份到vm中。其中要注意的就是要将这些方法的this指向vm

3. initData

initData方法则是这次源码分析的重头戏,我们可以看下在Vue中是如何对data做相应式处理的 initData函数 在这里我们可以看到,initData主要做了两件事情:

  1. proxy: 让data中的属性可以通过vm.xx直接访问。
  2. observer: 将data数据变为响应式数据。

因此我们继续深入到observer方法,该方法来自src/core/observer/index: observer方法 该方法时首先会检查value的类型,确定value是对象并且不是VNode的实例对象,在进行初始化时,函数会执行ob = new Observer(value),这里发现了一个熟悉的构造函数~~。前面我们也写过的Observer对象。查看Vue中的Observer跟我们写的有什么区别: Observer构造函数OBserver构造函数中,创建了Dep实例并保存到自己的属性dep中,将value保存到自己的value属性中,然后将自己(Observer实例)放置到value对象的__ob__属性上(由此可以推断,在Vue中一个对象如果有__ob__属性,则它是一个响应式对象)

最后通过Array.isArray判断value是否是一个数组,如果是数组则执行数组的响应式处理,为什么要将数组单独从对象中提取出来做特殊处理呢? 在数组中,存在push,pop,splice等改变原数组的原型方法,然而Object.definePrototype对于对象做的响应式处理在数组执行这些改变数组原型方法时无法对其进行响应,更新相关的视图。

Vue3用proxy代替Object.defineProtptype重写数据的响应式,proxy的一个好处就是对数组的支持比Object.defineProtptype更好。(后面有空的话,再去看下Vue3,写一篇关于proxy的文章)。

如果value不是数组(即value为对象),执行walk方法。下面我们先查看对象响应式处理的walk()方法,随后查看对数组进行的响应式的处理。

1. 对象的响应式处理:walk()

walk方法 walk方法的实现非常简单,遍历对象的属性,对每个对象的每个属性调用defineReactive方法,在initProps时我们也调用了该方法,接下来,我们可以查看这个方法究竟做了什么: defineReactive1 defineReactive2 defineReactive3

  1. 首先每次调用该方法都会创建一个Dep,该对象用于收集该属性的相关依赖
  2. 获取该属性在对象上对应的值,将其赋值给val,然后调用observer(val)(shallowdefineReactive的第五个参数,在这里为undefined),如果val也是对象。则也会递归遍历val中的属性,将其设置为响应式对象。在前面observer方法会返回一个Observer实例。故当val为对象时这里的childObval对象上的Observer实例,当val为基本类型时,则childobundefined.
  3. 调用Object.defineProperty()方法。查看其getset方法
  • get: 首先在函数中,我们获取了一下obj上属性对应的值并赋值给value,随后我们判断Dep.target是否有值(和我们写的window.target一样,只不过Dep.target是将target放到Dep的原型上),如果存在值的,就满足触发getDep.target两个条件我们可以去收集相关watcher,然后会去判断childOb是否存在。如果存在我们还需要建立childOb上的dep与当前触发getwatcher的联系:Object.definePrototype无法响应对象属性的新增和删除,无法响应到数组length属性的变化以及数组原型方法改变原数组的几个方法,因此响应这些变化需要将childob.dep也收集一下Watcher的实例的依赖,方便后续的更新操作
  • set: 和set一样,首先获取value,然后判断设置的新值与原来的值是否不一样,一样则直接返回。如果不一样则将新值赋值给val(或者调用setter更新),防止新的val是对象的情况,我们需要对其调用observe将其变为响应式对象。最后通知在defineReactive中创建的dep去通知相关依赖更新视图。

这里我们可以查看Vue中DepWatcher是如何实现的:

Dep:

// src/core/observer/dep.js 
// 部分与本章无关代码删除
let uid = 0;
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

Dep的构造函数中,每次创建的新的dep都会获取到该dep的唯一id,并且初始化自己的subs将其定义为空数组。 在上述代码中有notifydependaddSub三个方法。

  • notify方法就是将subs数组中收集的watcher实例依次执行相关的update函数更新视图。
  • addsub: 将参数添加到subs数组。
  • depend: 调用Dep.target.addDep方法。(Dep.target即为watcher) 在defineReactive中定义的Object.definePrototypeget方法中调用的是depend方法而不是addSub。明明要收集依赖,为什么又去Dep.target(即watcher)中调用addDep()方法呢? 我们去Watcher类中查看其定义:

Watcher:

// src/core/observer/watcher.js
// 部分与本章无关代码删除
export default class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)

    this.cb = cb
    this.id = ++uid // uid for batching
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()

    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      // parsePath为解析路径函数,返回一个函数。返回的函数可得到exprOrFn对应的值
      this.getter = parsePath(expOrFn)
    }
    // 触发依赖收集
    this.value = this.lazy
      ? undefined
      : this.get()
  }
  get () {
  	// 将自己加入到Dep.target上
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 获取value的值,触发Object.definePrototype中设置的set函数
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      if (this.deep) {
        traverse(value)
      }
      // 将自己从Dep.target上删除
      popTarget()
      this.cleanupDeps()
    }
    return value
  }
  addDep (dep: Dep) {
    const id = dep.id
    // 如果没有建立和dep之间关系
    if (!this.newDepIds.has(id)) {
      // 则建立watcher和dep关系
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      // 反向建立dep和watcher关系
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }

tips: 在Vue2中,watcher实例每个组件一个watcher,在之前我们写的地方我们是在Compile解析模板时每次使用data属性的时候会创建一个watcher,这样会创建大量的watcher对象影响性能,因此在vue2中每个组件创建一个watcher然后再组件内部使用虚拟Dom进行diff算法,然后重新渲染页面

看到这里我们有了一个大致的思路,在Vue中,我们首先在将数据变为响应式,然后再解析模板的时候将根据组件创建watcher,再创建watcher的过程中,调用get方法去触发依赖收集,然后再dep中调用当前watcheraddDep方法将dep中的id以及dep自己加入到当前的watcher中存起来,如果dep中没有添加当前wather,调用dep.addSub(this)watcher加入到dep.subs属性中。

看起来很绕,本质上就是在dep中,我们去收集watch。反过来,我们也要去了解哪些depwatch收集了,depwatcher是一种多对多的关系:一个数据可能在多个组件中被使用,故一个dep中可能存多个watcher。同时比如我们再使用计算属性的时候,一个函数可能用到多个属性,该组件内的watcher可能被多个属性的dep实例所收集。

2. 数组的响应式处理

对数组做的响应式处理用的是protoAugment(value, arrayMethods)方法,该方法直接将arrayMethods方法赋值给数组实例的__proto__上。那么继续查看arrayMethods是什么,代码位置:src/core/observer/array:

import { def } from '../util/index'

const arrayProto = Array.prototype
// 创建一个全新对象,克隆自数组原型对象
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  // cache original method
  // 数组原型方法
  const original = arrayProto[method]
  // 改变数组原型方法,定义新方法
  def(arrayMethods, method, function mutator (...args) {
    // 执行数组原始方法
    const result = original.apply(this, args)

    // 获取ob实例(Observer实例)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    // 新添加的数组项如果是对象则进行响应式处理
    if (inserted) ob.observeArray(inserted)
    // 获取dep通知watcher更新
    ob.dep.notify()
    return result
  })
})

arrayMethods对数组原型方法上会改变原数组的方法进行了扩充,将其重新定义,当使用push,pop等方法时,除了调用数组原型上的该方法,还对其做了额外处理:

  • 当添加新元素时,对新元素做响应式处理。
  • 调用ob上的dep实例,通知watcher更新视图.

tips: 由于对数组中每一项调用的是observeArray方法,该方法对数组的每一项进行observer(item)的操作,因此当数组内容为基本数据类型时,直接改变它的值,视图是不会更新的~。

4. initComputed

核心代码: initComputed computed的初始化主要是将computed中对应的每个方法创建一个watcher,然后将每个方法作为exprOrFn传入Watcher。在前面实现简单的computed功能时,已经知道watcher传入的expr为函数则可以实现computed的功能,然后由于computed还存在缓存功能,这里我们不过度深入,大家有兴趣的可以去看下源码。

5. initWatch

initWatch代码如下:

function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    // 将handler作为Watcher构造函数的cb
    const handler = watch[key]
    // 如果handler是一个数组。则创建多个Watcher
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      // 当handler不是数组
      createWatcher(vm, key, handler)
    }
  }
}

function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  // 如果handle是对象,则取handler函数,并得到传入的其他选项
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  // 如果handler是字符串,尝试在vm上获取vm[handler]方法
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  // 调用Vue.prototype.$watch 创建`watcher`
  return vm.$watch(expOrFn, handler, options)
}

Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {}
    options.user = true
    // 创建Watcher实例,实现watch功能
    const watcher = new Watcher(vm, expOrFn, cb, options)
    if (options.immediate) {
      try {
        cb.call(vm, watcher.value)
      } catch (error) {
        handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
      }
    }
    return function unwatchFn () {
      // 返回取消观察的函数
      watcher.teardown()
    }
  }

可以看到watch属性内部其实也是利用watcher来实现的,只不过computed创建的watcher,在构造是将函数传入Watcher构造函数的exprOrFn作为其观察对象, 而watcher则将vm[key]作为观察对象,传入Watcher构造函数的函数作为更新方法。

Vue.$setVue.$delete

在上面的内容中,讲述过Object.definePrototype有一定的局限性,就是当我们在一个对象上面添加新的属性或者删除属性时,该方法的setget是无法知道的,同样,在数组中使用该方法监听数组的每一项内容,当时数组调用自己的原型方法或者修改数组实例的length属性。该方法也无法相应。特别的,在Vue中,直接修改数组的内容,Vue无法实现更新。 这个时候,Vue提供了$set$delete来实现对数组的更新以及对对象属性的添加和删除。

我们来查看其内部实现:

// Vue.prototype.$set
export function set (target: Array<any> | Object, key: any, val: any): any {
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
 
  if (!ob) {
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

// Vue.prototype.$delete
export function del (target: Array<any> | Object, key: any) {
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = (target: any).__ob__
  if (!hasOwn(target, key)) {
    return
  }
  delete target[key]
  if (!ob) {
    return
  }
  ob.dep.notify()
}
  • Vue.prototype.$set:
    • 首先去判断传入的target参数是否为数组,如果是数组则在内部调用我们扩展过的splice原型方法对其进行更新,结束函数。
    • 如果不是数组,则判断target上是否存在key属性,存在的话将参数val直接赋值给target[key],然后结束。
    • 尝试获取target上的__ob__属性。如果该属性不存在,则说明该对象不是响应式对象,直接将value赋值给target[key]
    • 如果target上的__ob__属性存在,则调用defineReactiveobj.key变为响应式属性,并且将val作为该属性的初始值。最后调用ob.dep.notify()完成更新。

在上述内容中,可以知道observer方法中为每一个对象创建一个__ob__其实是十分有必要的,在我们为对象添加新属性或者删除属性时都需要利用__ob__.dep 去通知watcher更新。(在数组的splice中也是利用__ob__.dep去通知更新的)

  • Vue.prototype.$delete:
    • 判断传入的target参数是否为数组,如果是数组则在内部调用我们扩展过的splice原型方法对其进行更新,结束函数。
    • 如果不存在target[key],则直接返回结束函数。
    • 删除target[key],如果target上不存在__ob__则说明target不是响应式对象,结束函数。如果存在target,则调用__ob__if,.notify()更新视图。

写在最后

本文参考内容:

掘金小册: 《剖析 Vue.js 内部运行机制》 -染陌同学
《深入浅出Vue.js》 -刘博文
vue.js源码:https://github.com/vuejs/vue

去年就在掘金申请帐号了,想着每个月输出一点东西(求关注~),今年终于强迫自己踏出了第一步,算是走出舒适区的第一步。

各位走过路过的朋友们觉得写的文章还行就点点赞叭~~觉得写的很垃圾也 点个赞鼓励下叭 呜呜呜。

最后找一个名言装下X,开溜。

The farther behind i leave the past, the closer i am to forging my own character.
我把过去抛得越远,便越接近于我自己锻炼的自我。

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 真难取名字 原文链接:https://juejin.im/post/6861471544200462350

回到顶部