1. 组件通讯总结

1-1. 父子组件通信

绝大部分vue本身提供的通信方式,都是父子组件通信

1-1-1. prop

最常见的组件通信方式之一,由父组件传递到子组件

1-1-2. event

最常见的组件通信方式之一,当子组件发生了某些事,可以通过event通知父组件

1-1-3. styleclass

父组件可以向子组件传递styleclass,它们会合并到子组件的根元素中

示例:

父组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div id="app">
<HelloWorld
style="color:red"
class="hello"
msg="Welcome to Your Vue.js App"
/>
</div>
</template>

<script>
import HelloWorld from "./components/HelloWorld.vue";

export default {
components: {
HelloWorld,
},
};
</script>

子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div class="world" style="text-align:center">
<h1>{{msg}}</h1>
</div>
</template>

<script>
export default {
name: "HelloWorld",
props: {
msg: String,
},
};
</script>

渲染结果:

1
2
3
4
5
<div id="app">
<div class="hello world" style="color:red; text-aling:center">
<h1>Welcome to Your Vue.js App</h1>
</div>
</div>

1-1-4. attribute

如果父组件传递了一些属性到子组件,但子组件并没有声明这些属性,则它们称之为attribute,这些属性会直接附着在子组件的根元素上

不包括styleclass,它们会被特殊处理

示例:

父组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div id="app">
<!-- 除 msg 外,其他均为 attribute -->
<HelloWorld
data-a="1"
data-b="2"
msg="Welcome to Your Vue.js App"
/>
</div>
</template>

<script>
import HelloWorld from "./components/HelloWorld.vue";

export default {
components: {
HelloWorld,
},
};
</script>

子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div>
<h1>{{msg}}</h1>
</div>
</template>

<script>
export default {
name: "HelloWorld",
props: {
msg: String,
},
created() {
console.log(this.$attrs); // 得到: { "data-a": "1", "data-b": "2" }
},
};
</script>

渲染结果:

1
2
3
4
5
<div id="app">
<div data-a="1" data-b="2">
<h1>Welcome to Your Vue.js App</h1>
</div>
</div>

子组件可以通过inheritAttrs: false配置,禁止将attribute附着在子组件的根元素上,但不影响通过$attrs获取

1-1-5. natvie修饰符

在注册事件时,父组件可以使用native修饰符,将事件注册到子组件的根元素上

示例:

父组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div id="app">
<HelloWorld @click.native="handleClick" />
</div>
</template>

<script>
import HelloWorld from "./components/HelloWorld.vue";

export default {
components: {
HelloWorld,
},
methods: {
handleClick() {
console.log(1);
},
},
};
</script>

子组件

1
2
3
4
5
<template>
<div>
<h1>Hello World</h1>
</div>
</template>

渲染结果

1
2
3
4
5
6
<div id="app">
<!-- 点击该 div,会输出 1 -->
<div>
<h1>Hello World</h1>
</div>
</div>

1-1-6. $listeners

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

1-1-7. v-model

后续章节讲解

1-1-8 sync修饰符

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

示例

子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<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 {
props: ["num1", "num2"],
};
</script>

父组件

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
<template>
<div id="app">
<Numbers :num1.sync="n1" :num2.sync="n2" />
<!-- 等同于 -->
<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() {
return {
n1: 0,
n2: 0,
};
},
};
</script>

1-1-9. $parent$children

在组件内部,可以通过$parent$children属性,分别得到当前组件的父组件和子组件实例

1-1-10. $slots$scopedSlots

后续章节讲解

1-1-11. ref

父组件可以通过ref获取到子组件的实例

1-2. 跨组件通信

1-2-1. ProvideInject

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 父级组件提供 'foo'
var Provider = {
provide: {
foo: 'bar'
},
// ...
}

// 组件注入 'foo'
var Child = {
inject: ['foo'],
created () {
console.log(this.foo) // => "bar"
}
// ...
}

详见:https://v2.cn.vuejs.org/v2/api/#provide-inject

1-2-2. router

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

最常见的场景就是通过点击router-link组件改变了地址,router-view组件就渲染其他内容

1-2-3. vuex

适用于大型项目的数据仓库

1-2-4. store模式

适用于中小型项目的数据仓库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// store.js
const store = {
loginUser: ...,
setting: ...
}

// compA
const compA = {
data(){
return {
loginUser: store.loginUser
}
}
}

// compB
const compB = {
data(){
return {
setting: store.setting,
loginUser: store.loginUser
}
}
}

1-2-5. eventbus

组件通知事件总线发生了某件事,事件总线通知其他监听该事件的所有组件运行某个函数

2. 虚拟DOM详解

面试题:请你阐述一下对vue虚拟dom的理解

2-1. 什么是虚拟dom?

虚拟dom本质上就是一个普通的JS对象,用于描述视图的界面结构

在vue中,每个组件都有一个render函数,每个render函数都会返回一个虚拟dom树,这也就意味着每个组件都对应一棵虚拟DOM树

虚拟dom

2-2. 为什么需要虚拟dom?

vue中,渲染视图会调用render函数,这种渲染不仅发生在组件创建时,同时发生在视图依赖的数据更新时。如果在渲染时,直接使用真实DOM,由于真实DOM的创建、更新、插入等操作会带来大量的性能损耗,从而就会极大的降低渲染效率。

因此,vue在渲染时,使用虚拟dom来替代真实dom,主要为解决渲染效率的问题。

2-3. 虚拟dom是如何转换为真实dom的?

在一个组件实例首次被渲染时,它先生成虚拟dom树,然后根据虚拟dom树创建真实dom,并把真实dom挂载到页面中合适的位置,此时,每个虚拟dom便会对应一个真实的dom。

如果一个组件受响应式数据变化的影响,需要重新渲染时,它仍然会重新调用render函数,创建出一个新的虚拟dom树,用新树和旧树对比,通过对比,vue会找到最小更新量,然后更新必要的虚拟dom节点,最后,这些更新过的虚拟节点,会去修改它们对应的真实dom

这样一来,就保证了对真实dom达到最小的改动。

虚拟dom对比

2-4. 模板和虚拟dom的关系

vue框架中有一个compile模块,它主要负责将模板转换为render函数,而render函数调用后将得到虚拟dom。

编译的过程分两步:

  1. 将模板字符串转换成为AST(抽象语法树)
  2. AST转换为render函数

如果使用传统的引入方式,则编译时间发生在组件第一次加载时,这称之为运行时编译。

如果是在vue-cli的默认配置下,编译发生在打包时,这称之为模板预编译。

编译是一个极其耗费性能的操作,预编译可以有效的提高运行时的性能,而且,由于运行的时候已不需要编译,vue-cli在打包时会排除掉vue中的compile模块,以减少打包体积

模板的存在,仅仅是为了让开发人员更加方便的书写界面代码

注意:vue最终运行的时候,最终需要的是render函数,而不是模板,因此,模板中的各种语法,在虚拟dom中都是不存在的,它们都会变成虚拟dom的配置

3. v-model

面试题:请阐述一下 v-model 的原理

v-model即可以作用于表单元素,又可作用于自定义组件,无论是哪一种情况,它都是一个语法糖,最终会生成一个属性和一个事件

当其作用于表单元素时vue会根据作用的表单元素类型而生成合适的属性和事件。例如,作用于普通文本框的时候,它会生成value属性和input事件,而当其作用于单选框或多选框时,它会生成checked属性和change事件。

v-model也可作用于自定义组件,当其作用于自定义组件时,默认情况下,它会生成一个value属性和input事件。

1
2
3
<Comp v-model="data" />
<!-- 等效于 -->
<Comp :value="data" @input="data=$event" />

开发者可以通过组件的model配置来改变生成的属性和事件

1
2
3
4
5
6
7
8
// Comp
const Comp = {
model: {
prop: "number", // 默认为 value
event: "change" // 默认为 input
}
// ...
}
1
2
3
<Comp v-model="data" />
<!-- 等效于 -->
<Comp :number="data" @change="data=$event" />

4. 数据响应原理

面试题:请阐述vue2响应式原理

vue官方阐述:https://v2.cn.vuejs.org/v2/guide/reactivity.html

响应式数据的最终目标,是当对象本身或对象属性发生变化时,将会运行一些函数,最常见的就是render函数。

在具体实现上,vue用到了几个核心部件

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

4-1. Observer

Observer要实现的目标非常简单,就是把一个普通的对象转换为响应式的对象

为了实现这一点,Observer把对象的每个属性通过Object.defineProperty转换为带有gettersetter的属性,这样一来,当访问或设置属性时,vue就有机会做一些别的事情。

Observer

Observer是vue内部的构造器,我们可以通过Vue提供的静态方法Vue.observable( object )间接的使用该功能。

在组件生命周期中,这件事发生在beforeCreate之后,created之前。

具体实现上,它会递归遍历对象的所有属性,以完成深度的属性转换。

由于遍历时只能遍历到对象的当前属性,因此无法监测到将来动态增加或删除的属性,因此vue提供了$set$delete两个实例方法,让开发者通过这两个实例方法对已有响应式对象添加或删除属性。

对于数组,vue会更改它的隐式原型,之所以这样做,是因为vue需要监听那些可能改变数组内容的方法

Observer

总之,Observer的目标,就是要让一个对象,它属性的读取、赋值,内部数组的变化都要能够被vue感知到。

4-2. Dep

这里有两个问题没解决,就是读取属性时要做什么事,而属性变化时要做什么事,这个问题需要依靠Dep来解决。

Dep的含义是Dependency,表示依赖的意思。

Vue会为响应式对象中的每个属性、对象本身、数组本身创建一个Dep实例,每个Dep实例都有能力做以下两件事:

  • 记录依赖:是谁在用我
  • 派发更新:我变了,我要通知那些用到我的人

当读取响应式对象的某个属性时,它会进行依赖收集:有人用到了我

当改变某个属性时,它会派发更新:那些用我的人听好了,我变了

Dep

4-3. Watcher

这里又出现一个问题,就是Dep如何知道是谁在用我?

要解决这个问题,需要依靠另一个东西,就是Watcher。

当某个函数执行的过程中,用到了响应式数据,响应式数据是无法知道是哪个函数在用自己的

因此,vue通过一种巧妙的办法来解决这个问题

我们不要直接执行函数,而是把函数交给一个叫做watcher的东西去执行,watcher是一个对象,每个这样的函数执行时都应该创建一个watcher,通过watcher去执行

watcher会设置一个全局变量,让全局变量记录当前负责执行的watcher等于自己,然后再去执行函数,在函数的执行过程中,如果发生了依赖记录dep.depend(),那么Dep就会把这个全局变量记录下来,表示:有一个watcher用到了我这个属性

当Dep进行派发更新时,它会通知之前记录的所有watcher:我变了

Watcher

每一个vue组件实例,都至少对应一个watcher,该watcher中记录了该组件的render函数。

watcher首先会把render函数运行一次以收集依赖,于是那些在render中用到的响应式数据就会记录这个watcher。

当数据变化时,dep就会通知该watcher,而watcher将重新运行render函数,从而让界面重新渲染同时重新记录当前的依赖。

4-4. Scheduler

现在还剩下最后一个问题,就是Dep通知watcher之后,如果watcher执行重运行对应的函数,就有可能导致函数频繁运行,从而导致效率低下

试想,如果一个交给watcher的函数,它里面用到了属性a、b、c、d,那么a、b、c、d属性都会记录依赖,于是下面的代码将触发4次更新:

1
2
3
4
state.a = "new data";
state.b = "new data";
state.c = "new data";
state.d = "new data";

这样显然是不合适的,因此,watcher收到派发更新的通知后,实际上不是立即执行对应函数,而是把自己交给一个叫调度器的东西

调度器维护一个执行队列,该队列同一个watcher仅会存在一次,队列中的watcher不是立即执行,它会通过一个叫做nextTick的工具方法,把这些需要执行的watcher放入到事件循环的微队列中,nextTick的具体做法是通过Promise完成的

nextTick 通过 this.$nextTick 暴露给开发者

nextTick 的具体处理方式见:https://v2.cn.vuejs.org/v2/guide/reactivity.html#%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0%E9%98%9F%E5%88%97

也就是说,当响应式数据变化时,render函数的执行是异步的,并且在微队列中

4-5. 总体流程

image-20210226163936839

5. diff

面试题:请阐述vue的diff算法

参考回答:

当组件创建和更新时,vue均会执行内部的update函数,该函数使用render函数生成的虚拟dom树,将新旧两树进行对比,找到差异点,最终更新到真实dom

对比差异的过程叫diff,vue在内部通过一个叫patch的函数完成该过程

在对比时,vue采用深度优先、同层比较的方式进行比对。

在判断两个节点是否相同时,vue是通过虚拟节点的key和tag来进行判断的

具体来说,首先对根节点进行对比,如果相同则将旧节点关联的真实dom的引用挂到新节点上,然后根据需要更新属性到真实dom,然后再对比其子节点数组;如果不相同,则按照新节点的信息递归创建所有真实dom,同时挂到对应虚拟节点上,然后移除掉旧的dom。

在对比其子节点数组时,vue对每个子节点数组使用了两个指针,分别指向头尾,然后不断向中间靠拢来进行对比,这样做的目的是尽量复用真实dom,尽量少的销毁和创建真实dom。如果发现相同,则进入和根节点一样的对比流程,如果发现不同,则移动真实dom到合适的位置。

这样一直递归的遍历下去,直到整棵树完成对比。

5-1. diff的时机

当组件创建时,以及依赖的属性或数据变化时,会运行一个函数,该函数会做两件事:

  • 运行_render生成一棵新的虚拟dom树(vnode tree)
  • 运行_update,传入虚拟dom树的根节点,对新旧两棵树进行对比,最终完成对真实dom的更新

核心代码如下:

1
2
3
4
5
6
7
8
9
// vue构造函数
function Vue(){
// ... 其他代码
var updateComponent = () => {
this._update(this._render())
}
new Watcher(updateComponent);
// ... 其他代码
}

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

5-2. _update函数在干什么

_update函数接收到一个vnode参数,这就是生成的虚拟dom树

同时,_update函数通过当前组件的_vnode属性,拿到的虚拟dom树

_update函数首先会给组件的_vnode属性重新赋值,让它指向新树

update

然后会判断旧树是否存在:

  • 不存在:说明这是第一次加载组件,于是通过内部的patch函数,直接遍历新树,为每个节点生成真实DOM,挂载到每个节点的elm属性上

    update

    1
    2
    3
    4
    //patch 方法中如果不存在旧树,则传输真是dom
    if (!oldVnode) {
    this.__patch__(this.$el, vnode);
    }
  • 存在:说明之前已经渲染过该组件,于是通过内部的patch函数,对新旧两棵树进行对比,以达到下面两个目标:

  • 完成对所有真实dom的最小化处理

  • 让新树的节点对应合适的真实dom

    update

5-3. patch函数的对比流程

术语解释

  1. 相同」:是指两个虚拟节点的标签类型、key值均相同,但input元素还要看type属性
  2. 新建元素」:是指根据一个虚拟节点提供的信息,创建一个真实dom元素,同时挂载到虚拟节点的elm属性上
  3. 销毁元素」:是指:vnode.elm.remove()
  4. 更新」:是指对两个虚拟节点进行对比更新,它仅发生在两个虚拟节点「相同」的情况下。具体过程稍后描述。
  5. 对比子节点」:是指对两个虚拟节点的子节点进行对比,具体过程稍后描述

详细流程:

  1. 根节点比较

    根节点比较

    patch函数首先对根节点进行比较

    如果两个节点:

    • 「相同」,进 「更新」流程

      1. 将旧节点的真实dom赋值到新节点:newVnode.elm = oldVnode.elm
      2. 对比新节点和旧节点的属性,有变化的更新到真实dom中
      3. 当前两个节点处理完毕,开始「对比子节点」
    • 不「相同」

      1. 新节点递归「新建元素」
      2. 旧节点「销毁元素」
  2. 「对比子节点」

    在「对比子节点」时,vue一切的出发点,都是为了:

    • 尽量啥也别做
    • 不行的话,尽量仅改动元素属性
    • 还不行的话,尽量移动元素,而不是删除和创建元素
    • 还不行的话,删除和创建元素

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 什么叫「相同」是指两个虚拟节点的标签类型、`key`值均相同,但`input`元素还要看`type`属性
*
* <h1>asdfdf</h1> <h1>asdfasfdf</h1> 「相同」
*
* <h1 key="1">adsfasdf</h1> <h1 key="2">fdgdf</h1> 不「相同」
*
* <input type="text" /> <input type="radio" /> 不「相同」
*
* abc bcd 「相同」 注意:这里不比较text,而且相同后还会有后续动作
*
* {
* tag: undefined,
* key: undefined,
* text: "abc"
* }
*
* {
* tag: undefined,
* key: undefined,
* text: "bcd"
* }
*
*/

6. 生命周期详解

面试题:new Vue之后,发生了什么?数据改变后,又发生了什么?

生命周期详解

  1. 创建vue实例和创建组件的流程基本一致

    1. 首先做一些初始化的操作,主要是设置一些私有属性到实例中

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

    3. 进入注入流程:处理属性、computed、methods、data、provide、inject,最后使用代理模式将它们挂载到实例中

    4. 运行生命周期钩子函数created

    5. 生成render函数:如果有配置,直接使用配置的render,如果没有,使用运行时编译器,把模板编译为render

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

    7. 创建一个Watcher,传入一个函数updateComponent,该函数会运行render,把得到的vnode再传入_update函数执行。

      在执行render函数的过程中,会收集所有依赖,将来依赖变化时会重新运行updateComponent函数

      在执行_update函数的过程中,触发patch函数,由于目前没有旧树,因此直接为当前的虚拟dom树的每一个普通节点生成elm属性,即真实dom。

      如果遇到创建一个组件的vnode,则会进入组件实例化流程,该流程和创建vue实例流程基本相同,最终会把创建好的组件实例挂载vnode的componentInstance属性中,以便复用。

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

  2. 重渲染?

    1. 数据变化后,所有依赖该数据的Watcher均会重新运行,这里仅考虑updateComponent函数对应的Watcher

    2. Watcher会被调度器放到nextTick中运行,也就是微队列中,这样是为了避免多个依赖的数据同时改变后被多次执行

    3. 运行生命周期钩子函数beforeUpdate

    4. updateComponent函数重新执行

      在执行render函数的过程中,会去掉之前的依赖,重新收集所有依赖,将来依赖变化时会重新运行updateComponent函数

      在执行_update函数的过程中,触发patch函数。

      新旧两棵树进行对比。

      普通html节点的对比会导致真实节点被创建、删除、移动、更新

      组件节点的对比会导致组件被创建、删除、移动、更新

      当新组件需要创建时,进入实例化流程

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

      当组件属性更新时,相当于组件的updateComponent函数被重新触发执行,进入重渲染流程,和本节相同。

    5. 运行生命周期钩子函数updated

7. 你不知道的computed

面试题:computed和methods有什么区别

标准而浅显的回答:

  1. 在使用时,computed当做属性使用,而methods则当做方法调用
  2. computed可以具有getter和setter,因此可以赋值,而methods不行
  3. computed无法接收多个参数,而methods可以
  4. computed具有缓存,而methods没有

更接近底层原理的回答:

vue对methods的处理比较简单,只需要遍历methods配置中的每个属性,将其对应的函数使用bind绑定当前组件实例后复制其引用到组件实例中即可

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

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

它会遍历computed配置中的所有属性,为每一个属性创建一个Watcher对象,并传入一个函数,该函数的本质其实就是computed配置中的getter,这样一来,getter运行过程中就会收集依赖

但是和渲染函数不同,为计算属性创建的Watcher不会立即执行,因为要考虑到该计算属性是否会被渲染函数使用,如果没有使用,就不会得到执行。因此,在创建Watcher的时候,它使用了lazy配置,lazy配置可以让Watcher不会立即执行。

收到lazy的影响,Watcher内部会保存两个关键属性来实现缓存,一个是value,一个是dirty

value属性用于保存Watcher运行的结果,受lazy的影响,该值在最开始是undefined

dirty属性用于指示当前的value是否已经过时了,即是否为脏值,受lazy的影响,该值在最开始是true

Watcher创建好后,vue会使用代理模式,将计算属性挂载到组件实例中

当读取计算属性时,vue检查其对应的Watcher是否是脏值,如果是,则运行函数,计算依赖,并得到对应的值,保存在Watcher的value中,然后设置dirty为false,然后返回。

如果dirty为false,则直接返回watcher的value

巧妙的是,在依赖收集时,被依赖的数据不仅会收集到计算属性的Watcher,还会收集到组件的Watcher

当计算属性的依赖变化时,会先触发计算属性的Watcher执行,此时,它只需设置dirty为true即可,不做任何处理。

由于依赖同时会收集到组件的Watcher,因此组件会重新渲染,而重新渲染时又读取到了计算属性,由于计算属性目前已为dirty,因此会重新运行getter进行运算

而对于计算属性的setter,则极其简单,当设置计算属性时,直接运行setter即可

8. filter过滤器

参见官网文档:https://v2.cn.vuejs.org/v2/guide/filters.html

9. 作用域插槽

参见官网文档:https://v2.cn.vuejs.org/v2/guide/components-slots.html#%E4%BD%9C%E7%94%A8%E5%9F%9F%E6%8F%92%E6%A7%BD

属性:

  • $slots:用于访问父组件传递的普通插槽中的vnode
  • $scopedSlots:用于访问父组件传递的所有用于生成vnode的函数(包括默认插槽在内)

代码示例:

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
<!-- async-content.vue -->
<template>
<div>
<slot name="loading" v-if="isLoading">默认加载中的效果...</slot>
<slot name="error" v-else-if="error" :error="error">{{ error }}</slot>
<!-- 通过 v-bind 将子组件的插槽数据绑定到了父组件插槽的位置 -->
<slot name="default" v-else :content="content">{{ content }}</slot>
</div>
</template>
...

<!-- app.vue -->
<template>
<div id="app">
<async-content :contentPromise="fetchProducts()">
<template #loading>加载中...</template>
<template v-slot:default="{ content }">
<ul>
<li v-for="item in content" :key="item.id">
商品名:{{ item.name }} 库存:{{ item.stock }}
</li>
</ul>
</template>
<template v-slot:error="{ error }">
<p style="color:red">{{ error.message }}</p>
</template>
</async-content>
</div>
</template>
...

10. 过渡和动画

10-1. 内置组件Transition

官网详细文档:https://v2.cn.vuejs.org/v2/guide/transitions.html

10-2. 时机

Transition组件会监控slot唯一根元素的出现和消失,并会在其出现和消失时应用过渡效果

具体的监听内容是:

  • 它会对新旧两个虚拟节点进行对比,如果旧节点被销毁,则应用消失效果,如果新节点是新增的,则应用进入效果
  • 如果不是上述情况,则它会对比新旧节点,观察其v-show是否变化,true->false应用消失效果,false->true应用进入效果

10-3. 流程

类名规则:

  1. 如果transition上没有定义name,则类名为v-xxxx
  2. 如果transition上定义了name,则类名为${name}-xxxx
  3. 如果指定了类名,直接使用指定的类名

指定类名见:自定义过渡类名

1. 进入效果:

进入效果

2. 消失效果:

消失效果

10-4. 过渡组

Transision可以监控其内部的单个dom元素的出现和消失,并为其附加样式

如果要监控一个dom列表,就需要使用TransitionGroup组件

它会对列表的新增元素应用进入效果,删除元素应用消失效果,对被移动的元素应用v-move样式

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

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<template>
<div id="app">
<button @click="titleLevel--">Level-</button>
<button @click="titleLevel++">Level+</button>
<transition name="title">
<component :is="`h${titleLevel}`">title level {{ titleLevel }}</component>
</transition>
</div>
</template>

<script>
export default {
name: 'App',
data() {
return {
titleLevel: 1,
}
}
}
</script>

<style>
#app {
text-align: center;
}
.title-enter,
.title-leave-to {
opacity: 0;
}
.title-enter-active,
.title-leave-active {
transition: 3s;
}
</style>

11. 优化

11-1. 使用key

对于通过循环生成的列表,应给每个列表项一个稳定且唯一的key,这有利于在列表变动时,尽量少的删除、新增、改动元素

11-2. 使用冻结的对象

冻结的对象不会被响应化

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<template>
<div id="app">
<button @click="loadNormalDatas">load normal datas</button>
<button @click="loadFrozenDatas">load frozen datas</button>
<h1>normal datas count: {{ normalDatas.length }}</h1>
<h1>freeze datas count: {{ freezeDatas.length }}</h1>
</div>
</template>

<script>
export default {
data() {
return {
normalDatas: [],
freezeDatas: [],
};
},
methods: {
loadNormalDatas() {
this.normalDatas = this.getDatas();
console.log("normalDatas", this.normalDatas);
},
loadFrozenDatas() {
this.freezeDatas = Object.freeze(this.getDatas());
console.log("freezeDatas", this.freezeDatas);
},
getDatas() {
const result = [];
for (var i = 0; i < 1000000; i++) {
result.push({
id: i,
name: `name${i}`,
address: {
city: `city${i}`,
province: `province${i}`,
},
});
}
return result;
},
},
};
</script>

11-3. 使用函数式组件

参见函数式组件

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template functional>
<h1>NormalComp: {{ props.count }}</h1>
</template>

<script>
export default {
functional: true,
props: {
count: Number,
},
};
</script>

<style></style>

11-4. 使用计算属性

如果模板中某个数据会使用多次,并且该数据是通过计算得到的,使用计算属性以缓存它们

11-5. 非实时绑定的表单项

当使用v-model绑定一个表单项时,当用户改变表单项的状态时,也会随之改变数据,从而导致vue发生重渲染(rerender),这会带来一些性能的开销。

特别是当用户改变表单项时,页面有一些动画正在进行中,由于JS执行线程和浏览器渲染线程是互斥的,最终会导致动画出现卡顿。

我们可以通过使用lazy或不使用v-model的方式解决该问题,但要注意,这样可能会导致在某一个时间段内数据和表单项的值是不一致的。

11-6. 保持对象引用稳定

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

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

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

1
2
3
4
// value 为旧值, newVal 为新值
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}

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

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

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

从另一方面来说,由于可以通过保持属性引用稳定来避免子组件的重渲染,那么我们应该细分组件来尽量避免多余的渲染

11-7. 使用v-show替代v-if

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

关键字:频繁切换显示状态、内部包含大量dom元素

11-8. 使用延迟装载(defer)

首页白屏时间主要受到两个因素的影响:

  • 打包体积过大

    巨型包需要消耗大量的传输时间,导致JS传输完成前页面只有一个<div>,没有可显示的内容

  • 需要立即渲染的内容太多

    JS传输完成后,浏览器开始执行JS构造页面。

    但可能一开始要渲染的组件太多,不仅JS执行的时间很长,而且执行完后浏览器要渲染的元素过多,从而导致页面白屏

打包体积过大需要自行优化打包体积,本节不予讨论

本节仅讨论渲染内容太多的问题。

一个可行的办法就是延迟装载组件,让组件按照指定的先后顺序依次一个一个渲染出来

延迟装载是一个思路,本质上就是利用requestAnimationFrame事件分批渲染内容,它的具体实现多种多样

10-9. 使用keep-alive

面试题:请阐述keep-alive组件的作用和原理

keep-alive组件是vue的内置组件,用于缓存内部组件实例。这样做的目的在于,keep-alive内部的组件切回时,不用重新创建组件实例,而直接使用缓存中的实例,一方面能够避免创建组件带来的开销,另一方面可以保留组件的状态。

keep-alive具有include和exclude属性,通过它们可以控制哪些组件进入缓存。另外它还提供了max属性,通过它可以设置最大缓存数,当缓存的实例超过该数时,vue会移除最久没有使用的组件缓存。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 数组形式 -->
<keep-alive :include="['Comp1','Comp2']">
<component :is="comps[curIndex]"></component>
</keep-alive>

<!-- 字符串 -->
<keep-alive include="Comp1,Comp2">
<component :is="comps[curIndex]"></component>
</keep-alive>

<!-- 正则 -->
...

受keep-alive的影响,其内部所有嵌套的组件都具有两个生命周期钩子函数,分别是activateddeactivated,它们分别在组件激活和失活时触发。第一次activated触发是在mounted之后

在具体的实现上,keep-alive在内部维护了一个key数组和一个缓存对象

1
2
3
4
5
// keep-alive 内部的声明周期函数
created () {
this.cache = Object.create(null)
this.keys = []
}

key数组记录目前缓存的组件key值,如果组件没有指定key值,则会为其自动生成一个唯一的key值

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

在keep-alive的渲染函数中,其基本逻辑是判断当前渲染的vnode是否有对应的缓存,如果有,从缓存中读取到对应的组件实例;如果没有则将其缓存。

当缓存数量超过max数值时,keep-alive会移除掉key数组的第一个元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
render(){
const slot = this.$slots.default; // 获取默认插槽
const vnode = getFirstComponentChild(slot); // 得到插槽中的第一个组件的vnode
const name = getComponentName(vnode.componentOptions); //获取组件名字
const { cache, keys } = this; // 获取当前的缓存对象和key数组
const key = ...; // 获取组件的key值,若没有,会按照规则自动生成
if (cache[key]) {
// 有缓存
// 重用组件实例
vnode.componentInstance = cache[key].componentInstance
remove(keys, key); // 删除key
// 将key加入到数组末尾,这样是为了保证最近使用的组件在数组中靠后,反之靠前
keys.push(key);
} else {
// 无缓存,进行缓存
cache[key] = vnode
keys.push(key)
if (this.max && keys.length > parseInt(this.max)) {
// 超过最大缓存数量,移除第一个key对应的缓存
pruneCacheEntry(cache, keys[0], keys, this._vnode)
}
}
return vnode;
}

代码示例:

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
<template>
<div id="app">
<p>
<button @click="curIndex = (curIndex + 1) % comps.length">
switch
</button>
</p>
<keep-alive>
<component :is="comps[curIndex]"></component>
</keep-alive>
</div>
</template>

<script>
import Comp1 from './components/Comp1';
import Comp2 from './components/Comp2';
import Comp3 from './components/Comp3';
export default {
data() {
return {
comps: Object.freeze([Comp1, Comp2, Comp3]),
curIndex: 0,
};
},
};
</script>

10-10. 长列表优化

插件:vue-virtual-scroller

代码示例:[非插件]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
<template>
<div class="recycle-scroller-container" @scroll="setPool" ref="container">
<div class="recycle-scroller-wrapper" :style="{ height: `${totalSize}px` }">
<div
class="recycle-scroller-item"
v-for="poolItem in pool"
:key="poolItem.item[keyField]"
:style="{
transform: `translateY(${poolItem.position}px)`,
}"
>
<slot :item="poolItem.item"></slot>
</div>
</div>
</div>
</template>

<script>
var prev = 10,
next = 10;
export default {
props: {
// 数据的数组
items: {
type: Array,
default: () => [],
},
// 每条数据的高度
itemSize: {
type: Number,
default: 0,
},
keyField: {
// 给我的items数组中,每个对象哪个属性代表唯一且稳定的编号
type: String,
default: 'id',
},
},
data() {
return {
// { item: 原始数据, position: 该数据对应的偏移位置 }
pool: [], // 渲染池,保存当前需要渲染的数据
};
},
mounted() {
this.setPool();
window.vm = this;
},
computed: {
totalSize() {
return this.items.length * this.itemSize; // 总高度
},
},
methods: {
setPool() {
const scrollTop = this.$refs.container.scrollTop;
const height = this.$refs.container.clientHeight;
let startIndex = Math.floor(scrollTop / this.itemSize);
let endIndex = Math.ceil((scrollTop + height) / this.itemSize);
startIndex -= prev;
if (startIndex < 0) {
startIndex = 0;
}
endIndex += next;
const startPos = startIndex * this.itemSize;
this.pool = this.items.slice(startIndex, endIndex).map((it, i) => ({
item: it,
position: startPos + i * this.itemSize,
}));
},
},
};
</script>

__END__