十五、Vue相关面试题讲解
(一)组件通信总结
面试题:vue 组件之间有哪些通信方式?
1.父子组件通信
绝大部分 vue 本身提供的通信方式,都是父子组件通信
- prop
- event
- style & class
- attribute
- native 修饰符
- $listeners
- v-model
- sync 修饰符
- $parent & $children
- $slots & $scopedSlots
- ref
2.prop
- 最常见的组件通信方式之一
- 由父组件传递到子组件
3.event
- 最常见的组件通信方式之一
- 当子组件发生了某些事,可以通过 event 通知父组件
4.style 和 class
- 父组件可以向子组件传递 style 和 class
- 会合并到 子组件的根元素 中
1)父组件
<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>
2)子组件
<template>
<div class="world" style="text-align:center">
<h1>{{ msg }}</h1>
</div>
</template>
<script>
export default {
name: "HelloWorld",
props: {
msg: String,
},
};
</script>
3)渲染结果
<div id="app">
<div class="hello world" style="color:red; text-align:center">
<h1>Welcome to Your Vue.js App</h1>
</div>
</div>
5.attribute
- 如果父组件传递了一些属性到子组件,但子组件并没有声明这些属性
- 则它们称为 attribute,这些属性会直接附着在 子组件的根元素 上
不包括 style 和 class,它们会被特殊处理
1)父组件
<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>
2)子组件
<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>
3)渲染结果
<div id="app">
<div data-a="1" data-b="2">
<h1>Welcome to Your Vue.js App</h1>
</div>
</div>
- 子组件可以通过
inheritAttrs: false
配置 - 禁止将 attribute 附着在子组件的根元素上,但不影响通过
$attrs
获取
6.native 修饰符
- 在注册事件时,父组件可以使用 native 修饰符
- 将事件注册到 子组件的根元素 上
1)父组件
<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>
2)子组件
<template>
<div>
<h1>Hello World</h1>
</div>
</template>
3)渲染结果
<div id="app">
<!-- 点击该 div,会输出 1 -->
<div>
<h1>Hello World</h1>
</div>
</div>
7.$listeners
- 子组件可以通过
$listeners
获取父组件传递过来的所有事件处理函数
8.v-model
- 针对一个数据进行双向绑定
9.sync 修饰符
- 和 v-model 的作用类似,用于双向绑定
- 不同点在于 v-model 只能针对一个数据进行双向绑定,而 sync 修饰符没有限制
1)子组件
<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>
2)父组件
<template>
<div id="app">
<Numbers :num1.sync="n1" :num2.sync="n2" />
<!-- 等同于 -->
<Numbers :num1="n1" :num2="n2" @update:num1="n1 = $event" @update:num2="n2 = $event" />
</div>
</template>
<script>
import Numbers from "./components/Numbers.vue";
export default {
components: {
Numbers,
},
data() {
return {
n1: 0,
n2: 0,
};
},
};
</script>
10.$parent 和 $children
- 在组件内部,可以通过 $parent 和 $children 属性
- 分别得到当前组件的父组件和子组件实例
11.$slots 和 $scopedSlots
12.ref
- 父组件可以通过 ref 获取到子组件的实例
13.跨组件通信
- Provide & Inject
- router
- vuex
- store 模式
- eventbus
14.Provide 和 Inject
// 父级组件提供 'foo'
var Provider = {
provide: {
foo: "bar",
},
// ...
};
// 组件注入 'foo'
var Child = {
inject: ["foo"],
created() {
console.log(this.foo); // => "bar"
},
// ...
};
15.router
- 如果一个组件改变了地址栏,所有监听地址栏的组件都会做出相应反应
- 最常见的场景就是通过点击
router-link
组件改变了地址,router-view
组件就渲染其他内容
16.vuex
- 适用于大型项目的数据仓库
17.store 模式
- 适用于中小型项目的数据仓库
// 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
}
}
}
18.eventBus
- 组件通知事件总线发生了某件事
- 事件总线通知其他监听该事件的所有组件运行某个函数
(二)虚拟 DOM 详解
面试题:请你阐述一下对 vue 虚拟 dom 的理解
1.什么是虚拟 dom?
- 虚拟 dom 本质上就是一个普通的 JS 对象,用于描述视图的界面结构
- 在 vue 中,每个组件都有一个
render
函数,每个render
函数都会返回一个虚拟 dom 树- 即:每个组件都对应一棵虚拟 DOM 树
2.为什么需要虚拟 dom?
- 在 vue 中,渲染视图会调用
render
函数 - 这种渲染不仅发生在 组件创建 时,同时发生在 视图依赖的数据更新 时
- 如果在渲染时,直接使用真实 DOM,由于真实 DOM 的创建、更新、插入等操作会带来大量的性能损耗,就会极大地降低渲染效率
- 因此, vue 在渲染时,使用虚拟 dom 来替代真实 dom,主要是为了解决渲染效率的问题
3.虚拟 dom 是如何转换为真实 dom 的?
- 在一个组件实例首次被渲染时,先生成虚拟 dom 树
- 然后根据虚拟 dom 树创建真实 dom,并把真实 dom 挂载到页面中合适的位置
- 此时,每个虚拟 dom 便会对应一个真实的 dom
- 如果一个组件受响应式数据变化的影响,需要重新渲染时,仍然会重新调用 render 函数
- 创建出一个新的虚拟 dom 树,用新树和旧树对比
- 通过对比,vue 会找到最小更新量,然后更新必要的虚拟 dom 节点
- 最后,这些更新过的虚拟节点,会去修改它们对应的真实 dom
- 这样一来,就保证了对真实 dom 达到最小的改动
4.模板和虚拟 dom 的关系
- vue 框架中有一个
compile
模块,主要负责将模板转换为render
函数,而render
函数调用后将得到虚拟 dom - 编译的过程分两步
- 将模板字符串转换成为抽象语法树
AST
- 将
AST
转换为render
函数
- 将模板字符串转换成为抽象语法树
- 如果使用传统的引入方式,则编译时间发生在 组件第一次加载 时【运行时编译】
- 如果是在
vue-cli
的默认配置下,编译发生在 打包 时【模板预编译】 - 编译是一个极其耗费性能的操作,预编译可以有效的提高运行时的性能
- 由于运行的时候已不需要编译,
vue-cli
在打包时会排除掉 vue 中的 compile 模块,以减少打包体积 - 模板的存在,仅仅是为了让开发人员更加方便的书写界面代码
警告
vue 最终运行的时候,最终需要的是 render 函数而不是模板,因此,模板中的各种语法在虚拟 dom 中都是不存在的,都会变成虚拟 dom 的配置
(三)v-model
面试题:请阐述一下
v-model
的原理
1.作用范围
- 可以作用于表单元素,又可作用于自定义组件
- 无论是哪一种情况都是一个语法糖,最终会生成一个属性和一个事件
2.作用于表单元素时
- vue 会根据作用的表单元素类型而生成合适的属性和事件
- 作用于普通文本框时,会生成
value
属性和input
事件 - 作用于单选框或多选框时,会生成
checked
属性和change
事件
3.作用于自定义组件时
- 默认情况下,会生成一个
value
属性和input
事件
<Comp v-model="data" />
<!-- 等效于 -->
<Comp :value="data" @input="data = $event" />
4.改变属性和事件
- 可以通过组件的
model
配置来改变生成的属性和事件
// Comp
const Comp = {
model: {
prop: "number", // 默认为 value
event: "change", // 默认为 input
},
// ...
};
<Comp v-model="data" />
<!-- 等效于 -->
<Comp :number="data" @change="data = $event" />
(四)数据响应原理
面试题:请阐述 vue2 响应式原理
- 响应式数据的最终目标
- 当对象本身或对象属性发生变化时,将会运行一些函数
- 最常见的就是 render 函数
- 在具体实现上,vue 用到了几个核心部件
- Observer
- Dep
- Watcher
- Scheduler
1.Observer
- Observer 要 把一个普通的对象转换为响应式的对象
- 为了实现这一点,Observer 把对象的每个属性通过
Object.defineProperty
转换为带有getter
和setter
的属性 - 这样一来,当访问或设置属性时, vue 就有机会做一些别的事情
- Observer 是 vue 内部的构造器,可以通过 Vue 提供的静态方法间接的使用该功能
Vue.observable(object);
- 在组件生命周期中,这件事发生在
beforeCreate
之后,created
之前 - 会递归遍历对象的所有属性,以完成深度的属性转换
- 由于遍历时只能遍历到对象的当前属性,因此无法监测到将来动态增加或删除的属性
- 因此 vue 提供了
$set
和$delete
两个实例方法,让开发者通过这两个实例方法对已有响应式对象添加或删除属性 - 对于数组,vue 会更改它的 隐式原型
- 因为 vue 需要监听那些可能改变数组内容的方法
- 总之,Observer 的目标,就是要让一个对象,它属性的读取、赋值,内部数组的变化都要能够被 vue 感知到
2.Dep
- Dep 需要解决两个问题
- 读取属性时要做什么事
- 属性变化时要做什么事
- Dep 的含义是
Dependency
,表示依赖 - Vue 会为响应式对象中的每个属性、对象本身、数组本身创建一个 Dep 实例
- 每个 Dep 实例都有能力做以下两件事
- 记录依赖:是谁在用我
- 派发更新:我变了,我要通知那些用到我的人
- 当读取响应式对象的某个属性时,会进行依赖收集:有人用到了我
- 当改变某个属性时,会派发更新:那些用我的人听好了,我变了
3.Watcher
- Watcher 要解决一个问题,就是 Dep 如何知道是谁在用我
- 当某个函数执行的过程中,用到了响应式数据,响应式数据是无法知道是哪个函数在用自己的
- 因此,vue 通过一种巧妙的办法来解决这个问题
- 不要直接执行函数,而是把函数交给一个叫做 watcher 的东西去执行
- watcher 是一个对象,每个这样的函数执行时都应该创建一个 watcher,通过 watcher 去执行
- watcher 会设置一个全局变量,让全局变量记录当前负责执行的 watcher 等于自己,然后再去执行函数
- 在函数的执行过程中,如果发生了依赖记录
dep.depend()
,那么Dep
就会把这个全局变量记录下来,表示:有一个 watcher 用到了我这个属性 - 当 Dep 进行派发更新时,会通知之前记录的所有 watcher:我变了
- 每一个 vue 组件实例,都至少对应一个 watcher,该 watcher 中记录了该组件的 render 函数
- watcher 首先会把 render 函数运行一次以收集依赖
- 于是那些在 render 中用到的响应式数据就会记录这个 watcher
- 当数据变化时,dep 就会通知该 watcher,而 watcher 将重新运行 render 函数
- 从而让界面重新渲染同时重新记录当前的依赖
4.Scheduler
- 现在还剩下最后一个问题,就是 Dep 通知 watcher 之后,如果 watcher 执行重运行对应的函数,就有可能导致函数频繁运行,从而导致效率低下
试想,如果一个交给 watcher 的函数,它里面用到了属性 a、b、c、d,那么 a、b、c、d 属性都会记录依赖,于是下面的代码将触发 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://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 函数的执行是 异步 的,并且在 微队列 中
5.总体流程
(五)Diff 算法
面试题:请阐述 vue 的 diff 算法
- 当组件创建和更新时,vue 均会执行内部的 update 函数,该函数使用 render 函数生成的虚拟 dom 树,将新旧两树进行对比,找到差异点,最终更新到真实 dom
- 对比差异的过程叫 diff,vue 在内部通过一个叫 patch 的函数完成该过程
- 在对比时,vue 采用深度优先、同层比较的方式进行比对
- 在判断两个节点是否相同时,vue 是通过虚拟节点的 key 和 tag 来进行判断的
- 首先对根节点进行对比,如果相同则将旧节点关联的真实 dom 的引用挂到新节点上,然后根据需要更新属性到真实 dom,然后再对比其子节点数组
- 如果不相同,则按照新节点的信息递归创建所有真实 dom,同时挂到对应虚拟节点上,然后移除掉旧的 dom
- 在对比其子节点数组时,vue 对每个子节点数组使用了两个指针,分别指向头尾,然后不断向中间靠拢来进行对比,这样做的目的是尽量复用真实 dom,尽量少的销毁和创建真实 dom
- 如果发现相同,则进入和根节点一样的对比流程,如果发现不同,则移动真实 dom 到合适的位置
- 这样一直递归的遍历下去,直到整棵树完成对比
1.Diff 的时机
- 当组件创建时,以及依赖的属性或数据变化时,会运行一个函数,该函数会做两件事
- 运行
_render
生成一棵新的虚拟 dom 树(vnode tree) - 运行
_update
,传入虚拟 dom 树的根节点,对新旧两棵树进行对比,最终完成对真实 dom 的更新
- 运行
// vue构造函数
function Vue() {
// ... 其他代码
var updateComponent = () => {
this._update(this._render());
};
new Watcher(updateComponent);
// ... 其他代码
}
- diff 就发生在
_update
函数的运行过程中
_update
函数在干什么
2._update
函数接收到一个vnode
参数,这就是 新生成 的虚拟 dom 树_update
函数通过当前组件的_vnode
属性,拿到 旧 的虚拟 dom 树
_update
函数首先会给组件的 _vnode
属性重新赋值,让它指向新树
1)2)然后会判断旧树是否存在
- 不存在:说明这是第一次加载组件,于是通过内部的
patch
函数直接遍历新树,为每个节点生成真实 DOM,挂载到每个节点的elm
属性上
- 存在:说明之前已经渲染过该组件,于是通过内部的
patch
函数对新旧两棵树进行对比,以达到下面两个目标:- 完成对所有真实 dom 的最小化处理
- 让新树的节点对应合适的真实 dom
3.patch 函数的对比流程
术语 | 解释 |
---|---|
相同 | 指两个虚拟节点的标签类型、key 值均相同,但 input 元素还要看 type 属性 |
新建元素 | 指根据一个虚拟节点提供的信息,创建一个真实 dom 元素,同时挂载到虚拟节点的 elm 属性上 |
销毁元素 | vnode.elm.remove() |
更新 | 指对两个虚拟节点进行对比更新,仅发生 在两个虚拟节点相同的情况下 |
对比子节点 | 指对两个虚拟节点的子节点进行对比 |
1)根节点比较
patch
函数首先对根节点进行比较- 如果两个节点 相同 ,进入 更新 流程
- 将旧节点的真实 dom 赋值到新节点
newVnode.elm = oldVnode.elm
- 对比新节点和旧节点的属性,有变化的更新到真实 dom 中
- 当前两个节点处理完毕,开始 对比子节点
- 将旧节点的真实 dom 赋值到新节点
- 如果两个节点 不相同
- 新节点递归 新建元素
- 旧节点 销毁元素
2)对比子节点
- vue 一切的出发点,都是为了
- 尽量啥也别做
- 不行的话,尽量仅改动元素属性
- 还不行的话,尽量移动元素,而不是删除和创建元素
- 还不行的话,删除和创建元素
(六)生命周期详解
面试题:
new Vue
之后,发生了什么?数据改变后,又发生了什么?
1.创建 vue 实例和创建组件的流程基本一致
1)首先做一些初始化的操作,主要是设置一些私有属性到实例中
beforeCreate
2) 运行生命周期钩子函数 3)进入注入流程
- 处理属性、computed、methods、data、provide、inject,最后使用代理模式将它们挂载到实例中
created
4)运行生命周期钩子函数 render
函数
5)生成 - 如果有配置,直接使用配置的 render
- 如果没有,使用运行时编译器,把模板编译为 render
beforeMount
6)运行生命周期钩子函数 Watcher
7)创建一个 - 传入一个函数
updateComponent
,该函数会运行render
,把得到的vnode
再传入_update
函数执行 - 在执行
render
函数的过程中,会收集所有依赖,将来依赖变化时会重新运行updateComponent
函数 - 在执行
_update
函数的过程中,触发patch
函数,由于目前没有旧树,因此直接为当前的虚拟 dom 树的每一个普通节点生成 elm 属性,即真实 dom - 如果遇到创建一个组件的 vnode,则会进入组件实例化流程,该流程和创建 vue 实例流程基本相同,最终会把创建好的组件实例挂载 vnode 的
componentInstance
属性中,以便复用
mounted
8)运行生命周期钩子函数 2.重渲染
Watcher
均会重新运行
1)数据变化后,所有依赖该数据的 - 这里仅考虑
updateComponent
函数对应的Watcher
Watcher
会被调度器放到 nextTick
中运行
2)- 也就是微队列中
- 为了避免多个依赖的数据同时改变后被多次执行
beforeUpdate
3)运行生命周期钩子函数 updateComponent
函数重新执行
4)- 在执行
render
函数的过程中,会去掉之前的依赖,重新收集所有依赖,将来依赖变化时会重新运行updateComponent
函数 - 在执行
_update
函数的过程中,触发patch
函数 - 新旧两棵树进行对比
- 普通 html 节点的对比会导致真实节点被创建、删除、移动、更新
- 组件节点的对比会导致组件被创建、删除、移动、更新
- 当新组件需要创建时,进入实例化流程
- 当旧组件需要删除时,会调用旧组件的
$destroy
方法删除组件- 该方法会先触发 生命周期钩子函数
beforeDestroy
- 然后递归调用 子组件的
$destroy
方法 - 然后触发 生命周期钩子函数
destroyed
- 该方法会先触发 生命周期钩子函数
- 当组件属性更新时,相当于组件的
updateComponent
函数被重新触发执行,进入重渲染流程
updated
5)运行生命周期钩子函数 (七)你不知道的 computed
面试题:computed 和 methods 有什么区别
1.标准而浅显的回答
- 在使用时,computed 当做属性使用,而 methods 则当做方法调用
- computed 可以具有 getter 和 setter,因此可以赋值,而 methods 不行
- computed 无法接收多个参数,而 methods 可以
- computed 具有缓存,而 methods 没有
2.更接近底层原理的回答
- 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 即可
(八)Filter 过滤器
参见 官网文档
1.App.vue
<template>
<div id="app">
<input v-model.number="money" type="number" />
<p>
{{ money | formatMoney(" ") }}
</p>
</div>
</template>
<script>
import { formatMoney } from "./filter/moneyFilter";
export default {
data() {
return {
money: 1000,
};
},
filters: {
formatMoney,
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
text-align: center;
}
</style>
2.filter/moneyFilter.js
/**
* 过滤器格式化金额
* @param {Number} money
* @param {String} split
*/
export function formatMoney(money, split = ",", fixedDigit = 2) {
const str = money.toFixed(fixedDigit).toString();
const parts = str.split(".");
let part1 = parts[0]; // 左边整数
const part2 = parts[1]; // 右边小数
part1 = part1.replace(/(?=(\d{3})+$)/g, split);
return `${part1}.${part2}`;
}
(九)作用域插槽
参见 官网文档
1.属性
1)$slots
- 用于访问父组件传递的普通插槽中的 vnode
2)$scopedSlots
- 用于访问父组件传递的所有用于生成 vnode 的函数
- 包括默认插槽在内
2.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>
<script>
import AsyncContent from "./components/AsyncContent.vue";
// ajax模拟函数
function getProducts() {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.5) {
resolve([
{ id: 1, name: "xiaomi", stock: 50 },
{ id: 2, name: "iphone", stock: 70 },
{ id: 3, name: "huawei", stock: 60 },
]);
} else {
reject(new Error("not found"));
}
}, 1000);
});
}
export default {
components: { AsyncContent },
methods: {
async fetchProducts() {
return await getProducts();
},
},
};
</script>
2.components/AsyncContent.vue
<template>
<div>
<slot v-if="isLoading" name="loading">默认加载中的效果...</slot>
<slot v-else-if="error" :error="error" name="error">{{ error }}</slot>
<!-- 通过 v-bind 将子组件的插槽数据绑定到了父组件插槽的位置 -->
<slot v-else :content="content" name="default">{{ content }}</slot>
</div>
</template>
<script>
export default {
props: {
contentPromise: Promise,
},
data() {
return {
isLoading: true,
content: null,
error: null,
};
},
async created() {
try {
this.content = await this.contentPromise;
this.error = null;
} catch (err) {
this.content = null;
this.error = err;
} finally {
this.isLoading = false;
}
},
mounted() {
console.log("$slots", this.$slots);
console.log("$scopedSlots", this.$scopedSlots);
},
};
</script>
(十)过渡和动画
内置组件 Transition 官网详细文档:https://cn.vuejs.org/v2/guide/transitions.html
1.时机
- Transition 组件会监控 slot 中 唯一根元素 的出现和消失
- 并会在其出现和消失时应用过渡效果
1)具体的监听内容
- 对新旧两个虚拟节点进行对比
- 如果旧节点被销毁,则应用消失效果
- 如果新节点是新增的,则应用进入效果
- 如果不是上述情况,则会对比新旧节点,观察其
v-show
是否变化true -> false
应用消失效果false -> true
应用进入效果
2.流程
1)类名规则
- 如果 transition 上没有定义
name
,则类名为v-xxxx
- 如果 transition 上定义了
name
,则类名为${name}-xxxx
- 如果指定了类名,直接使用指定的类名
指定类名见:自定义过渡类名
2)进入效果
3)消失效果
3.过渡组
- Transition 可以监控其内部的 单个 dom 元素 的出现和消失,并为其附加样式
- 如果要监控一个 dom 列表,就需要使用
TransitionGroup
组件- 对列表的新增元素应用进入效果
- 对列表的删除元素应用消失效果
- 对列表被移动的元素应用
v-move
样式
被移动的元素之所以能够实现过渡效果,是因为
TransitionGroup
内部使用了 Flip 过渡方案
(十一)优化
1.使用 key
- 对于通过循环生成的列表,应给每个列表项一个稳定且唯一的 key
- 有利于在列表变动时,尽量少的删除、新增、改动元素
2.使用冻结的对象
- 冻结的对象不会被响应化
<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>
<style>
#app {
text-align: center;
}
</style>
3.使用函数式组件
- 参见函数式组件
1)components/FunctionalComp.vue
<template functional>
<h1>NormalComp: {{ props.count }}</h1>
</template>
<script>
export default {
functional: true,
props: {
count: Number,
},
};
</script>
2)App.vue
<template>
<div id="app">
<button @click="normalCount = 10000">生成10000个普通组件</button>
<button @click="functionalCount = 10000">生成10000个函数组件</button>
<div class="container">
<div class="item">
<NormalComp v-for="n in normalCount" :key="n" :count="n"></NormalComp>
</div>
<div class="item">
<FunctionalComp v-for="n in functionalCount" :key="n" :count="n"></FunctionalComp>
</div>
</div>
</div>
</template>
<script>
import NormalComp from "./components/NormalComp";
import FunctionalComp from "./components/FunctionalComp";
export default {
components: {
NormalComp,
FunctionalComp,
},
data() {
return {
functionalCount: 0,
normalCount: 0,
};
},
mounted() {
window.vm = this;
},
};
</script>
<style>
#app {
text-align: center;
}
.container {
width: 90%;
display: flex;
margin: 0 auto;
}
.item {
padding: 30px;
border: 1px solid #ccc;
margin: 1em;
flex: 1 1 auto;
}
</style>
4.使用计算属性
- 如果模板中某个数据会使用多次,并且该数据是通过计算得到的
- 应该使用计算属性以缓存它们
5.非实时绑定的表单项
- 当使用
v-model
绑定一个表单项时,当用户 改变表单项的状态 时,数据也会随之改变- 从而导致 vue 发生 重渲染(rerender),这会带来一些性能的开销
- 特别是当用户改变表单项时,页面有一些动画正在进行中,由于 JS 执行线程和浏览器渲染线程是互斥的,最终会导致动画出现卡顿
- 可以通过使用
lazy
或不使用v-model
的方式解决该问题- 这样可能会导致在某一个时间段内数据和表单项的值是不一致的
6.保持对象引用稳定
- 在绝大部分情况下,vue 触发 rerender 的时机是其 依赖的数据发生变化
- 若数据没有发生变化,哪怕给数据重新赋值了,vue 也不会做出任何处理
- vue 判断数据 没有变化 的源码
// value 为旧值, newVal 为新值
if (newVal === value || (newVal !== newVal && value !== value)) {
return;
}
- 因此只要能保证组件的依赖数据不发生变化,组件就不会重新渲染
- 对于原始数据类型,保持其值不变 即可
- 对于对象类型,保持其引用不变 即可
- 由于可以通过保持属性引用稳定来避免子组件的重渲染,应该 细分组件 来尽量避免多余的渲染
<template>
<div id="app">
<div class="container">
<button @click="handleAdd1">添加:点击后重新获取数据</button>
<user-comment v-for="item in comments1" :key="item.id" :comment="item"></user-comment>
</div>
<div class="container">
<button @click="handleAdd2">添加:点击后向当前数据插入数据</button>
<user-comment v-for="item in comments2" :key="item.id" :comment="item"></user-comment>
</div>
</div>
</template>
<script>
import axios from "axios";
import UserComment from "./components/UserComment.vue";
// api
async function getComments() {
return await axios.get("/api/comment").then((resp) => resp.data);
}
async function addComment() {
return await axios.post("/api/comment").then((resp) => resp.data);
}
export default {
components: { UserComment },
data() {
return {
comments1: [],
comments2: [],
};
},
async created() {
this.comments1 = await getComments();
this.comments2 = await getComments();
},
methods: {
async handleAdd1() {
await addComment();
this.comments1 = await getComments();
},
async handleAdd2() {
const cmt = await addComment();
this.comments2.unshift(cmt);
},
},
};
</script>
<style>
#app {
width: 800px;
margin: 0 auto;
display: flex;
}
.container {
margin: 1em;
flex: 1 0 auto;
padding: 10px;
border: 1px solid #ccc;
}
</style>
7.使用 v-show 替代 v-if
- 对于 频繁切换显示状态 的元素,使用 v-show 可以保证虚拟 dom 树的稳定
- 避免频繁的新增和删除元素,特别是对于那些 内部包含大量 dom 元素 的节点
8.使用延迟装载(defer)
- 首页白屏时间主要受到两个因素的影响
1)打包体积过大
- 巨型包需要消耗大量的传输时间
- 导致 JS 传输完成前页面只有一个 div ,没有可显示的内容
- 需要 自行优化打包体积
2)需要立即渲染的内容太多
- JS 传输完成后,浏览器开始执行 JS 构造页面
- 但可能一开始要渲染的组件太多,不仅 JS 执行的时间很长,而且执行完后浏览器要渲染的元素过多,从而导致页面白屏
- 延迟装载组件,让组件按照指定的先后顺序依次一个一个渲染出来
延迟装载是一个思路,本质上就是利用
requestAnimationFrame
事件分批渲染内容,具体实现多种多样
3)延迟装载组件
- App.vue
<template>
<div class="container">
<div v-for="n in 21" v-if="defer(n)" class="block">
<heavy-comp></heavy-comp>
</div>
</div>
</template>
<script>
import HeavyComp from "./components/HeavyComp.vue";
import defer from "./mixin/defer";
export default {
mixins: [defer(21)],
components: { HeavyComp },
};
</script>
<style scoped>
.container {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 1em;
}
.block {
border: 3px solid #f40;
}
</style>
- mixin/defer.js
export default function (maxFrameCount) {
return {
data() {
return {
frameCount: 0,
};
},
mounted() {
const refreshFrameCount = () => {
requestAnimationFrame(() => {
this.frameCount++;
if (this.frameCount < maxFrameCount) {
refreshFrameCount();
}
});
};
refreshFrameCount();
},
methods: {
defer(showInFrameCount) {
return this.frameCount >= showInFrameCount;
},
},
};
}
9.使用 keep-alive
10.长列表优化
(十二)keep-alive
面试题:请阐述 keep-alive 组件的作用和原理
- keep-alive 组件是 vue 的内置组件,用于 缓存内部组件实例
- keep-alive 内部的组件切回时,不用重新创建组件实例
- 直接使用缓存中的实例
1.作用
- 能够避免创建组件带来的开销
- 还可以保留组件的状态
2.属性
1)include 和 exclude 属性
- 可以控制哪些组件进入缓存
2)max 属性
- 可以设置最大缓存数
- 当缓存的实例超过该数时,vue 会移除最久没有使用的组件缓存
3.生命周期函数
- 内部所有嵌套的组件都具有两个生命周期钩子函数
1)activated
- 在组件 激活 时触发
2)deactivated
- 在组件 失活 时触发
4.原理
- 第一次 activated 触发是在 mounted 之后
// keep-alive 内部的生命周期函数
created () {
this.cache = Object.create(null)
this.keys = []
}
- keep-alive 在内部维护了一个 key 数组和一个缓存对象
- key 数组记录目前缓存的组件 key 值,如果组件没有指定 key 值,则会为其自动生成一个唯一的 key 值
- cache 对象以 key 值为键,vnode 为值,用于 缓存组件对应的虚拟 DOM
- 在 keep-alive 的渲染函数中,其基本逻辑是
- 判断当前渲染的 vnode 是否有对应的缓存
- 如果有,从缓存中读取到对应的组件实例
- 如果没有则将其缓存
- 当缓存数量超过 max 数值时,keep-alive 会移除掉 key 数组的第一个元素
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.App.vue
<template>
<div id="app">
<RecycleScroller v-slot="{ item }" :items="items" :itemSize="54" class="scroller">
<ListItem :item="item" />
</RecycleScroller>
</div>
</template>
<script>
import ListItem from "./components/ListItem.vue";
// import RecycleScroller from './components/RecycleScroller';
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
var items = [];
for (var i = 0; i < 10000; i++) {
items.push({
id: i + 1,
count: i + 1,
});
}
export default {
components: { ListItem, RecycleScroller },
data() {
return {
items,
};
},
};
</script>
<style>
#app {
width: 100%;
margin: 0 auto;
}
.scroller {
width: 500px;
margin: 0 auto;
height: 500px;
}
</style>
2.RecycleScroller.vue 实现原理
<template>
<div @scroll="setPool" class="recycle-scroller-container" ref="container">
<div :style="{ height: `${totalSize}px` }" class="recycle-scroller-wrapper">
<div
v-for="poolItem in pool"
:key="poolItem.item[keyField]"
:style="{
transform: `translateY(${poolItem.position}px)`,
}"
class="recycle-scroller-item"
>
<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>
<style>
.recycle-scroller-container {
overflow: auto;
}
.recycle-scroller-wrapper {
position: relative;
}
.recycle-scroller-item {
position: absolute;
width: 100%;
left: 0;
top: 0;
}
</style>
(十四)其他 API
- main.js
import Vue from "vue";
import App from "./App.vue";
Vue.config.productionTip = false;
Vue.config.optionMergeStrategies.created = function (parent, child) {
if (!parent) {
return child;
}
if (!child) {
return parent;
}
return [child];
};
new Vue({
render: (h) => h(App),
}).$mount("#app");
(十五)模式和环境变量
详见 vue-cli 官网 模式和环境变量
1.读取环境变量
- 首先读取当前机器的环境变量
- 读取.env 文件
2.定义生产环境变量
- .env.production
VUE_APP_SERVERBASE=http://www.duyiservice.com
3.定义开发环境变量
- .env.development
VUE_APP_SERVERBASE=http://www.test.com
4.使用环境变量
- vue-cli 在打包时,会将 p
rocess.env.XXX 进行替换
1)api/request.js
export default function (url, ...args) {
return fetch(`${process.env.VUE_APP_SERVERBASE}${url}`, ...args);
}
2)api/news.js
import request from "./request";
export async function getNews() {
/*
开发环境:http://www.test.com
生产环境:http://www.duyiservice.com
*/
// let baseUrl;
// if (process.env.NODE_ENV === 'development') {
// baseUrl = 'http://www.test.com';
// } else {
// baseUrl = 'http://www.duyiservice.com';
// }
// await fetch(baseUrl + '/api/news');
// console.log('正在请求', process.env.VUE_APP_SERVERBASE);
// console.log('VUE_APP_ABC', process.env.VUE_APP_ABC);
// console.log('VUE_APP_BCD', process.env.VUE_APP_BCD);
return await request("/api/news");
}
(十六)更多配置
1.vue.config.js
- devServer
- publicPath
- outputDir
- runtimeCompiler
- transpileDependencies
- configureWebpack
- css.requireModuleExtension
// vue-cli的配置文件
module.exports = {
devServer: {
proxy: {
"/api": {
target: "http://test.my-site.com",
},
},
},
publicPath: "/news",
// runtimeCompiler: true,
// transpileDependencies: []
// configureWebpack: {
// // webpack配置
// }
// css: {
// requireModuleExtension: false,
// },
};
2.babel 配置
- 写到项目根目录下的
babel.config.js
中
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};
3.ESLint
- 可以通过
.eslintrc
或package.json
中的eslintConfig
字段来配置
1).eslintrc.js
module.exports = {
root: true,
env: {
node: true,
},
extends: ["plugin:vue/essential", "@vue/airbnb"],
parserOptions: {
parser: "babel-eslint",
},
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"import/no-extraneous-dependencies": 0,
"implicit-arrow-linebreak": 0,
},
};
2)eslintConfig
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100
4.postcss
- 写到
postcss.config.js
module.exports = {
plugins: {
tailwindcss: {
purge: ["./src/**/*.vue"],
},
autoprefixer: {},
},
};
(十七)更多命令
(十八)嵌套路由
- router/index.js
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";
Vue.use(VueRouter);
const routes = [
{
path: "/",
component: Home,
},
{
path: "/about",
component: () => import("../views/About.vue"),
},
{
path: "/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",
component: () => import("../views/user/Security.vue"),
},
{
path: "friends",
component: () => import("../views/user/Friends.vue"),
},
],
},
];
const router = new VueRouter({
mode: "history",
base: process.env.BASE_URL,
routes,
});
export default router;
(十九)路由切换动画
- views/user/Layout.vue
<template>
<div class="flex">
<nav class="w-50 flex-none border-r p-10 flex flex-col leading-loose text-gray-500">
<router-link exact to="/user" active-class="text-yellow-600">profile</router-link>
<router-link to="/user/address" active-class="text-yellow-600">address</router-link>
<router-link to="/user/security" active-class="text-yellow-600">security</router-link>
<router-link to="/user/friends" active-class="text-yellow-600">friends</router-link>
</nav>
<div class="flex-auto">
<transition mode="out-in">
<router-view></router-view>
</transition>
</div>
</div>
</template>
<style scoped>
.v-leave-active {
transition: 0.2s;
opacity: 0;
}
.v-enter {
opacity: 0;
}
.v-enter-active {
transition: 0.2s;
}
</style>
- App.vue
<template>
<div>
<div class="fixed bg-gray-900 text-white w-full leading-10 flex justify-center h-10">
<router-link active-class="text-yellow-600" exact class="mx-5" to="/">Home</router-link>
<router-link active-class="text-yellow-600" class="mx-5" to="/about">About</router-link>
<router-link active-class="text-yellow-600" class="mx-5" to="/user">User</router-link>
</div>
<div class="h-10"></div>
<div class="min-h-screen py-10 lg:w-1/2 h-10 mx-auto">
<transition :name="routerSwitchInfo.direction">
<router-view />
</transition>
</div>
<div class="border-solid border-t mt-10 text-center py-10 bg-gray-100">Footer</div>
</div>
</template>
<script>
import store from "./store";
export default {
data() {
return {
routerSwitchInfo: store.routerSwitch,
};
},
};
</script>
<style scoped>
.right-leave-active {
position: absolute;
width: 100%;
transition: 0.5s;
transform: translateX(-50%);
opacity: 0;
}
.right-enter {
transform: translateX(50%);
opacity: 0;
}
.right-enter-active {
transition: 0.5s;
}
.left-leave-active {
position: absolute;
width: 100%;
transition: 0.5s;
transform: translateX(50%);
opacity: 0;
}
.left-enter {
transform: translateX(-50%);
opacity: 0;
}
.left-enter-active {
transition: 0.5s;
}
</style>