Vue2进阶

229 阅读42分钟

组件通信总结

父子组件通信

  • prop & event

    prop用于父组件传递数据给子组件

    子组件发生了某件事,通过event通知父组件

  • style & class

    父组件可以通过给子组件添加style或class来为子组件的根元素添加样式或类名

    若子组件的根元素中本身就含有style或class属性,则会将父组件传递过来的style或class进行合并

    <template>
    <div id="parent">
        <Child style="color:red" class="child"/>
    </div>
    </template>
    
    <script>
    import Child from "./components/Child.vue";
    
    export default {
    	components: {
        	Child
    	},
    };
    </script>
    
  • attribute

    父组件传递给子组件的prop,需要在子组件中进行声明后才能够使用

    而attribute则与prop不同,子组件不需要对attribute进行声明就可以使用父组件传递过来的数据

    <!-- 父组件 -->
    <template>
    <div id="parent">
        <Child data-a="1" data-b="2" msg="hello child" />
    </div>
    </template>
    
    <script>
    import Child from "./components/Child.vue";
    
    export default {
    	components: {
        	Child
    	},
    };
    </script>
    

    在子组件中,通过$attr可以得到父组件传递过来的attribute

    <!-- 子组件 -->
    <template>
    <div id="child"></div>
    </template>
    
    <script>
    export default {
        prop: ["msg"],
    	created(){
            console.log(this.$attr);		// { "data-a": "1", "data-b": "2" }
        }
    };
    </script>
    

    需要注意的是,父组件传递过来的style和class,虽然子组件也能不声明就使用,但$attr是获取不到style和class

    默认情况下,子组件渲染出来的真实DOM的根元素上,会附着有父组件传递过来的attribute

    <div id="parent">
        <div id="child" data-a="1" data-b="2"></div>
    </div>
    

    可以通过设置子组件配置的inheritAttrs为false来取消附着效果

    <template>
    <div id="child"></div>
    </template>
    
    <script>
    export default {
        inheritAttrs: false
    };
    </script>
    
  • native修饰符

    父组件可以通过native事件修饰符,将事件注册到子组件的根元素上,而无论子组件是否对该事件进行emit

    <template>
    <div id="parent">
        <Child @click.native="handleClick" />
    </div>
    </template>
    
    <script>
    export default {
        methods: {
            handleClick(){
                //...
            }
        }
    }
    </script>
    
  • $listeners

    子组件可以通过$listeners来获取父组件传递过来的所有事件处理函数

  • v-model

    后续章节详解

  • sync修饰符

    和v-model的作用类似,用于数据的双向绑定,不同点在于v-model只能针对一个数据进行双向绑定,而sync修饰符没有限制

    例:

    子组件:

    <template>
    <div>
        <p>
            <button @click="$emit(`update:num1`, num1 - 1)">-</button>
            {{ num1 }}
            <button @click="$emit(`update:num1`, num1 + 1)">+</button>
        </p>
        <p>
            <button @click="$emit(`update:num2`, num2 - 1)">-</button>
            {{ num2 }}
            <button @click="$emit(`update:num2`, num2 + 1)">+</button>
        </p>
    </div>
    </template>
    
    <script>
    export default {
        name: "Numbers",
        props: ["num1", "num2"]
    };
    </script>
    

    父组件:

    <template>
    <div id="app">
        <Numbers :num1="n1" @update:num1="n1 = $event" :num2="n2" @update:num2="n2 = $event" />
    </div>
    </template>
    
    <script>
    import Numbers from "./components/Numbers.vue";
    export default {
        components: { Numbers },
        data: ()=>({
            n1: 0,
            n2: 0,
        })
    };
    </script>
    

    等价于

    <template>
    <div id="app">
        <Numbers :num1.sync="n1" :num2.sync="n2" />
    </div>
    </template>
    
    <script>
    import Numbers from "./components/Numbers.vue";
    export default {
        components: { Numbers },
        data: ()=>({
            n1: 0,
            n2: 0,
        })
    };
    </script>
    
  • $parent & $children

    父组件可以通过$children获取到自己所使用到的所有子组件的组件实例

    子组件可以通过$parent获取到自己的父组件的组件实例

  • $slots & $scopedSlots

    后续章节详解

  • $refs

    父组件可以通过$refs得到子组件的实例对象

跨组件通信

  • provide & inject

    var Provider = {
        provide: {
            foo: "bar"			// 祖先组件提供 "foo"
        }
    }
    
    var Child = {
        inject: ["foo"],		// 后代组件注入 "foo"
        created () {
            console.log(this.foo) 	// "bar"
        }
    }
    
  • router

    如果某个组件改变了地址栏,则所有监听地址栏变化的组件都会做出相应反应

  • vuex

    利用vuex可以实现多个组件共享一份数据

    vuex适用于大型项目

  • store模式

    store模式去除了vuex中繁琐的更改数据操作,只保留了最基本的数据共享的功能,因此更适用于中小型项目

    store模式的使用方法如下:

    // store.js
    export default {					// 需要导出一个对象
    	number: 1
    }
    
    <template>
    <div id="A">
        <button @click="store.number++">add</button>
        {{store.number}}
        <button @click="store.number--">sub</button>
    </div>
    </template>
    
    <script>
    import store from "./store.js";
    export default {
        data: ()=>({
            store
        })
    };
    </script>
    

    将store加入到组件的data配置中后,store就变为了响应式数据,只要store中的内容发生了变化,使用到store的组件就会做出反应

    <template>
    <div id="B">
        <button @click="store.number++">add</button>
        {{store.number}}
        <button @click="store.number--">sub</button>
    </div>
    </template>
    
    <script>
    import store from "./store.js";
    export default {
        data: ()=>({
            store
        })
    };
    </script>
    

    由于组件A和组件B使用的是同一个store,因此A对store中的内容进行更改后,B也能进行监听到变化并进行处理

  • eventbus

    一个组件通过事件总线监听其他组件中的某个动作(事件),当这些组件发生了该动作后,事件总线就会运行对应的事件处理函数

虚拟DOM详解

什么是虚拟DOM

虚拟DOM本质上就是一个JS对象,虚拟DOM能够反映用户界面结构

通过组件实例中的_vnode属性即可得到组件对应的虚拟DOM

在vue中,每个组件都有一个render函数,而render函数的返回值就是虚拟DOM树,因此每一个组件就对应着一颗虚拟DOM树

这么做的好处在于,如果某个组件的数据发生了变化,只需要对该组件的虚拟DOM树进行更新即可,而不会影响到其它组件

image.png

为什么需要虚拟DOM

真实DOM的生成要依托于render函数所创建出的虚拟DOM树,因此但凡涉及界面渲染就需要调用render函数,例如:组件被创建时、组件中所依赖的数据发生了变化时,所以,render函数的调用是十分频繁的

vue之所以会选择使用虚拟DOM而不是直接操作真实DOM,是因为直接操作真实DOM是非常损耗性能的

在一个组件首次被创建并渲染时,它会先生成虚拟DOM树,然后根据虚拟DOM树创建真实DOM树,最后把真实DOM树挂载到页面的合适位置,在真实DOM树的创建过程中,真实DOM节点会与虚拟DOM树的节点进行一一对应,具体为将虚拟DOM节点的elm属性指向生成的真实DOM节点

image.png

当组件所依赖的响应式数据发生了变化,Vue就会重新调用render函数,即重新创建出一棵虚拟DOM树,然后将新旧虚拟DOM树进行对比,(尽可能)找出两棵树的最小差异,并根据这个差异去修改对应的真实DOM,这样一来,即保证了视图能够更新,也保证了渲染效率不会太低

模版与虚拟DOM的关系

vue框架中有一个compile模块(编译模块),该模块的任务就是将template模版编译为render函数

虚拟DOM树只能通过render函数来生成,template的作用仅是为了开发者书写方便

具体的编译过程大致分为两步:

  1. 将模版字符串进行分析,从而形成一棵AST(抽象语法树)
  2. 将AST转换为render函数

如果是使用传统的script元素引入的vue,则组件模版编译的时间点发生在该组件第一次加载完成时,这称为运行时编译

如果是使用vue-cli创建的vue工程,在默认情况下,模版编译发生在打包过程中,这称之为模版预编译,之后打包结果中就不会存在template模版,而是包含编译过后的render函数

可以在vue工程中的vue.config.js配置文件中设置runtimeCompiler为true来关闭模版预编译的功能,关闭后就变为了运行时编译,该配置默认为false

// vue.config.js
module.exports = {
    runtimeCompiler: true
   };

编译是一个非常耗费性能的操作,因此采用预编译模式可以有效提高运行时的效率,并且由于打包结果中已经不存在template模版了,因此vue-cli在打包时就会将组件的template模版以及框架中的compile模块排除,减少了打包体积

在SFC文件(.vue文件)中,只有将模版书写在template标签中,vue才能够对模版进行预编译,如果是将模版书写在组件配置的template属性中,则vue不会对模版进行预编译

模版的存在,仅仅是为了让开发者更为方便地书写界面代码,而在vue最终运行时,需要的是render而不是模版,而模版中的各种语法(例如mustache语法,以及各种指令和属性)最终都会成为render函数所生成的虚拟DOM中的配置属性

扩展

在传统的引用外部vue.js文件的vue工程中,若组件中同时包含render配置、template配置、元素的outerHTML,则三者的使用优先级为:render > template配置 > 元素outerHTML

<!DOCTYPE html>
<div id="app">
    <h3>third</h3>
</div>
<script src="./vue.js"></script>
<script>
const vm = new Vue({
    el: "#app",
    template: "<h2>second</h2>",
    render(h){
        return h("h1", "first");
    }
});
</script>

在使用vue-cli搭建的vue工程中,若将开启运行时编译,且组件中同时包含render配置、template配置、template元素,则三者的使用优先级为:template元素 > render配置 > template配置

<template>
	<h1>first</h1>
</template>

<script>
export default {
    template: "<h3>third</h3>",
    render(h){
        return h("h2", "second");
    }
}
</script>

v-model

v-model只是一个语法糖,它可以使用在表单元素上,也可以使用自定义组件上,但无论哪一种情况,v-model最终都是被转换成一个属性和一个事件

对于表单元素,vue会根据表单元素的类型将v-model转换为合适的属性和事件,例如:作用于普通文本框元素或文本域元素时,v-model就会被转换为value属性和input事件,而作用于单选框或多选框时,它会被转换为checked属性和change事件

对于自定义组件,v-model默认情况下会被转换为value属性和input事件

例如:

<Comp v-model="data" />

等效于:

<Comp :value="data" @input="data = $event.target.value" />

开发者可以通过组件的model配置来修改转换后的属性和事件

<!-- Comp -->
<script>
export default {
    model: {
        prop: "num",			// 默认为"value"
        event: "change"			// 默认为"input"
    }
}
</script>

数据响应式原理

数据响应式,简单来说就是希望在数据发生变化时能够自动运行一些函数

vue在实现数据响应式的功能的过程中,使用到了下面几个核心部件:

  1. Observer
  2. Dep
  3. Watcher
  4. Scheduler

Observer

Observer的目标就是将普通对象(如data配置方法所返回的对象)转换为响应式对象

响应式对象是指访问该对象的属性时能够自动进行一些处理

Observer是Vue内部使用的一个构造函数,开发者不能直接使用到该构造函数,但可以使用Vue的静态方法observable(obj)来间接使用到Observer提供的功能

具体来说,Observer会深度遍历对象,将对象中的所有属性都通过Object.defineProperty转换为带有getter和setter的访问器属性,这样一来,当获取和设置这些属性时Vue就可以通过getter和setter对数据进行额外的处理

image.png

Observer会将对象进行深度遍历,即如果发现属性是引用值,则Observer就会对该值进行递归转换:

  • 对于(除数组外的)对象类型的属性,会递归处理该对象中的所有属性,对这些属性的内容进行响应式处理

    由于Vue是使用Object.defineProperty对属性设置为访问器属性的,而Object.defineProperty的缺陷在于只能对获取和设置操作进行拦截,因此对于一些特殊的操作,如删除属性,添加属性,Vue无法进行干预

    虽然Vue无法直接观测到删除属性和添加属性的动作,但Vue提供了两个对应的处理方法 —— $set和$delete,这两个方法都在组件实例之中,当调用这两个函数时,其内部就会通知Vue动作的发生,同时将属性在对象中进行添加或删除

    因此,当需要为响应式对象添加属性时,应当使用$set(obj, "prop", value)来添加,当需要删除响应式对象中的某个属性时,应当使用$delete(obj, "prop")来删除

    对于$set,它在将数据插入到响应式对象上后,还会将添加的属性变为响应式属性

  • 对于数组类型的属性,会递归处理数组的每一个元素,对这些元素进行转换

    不过,由于种种原因(主要是性能问题),直接通过下标对数组中的某个元素重新赋值,Vue是收不到通知的,也不会做出反应;要想在修改数组元素时Vue能够做出反应,可以使用$set方法

    如果数组元素是对象,则对该对象内属性的修改Vue是可以收到通知的

    <template>
    <div id="app">
        <p>
            obj.a: {{ obj.a }}
            <button @click="obj.a++">change obj.a</button>
        </p>
        <p>
            arr[0]: {{ arr[0] }}
            <button @click="arr[0]++">change arr[0]</button>
        </p>
        <p>
            arr[1].a: {{ arr[1].a }}
            <button @click="arr[1].a++">change arr[0].a</button>
        </p>
    </div>
    </template>
    
    <script>
        export default {
            data(){
                return {
                    obj: { a: 1 },
                    arr: [1, { a: 1 }]
                }
            },
        };
    </script>
    

    此外,Vue还会将响应式数组的隐式原型指向一个Vue内部创建的类似于Array.prototype的原型对象

    在该对象中包含了一些常见的(会修改数组本身的)原型方法,例如:push、pop、unshift、reverse、sort等,在这些方法内部,除了实现原本的功能外,还增加了一个通知Vue的功能,之后当数组使用到了这些方法时,Vue就能够收到通知,并做出反应

    Vue为了能够让响应式的数组使用到Array.prototype上的一些其他方法(这些都是不会改变原数组的方法,例如map、filter等),又让这个内部创建的原型对象的隐式原型指向了Array.prototype,因此响应式数组就能够通过原型链也能够使用到Array.prototype上的原型方法

    image.png

在组件的生命周期中,Observer对数据的转换发生在beforeCreate钩子运行之后,created运行之前

Dep

Dep全称为Dependency,表示依赖

Vue会为每一个的即将成为响应式数据的属性创建Dep实例,属性的getter和setter中就会使用到该Dep实例

{
    name: "zhangsan",					// dep
	age: 20,						// dep
	addr: {							// dep
        city: "shantou",				// dep
		province: "guangdong"		// dep
    },
	hobby: [						// dep
        "music",
        "movie",
        "game",
        {
            code: [					// dep
                "js",
                "python",
                "java"
            ]
        }
    ]
}

数组元素本身不会对应有Dep,但如果数组元素是对象,对象的属性是会有Dep的

Dep需要完成下面两个事情:

  1. 收集依赖

    记录哪些函数在使用【我】

  2. 通知更新

    当【我】发生变化后,通知所有使用到【我】的函数

当某个函数运行过程中使用了某个响应式属性,此时属性的getter方法就会执行,而getter就会让Dep将使用自己的函数记录起来

当响应式属性被重新赋值时,则属性的setter方法就会执行,此时setter若发现新值与旧值不一样,就会让Dep去通知之前它所记录的所有函数,让这些函数重新运行

image.png

为对象调用$set和delete来添加或删除某个属性时,触发的是该对象的Dep的通知更新操作,例如:\set(obj, 'a', 1)以及$delete(obj, 'a')都会触发对象obj的Dep的通知更新

细节:

  • Dep所记录的函数不包括methods中的函数
  • Vue不会为数组的直接子元素创建Dep实例,因此直接对数组元素重新赋值Vue是不会有进一步动作的

Watcher

Dep只是负责记录和通知,它并没有能力直接去定位此时是哪个函数在使用自己,而Watcher存在的意义就是告诉Dep什么地方会使用到它

对于会使用到响应式数据的函数(例如:render函数,watch配置中的一个个handler函数),Vue都会为其创建一个对应的Watcher,之后Vue并不会直接运行函数,而是会运行Watcher,并让Watcher去运行对应的函数

methods中的函数不会对应有Watcher,因此Dep也记录不到methods

Watcher在执行时,会先设置一个“全局”变量,让该变量指向Watcher自己,然后再调用自己所对应的函数

一旦函数执行过程中涉及到了获取响应式数据的操作,即会运行该数据的getter,于是getter中的Dep就会将此时全局变量所指向的Watcher记录下来

之后一旦响应式数据发生了变化,setter中的Dep就可以通知之前所记录的一系列Watcher,让这些Watcher重新去执行它们所对应的函数

image.png

每一个组件实例,都至少会对应一个Watcher,因为每一个组件都会有自己的render函数

当组件实例被创建时,Vue会为该组件的render函数创建一个Watcher,创建完成后会立即执行该Watcher,以便运行内部的render函数来让使用到的响应式数据去记录该Watcher

之后当这些响应式数据发生变化后,数据的Dep就会通知到该Watcher,Watcher便会重新运行自己的render函数

Scheduler

站在render函数的角度思考,由于render函数中会使用大量的响应式数据,若每个响应式数据在改变后都立即让render重运行,这显然是非常浪费效率的

实际上,Watcher在接收到来自Dep的数据更新通知后,它并不会立即被执行,而是将自己交付给调度器Scheduler

Scheduler会维护一个执行队列,它就会把交付过来的Watcher加入到这个执行队列中,对于同一个Watcher在该队列中只会出现一次,避免了同一个Watcher被重复调用

执行队列存在的意义是过滤掉重复的Watcher

处于执行队列中的Watcher又会被Scheduler通过一个工具方法nextTick有序地送入到事件循环的消息队列中

默认情况下,nextTick内部会利用promise.then()将函数放入到微队列中,但如果浏览器版本较老,不支持Promise,则可能会使用setTimeout放入到宏队列中

nextTick是Vue内部定义的一个工具方法,开发者可以通过this.$nextTick使用到该方法

注意:对于render的Watcher,在第一次会立即执行,但之后的派发更新导致的执行就是异步的

image.png

简易版Vue

// vue.js
class Vue {
    constructor(options) {
        // 1. 通过属性保存选项的数据
        this.$options = options || {};
        this.$data = options.data || {};
        this.$el = typeof (options.el) === "string" ? document.querySelector(options.el) : options.el;
        // 2. 代理注入data配置中的属性
        this._proxyData(this.$data);
        // 3. 调用observer,监听数据的辩护
        new Observer(this.$data);
        // 4. 调用compiler对象, 解析指令和差值表达式
        new Compiler(this);
    }
    _proxyData(data) {
        // 遍历data中的所有属性
        for (var key in data) {
            // 把data的属性注入到Vue实例中
            Object.defineProperty(this, key, {
                get() {
                    return data[key];
                },
                set(newVal) {
                    if (newVal === data[key]) {
                        return
                    }
                    data[key] = newVal;
                }
            });
        }
    }
}

class Observer {
    constructor(obj) {
        // 遍历obj的所有属性
        for (const key in obj) {
            let value = obj[key];
            const dep = new Dep();
            Object.defineProperty(obj, key, {
                get() {
                    // 收集依赖
                    Dep.target && dep.addSub(Dep.target);
                    return value;
                },
                set(newVal) {
                    if (newVal === value) {
                        return;
                    }
                    value = newVal;
                    // 派发更新
                    dep.notify();
                }
            })
        }
    }
}

class Compiler {
    constructor(vm) {
        this.el = vm.$el;
        this.vm = vm;
        this.compile(vm.$el);
    }
    // 编译模板, 处理文本节点和元素节点
    compile(elem) {
        const childNodes = elem.childNodes;
        for (const node of childNodes) {
            // 处理文本节点
            if (this.isTextNode(node)) {
                this.compileText(node);
            } else if (this.isElementNode(node)) {
                // 处理元素节点
                this.compileElement(node);
            }
            // 判断node节点,是否有子节点, 如果有子节点,要递归调用compile
            if (node.childNodes.length) {
                this.compile(node);
            }
        }
    }

    // 编译元素节点, 出来指令
    compileElement(node) {
        // 遍历所有的属性节点
        const attrs = node.attributes;
        for (const attr of attrs) {
            let attrName = attr.name;
            if (this.isDirective(attrName)) {
                // 删除"v-"
                attrName = attrName.substr(2);
                const key = attr.value;
                this.update(node, key, attrName);
            }
        }
    }

    // 处理 v-text 指令
    textUpdater(node, value, key) {
        node.textContent = value
        new Watcher(this.vm, key, (newValue) => {
            node.textContent = newValue
        })
    }

    // 处理 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;
        });
    }

    // 传入key
    update(node, key, attrName) {
        const updateFn = this[attrName + "Updater"];
        updateFn && updateFn.call(this, node, this.vm[key], key);
    }

    // 编译文本节点,出来插值表达式
    compileText(node) {
        let reg = /{{(.*)}}/;
        let value = node.textContent;
        const matchRes = reg.exec(value);
        if (matchRes) {
            const key = matchRes[1].trim();
            node.textContent = value.replace(reg, this.vm[key]);
            // 创建watcher对象, 当数据改变时更新视图
            new Watcher(this.vm, key, (newValue) => {
                node.textContent = newValue;
            });
        }
    }

    // 判断元素属性是否是指令
    isDirective(attrName) {
        return attrName.startsWith('v-');
    }
    // 判断节点是否是文本节点
    isTextNode(node) {
        return node.nodeType === 3;
    }
    // 判读节点是否是元素节点
    isElementNode(node) {
        return node.nodeType === 1;
    }
}

class Dep {
    constructor() {
        // 存储所有的观察者
        this.subs = new Set();
    }
    // 添加观察者
    addSub(sub) {
        if (sub && sub.update && !this.subs.has(sub)) {
            this.subs.add(sub);
        }
    }
    // 发送通知
    notify() {
        for (const sub of this.subs) {
            sub.update();
        }
    }
}

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

        // 把Watcher对象记录到Dep 类的静态属性target中
        Dep.target = this;
        // 触发get方法, 在get方法中调用addSub
        this.oldValue = vm[key];
        Dep.target = null;
    }
    // 当数据发生变化的时候更新视图
    update() {
        const newValue = this.vm[this.key];
        // 判断新值和旧值是否相等
        if (this.oldValue === newValue) {
            return;
        }
        this.fn(newValue);
    }
}

diff

diff发生的时机

当组件被创建时,或者组件所依赖的数据发生了变化时,会运行一个函数 —— updateComponent,该函数会做下面两件事情:

  1. 运行_render函数生成一棵新的虚拟DOM树

    _render函数的内部就会调用到组件配置中的render方法

  2. 运行_update函数,传入上一步所生成的虚拟DOM树

    该函数内部会将新树和旧树进行对比,最终完成对真实DOM的更新

    function Vue(options) {
    	// ...
        var updateComponent = () => {
            this._update(this._render);
        }
        new Watcher(updateComponent);			// 该Wather通过组件实例的_watcher中可获取到
        // ...
    }
    

    diff就发生在_update函数的运行过程中

每一个组件都会有自己的updateComponent函数,组件依赖的数据发生变化时,只需要调用该组件的updateComponent函数即可,不会影响到其它组件

_update函数会接收一个vnode,该vnode就是新的虚拟DOM树的根节点

在函数内部,它首先会通过组件实例的_vnode属性获取到当前的虚拟DOM树(即旧树),并将旧树用一个变量保存起来,然后将组件实例的_vnode属性指向传入的新虚拟DOM树

image.png

接下来_update函数会判断旧树是否存在(是否为null),如果旧树不存在(为null),则说明是组件是第一次加载,于是便调用patch函数,并告诉它只需要根据新的虚拟DOM创建出对应的真实DOM即可,同时_update函数还会告诉patch要将生成好的真实DOM树挂载到页面的什么位置

patch函数每根据一个vnode生成一个真实DOM节点,就会让该vnode的elm属性指向该真实DOM节点

如果vnode是组件vnode,patch函数会为其创建组件实例,然后将vnode的componentInstance属性指向该组件实例

当patch将所有的真实DOM节点都生成完成后,就会让这棵真实DOM树的根节点挂载到页面的_update所指定的位置

image.png

如果存在旧的虚拟DOM树(不为null),则_update还是调用patch函数,不过_update会告诉patch让其对新旧两棵树进行对比,目标是完成对真实DOM的最小化更新

image.png

由于真正的对比操作是由patch函数完成的,因此diff算法也常称为patch算法

diff的具体流程

一些概念:

  • 相同

    是指标签名称和key属性均一致的两个vnode

    对于input元素的vnode,还需要考虑其type属性是否一致

    模版中的所有元素(包括普通元素以及组件)均有key属性,在不显式添加key属性的情况下,其值默认为undefined

  • 更新

    在两个vnode相同的情况下,根据新vnode中的属性,对旧vnode所对应的真实DOM进行更新操作,例如更改元素类名、更改元素样式等

    对于两个相同的组件vnode,则更新的是旧vnode的组件实例

patch在对比两棵树时,采用的是同层比较,深度优先的方式进行对比的,并遵循下面的原则:

  1. 尽可能地利用原来已经存在的真实DOM
  2. 如果存在可以利用的真实DOM,但DOM的位置对应不上,则将其位置进行移动即可
  3. 如果实在没有可以利用的真实DOM,则才会去创建真实DOM

之所以采用同层比较而不采用跨层比较,是因为跨层比较会让对比的时间复杂度急剧增大,虽然这能够提高真实DOM节点的重利用率,但是降低了对比的效率,因此同层比较是折中方案

image.png

首先,patch对两棵虚拟DOM树的根节点进行对比:

若两个vnode相同,则将新vnode的elm属性指向旧vnode对应的真实DOM节点,并根据两个vnode的差异对真实DOM进行更新,更新完成后再进入对两个vnode的所有子vnode的对比流程

若两个vnode不相同,则直接根据新的虚拟DOM树生成完整的真实DOM树,并将旧虚拟DOM树中的所有真实DOM节点删除

image.png


若两棵虚拟DOM树的根节点相同,patch就会对这两个根节点的所有子节点进行比较,但在真正比较之前,会先为新节点列表和旧节点列表分别设置两个指针:

  • oldStart:指向旧树的根节点的第一个子节点
  • oldEnd:指向旧树的根节点的最后一个子节点
  • newStart:指向新树的根节点的第一个字节点
  • newEnd:指向新树的根节点的最后一个子节点

image.png

接下来,patch就会利用这四个指针来完成真正的比较操作,具体过程如下:

  1. 对比oldStart和newStart是否相同

    若相同,则将newStart的elm指向oldStart的elm,并将该真实DOM进行更新,更新完成后,再对这两个节点的所有子节点进行递归处理

    想象这两个相同的vnode是新虚拟DOM树和旧虚拟DOM树中的某棵子树的根节点,而当两个根节点相同时,就需要对两个根节点的所有子节点进行对比

    image.png

    紫色方块为真实DOM节点,实线表示其已经更新完毕

    当这两个vnode的所有子节点都处理完成后,oldStart和newStart均向后移动,让它们指向自己的下一个结点

    image.png


    若不同,则进行下一步

  2. 对比oldEnd和newEnd是否相同

    若相同,则将newEnd的elm指向oldEnd的elm,并将该真实DOM进行更新,更新完成后,再对这两个节点的所有子节点进行递归处理

    image.png

    当这两个vnode的所有子节点都处理完成后,oldEnd和newEnd均向前移动,让它们指向自己的上一个结点

    image.png


    若不同,则进行下一步

  3. 对比oldStart和newEnd是否相同

    若相同,则将newEnd的elm指向oldStart的elm,并将该真实DOM进行更新,更新完成后,将该真实DOM移动至oldEnd的真实DOM的后面一个位置,最后再对这两个节点的所有子节点进行递归处理

    image.png

    当这两个vnode的所有子节点都处理完成后,oldStart后移,newEnd前移

    image.png


    若不同,则进行下一步

  4. 对比oldEnd和newStart是否相同

    若相同,则将newStart的elm指向oldEnd的elm,并将该真实DOM进行更新,更新完成后,将该真实DOM移动至oldStart的真实DOM的前面一个位置,之后再对这两个节点的所有子节点进行递归处理

    image.png

    当这两个vnode的所有子节点都处理完成后,oldEnd前移,newStart后移

    image.png


    若不同,则进行下一步

  5. 对还未对比过的旧vnode列表进行遍历,搜索是否有与newStart相同的vnode

    若有与newStart相同的节点,则将newStart的elm指向该旧vnode的elm,并将该真实DOM进行更新,更新完成后,将该真实DOM移动至oldStart的真实DOM的前面一个位置,之后再对这两个节点的所有子节点进行递归处理

    image.png

    当这两个vnode的所有子节点都处理完成后,newStart后移,而oldStart和oldEnd之间的已经处理过的节点,之后会直接跳过,不会对其重复进行对比和处理

    image.png


    若没有找到与newStart相同的节点,则将newStart视为根节点,为以此为根节点的虚拟DOM树上的所有vnode创建对应的真实DOM节点,创建完成后,将newStart对应的真实DOM移动到oldStart的真实DOM的前面一个位置,然后newStart向后移动

    image.png

当 oldStart > oldEnd 或 newStart > newEnd 时,说明本层及本层结点的所有后代结点就对比结束了

若是 newStart > newEnd 但 oldStart <= oldEnd ,则还需要销毁从oldStart到oldEnd(包括这些节点的子节点)的所有真实DOM,即销毁旧的没有办法利用的真实DOM节点

若是 oldStart > oldEnd 但 newStart <= newEnd ,则还需要为从newStart到newEnd之间的所有vnode(包括这些节点的子节点)创建真实DOM,并挂载到合适的位置

直至将新旧虚拟DOM树中的全部节点进行完上述流程后,diff结束,页面也就更新完成了

diff在对比时不仅仅会遇到元素类型的vnode,还会遇到组件类型的vnode

当需要复用真实节点时,元素vnode复用的是真实DOM节点,组件vnode复用的是组件实例

当需要为vnode创建真实节点时,为元素vnode创建的是真实DOM节点,调用的方法是document.createElement(),而为组件vnode创建的是组件实例,调用的是new VueComponent()

在Vue2中,Vue和VueComponent并没有本质区别,可以将它们视为同一个构造函数

生命周期详解

image.png

当Vue实例(或组件实例)被创建时,其内部会进行如下流程:

  1. 进行一些初始化操作

    主要是给实例设置一些私有属性(以_开头的属性)

  2. 运行生命周期钩子函数beforeCreate

  3. 利用Observer对配置中的props、computed、data、provide、inject进行处理,将它们转换为响应式数据,之后再将这些响应式数据注入到实例中

    除上面这些配置外,还会将methods中的函数进行this绑定,然后直接注入到实例中

    伪代码如下:

    function VueComponent(options){
        // ...
        var data = options.data();			// props、computed等也类似
        
        Observer(data);						// 将数据转换响应式数据
        
        for(var key in data){				// 将响应式数据注入到实例中
            Object.defineProperty(this, key, {
                get(){
                    return data[key];
                },
                set(val){
                    data[key] = val;
                }
            });
        }
        
        // 对methods的处理比较简单,为其绑定this后直接添加到实例中即可
        var methods = options.methods;
        
        for(var fnName in methods){
            var handler = methods[fnName];
            this[fnName] = handler.bind(this);
        }
        //...
    }
    
  4. 运行生命周期钩子函数created

  5. 编译模版生成render函数

    如果配置中有render,则使用配置中的render

    如果没有,则使用运行时编译器,将模版编译为render函数

    对于开启了模版预编译的Vue工程,其在打包时就已经将render生成好了,因此其内部在创建组件实例时此阶段什么也不会做

  6. 运行生命周期钩子函数beforeMount

  7. 创建一个Watcher并执行,执行时会向该Watcher中传入updateComponent函数

    updateComponent函数内部会执行render函数,并会把render所生成的虚拟DOM树传递给其内部的另一个函数_update并让其执行

    在执行render函数的过程中,其使用到的响应式数据就会记录此时的Watcher,将来这些数据变化时就会让Watcher重新执行

    在执行_update函数的过程中,会触发patch函数的执行,但由于此时旧虚拟DOM树为null,因此会直接为render所生成的虚拟DOM树中的节点创建出对应的真实DOM节点,并将vnode和对应的真实DOM通过elm属性关联起来

    在构建真实DOM树的期间,若遇到的是vnode是子组件vnode,则会递归进入到子组件的实例化流程

    当子组件实例创建完成后,会把其关联到子组件vnode的componentInstance属性上

    真实DOM生成完成后,将其挂载到页面的相应位置

  8. 运行生命周期钩子函数mounted

当组件模版中使用的响应式数据发生变化时:

  1. 依赖该数据的Watcher全部重新运行,其中就包含组件的updateComponent函数对应的Watcher

  2. Watcher被调度器通过nextTick工具方法放入到微队列中等待执行

    对于updateComponent函数的Watcher,其在执行时会进行下面的操作:

    1. 运行组件的生命周期钩子函数beforeUpdate

    2. 调用updateComponent函数

      函数内部又调用render函数生成新的虚拟DOM树,之后再执行_update函数,于是又触发了patch函数的执行,patch就会对新旧两棵树进行对比

      在对比时,如果对比的是元素vnode,则会导致真实DOM节点的创建、删除、移动、更新等操作,如果对比的是组件vnode,则会导致组件的创建、销毁、移动、更新

      当需要创建新组件时,则递归进入该组件的实例化流程

      当需要删除旧组件时,会调用旧组件实例中的$destroy方法,该方法首先会触发生命周期钩子函数beforeDestroy,然后再递归调用旧组件中的子组件的$destroy方法,最后再触发生命周期钩子函数destroyed

      当变化的响应式数据恰好是传递给子组件的属性,并且子组件的render函数中也用到了该属性,则又会递归进入子组件的重渲染流程

    3. 运行组件的生命周期钩子函数updated

注意:子组件中的重渲染不会导致父组件的重渲染(子组件的数据变化不会影响到父组件),但反过来,父组件的重渲染可能会导致子组件模版的重渲染

computed与methods

vue对methods的处理比较简单,它会对遍历methods中的每个函数,将函数使用bind绑定this为当前的组件实例,然后将绑定this后的函数注入到组件实例中

而vue对computed的处理会复杂一些

当组件实例触发生命周期函数beforeCreate后,它会做一系列的事情,其中就包含了对computed的处理

vue会遍历computed配置中的所有属性,为每一个属性创建一个Watcher对象,创建时会将开发者为计算属性的设置的get函数的引用保存到Watcher之中

为计算属性创建完Watcher后,Vue就会将该计算属性注入到组件实例中

计算属性的watcher通过组件实例的_computedWatchers属性可以获取到

在讲解后面的知识前,需要先理清楚一个的概念:开发者在组件配置中为计算属性书写的get和set,其实并不是真正的getter和setter

export default {
    computed: {
        test: {
            get(){ },					// 并非真正的getter
            set(newVal){ }				// 并非真正的setter
        },
        fullname(){ }					// 并非真正的getter
    }
}

当Vue为计算属性创建完Watcher后,会将计算属性使用Object.defineProperty方法注入到组件实例之中

之后,通过组件实例访问这些计算属性时,会触发计算属性真正的getter或setter,而getter或setter中就会调用开发者为计算属性设置的get或set方法

for(const key in computed){
       const { get, set } = computed[key];
       const watcher = new Watcher(get);
       Object.defineProperty(this, key, {
           get(){						// 真正的getter
               get();
           },
           set(newVal){				// 真正的setter
               set(newVal);
           }
       });
}

不同于updateComponent函数的Watcher在创建完成后会立即执行,计算属性的Watcher创建完成后并不会立即执行,因为Vue考虑只有该计算属性在模版中使用到时,其Watcher需要被执行

更准确的说法是:只有在render函数中使用到计算属性时,计算属性的watcher才会被执行

Vue通过设置Watcher的lazy属性来达到控制Watcher是否立即执行的目的,当lazy为true时,Watcher就不会立即执行

updateComponent函数的Watcher的lazy属性为false,因此它会在创建时立即执行,而计算属性的Watcher的lazy属性为true

当Watcher的lazy属性为true时,Watcher内部还会设置两个属性以实现缓存的目的,分别是value和dirty

对于计算属性,其Watcher的value属性中保存的是开发者为计算属性设置的get函数的运行结果,由于Watcher并不会立即执行(导致get不会被立即执行),因此其初始值为undefined,dirty属性用于表示当前的value属性是否已经过时(是否是脏值),受lazy为true的影响,其初始值为true

lazy属性为false时,value和dirty属性无意义

当在模版中通过组件实例读取计算属性时,vue首先会检测该计算属性对应的Watcher的dirty是否为true

如果是,则说明此时Watcher的value是不准确的,因此需要重新执行Watcher,Watcher运行过程中,会调用之前保存的计算属性的get函数,get在运行时,内部使用到的响应式数据就可以将此时的Watcher记录下来,get运行结束后,Watcher会将get函数的返回值保存到自己的value属性中,并将自己的dirty属性设置为false,最后将value属性值返回出去

如果不是,则直接将此时的value返回,而不会重新执行Watcher

对于开发者为计算属性设置的set函数,对计算属性重新赋值时,set函数就会运行,之后该怎么处理就怎么处理

扩展

当计算属性的get函数所依赖的响应式数据被改变时,计算属性的get函数并不会直接被执行,而是采用了另一种更为巧妙的方式来执行get

这里的get都是开发者在组件配置中为计算属性设置的get

当计算属性的get函数被调用(包括首次)时,get中所使用的响应式数据不仅仅会记录该计算属性的Watcher,还会记录该计算属性所在组件的updateComponent函数的Watcher

当计算属性的get函数中使用到的响应式数据发生变化时,响应式数据的Dep首先会通知该计算属性的Watcher让其执行,再通知updateComponent函数的Watcher执行

计算属性的Watcher在执行时,仅仅只会修改自己的dirty为true,其它什么也不会做

之后当updateComponent函数的Watcher执行时,render函数又使用到了该计算属性,由于计算属性的Watcher的dirty已经变为true,因此又会导致该计算属性的Watcher被执行,于是Watcher就会重新执行保存的get函数,并返回最新的计算结果

计算属性的大致代码如下所示:

function Vue(options){
    function ComputedWatcher(get){
        this.get = get;
        this.dirty = true;
        this.value = undefined;
    }
    
    ComputedWatcher.prototype.call = function(){
        if(!this.dirty){
            this.dirty = true;
        }else{
            this.dirty = false;
            this.value = this.get();
            return this.value;
        }
    }
    
    const computed = options.computed;
    for(const key in computed){
        const { get, set } = computed[key];
        const watcher = new ComputedWatcher(get);
        Object.defineProperty(this, key, {
            get(){
                if(watcher.dirty){
                    return watcher.call();
                }else{
                    return watcher.value;
                }
            },
            set(newVal){
                set(newVal);
            }
        });
    }
}

探究:为什么对于连续的计算属性读取操作,若在中间将计算属性的依赖数据改变,计算属性的Watche就变成同步执行的了?

export default {
    data(){
        return { num: 1 }
    },
    computed: {
        doubleNum(){
	    	console.log("computed");
    		return this.num * 2;
        }
    },
    mounted(){
        Promise.resolve().then(()=>{
            console.log("promise.then");
        });
    	console.log(this.doubleNum);
        this.num++;
        console.log(this.doubleNum);
	}
}

/** 
	"computed"
	2
	"computed"
	4
	"promise.then"
*/
export default {
    data(){
        return { num: 1 }
    },
    computed: {
        doubleNum(){
	    	console.log("computed");
    		return this.num * 2;
        }
    },
    render(h){
        // 不要书写template
        console.log("render");
        return h("div", `${this.doubleNum}`);
    },
    mounted(){
        Promise.resolve().then(()=>{
            console.log("promise.then");
        });
        this.num++;
	}
}

/** 
	"render"
	"computed"
	"promise.then"
	"render"
	"computed"
*/

filter过滤器

组件配置中包含一个filter配置,在该配置中可以添加一些过滤器函数

在模版中,可以在{{}}和v-bind绑定的属性中使用过滤器函数,使用的方式如下:

<template>
<div class="container">
    <div :id="'id' | formatStr">{{"content" | formatStr}}</div>
</div>
</template>

<script>
export default {
    filters: {
        formatStr(value){
            // ...
        }
    }
}
</script>

过滤器存在的目的其实是为了简化代码,例如上面的代码本质上是:

<template>
<div class="container">
    <div :id="formatStr('id')">{{formatStr("content")}}</div>
</div>
</template>

<script>
export default {
    filters: {
        formatStr(value){
            // ...
        }
    }
}
</script>

允许向过滤器中传入其他参数:

{{"content" | formatStr(1, 'a')}}

等价于:

{{formatStr("content", 1, 'a')}}

在组件的filters配置中加入的过滤器属于局部定义的过滤器,也允许全局定义一些过滤器函数:

// main.js
import Vue from "vue"

Vue.filter("formatStr", function(){
    // ...
});

new Vue({
    // ...
}).$mount("#app");

过滤器串连

允许对一个数据使用对个过滤器进行处理:

{{"content" | formatStr1 | formatStr2}}

等价于:

{{formatStr2(formatStr1("content"))}}

作用域插槽

子组件中可以在slot组件上使用v-bind指令绑定数据,而父组件中可以使用v-slot指令来接收子组件中对应的slot上绑定的数据,实现在父组件中使用子组件内部数据的功能

例如:

子组件:

<template>
<div class="container">
    <slot name="default" v-bind="obj"></slot>
</div>
</template>

<script>
export default {
    data(){
		return {
            obj: {...}
        }
    }
}
</script>

父组件:

<template>
<div class="container">
    <Child>
    	<template #default="obj">
			{{obj}}
		</template>
		<!-- obj = {...} -->
    </Child>
</div>
</template>

<script>
import Child from "./Child.vue";
    
export default {
    components: {
        Child,
    },
    data(){
		return {}
    }
}
</script>

Vue2规定子组件通过插槽传递给父组件的数据只能是对象

如果直接使用v-bind传递数据,则传递的数据必须得对象,如:v-bind="obj"

可以通过在v-bind后面书写参数来向父组件传递多个数据,此时,这些数据会成为父组件所接收到的对象的属性,因此这种方式允许在一个v-bind:xxx中传递原始值

例如:

子组件:

<template>
<div class="container">
    <slot name="default" :a="a" :b="b"></slot>
</div>
</template>

<script>
export default {
    data(){
		return {
            a: 1,
            b: 2
        }
    }
}
</script>

父组件:

<template>
<div class="container">
    <Child>
    	<template #default="obj">
			{{ obj }}
		</template>
		<!-- obj = { a: 1, b: 2 } -->
    </Child>
</div>
</template>

<script>
import Child from "./Child.vue";
    
export default {
    components: {
        Child,
    }
}
</script>

可以直接在子组件标签上通过v-slot获取子组件中某一个具名的作用域插槽传递过来的数据

例如:

<template>
<div class="container">
    <Child #default="obj">
        {{ obj }}
    </Child>
</div>
</template>

<script>
import Child from "./Child.vue";
    
export default {
    components: {
        Child,
    }
}
</script>

父组件可以在接收子组件传递过来的数据的位置直接对数据进行解构

例如:

<template>
<div class="container">
    <Child #default="{num}">		<!-- {}不是对象,而是解构操作符 -->
        {{num}}
    </Child>
    <!-- num = 1 -->
</div>
</template>

<script>
import Child from "./Child.vue";
    
export default {
    components: { Child },
    data: ()=>({ })
}
</script>

$slots和$scopedSlots

可以通过组件实例的$slots获取到所有的父组件传递过来给普通插槽对应的vnode

可以通过$scopedSlots获取到组件中所有插槽对应的函数,调用这些函数即可获取到父组件传递给相应插槽对应的vnode

普通插槽是没有数据传递的特殊的作用域插槽

过渡和动画

内置组件Transition

Transition组件会监控插槽中唯一根元素的出现和消失,并在其对应的真实DOM元素上应用一些类名

元素的出现可以是元素的v-if从false变为了true,也可以是v-show从false变为了true

元素的消失可以是元素的v-if从true变为了false,也可以是v-show从true变为了false

过渡时间线

位于transition组件中的唯一根DOM元素,其在出现或消失时会被自动应用类名,在这些类名中书写过渡样式就可以轻松实现动画效果

当元素出现时,Vue会按照下面的时间线为它的真实DOM添加类样式:

image.png

当元素的v-if从false变为了true并且对应的真实DOM元素被插入到页面之中,或者元素的v-show从false变为了true时,Vue会为其真实DOM元素添加类名enter和enter-active

之后真实DOM元素会进行渲染,当第一帧渲染结束后,Vue会将DOM的enter类移除,并添加enter-to类

之后在DOM存在过渡效果(或动画效果)的情况下,Vue会监听transitionEnd事件(或animationEnd事件),在事件触发时将enter-to和enter-active类进行移除

如果DOM不存在过渡效果,则第一帧渲染结束后就会将这些类名全部移除

当元素消失时,Vue会按照下面的时间线为它的真实DOM添加类样式:

image.png

当元素的v-if从true变为了false,或者元素的v-show从true变为了false时,Vue不会立即将真实DOM给remove或display设置为none,而是会为其真实DOM元素添加类名leave和leave-active

之后真实DOM元素仍然还会被渲染,当DOM再次渲染完一帧后,Vue会将DOM的leave类移除,并添加leave-to类

之后在DOM存在过渡效果(或动画效果)的情况下,Vue会监听transitionEnd事件(或animationEnd事件),在事件触发时将leave-to和leave-active类进行移除

如果DOM不存在过渡效果,则在渲染结束后就会立即将这些类名移除,然后将真实DOM进行remove或者将display设置为none

为了避免Vue指定的这些类名与开发者定义的类名发生冲突,Vue会在这些添加的类名上加入前缀,上面的类名只是完整类名的后缀部分,前缀的命名规则:

  1. 如果transition上没有指定name属性,则类名前缀为v-

    <template>
    <transition>
        <Comp />
    </transition>
    </template>
    
    <style>
        .v-enter{ }
        .v-enter-to{ }
    	.v-enter-active{ }
    </style>
    
  2. 如果transition上指定了name,则类名前缀为name-

    <template>
    <transition name="toggle">
        <Comp />
    </transition>
    </template>
    
    <style>
        .toggle-enter{ }
        .toggle-enter-to{ }
    	.toggle-enter-active{ }
    </style>
    

除了可以指定类名的前缀,Vue也允许指定完整的类名:自定义过渡类名

过渡组

Transition用于监控其内部的唯一的根元素的出现和消失,并为其附加类名

如果要监控一个元素列表,就需要使用TransitionGroup

该组件会对列表中新增的元素应用进入效果,从列表中移除的元素应用消失效果,还会对相对位置发生变化的元素应用move样式

被移动的元素之所以能够实现过渡效果,是因为TransitionGroup内部使用了Flip过渡方案

TransitionGroup与Transition的另一区别是,Transition组件本身并不会在页面中生成真实DOM元素,而TransitionGroup会,默认情况下,Vue会将TransitionGroup渲染为span元素,可以在TransitionGroup组件上设置tag来指定其最终渲染出来的元素类型

<template>
<div>
    <TransitionGroup tag="ul">
    	<li>1</li>
    	<li>2</li>
    	<li>3</li>
    </TransitionGroup>
</div>
</template>

路由切换动画

在Vue2中直接将让transition组件包裹住RouterView组件即可

<transition>
    <RouterView />
</transition>

优化

使用tree shaking

遗憾的是,Vue2即其相关生态大部分都不支持tree shaking,因为Vue2和大部分Vue的生态工具使用的都是默认导出来导出一个聚合的对象

使用CDN

CDN全称为Content Delivery Network,内容分发网络

它的基本原理是:在世界各地架设多台服务器,这些服务器会定期从主服务器中拿取资源并保存至本地,这样处在不同地域的用户能够通过访问最近的服务器来获得到资源,从而减少了网络延迟和提高了内容的传输速度

image.png

通常会使用CDN来获取公共库资源,而非公共资源通过源服务器进行获取

由于CDN服务器是一个单独的服务器(即独立的域名),因此浏览器可以为该服务器建立单独的TCP连接,使得多个资源能够并行传输,此外,CDN服务器通常使用的是HTTP 2.0并遵循HTTP缓存协议,这也能提高资源的传输性能

image.png

CDN不仅能够加快网站的加载速度,也能够减轻服务器的压力

使用CDN后,就需要告诉webpack不要对公共库进行打包,通知的方式如下:

// vue.config.js

module.exports = {
    configureWebpack: {
        externals: {
            // 该配置应该只在开发环境下存在,因为生产环境下可以直接使用nodemodules下的库代码
            vue: "Vue",
            vuex: "Vuex",
            "vue-router": "VueRouter",
        }
    },
};

然后,在html页面中手动加入cdn链接

<body>
    <div id="app"></div>
    <!-- 只需要在生产环境下使用CDN -->
    <% if(NODE_ENV === "production") { %>
    <script src="https://cdn.com/vue.js"></script>
    <script src="https://cdn.com/vuex.js"></script>
    <script src="https://cdn.com/vue-router.js"></script>
    <% } %>
    <!-- built files will be auto injected -->
</body>

使用这种传统的引入方式引入Vue工具(vuexvue-router),Vue工具会自动成为Vue的插件,而无需开发者手动应用插件(也不允许手动引用)

这种传统的引入方式引入的Vue工具库还会自动在全局暴露一些变量,例如:vue-router会向全局暴露一个变量VueRouter、vuex会向全局暴露一个变量Vuex,可以利用这些全局变量在源代码中判断是否需要对插件进行应用

// main.js

import Vue from "vue";
import Vuex from "vuex";
import VueRouter from "vue-router";

if(!window.Vuex){
    Vue.use(Vuex);
}

if(!window.VueRouter){
    Vue.use(VueRouter);
}

启用现代模式

为了兼容各种浏览器,通常会让vue-cli使用babel对js代码进行降级,而对js代码进行降级后,会导致代码量增加,从而导致打包结果体积增大

对于使用现代浏览器的用户,他们无需获取和执行降级过后的js代码,而是只需获取未降级的js代码文件并直接对其执行

通过vue-cli提供的命令就可以轻松实现这一目的:

vue-cli-service build --modern

运行该命令后,vue-cli会让webpack在一个打包结果中分别输出未降级以及降过级的共两份js代码文件,

并对html页面进行如下处理:

<script type="module" src="/js/未降级的js文件.js"></script>
<script src="/js/降级处理过后的js文件.js" nomodule></script>

对于现代浏览器,它们可以解析识别具有type="module"属性的script,从而加载未降级的js,并且它们会忽略带有nomodule标记的script,因此不会加载和执行降过级的js代码文件

对于旧版本的浏览器,刚好相反,它们识别不了带有type="module"属性的script,也不会加载其对应的js文件,同时它们会把nomodule当做自定义属性,因此会加载其script对应的js文件

路由懒加载

在不使用路由懒加载的情况下,vue-cli会利用webpacksrc目录中的所有代码打包成一个bundle

这样就导致在初次访问网站时,需要加载所有页面的js代码,降低了首屏响应的速度

可以利用webpack对的动态import来达到把不同页面的代码输出到不同文件中,这样一来,当初次访问网站时,只需要加载网站首页的js文件即可,加快了首屏响应的速度

// routes.js
export default [
    {
        name: "Home",
        path: "/",
        component: () => import(/* webpackChunkName: "home" */ "@/views/Home"),
    },
    {
        name: "About",
        path: "/about",
        component: () => import(/* webpackChunkName: "about" */"@/views/About"),
    }
];

其实,vue-cli对于还未访问到的页面的js代码文件,也会在浏览器空闲时进行下载(原理是使用prefetch),之后当页面进行切换时就可以直接获取到下载好的js文件,因此非首屏的响应速度也不会变慢

使用key

对于通过循环生成的列表,应给每个列表项一个稳定且唯一的key

因为当列表发生变化时,唯一且稳定的key能够让diff在对比时尽量少的删除和创建真实DOM

使用冻结的对象

使用Object.freeze()可以冻结对象或数组,冻结过后,对象和数组将不能增加或删除属性或元素,也不能对已有属性或元素重新赋值

使用Object.isFrozen()来判断对象是否是被冻结的

对于冻结的对象,Vue不会对它以及它的所有后代属性进行响应式转换

使用函数式组件

Vue并不会为函数式组件创建对应的组件实例,而是简单地将组件中的内容渲染出来即可

函数式组件由于没有对应的组件实例,因此该组件没有生命周期函数,也不存在响应式数据,所以函数式组件的渲染效率要比普通组件高

函数式组件可以接收来自父组件传递过来的prop,因此组件配置中可以有props配置,而在组件模版中使用props内的数据时,需要加上props.(因为函数式组件没有组件实例)

当组件配置中的functional配置为true并在template上加上functional标记,该组件就变为了函数式组件

<template functional>
<div>{{props.content}}</div>
</template>

<script>
export default {
    functional: true,
    props: ["content"]
}
</script>

使用计算属性

当模版中多次使用到某个数据,并且该数据是通过计算得到的,则应该尽量使用计算属性,而不是方法,因为计算属性有缓存,可以避免不必要的重复计算

使用非实时绑定的表单数据

使用v-model为一个表单绑定数据后,由于绑定的表单数据也是响应式数据,如果用户快速地对表单数据进行改变,这将会导致组件的render函数频繁被触发,因此会带来性能上的开销

例如使用v-model为类型为text的input元素绑定了一段文本数据,当用户快速地在文本框中输入字符时,每输入一个字符,表单数据就会变化一次,也就将导致render被执行一次

可以通过在会频繁更新数据的事件上使用事件修饰符lazy或不使用v-model来解决该问题

但随之而来的另一个问题是页面实时性的下降,因为这可能会导致页面中显示的内容与实际的表单元素的值不一致

保持对象引用稳定

在绝大部分情况下,vue触发rerender的时机是其依赖的数据发生变化

若数据没有发生变化,哪怕给数据重新赋值了,vue也是不会做出任何处理的

下面是vue判断数据没有变化的源码

// value 为旧值, newVal 为新值
if (newVal === value || (newVal !== newVal && value !== value)) {
    return;
}

因此,如果可以,只要能保证组件的依赖数据不发生变化,组件就不会重新渲染

对于原始数据类型,保持其值不变即可

对于对象类型,保持其引用不变即可

结合评论列表场景进行分析:

当用户向评论列表中添加评论时,应该单独将该用户添加的评论加入到之前获取到的评论数组中

而不应该在用户添加评论时将所有评论重新获取一遍,然后覆盖原来获取的评论数组

因为单独将新增的评论项加入到已获取到的评论数组中能够保证原来已渲染出来的评论内容不发生rerender,而重新获取完整的评论数组并将原来已经获取到的评论数组覆盖则会导致所有内容都发生rerender

细分组件

当一个组件所依赖的响应式数据发生变化时,组件的render函数就需要被重新调用,而render函数的调用一定会将组件中所有的vnode都重新生成一次

此时,如果将组件中的某一区域的元素封装为一个子组件,之后当父组件所依赖的数据发生变化时,只要该变化的数据不是传递给封装的子组件的(或者传递给了子组件但子组件不使用),则针对这片区域,父组件只需要生成一个子组件vnode即可,而不需要生成一堆vnode(即便这些vnode中并没有使用到变化的响应式数据)

v-show和v-if的合理选择

对于会频繁切换显示状态的元素,使用v-show可以保证虚拟dom树的稳定,避免频繁的新增和删除元素,特别是对于那些内部包含大量dom元素的节点

优化首屏响应

首页白屏的持续时间主要受两个方面的影响:

  1. 传输的内容体积过大

    大型包需要消耗大量的传输时间,导致JS文件在传输完成之前页面中只有一个<div>元素,页面此时就没有什么可以显示的内容

    这就需要通过减少打包体积或进行页面分包来减少首页白屏的时间

    也可以在<div id="app">元素中加入一些loading动效,提示用户正在加载中,这能够起到一定的缓解作用(当JS加载完成后,div的outerHTML就会被覆盖,那时loading动效也将自动消失)

  2. 需要渲染的内容太多

    JS传输完成后,浏览器需要执行JS构造页面内容。如果在一开始就需要渲染大量组件,不仅JS执行的时间很长,执行JS完成后浏览器要渲染内容也太多,从而导致页面白屏

    处理该问题的一种方法是使用延迟装载

    延迟装载只是一个思路,能够实现延迟装载的方法有很多,但总体流程都是利用requestAnimationFrame方法来分批渲染内容,当页面帧数达到某个值时才会去渲染某些组件

    <template>
      <ul>
        <li v-for="n in maxFrameCount" v-if="defer(n)">
        	...
        </li>
      </ul>
    </div>
    </template>
    
    <script>
    export default {
      data(){
        return {
          frameCount: 0,
          maxFrameCount: 21
        }
      },
      mounted(){
        this.changeOnce();
      },
      methods: {
        changeOnce() {
          requestAnimationFrame(()=>{
            this.frameCount++;
            if(this.frameCount < this.maxFrameCount){
              this.changeOnce();
            }
          });
        },
        defer(n){
          return this.frameCount >= n;
        }
      }  
    };
    </script>
    

其它优化手段

  • keep-alive
  • 虚拟列表

keep-alive

keep-alive是Vue中的一个内置组件,位于keep-alive内部的组件,其组件实例会被keep-alive缓存起来

keep-alive不会对其内部的普通元素节点进行处理,只会处理组件节点

组件实例中包含$el属性,其属性值就是组件的真实DOM结构,因此keep-alive也将真实DOM保存了下来

keep-alive常用在组件切换的场景中,被keep-alive缓存起来的组件,其在被切换掉后组件实例并不会被销毁,而当其再次被切换回来后,也并不会重新创建新的组件实例,而是使用原来缓存下来的组件实例

由于切回时使用的是原来缓存下来的实例,因此之前的组件状态在此时也会得到恢复

状态包括各种数据、组件中的真实DOM、组件中的后代组件

<template>
<div class="container">
    <keep-alive>
    	<Comp1 v-if="showIndex=0" />
        <Comp2 v-else-if="showIndex=1"/>
        <Comp3 v-else/>
    </keep-alive>
    <button @click="showIndex=(showIndex+1)%3">Click</button>
</div>
</template>

<script>
import Comp1 from "./Comp1.vue";
import Comp2 from "./Comp2.vue";
import Comp3 from "./Comp3.vue";

export default {
    components: {
        Comp1,
        Comp2,
        Comp3
    },
    data(){
    	return {
            showIndex: 1
        }
	}
}
</script>

由于已经缓存下来的组件,其在切回时使用的是缓存中的实例,因此该组件以及该组件内部的所有后代组件就不会触发created等钩子函数

切换走时也不会出发destroy钩子函数

keep-alive会为缓存下来的组件以及该组件的所有后代组件设置了两个新的钩子函数,分别是activated和deactivated

activated在组件被激活(首次被缓存,以及被切换回)时触发,deactivated在组件失活(被移出缓存,以及被切换走)时触发

可以给keep-alive设置include和exclude属性,keep-alive可以根据这两个属性判断应该缓存哪些组件的实例

include和exclude属性的值可以是数组,字符串或正则表达式,其中需要填入组件的name(组件配置中的name)

如果keey-alive即包含include又包含exclude,则组件的name只有包含在include中且不包含在exclude中时,它才会被keep-alive缓存下来

还可以给keep-alive设置max属性,max属性用于设置keep-alive缓存组件实例的最大数量,当缓存的实例的数量超过该值时,Vue会将最久没有被使用的组件实例移除缓存(移除缓存后,该组件实例就会被销毁,即运行生命周期函数destroyed)

<template>
<div class="container">
    <keep-alive :include="['Comp1', 'Comp2', 'Comp3']" :max="2">
    	<component :is="comps[showIndex]"></component>
    </keep-alive>
    <button @click="showIndex=(showIndex+1)%3">Click</button>
</div>
</template>

<script>
import Comp1 from "./Comp1.vue";
import Comp2 from "./Comp2.vue";
import Comp3 from "./Comp3.vue";
export default {
    data: ()=>({
		comps: [Comp1, Comp2, Comp3],
        showIndex: 1
    })
}
</script>

Vue2中keep-alive可以直接包裹router-view组件,达到缓存页面组件的目的

<KeepAlive>
	<RouterView />
</KeepAlive>

原理

在keep-alive的具体实现上,它会维护一个keys数组和一个cache对象

// keep-alive组件的生命周期函数
created(){
    this.cache = Object.create(null);
    this.keys = [];
}

keys数组用于记录所当前缓存下来的所有组件的key值,若缓存的组件没有指定key值,则keep-alive会为其生成一个唯一的key值

key数组存在的目的是为了方便进行缓存移除,keep-alive会将最近使用过的组件的key移动到keys数组的末尾,这样需要移除的组件,也就是最久没有被使用过的组件一定就是key位于keys数组头部的组件

cache对象用于缓存组件,它会以组件的key为键,组件的vnode为值,来缓存组件的虚拟DOM

在keep-alive组件的渲染函数中,会根据cache对象中是否存在组件对应的key来判断即将渲染的组件是否存在于缓存中,如果有就从缓存中读取组件实例,并移动key到keys数组的末尾;如果没有就直接将新的vnode给缓存下来(此时vnode中还没有组件实例,组件实例会在之后的patch函数被调用时被加入到vnode中,可能是新建的,也可能是复用旧vnode的)

当缓存数量超过max时,keep-alive会找到key数组中的第一个元素,调用该元素在cache对象中所记录的组件实例的$destroy方法

render(){
    // 获取默认插槽,即我们传递给keep-alive标签中的内容(keep-alive只认默认插槽)
    const slot = this.$slots.default;
    // 得到插槽中的第一个组件的vnode,因此keep-alive不会理会元素vnode
    const vnode = getFirstComponentChild(slot);
    // 获取组件名字
    const name = getComponentName(vnode.componentOptions);
    // 进行一些其他操作,例如判断name是否在include或exclude中
    ...
    // 获取当前的缓存对象和key数组
    const { cache, keys } = this;
    // 获取组件的key值,若没有,会按照一定的规则规则为其生成key
    const key = ...;
    if (cache[key]) {
        // 如果缓存中包含了组件对应的vnode,就使用缓存结果中的组件实例
        vnode.componentInstance = cache[key].componentInstance;
        // 先删除key
        remove(keys, key);
        // 再将key加入到数组末尾,这样是为了保证最近使用的组件在数组中靠后,反之靠前
        keys.push(key); 
    } else {
        // 缓存中无该组件的vnode,则对其进行缓存
        cache[key] = vnode;
        keys.push(key);
        if (this.max && keys.length > parseInt(this.max)) {
            // 超过最大缓存数量,移除第一个key对应的缓存
            pruneCacheEntry(cache, keys[0], keys, this._vnode);
        }
    }
    return vnode;
}

打包模式与环境变量

源代码中的打包环境区分

vue-cli在对工程进行打包时,会对开发者编写的源代码(即最终要被执行的代码)中的表达式process.env.XXX进行替换

例如,vue-cli会对表达式process.env.NODE_ENV直接进行替换:当使用npm run serve对工程进行打包时,vue-cli会将源代码中的process.env.NODE_ENV替换为"development";当使用npm run build对工程进行打包时,vue-cli会将源代码中的process.env.NODE_ENV替换为"production"

开发者可以依次来区分当前的打包环境,并根据打包环境进行不同的处理

除了NODE_ENV属性会被vue-cli直接进行替换外,vue-cli对于其它的属性需要依次按照下面的规则进行替换:

  1. 属性需要满足以VUE_APP_开头,即process.env.VUE_APP_XXX,否则不进行替换

  2. 若满足上面要求,则:

    1. 读取主机中系统环境变量中相应的内容,并将该表达式替换为该内容

    2. 读取工程根目录中.env文件中相应的内容,并将该表达式替换为该内容

      vue-cli除了会读取.env文件,还支持读取.env.development和.env.production文件,这两个文件中的内容也可以作为替换的内容

      当使用npm run serve对工程进行打包时,vue-cli会读取.env以及.env.development中的内容;当使用npm run build对工程进行打包时,vue-cli会读取.env以及.env.production中的内容

      因此使用这两个文件也可以很方便地在源代码中区分打包环境

    3. 替换为undefined

还有一个特殊的表达式是process.env.BASE_URL,其会被vue-cli替换为工程的vue.config.js配置文件中的publicPath配置项的内容

注意:这些表达式会在打包结果被替换,之后打包结果中便不会出现这些表达式,而是会出现相应的替换内容

配置文件中的打包环境区分

当使用vue-cli打包工程时,如果是使用npm run serve对工程进行打包,vue-cli会临时设置一个系统环境变量NODE_ENV,并令其值为"development"

当使用npm run build对工程进行打包时,vue-cli会临时设置一个系统环境变量NODE_ENV,并令其值为"production"

开发者可以依此在不同的打包模式下分别使用不同的配置

更多配置

本节所讲的是vue.config.js中的配置

  • devServer

    完全等同于webpack中的devServer配置

  • publicPath

    完全等同于webpack中的publicPath配置,默认值为"/"

  • outputDir

    设置输出目录,默认为"dist"

  • runtimeCompiler

    是否开启运行时编译,值类型为Boolean

  • transpileDependencies

    设置babel还需要对哪些包进行降级处理,值类型为Array

    由于node_modules目录中的包基本上都是已经降过级的,因此babel默认是不会对node_modules中的包进行降级处理,可以通过该配置进行设置

  • configureWebpack

    为webpack添加配置,值类型为对象,对象中需要加入的就是webpack的配置

  • css.requireModuleExtension

    默认情况下,vue-cli只视.module.xxx的样式文件为CSS Module模块

    将该配置设置为true后,无论哪种后缀的样式文件,均会被视为CSS Module模块

嵌套路由

vue-router提供了嵌套路由以实现组件的嵌套展示

嵌套的路由匹配规则需要设置在父级匹配规则的children属性中:

new VueRouter({
    mode: "history",
    routes: [
        {
            path: '/',		// 匹配"/"
            component: () => import('../views/Home.vue'),
        },
        {
            path: '/about',		// 匹配"/about"
            component: () => import('../views/About.vue'),
        },
        {
            path: '/user',		// 匹配"/user"
            component: () => import('../views/user/Layout.vue'),
            children: [
                {
                    path: '',		// 匹配"/user"
                    component: () => import('../views/user/Profile.vue'),
                },
                {
                    path: 'address',		// 匹配"/user/address"
                    component: () => import('../views/user/Address.vue'),
                },
                {
                    path: 'security',		// 匹配"/user/security"
                    component: () => import('../views/user/Security.vue'),
                }
            ],
        },
    ]
});

在App.vue和Layout.vue中都会使用到RouterView占位组件

当页面url的path变为"/user"时,App.vue中的RouterView会被替换为Layout.vue的内容,而Layout.vue中的RouterView有会根据path的剩余部分在user的嵌套路由中进行匹配,此时将会匹配到Profile.vue,于是将Layout.vue中的RouterView替换为Profile.vue的内容

导航守卫

全局前置守卫

var router = new VueRouter({});

router.beforeEach((to, from, next) => {
	// ...
});
  • to

    即将进入的组件的路由配置

  • from

    即将离开的组件的路由配置

  • next

    只有调用了next,路由才会真正地发生切换

    next可以直接被调用,此时进入的路由就是to对应的路由

    next也可以像$route.push一样进行使用,例如:next("/")next({name: "home"}),此时进入的路由取决于传递给next的参数

    还可以在调用next时传入false,这等效于不调用next

    注意:无论哪种情况,应该保证next最多只会被调用一次

在路由即将发生切换之前,会触发该守卫

对于页面首次渲染,也可以算作是一种切换,即从无切换到了首页

全局解析守卫

var router = new VueRouter({});

router.beforeResolve((to, from, next) => {
	// ...
});

在路由即将发生切换之前,会触发该守卫,但beforeResolve的触发时间发生在beforeEach之后

全局后置守卫

var router = new VueRouter({});

router.afterEach((to, from) => {
	// ...
});

该守卫被触发时,说明已经切换到目标路由中了

路由独享的守卫

可以直接在路由配置中设置导航守卫:

var router = new VueRouter({
    routes: [
        {
            path: '/foo',
            component: Foo,
            beforeEnter: (to, from, next) => {
                // ...
            }
        }
    ]
})

只有即将进入到该路由中时,该导航守卫才会被触发

组件内的守卫

在组件配置对象中可以添加下面三种导航守卫:

  1. beforeRouteEnter

    函数本身无法访问到当前组件的组件实例this

    该导航守卫的next参数中可以接收一个回调函数,该回调函数会在组件实例被创建后执行,执行时组件的实例还会作为回调函数的参数传入,因此可以在回调函数中访问到组件的实例

  2. beforeRouteUpdate

    当url发生改变,但该组件被复用时调用

    对于在路由配置中包含一些动态参数的路径,例如:/foo/:id,如果url中只有这个动态参数发生了变化(例如从/foo/1变化为/foo/2),则是会复用该组件的(复用是指重用原组件,而不是先销毁再重新创建),此时就会调用组件的此导航守卫

    除了url中的动态参数部分发生变化会导致组件复用外,query的变化也同样会导致组件被复用

    可以访问到当前组件的组件实例this

  3. beforeRouteLeave

    可以访问到当前组件的组件实例this

<script>
export default {
	beforeRouteEnter(to, from, next) {
        // 此时还无法获取到组件实例
        next((vm)=>{
            // vm 就是该组件的实例
        });
    },
    beforeRouteUpdate(to, from, next) {  },
    beforeRouteLeave(to, from, next) {  }
}
</script>

导航的完整触发流程

  1. 导航被触发
  2. 调用即将要离开的组件中的beforeRouteLeave守卫
  3. 调用全局的beforeEach守卫
  4. 调用被复用的组件中的beforeRouteUpdate守卫
  5. 调用路由配置中的beforeEnter守卫
  6. 开始加载并解析异步的路由组件
  7. 调用即将被激活的组件中的beforeRouteEnter守卫
  8. 调用全局的beforeResolve守卫
  9. 导航被确认
  10. 调用全局的afterEach守卫
  11. 触发DOM更新
  12. 调用beforeRouteEnter守卫中传递给next的回调函数,已创建好的组件实例会作为参数传递给该回调函数