本文是本人根据Vue官网上的内容或者网上一些其他朋友的文章以及自己的理解,整理归纳出来的一篇Vue方面的面试题及知识点,其中不免有许多漏掉的问题或是答案,发现错误的或者是有什么问题需要补充的朋友们,可以在评论区留言,大家虚心交流,一起进步。
Vue
是一款基于MVVM
架构的渐进式框架
,它主要用于构建单页面应用(spa)
,它的特点有声明式渲染
、响应式
两大点。
MVVM
就是Model-View-ViewModel
的缩写,它是一种架构模式
,是MVC(Model-View-Controller)
的改进版。
Model
:指的就是负责应用的数据处理以及整体业务逻辑
(相当于后端);View
:指的就是展示给用户的界面
(相当于HTML页面);MVVM中的ViewModel
:指的就是视图模型
,它是View
和Module
沟通的桥梁,用于展示数据、处理用户交互、并且更新模型,并且视图
会与视图模型
进行关联,将视图
中的一些方法和属性封装在视图模型中
,然后通过视图模型
和模型
之间获取、更新数据,然后将真实的数据反映到视图
中。MVVM
的架构的优点有:
低耦合
:视图(View)可以独立于Model变化和修改
,一个ViewModel
可以绑定到不同的View上
,当View变化
的时候Model可以不变
,当Model变化
的时候View也可以不变
;可复用
:可以将一个ViewModule
的逻辑给多个View
使用;独立开发
:开发人员可以专注于业务逻辑和数据的开发(ViewModel)
,设计人员可以专注于页面设计;可测试
:因为ViewModel
是独立于界面的,因此测试人员可以专注于业务逻辑
,而无需依赖具体的页面实现;渐进式框架
指的就是一种框架概念
,一般来说,使用渐进式框架时,无需引入其所有功能,而是需要什么就用什么
,就拿Vue
来说,我们可以引入一个vue.js的文件
,然后在其它框架中
去使用Vue
,也可以使用它的脚手架
,来进行构建一个Vue项目,这完全取决于用户想怎么使用,而框架为我们提供了多种使用方式
以及各个模块的功能
。
渐进式框架
的优点
有:
灵活
:开发者可以按需引入
框架的各个功能;可维护性
:开发者可以先少量引入框架部分功能
,然后在需要的时候引入其它功能,防止项目从一开始就变得结构复杂
、难以维护
。SPA
就是单页面应用
,这是一种网站的设计模式
,它的意思就是一个网站中,只有一个HTML文件
,用户在进行页面交互时,或者刷新页面时,只是利用JavaScript动态变换HTML的内容
,而并非真正意义上的变换页面
。
SPA应用
的优点:
良好的交互体验
:因为用户在交互时,只是动态刷新局部内容,并不用请求新的HTML文件
,因此也就不会造成长时间的页面白屏
;良好的工作模式
:更好的实现前后端分离
,让不同岗位的工程师专注于自己的领域,提升代码的性能以及复用性;路由
:使用前端路由
,通过浏览器的API
来模拟前进后退
操作,让用户在使用感知上并无变化。SPA应用
的缺点:
首页开销大
:因为所有的资源都需要在首页进行加载,因此资源过多时会产生白屏问题
;内存占用较大
:在SPA中,一旦页面加载完成,所有的页面内容和状态都保存在内存中,如果页面过于复杂或用户长时间停留在页面上,可能导致内存占用较大,影响设备性能;不利于SEO
:SPA页面在初始化时只有HTML的基本骨架,其它内容需要依赖于JavaScript的异步加载,浏览器不能完整的捕获到页面内容,不利于SEO。声明式渲染
,就是你只需要告诉框架你的目的
,至于它内部如何达成你的目的,你无需关心。与之对应的是命令式渲染
,你需要一步步操作,让框架执行你的操作,最终达成你的目的。
比如原生js的编程,我们想改变标签的内容:
<div>原内容div>
<script>
// 获取到标签
const node = document.getElementsByTagName('div')[0]
node.innerText = '新内容'
script>
而在Vue中,我们只需要:
<template>
<div>{{ msg }}div>
template>
<script>
export default {
data(){
return {
msg: '原内容'
}
},
methods: {
change() {
this.msg = '新内容'
}
}
}
script>
看似Vue更麻烦,是因为我们使用了Vue的组件模板,看起来繁杂了。但是这只是一个标签的情况下,如果想给多个标签修改内容
,在我们原生开发的过程中,每次都要先获取标签
,然后修改标签内容,但是在Vue中,我们只需要将标签和内容建立联系
,然后只修改内容,就可以自动让标签中的内容也更改。这得益于Vue强大的模板编译
以及响应式
。
在Vue2
中,共有以下几个生命周期:
beforeCreate
:在组件实例化之后
,但是在数据监听和事件配置之前
调用,此时data、methods
中的属性未初始化,访问不到正确内容,this
也无法访问;created
:数据已经监听完毕
,但是还未进行挂载,此时可以访问到data、methods中的内容
,但是无法访问到dom内容
;beforeMount
:在组件挂载之前
调用,此时模板编译完成
,但是还未将模板渲染成dom;mounted
:组件已经完成挂载
,可以获取到一些dom信息
;beforeUpdate
:数据更新,dom未重新渲染之前
调用;update
:数据更新,dome重新渲染之后
调用;beforeDestroy
:组件实例卸载之前
调用,这时可以将组件中的一些定时器
等进行清理;destroyed
:组件实例销毁之后
调用,这时组件实例已经完全销毁,无法访问到组件内的内容;activated
:特殊
的生命周期函数,只在当前组件
设置了keep-alive
时调用,因为设置keep-alive
时,组件不会销毁
,监听不到组件的销毁以及创建
,该生命周期函数代表当前组件处于活跃状态
(当前组件没有展示);deactivated
:和activated
类似,只是它代表当前组件处于非活跃状态
。Vue3
中,去掉了beforeCreate
、created
两个生命周期函数,用setup
来替代(也就是说在setup中写的代码,相当于之前在这两个函数中写的代码);Vue3中
,将beforeDestroy
、destroyed
两个生命周期函数更名为onBeforeUnmount
、onUnmounted
;Vue3
中,其它生命周期函数并没有改变,只是在每个生命周期函数前面加上了一个on
。keep-alive
是一个内置组件
,它不会被渲染到dom树中,普通的组件在被替换之后,会销毁组件
,组件中的一些状态都会被销毁,当这个组件再被创建时,组件内的所有属性和方法都是初始状态的
。而keep-alive
的作用就是,用keep-alive包裹某个组件
,在这个组件被替换时,保存当前组件的状态
,并触发deactivated
函数,当该组件再次被激活时,组件内的属性和方法都是上次被替换时的状态
,也就是说它的作用就是缓存组件
。
keep-alive
组件会默认缓存所有组件实例
,但是它有三个属性,分别为include(包含)
、exclude(排除)
、max(最大缓存数)
。
include
用于表明哪些组件可以被缓存
,exclude
用于表明哪些组件不可以被缓存
;字符串(include="a,b")
、数组(:include="['a','b']")
、正则(:include="/a|b/")
的方式;正则
、数组
格式的参数,需要使用v-bind:incluce(exclude)
进行传参;max
表示最大缓存数,比如规定了max=10
,当缓存的组件达到10个时,如果又添加进来一个新的需要缓存的组件
,就会在已缓存的10个组件中
找出最久没有被访问的
,然后将新的组件替换(LRU算法)。在给keep-alive
的include、exclude
传参时,我们需要传入的是组件的name值
,也就是说,keep-alive
组件会将我们传入的值视为组件实例的name
,然后就会去找到相应的组件实例,对它们进行indluce或者exclude
。
Vue2
中,每次创建一个组件实例时,可以给当前实例添加name属性
;Vue3
中,如果我们使用composition api
时,也可以给当前组件实例添加name属性
;Vue3
中,还有一个setup语法糖
,在使用setup语法糖时
,无法给组件实例添加name属性
了,但是在Vue3.3+中
,Vue为我们提供了一个defineOptions宏
,可以在它里面声明一些组件选项(比如name);如果组件没有设置name值
,找不到对应的组件怎么办呢?如果没有找到对应name的组件实例,就会查看组件在被导入时定义在components中的名称
,比如:
import a from './a.vue'
export default {
name: B,
components: {
A: a
}
}
此时就会视作,A
就是组件a
的name,如果include/exclude
中有A
这个字符,就会把组件a
进行include/exclude
。
keep-alive
是一个内置的组件
,因此它其实和普通组件类似,只是在keep-alive组件的不同生命周期函数
中做了一些特殊的处理,可以缓存我们的组件,主要是通过created
、destroyed
、mounted
、render
四个函数进行处理。
在created阶段
,keep-alive组件会创建一个空对象
,用于存放需要缓存的组件的信息。
在render
阶段,就会拿插槽
中的内容,然后获取插槽中的第一个组件
,这也是为什么在keep-alive中写多个组件时,只会缓存第一个
的原因。
拿到第一个组件之后,就会去获取当前组件的name
,然后和include/exclude
中的内容匹配,如果判断该组件不需要缓存
,就直接返回组件VNode信息,如果需要缓存,就执行下一步操作。
缓存时,会看组件的VNode信息中有没有key
,如果没有key,就会根据组件标签(tag)和cid拼接成一个key,然后判断key是否已经存在于缓存中
,如果不存在,就将key和当前VNode作为一组数据进行缓存,如果存在,就会把当前VNode的组件实例替换为缓存中当前key的组件实例
,最后返回VNode,也就是说当我们访问一个组件时,如果命中了缓存
,那么访问的其实就是缓存中上一次该组件的实例信息
,并且每次命中缓存时,都会调整该组件在缓存中的位置
,会移动到末尾
。这时如果设置了max
,当缓存内容超出这个范围时,就会删掉缓存中的第一项
(因为最近访问的组件缓存都被移动到后面了,所以排在第一位的肯定就是最久未访问的了)。
在mounted
阶段,主要对include/exclude
的值进行了监听
,监听到值有变化时,说明需要被缓存的组件有改动
,这时就会去遍历已缓存的组件,判断哪些已经不需要被缓存
,然后将它们移除出缓存数据。
在destroyed
阶段,说明keep-alive组件要销毁了,那它组件实例上的一些方法和属性也都会被销毁,因此在这个阶段就会清除所有已缓存的组件实例
。
Vue中常见的指令有以下几种:
v-bind
:用于动态
绑定一个或多个属性
,它的语法糖是:
,在Vue3中,v-bind可以作用于style标签中的CSS属性
;v-model
:用于双向绑定
,一般用于input
之上,在input中输入内容,会改变data的数据,改变data数据也会让input的内容发生改变;v-on
:用于监听dom的事件
,如点击事件、input事件、滚动事件等,它的语法糖为@
;v-if/v-else-if/v-else
:用于条件渲染
,在标签上使用v-if
时,需要传入判断条件,满足条件的才会渲染,不满足的不会渲染;v-show
:用于条件展示
,因为使用v-show
和v-if
不一样,v-show
的标签是会进行渲染的,v-show
的作用只是改变标签的CSS中的display属性
;v-html
:用于将HTML模板插入标签内容
,这种场景常在后台返回富文本
时常用,平时尽量不要用,因为很容易导致XSS攻击
;v-for
:用于循环渲染
,渲染多个相同类型的标签
,标签中的内容可以自己根据条件定义;v-slot
:只能用于template
标签上,用于绑定插槽
,传入指定的插槽名从而绑定对应的插槽,语法糖为#
;v-pre
:用于跳过编译
,普通写在模板中的标签会被vue给编译
,如果给标签添加了该属性,就会跳过该标签的编译,比如标签中想展示{{}}
这种字符,如果交由vue编译,会将该字符编译为mustache语法
,使用该属性就不会编译了,就会当作普通的字符来处理;v-once
:用于仅渲染一次
,该标签渲染一次之后,在后续页面的更新中,会将该标签视为静态节点
,跳过更新,以用来优化性能;v-memo
:用于缓存节点
,需要传入一个数组
,如果下次更新时,数组中的元素都没有改变,那么该标签和它的子节点就会使用缓存,不进行更新,当传入一个空数组时
,效果和v-once一样
,通常可以和v-for
一起使用,来达到优化的效果;v-if
会根据判断条件控制是否渲染元素
,如果不满足条件,元素不会被渲染,节约内存;v-show
只是根据条件修改元素的display属性
,元素还是会被渲染,只是控制在页面是否展示;频繁的在展示和隐藏之间切换时
,使用v-show性能更好
,因为可以节约每次重新渲染带来的开销;永久性隐藏或者展示时
,使用v-if性能更好
,因为可以减少dom结构。.stop
:阻止事件冒泡;.prevent
:阻止默认事件;.self
:只有事件在当前元素自身触发时,才会调用函数;.capture
:捕获模式,内部元素的事件在被内部元素处理之前
,先被外部元素处理
;.once
:该事件只会触发一次;.passive
:一般用于触摸事件
的监听器,可以用来改善移动端设备的滚动性能
;.enter
:仅在Enter
键时调用;.page-down
:仅在PageDown
键时调用;.tab
:仅在tab
键时调用;.delete
:仅在delete或Backspace
键时调用;.esc
:仅在esc
键时调用;.space
:仅在space
键时调用;.up
:仅在up
键时调用;.down
:仅在down
键时调用;.left
:仅在left
键时调用;.right
:仅在right
键时调用;.ctrl
:仅在ctrl
键时调用;.alt
:仅在alt
键时调用;.shift
:仅在shift
键时调用;.meta
:在Windows
上是Win键
,在Mac
上是Command键
,不同机器上键位不同。.left
:鼠标左键触发;.right
:鼠标右键触发;.middle
:鼠标中键触发;.sync
修饰符是v-bind:xxx
和@update:xxx
的语法糖,在Vue3中已被移除,使用v-model:xxx替代
。
<script>
export default {
name: 'HelloWorld',
props: {
name: {
type: String,
default: ''
}
},
methods: {
changeName() {
this.$emit('update:name', 'child')
}
}
}
script>
<template>
<div id="app">
<HelloWorld :name.sync="name" ref="Child" />
div>
template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
},
data() {
return {
name: 'Lee'
}
}
}
script>
<template>
<div id="app">
<HelloWorld :name="name" @update:name="changeName" />
div>
template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
},
data() {
return {
name: 'Lee'
}
},
methods: {
changeName(val) {
this.name = val
}
}
}
script>
这种方式主要场景是父组件向子组件传参
,父组件
通过v-bind:xxx
的形式传递参数,子组件通过在组件实例的props
属性中声明需要的参数以及类型。
<template>
<HelloWorld v-bind:name="name" :age="age" just-str="字符串可以省略v-bind" />
template>
<script setup>
import { ref } from 'vue'
import HelloWorld from './components/HelloWorld.vue'
const name = ref('Lee')
const age = ref(18)
script>
<script setup>
import { defineProps } from 'vue'
import { onMounted } from 'vue'
const props = defineProps({
name: String,
age: Number,
justStr: String
})
onMounted(() => {
console.log(props.name) // Lee
console.log(props.age) // 18
console.log(props.justStr) // 字符串可以省略v-bind
})
script>
当父组件中有一个对象,想把对象中所有的属性
传递给子组件,但是又不想一个个去写v-bind
,这时候可以把父组件
这么做:
<template>
<HelloWorld v-bind="obj" />
template>
<script setup>
import { ref } from 'vue'
import HelloWorld from './components/HelloWorld.vue'
const obj = ref({
name: 'Lee',
age: 18,
justStr: '字符串可以省略v-bind'
})
script>
这种方式主要场景是子组件向父组件传参
,子组件
通过emits定义事件
,然后在有需要时向父组件
抛出事件,父组件通过@eventname
的方式接收子组件的事件以及参数。
<script setup>
import { defineEmits } from 'vue'
import { onMounted } from 'vue'
const emits = defineEmits(['tellMyFather'])
onMounted(() => {
emits('tellMyFather', '通知父组件')
emits('tellMyFather', {
data: '通知父组件'
})
})
script>
<template>
<HelloWorld @tell-my-father="getEvent" />
template>
<script setup>
import HelloWorld from './components/HelloWorld.vue'
// 每次抛出都会接收
const getEvent = data => {
console.log(data) // 第一次:通知父组件 第二次:{data: '通知父组件'}
}
script>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
},
data() {
return {
name: 'father'
}
},
mounted() {
console.log(this.$children.name) // vue2可以访问到
}
}
script>
<script>
export default {
name: 'HelloWorld',
data() {
return {
name: 'child'
}
},
mounted() {
console.log(this.$parent.name) // father
}
}
script>
provide/inject
用于多级组件间的传参
,只需要在需要传参的地方使用provide
,在它的其它后代组件
中使用inject
,就可以获取到传来的值。
<script setup>
import HelloWorld from './components/HelloWorld.vue'
import { provide } from 'vue'
provide('name', 'Lee')
script>
<script setup>
import { inject, onMounted } from 'vue'
const name = inject('name')
onMounted(() => {
console.log(name) // Lee
})
script>
$attrs
的值是父组件传过来的值中,没有被当前组件props接收的值
。
$listeners
的值是后代组件传过来的事件中,没有被当前组件接收的方法
。
也就是说,它们一个是为了传递属性
,一个是为了传递方法
。
<template>
<HelloWorld v-bind="obj" />
template>
<script setup>
import HelloWorld from './components/HelloWorld.vue'
import { ref } from 'vue'
const obj = ref({
name: 'Lee',
age: 18,
height: 180
})
script>
<script setup>
import { useAttrs, onMounted, defineProps } from 'vue'
defineProps({
name: String
})
// 在setup语法糖中,使用useAttrs接收attrs,vue2中使用$attrs,composition api中作为setup函数的参数
const attrs = useAttrs()
onMounted(() => {
// 因为父组件传来的name已经被当前组件接收,因此attrs的值只有age和height
console.log(attrs) // 1. Proxy(Object) {age: 18, height: 180}
})
script>
利用这种方法,就能实现父组件向孙组件甚至更深层级的组件传参
。
$listeners
就不多做赘述了,毕竟是已经删除的内容,它的使用就是在后代组件中抛出一个事件
,然后在中间传递的过程中使用v-on监听事件
,最后在需要接收事件的组件上使用@eventname
接收事件。
Vuex
和pinia
都是状态管理工具
,通常在Vue2中使用Vuex
,在Vue3中
作者更推崇我们使用pinia
,同时pinia也兼容了Vue2
。
什么是状态管理工具呢,我们可以把它看作是一个仓库(store)
,当有一些公共的属性和方法
时,我们可以存储到这个store中,不管是父子、爷孙
,甚至毫无关系
的两个组件,都可以向store中存储数据
,而其它组件想使用这些数据时,直接从store获取
,就实现了跨层级的数据共享和传参
。
在Vue2
中,我们可以通过新建一个文件夹,然后new一个Vue实例
,去实现一个事件总线
,它可以在任何组件
之间通过$emit发送事件
、$on监听事件
、$once监听一次事件
、$off停止监听事件
,即使是两个毫无关系的组件
,想进行消息传递时,只需要其中一个组件使用$emit发送一个事件
,在另一个组件中使用$on/$once监听该事件
,然后执行相应的逻辑,在组件销毁或非激活态
的时候使用$off停止监听
,就可以做到跨层级的通信
。
在Vue3
中,移除了事件总线
,但是依然有一些第三方库
实现了该功能,比如Mitt
,使用方式也和Vue2中的相似。
之所以移除了事件总线,是因为在使用该功能时,很容易导致代码的混乱
,不同的组件抛出各种事件
,这些事件又被各种组件接收,太多时就会产生难以溯源
的情况,不知道这些事件都是从哪个组件发出来的,让代码变得难以维护
。并且有些开发者在使用时,不遵守规范,只使用$on,不使用$off关闭
,浪费性能。
在使用optionsApi
(一般在Vue2时使用)时,可以这样使用provide/inject
。
<script>
export default {
name: 'App',
provide: {
name: 'Lee'
}
}
script>
<script>
export default {
name: 'HelloWorld',
inject: ['name'],
mounted() {
console.log(this.name) // Lee
}
}
script>
但是这种传参有一个缺点,那就是参数不是响应式
的,如果传递data中的数据呢
?
<template>
<HelloWorld ref="Child" />
<button @click="change">改变属性值button>
template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
},
provide() {
return {
name: this.name
}
},
data() {
return {
name: 'Lee'
}
},
methods: {
change() {
this.name = 'Father'
console.log(this.$refs.Child.name) // Lee
}
}
}
script>
<script>
export default {
name: 'HelloWorld',
inject: ['name'],
mounted() {
console.log(this.name) // Lee
}
}
script>
传递data
中的数据时,provide
就要写成一个函数,返回一个对象
,虽然传递的是data中的数据
,但是依旧不是响应式的
。
想要将它变成响应式,一共有三种方法:传递父组件实例
、使用Vue.observable
、使用computed
。
<template>
<HelloWorld ref="Child" />
<button @click="change">改变属性值button>
template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
},
provide() {
return {
father: this
}
},
data() {
return {
name: 'Lee'
}
},
methods: {
change() {
this.name = 'Father'
console.log(this.$refs.Child.father.name) // Father
}
}
}
script>
<script>
export default {
name: 'HelloWorld',
inject: ['father'],
mounted() {
console.log(this.father.name) // Lee
}
}
script>
Vue.observable
是Vue2.6.0
之后新增的一个API,它的作用就是让一个对象变成响应式的
。
<template>
<HelloWorld ref="Child" />
<button @click="change">改变属性值button>
template>
<script>
import HelloWorld from './components/HelloWorld.vue'
import Vue from 'vue'
export default {
name: 'App',
components: {
HelloWorld
},
provide() {
this.name = Vue.observable({
value: 'Lee'
})
return {
name: this.name
}
},
mounted() {
console.log(this.name) // 1. {value: 'Lee'}
},
methods: {
change() {
this.name.value = 'Father'
console.log(this.$refs.Child.name.value) // Father
}
}
}
script>
<script>
export default {
name: 'HelloWorld',
inject: ['name'],
mounted() {
console.log(this.name.value) // Lee
}
}
script>
在Vue3
中,已经无法使用Vue.observable
了,但是vue3提供了computed函数
。
```html
<template>
<HelloWorld ref="Child" />
<button @click="change">改变属性值button>
template>
<script>
import HelloWorld from './components/HelloWorld.vue'
import { computed } from 'vue'
export default {
name: 'App',
components: {
HelloWorld
},
provide() {
return {
name: computed(() => this.name)
}
},
data() {
return {
name: 'Lee'
}
},
methods: {
change() {
this.name = 'Father'
console.log(this.$refs.Child.name.value) // Father
}
}
}
script>
<script>
export default {
name: 'HelloWorld',
inject: ['name'],
mounted() {
console.log(this.name.value) // Lee
}
}
script>
在Vue2
中,v-for的优先级要高于
v-if,但是在Vue3
中,v-for的优先级要低于
v-if。
在Vue2
中,会先通过v-for
遍历,然后对每一项使用v-if
判断,不满足条件的不会渲染,但是这种方式并不好,相当于对很多个标签都添加了v-if
,每次渲染之前都要判断。于是在Vue3
中,v-if
的优先级要高于v-for
了,相当于在v-for外层包裹了一层
,但是这时的判断条件肯定是错的,因此在vue3中
同一标签使用v-for和v-if
时,会报错。
<div v-if="item === 1" v-for="item in 6" :key="index">
<span>{{ item }}span>
div>
<template v-if="item === 1" >
<div v-for="item in 6" :key="index">
<span>{{ item }}span>
div>
template>
key
的作用就是标识当前VNode节点
,一般用于v-for
中。使用key
进行标识的元素,在进行更新
操作时会将更新前后两个key相同的元素
视作同一元素,进行对比,然后进行相应的更新操作,如果没有key
,就只能按顺序
进行对比,在合理的场景使用合理的key
可以提升更新时的渲染性能
。
比如说有以下代码:
<template>
<div class="wrap">
<span v-for="(item, index) in arr" :key="index">{{ item }}span>
div>
template>
<script setup>
import { ref, onMounted } from 'vue'
const arr = ref([1, 2, 3, 4, 5])
onMounted(() => {
arr.value.unshift(0)
})
script>
在第一次渲染时,渲染了5个span标签,它们的key分别为0、1、2、3、4
。这时v-for遍历的数组头部
插入了一项新的值,页面进行更新
,渲染了6个span标签,它们的key变成了0、1、2、3、4、5
,虽然新的1、2、3、4、5
就是之前的0、1、2、3、4
,但是在进行更新时,会拿key
相同的去对比,这样一来就变成了旧的1和新的1(相当于旧的0)
、旧的2和新的2(相当于旧的1)
…以此类推,明明本来只是新增了一个节点,其它节点都不用改变
,但是现在却变成了每个节点都需要更新
,影响了渲染性能。这也是为什么不提倡使用索引值index
作为key的原因,因为它并没有对更新时的渲染起到任何优化作用
。
data
是一个组件的私有属性
,但是一个组件
可以被其它多个组件
使用,之所以必须是一个函数,是因为函数作用域是私有作用域
,保证变量不会被污染。如果我们返回一个普通对象
,在多个组件使用该组件时,如果都对data中的某个属性进行了修改,所有使用该组件的组件
都会被影响,而使用函数则每次都会创建一个新的对象,保证当前的data不会被其它组件所影响。
在每一次创建组件实例
时,Vue都会去初始化这个组件的状态。
是不是一个函数
,如果是函数,就直接调用这个函数,并将函数返回的对象赋值给组件实例的data属性
(这就是为什么使用函数不会造成变量污染,因为每次都会调用这个函数,生成新的对象);异常处理
,当我们的data不是一个函数
时,会抛出异常
。v-model
就是v-bind:xxx
和@xxx
的语法糖,默认为v-bind:value
和@input
,在input标签上使用v-model
时,类似于这样:
<template>
<div class="wrap">
<input v-model="value" type="text" />
div>
template>
<script>
export default {
data() {
return {
value: 1
}
}
}
script>
<template>
<div class="wrap">
<input :value="value" type="text" @input="changeVal" />
div>
template>
<script>
export default {
data() {
return {
value: 1
}
},
methods: {
changeVal(e) {
this.value = e.target.value
}
}
}
script>
在Vue2中,如果我们想改变v-model
绑定的值和事件,可以给组件添加model配置项
,比如这样:
<template>
<div class="wrap">
<input :value="name" type="text" @input="changeName" />
div>
template>
<script>
export default {
model: {
prop: 'name',
event: 'changeName'
},
props: {
name: String
},
methods: {
changeName(e) {
this.$emit('changeName', e.target.value)
setTimeout(() => {
console.log(this.$parent.name)
}, 2000)
}
}
}
script>
<template>
<div id="app">
<HelloWorld v-model="name" />
div>
template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
},
data() {
return {
name: 'Lee'
}
}
}
script>
在Vue3中,移除了.sync
修饰符,使用v-model
来替代。此时在自定义组件中v-model
代表的含义为v-model:moduleValue
,也就相当于:module-value="xxx" && @update:module-value="newValue => xxx = newValue"
。并且在Vue3中,v-model可以传参
,默认参数就是moduleValue
,我们也可以改为v-model:a
,这时就代表我们给子组件的一个名为a的props属性传参
,并且接收了一个@update:a的回调
。
computed
称为计算属性
,它必须拥有返回值
,它的值可以是固定的
,也可以是依赖其它响应式数据计算出来的
,它进行一些同步运算处理
,当它依赖其它值时,只有当其它值改变时,它才会触发更新。
watch
称为监听
,它需要监听一个响应式数据
,当它监听的数据改变时,它会处理回调任务,可以在回调中做一些异步操作
。
computed
是有缓存的,读取computed属性时,如果依赖的值没有变化
,就会读取缓存的内容,而watch
没有缓存,只要数据改动,就会触发回调;computed
必须要有返回值,watch
不需要有返回值;computed
在初始化时就会执行一次,而watch
初始化时默认不会执行
,如果我们想让它执行,可以设置它的immediate
属性为true
;computed
相当于创建了一个新的响应式属性
,而watch
相当于监听原有的响应式属性
,然后执行回调;computed
中处理的是同步操作
,而watch
可以处理异步任务
。在Vue2中
,可以通过将一些公共属性
挂载到Vue
的原型上,实现各个组件的共享,在组件中可以通过this
来访问。
Vue.prototype.a = 'a'
在Vue3中
,不再导出Vue构造函数了
,并且在composition API
中无法使用this
,因此如果我们想挂载全局属性,应该使用这种方法:
// 添加
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.config.globalProperties.a = 'a'
app.mount('#app')
// 使用
import { getCurrentInstance } from 'vue'
const { a } = getCurrentInstance().appContext.config.globalProperties
SPA单页面应用
其中特点之一就是更改HTML显示内容
,来模拟页面跳转。而vue-router
可以帮助我们做到这一点。
vue-router
提供了三种路由模式,分别是Hash模式
、History模式
以及在Vue-Router4.x新增的Memory模式
(一般不会用于浏览器环境),开发者可以通过设置mode(vue-router4.x之后为history)
属性,去选择不同的路由模式。
Hash模式
下,URL的样子是这样的https://xxx.com#123
。
Hash模式
是vue-router的默认使用方式。这种方式是利用了URL的hash
,URL的hash
就是#
,也可以称之为锚点
,只改变URL中#
之后的部分其实是相当于更改了网页的位置
,也相当于更改了window.location.hash
的属性值,但是网页地址还是没有改变,因此不会进行刷新网页
,在进行请求接口时,也只会向#
之前的地址进行请求,并不会带上#
之后的内容,因此不会对服务端产生影响。
每次改变#之后
的内容时,都会在浏览器的历史记录
中增加一个记录,使用浏览器的后退/前进
功能可以回到上一次或下一次的页面位置,而根据#之后的值
,我们就可以渲染不同的页面
,从而达到模拟页面跳转
的操作,而且可以通过hashchange
监听URL-hash的变化。
Hash模式
下,URL会出现一个#
符号,影响美观,而History模式
不会有这个符号,因此当我们不想要这个符号时,可以设置为History模式
。
History模式
是利用了HTML5中新增的history API
来进行URL管理,主要利用pushState()
和replaceState()
来改变URL,使用这两种方法,可以给浏览器的历史记录添加一条新纪录或者替换记录,但是这两种方法改变URL时,不会立即
向服务器发送请求,只有在执行history.back()
、history.forward()
、history.go()
的时候才会向服务器发送请求,可以通过监听popstate事件
,来监听浏览器的前进回退操作
,然后进行路由的匹配
。
History模式
需要服务端的支持,因为它在进行浏览器前进/回退操作
时会发送请求,如果这时没有匹配到资源,就会出现404
,因此需要服务端对这种场景做处理。
// router文件夹下index.js文件
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
// 导入组件
import HomePage from '@/components/home-page.vue'
import SecondPage from '@/components/second-page.vue'
// 定义路由,将路由路径和组件进行映射
const routes = [
{
path: '/',
component: HomePage
},
{
path: '/second',
// 可以设置路由的name属性
name: 'second',
component: SecondPage
}
]
// 创建路由
const router = createRouter({
// 设置history模式路由
history: createWebHistory(),
// 设置hash模式路由
history: createWebHashHistory(),
routes
})
// 导出路由实例
export default router
// Vue项目入口main.js文件
import { createApp } from 'vue'
import App from './App.vue'
import router from '@/router'
const app = createApp(App)
app.use(router).mount('#app')
vue-router
提供了router-link
标签来实现路由的跳转,相当于一个a标签
,但是可以使用vue-router
提供的一些方法;
vue-router
还提供了push
、replace
、go
、forward
、back
等常用方法进行编程式导航
。
push
:用于跳转下一个页面,会在历史记录添加一条新纪录;replace
:和push相同,只是会将当前历史记录替换掉;go
:可以传入一个数字,如果是负数则代表后退几条历史记录,如果是正数则代表前进;forward
:相当于go(1);back
:相当于go(-1);此外,在Vue2中,可以通过this.$router
去执行相应的方法,而在Vue3中,则需要先引入useRouter
组合式API,然后调用该方法得到一个router对象,才能够执行相应的方法。
路由的传参方式一共有两种,一种是query
,另一种是params
。
query
参数会在URL显式存在,params
一般不会出现在URL上(动态路由除外);query
传参可以和path
属性一起使用,params
不能和path
属性一起使用,只能和name
属性一起使用;params
传参在刷新页面时会参数丢失
(动态路由不会),而query
传参不会有这种情况。// 第一个页面通过query方式传参
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const jumpPage = () => {
router.push({
path: '/second',
query: {
a: 1,
b: 2
}
})
}
script>
// 跳转后的URL为 http://localhost:8080/second?a=1&b=2
// 第一个页面通过params方式传参
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const jumpPage = () => {
// 报警告:[Vue Router warn]: Path "/second" was passed with params but they will be ignored. Use a named route alongside params instead.
// router.push({
// path: '/second',
// params: {
// a: 1,
// b: 2
// }
// })
router.push({
name: 'second',
params: {
a: 1,
b: 2
}
})
}
script>
// 跳转后的URL为 http://localhost:8080/second
如何获取传递的参数呢?
Vue2中可以通过this.$route.query/this.$route.params
来获取,而Vue3中可以通过引入useRoute
,然后调用该函数,得到route对象,访问route.params/route.query
。
// in second-page.vue
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
// console.log(route.query)
console.log(route.params) // [Vue Router warn]: Discarded invalid param(s) "a", "b" when navigating.
script>
在vue-router
4.14版本之前
,是可以访问到params传参内容的
,但是这种传参方式一直是不被提倡的
,因为刷新页面会使参数丢失。虽然这种情况可以通过vuex或pinia等工具进行缓存
,在刷新页面时重新赋值,但是这种操作也会带来一定的风险。于是在4.14版本之后,如果没有使用动态路由
,直接使用params传参,参数是不会被下个页面接收的。
动态路由
就是路由的URL并不固定,是根据传入的参数动态决定的,动态路由的参数必须通过params
传递,并且可以在params中接收
。
const routes = [
{
path: '/',
component: HomePage
},
// 此时second就是一个动态路由,它可以接收两个参数a、b
{
path: '/second/:a/:b',
name: 'second',
component: SecondPage
}
]
此时进行params传参:
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const jumpPage = () => {
router.push({
name: 'second',
params: {
a: 1,
b: 2
}
})
}
script>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
console.log(route.params) // {a: '1', b: '2'}
script>
此时params的参数就可以被正常接收了
,也就是说在vue-router4.14版本之后
,如果我们想要通过params的方式给下个页面传递参数,那么必须使用动态路由
,也就是说必须先将要传递的参数定义在路由中。
每个路由对象中可以设置props
属性,默认为false,设置为true表示通过params传参的参数会作为组件的props传入
。
但是这种通过路由传递过来的参数,类型都会变成string
,因此在使用时要注意。
此外props属性
还可以被设置成函数
或者其它一些特殊场景的处理
,可以看Vue-router的官网示例。
$router
在Vue3中以useRouter
的方式使用,$route
在Vue3中以useRoute
的方式使用。
这是$router
包含的内容:
这是$route
包含的内容:
从中可以看到,$router
中包含了路由的一些公共方法,比如跳转
、路由拦截
等,而$route
则包含了一些当前路由的属性
,比如params
、query
、hash
等。
$router
视为全局的路由对象
,操作路由一些方法时,使用$router
;$route
视为当前活跃的路由对象
,当访问当前路由信息的时候,使用$route
;可以使用多个router-view
,设置不同的name
,然后在路由中配置不同name映射的组件。
<template>
<div class="router-wrap">
<router-view name="left" class="router-item">router-view>
<router-view class="router-item">router-view>
<router-view name="right" class="router-item">router-view>
div>
template>
<script setup>script>
<style scoped>
.router-wrap {
display: flex;
}
.router-item {
width: calc(100vw / 3);
height: 100vh;
}
style>
// 路由文件
import { createRouter, createWebHistory } from 'vue-router'
// 导入组件
import HomePage from '@/components/home-page.vue'
import SecondPage from '@/components/second-page.vue'
import LastPage from '@/components/last-page.vue'
// 定义路由,将路由路径和组件进行映射
const routes = [
{
path: '/',
components: {
// 设置不同name对应的组件
left: HomePage,
default: SecondPage,
right: LastPage
}
}
]
// 创建路由
const router = createRouter({
// 设置history模式路由
history: createWebHistory(),
routes
})
// 导出路由实例
export default router
在Vue2
中,vue-router为我们提供了一个*通配符
,可以匹配到那些未定义的路由
,从而可以让我们对这种场景进行一些处理,在Vue3
中,删掉了*通配符
,具体使用方法可以看官网。
路由懒加载
就是将路由组件的静态导入
变为动态导入
,从而实现只有在第一次进入页面时
,才会进行加载,后续将使用缓存。就拿我们上面定义的路由文件来说,它们可以通过懒加载改造成这样:
import { createRouter, createWebHistory } from 'vue-router'
const HomePage = () => import('@/components/home-page.vue')
const routes = [
{
path: '/',
component: HomePage
},
{
path: '/second',
name: 'second',
component: () => import('@/components/second-page.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
component/components
接收一个返回Promise组件
的函数,这样就实现了路由的懒加载
,在我们使用脚手架
进行开发时,借助webpack
之类的打包工具,可以将代码打包时进行分包
。比如在webpack中,我们可以通过魔法注释
和分包配置
,将路由组件打包到不同的包里去:
import { createRouter, createWebHistory } from 'vue-router'
const HomePage = () => import(/* webpackChunkName: "group-a" */ '@/components/home-page.vue')
const routes = [
{
path: '/',
component: HomePage
},
{
path: '/second',
name: 'second',
component: () => import(/* webpackChunkName: "group-b" */ '@/components/second-page.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
路由的导航守卫
可以分为全局前置守卫
、全局解析守卫
、全局后置钩子
、路由独享守卫
、组件内的守卫
等几大类。
全局前置守卫
就是router.beforeEach
,从名字来看,它的含义代表在进入路由之前
。它一共有三个参数to
、from
、next(可选)
。
异步解析执行
,此时导航在所有守卫 resolve 完之前
一直处于等待中
,也就是说等待我们的全局前置守卫执行完毕才会进入导航;to
代表要导航去哪个路由;from
代表从哪里来,或者说准备要离开的路由;false
,代表取消导航
,回到from的位置;路由地址
,比如return({name: second})
,那么将会中断当前导航,相当于重新创建一个to为{name:second}
的导航;undefined或者true
,说明导航是有效的,直接调用下一个导航守卫;next()
,它可以直接调用
,也可以传入一个路由地址
,直接调用相当于直接调用下一个导航守卫,这个参数已经被移除
,但是目前还支持使用,最好还是不要使用,以防哪天官方移除该属性,如果使用,请保证在同一逻辑处理下,该函数只被调用了一次
。全局解析守卫
就是router.beforeResolve
,和router.beforeEach
类似,每次进入导航之前都会调用,但是它会在导航被确认之前
、所有组件内守卫和异步路由守卫被解析之后
再进行调用。
全局后置钩子
就是afterEach
,它在导航完成之后调用,因此它不需要调用next
,也不会改变导航本身,一般的作用就是分析、更改页面标题
。
路由独享守卫
就是beforeEnter
,它是定义在路由文件中
的,只会在从不同路由进入
时触发,如果只是params、query、hash的改变
,不会触发该守卫,比如/user/1
到/user/2
并不会触发,因为这只是相当于params的改变
,只有从/home到/user/2
的时候才会被触发。
路由独享守卫
可以接收多个函数
,可以将一个函数数组
传递给该属性,触发守卫时数组中所有函数都会被触发。
组件内的守卫
一共有三种,分别为beforeRouteEnter
、beforeRouteUpdate
、beforeRouteLeave
,用法类似于组件的生命周期
,只是在这些守卫里访问不到组件实例
,因为守卫发生在导航确认之前
。但是可以在它的next中传入一个回调函数
,回调函数接收组件实例作为参数
,等到组件实例可以被访问时触发该回调,做一些相应的处理。
beforeRouteEnter
不能访问组件实例,其它两个可以;beforeRouteUpdate
会在组件实例复用时触发,比如动态路由
,因为只是参数不同,但是会将路由复用。beforeRouteLeave
守卫;beforeEach
守卫;beforeRouteUpdate
守卫;beforeEnter
守卫;beforeRouteEnter
守卫;beforeResolve
守卫;afterEach
钩子;beforeRouteEnter
守卫中next的回调函数
,创建好的组件实例会作为回调函数的参数传入。Object.defineProperty
变为Proxy
;filters
和mixin
,用methods
和hooks
替代;options API
转变为composition API
;Fragment
内置组件,可以在不引入多余DOM
的情况下,包裹并渲染多个子元素
,这也是为什么vue3
的模板不需要有一个根节点
了;Teleport
内置组件,它可以将内容绑定到指定的节点之下
,比如有些弹窗,我们想让它作为body的子元素
,而不是嵌套太深,就可以使用该组件包裹,然后设置to="body"
;Suspense
内置组件,该组件的作用是等待
,该组件内部有两个插槽
,默认插槽为default
,另一个为fallback
,我们可以将即将展示的东西放入默认插槽,在它还没有值时,展示fallback
插槽的内容,这目前还是一项实验性特性
;$children、$listeners
等属性;v-model
可以传参;在使用setup语法糖(script setup)
时,已经不能像Vue2和setup函数
那样去定义props
或者emits
了,取而代之的是各种各样的宏
。
defineProps
:用于定义组件的props
;defineEmits
:用于定义组件的emits
;defineOptions
:用于定义组件的一些信息name
等;withDefaults
:用于定义props的默认值
;defineExpose
:script setup
的组件默认是关闭的
(外界访问不到实例),需要通过该方法导出那些需要被外界访问的属性
;useSlots、useAttrs
:相当于Vue2的$slots
和$attrs
,它们是真实的函数,在setup函数
中也可以使用;响应式
就是在我们修改数据之后,无需手动
触发视图更新,视图会自动更新。
Vue2
中,响应式系统是通过依次遍历
data返回的对象,将里面每一个属性通过Object.defineProperty
进行定义,然后在属性描述符
中添加get/set
,实现getter/setter方法
,在访问属性
时,在getter函数中收集依赖
(记录哪些方法或变量在使用这个属性),在修改属性
时,在setter函数中派发依赖
(将收集到的依赖依次更新),从而达到响应式
。
Vue3
中,响应式系统是通过ES6中的Proxy
实现对一个对象的代理
,然后设置handler.get/handler.set
,在对代理对象
进行操作时,可以触发get/set
,和Object.defineProperty类似
,get中实现收集依赖
,在set中实现派发依赖
,从而达到响应式
的效果。
既然Vue3要更改响应式的实现方式,那么说明Vue2的响应式实现一定是有缺点的
。
Object.defineProperty
只能对对象的属性
进行监听,也就是说当我们想对某个对象
进行监听时,必须将这个对象遍历
,然后对其中的每一个属性进行监听。如果说对象中的某个属性又是一个对象,那就需要递归遍历
,将每一层都进行监听,这样的性能肯定是比较低的。Object.defineProperty
只能对已有属性
进行监听,也就是说,在Vue2中,created()阶段
Vue内部已经帮我们把data中的属性遍历完毕并且对每个属性进行监听了,如果在之后的阶段我们给某个对象使用obj.xx的方式
给对象添加了一个新属性,这个属性就不再是响应式了
,这也是为什么我们在添加新属性时,需要使用this.$set
的方式。Object.defineProperty
不能监听数组长度的改变
,这也就造成了我们在使用一些影响原数组
的数组方法时,它监听不到,比如我们使用pop、shift、push
等,这也是为什么Vue2要重写部分数组原型方法
。
Object.defineProperty
不能监听数组长度变化,但是它是可以监听数组内容变化的,前提是我们需要像对象一样,把数组进行遍历,然后对每一个索引值
进行监听。之所以Vue2没有对数组的每一项进行监听,是因为数组的长度有可能会很长
,一般来说对象的属性值并不会有太多,而数组中的数据可能长达上万甚至数十万
,如果对数组进行遍历监听每一项,代价无疑是巨大的。
Proxy
的作用是返回一个代理对象
,因此它不需要再遍历/深度遍历
一个对象,而是只需要将原对象作为参数传入
,就可以返回该对象的代理对象
,并且它的第二个参数handler
提供了13种
方法,能够监听代理对象的各种操作。代理对象
使用obj.xx
的方式添加一个新属性时,它依旧能够对新添加的数据进行监听。Proxy
不仅可以监听数组索引值的变化,还能够监听原型方法(pop、push)
等。$set
解决的就是对象/数组添加新属性不是响应式
的问题,因此它的核心就是调用此方法,vue内部帮助我们把添加的属性变成响应式
。
首先这个方法接收三个参数
:
target
:需要添加属性的对象;key
:需要添加/修改的对象key值;val
:需要修改的value值;数组
,并且key是否为一个正确的索引
,如果是,就会修改数组长度为key和原数组长度的最大值
,然后调用数组的splice
方法进行更新数组,我们知道splice方法也是不能被defineProperty监听的
,为什么这里要调用此方法呢?这是因为Vue内部帮我们重写了数组原型的该方法。传入的key值是否在对象中
,并且不是在对象的原型上
,如果已经在对象中的话,不管当前对象是否为响应式对象
,直接通过target[key] = val
修改属性值就行了(如果原对象是响应式,那么它已有的属性肯定是响应式的,如果不是,那它已有的属性也不需要是响应式)。vue实例
,那么就会抛出警告;target.__ob__
判断传入的对象是否为响应式的
,如果不是响应式
,那么给非响应式对象添加属性时,也不需要是响应式,直接使用target[key]=val
就行了,如果对象是一个响应式的
,那么给它添加新属性,也必须要变成响应式,于是就会调用defineReactive方法将该属性添加getter/setter(本质就是使用Object.defineProperty)
将其变成响应式。Vue2
一共重写了7个
数组原型上的方法,这些方法都会改变原数组
。分别是pop
、push
、unshift
、shift
、splice
、sort
、reverse
。
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method]
def(arrayMethods, method, function mutator(...args) {
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// notify change
if (__DEV__) {
ob.dep.notify({
type: TriggerOpTypes.ARRAY_MUTATION,
target: this,
key: method
})
} else {
ob.dep.notify()
}
return result
})
})
从源码可以看出,在调用数组这七个方法时,依旧会调用原来的这些方法
,只是在调用完成之后会触发依赖更新
,如果是push
、unshift
、splice
这些可能会新增属性的,会将新增的属性变为响应式,然后触发依赖更新。
diff
算法就是比较新旧虚拟节点(VNode)
,对旧节点进行更新、删除、新增
操作,然后更新到真实的DOM上。
同层对比
,不会跨层级,比如一个旧节点不会与新节点的子节点去进行对比;两端开始对比
,逐渐向中间收拢;let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
变量
,记录当前的头尾索引值和新旧节点的信息,如新旧节点的头节点、新旧节点的尾节点、新旧节点的长度
等;while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
}
旧节点头部索引值 <= 旧节点总长度
并且新节点头部当前索引值 <= 新节点总长度
时,执行以下代码,第一个判断,如果当旧节点头部索引值的VNode是空
,那就将头部的VNode变为第1项
(如果第一项还是空,那就变成第二项…依此类推),如果当旧节点尾部索引值的VNode为空
,旧将尾部的VNode变为前一项
。else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(
oldStartVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
旧节点头部节点和新节点头部节点相同
,那么就会执行patch
,进行更新,然后指针变化,开始第二个节点的对比;else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(
oldEndVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
旧节点尾部节点和新节点尾部节点是否相同
,如果相同,就执行patch
,然后指针前移,开始前一个节点的对比;else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(
oldStartVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
)
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
头头、尾尾都不相同
,接下来就会判断旧的头部和新的尾部是否相同
,如果相同,就会将旧头和新尾进行patch
,并且将旧的头部真实节点
插入到旧的尾部真实节点之后
,然后旧节点指针后移,新节点指针前移;else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(
oldEndVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
)
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
旧头部和新尾部还不相同
,就会对比旧尾部和新头部
,如果相同,就将旧尾和新头进行patch
,并且将旧的尾部真实节点
插入到旧的头部真实节点之前
,然后旧节点指针前移,新节点指针后移。else {
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) {
// New element
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(
vnodeToMove,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
)
oldCh[idxInOld] = undefined
canMove &&
nodeOps.insertBefore(
parentElm,
vnodeToMove.elm,
oldStartVnode.elm
)
} else {
// same key but different element. treat as new element
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
)
}
}
newStartVnode = newCh[++newStartIdx]
}
最复杂
的diff了,这时首先会将旧的VNode列表
从oldStartIdx到oldEndIdx
的数据进行遍历,然后将它们的key
和索引
建立映射关系
,存到一个表中记录。然后看当前新的节点的key是否在表中存在
,如果不存在,说明这是新增的节点,直接创建新节点。如果存在,就会去判断是不是可以复用的,类型一样的节点
,如果是就执行patch
,如果不是就创建新节点
。if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(
parentElm,
refElm,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
while循环
结束之后,判断如果oldStartIdx > oldEndIdx成立
说明旧的VNode节点先遍历完成了
(遍历完成之后,最后一次++index会让startIdx > endIdx),那么就说明旧的VNode节点更少
,然后就把新节点多出来的节点进行创建然后添加到真实dom中,反之说明旧的VNode节点更多
,需要删除节点
,那就把旧节点多出来的从真实dom进行删除。至此,整个diff算法结束。const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children
const { patchFlag, shapeFlag } = n2
// fast path
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// this could be either fully-keyed or mixed (some keyed some not)
// presence of patchFlag means children are guaranteed to be arrays
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
return
}
patchFlag标识
去判断子代是否为全都有key或者一部分有key
,patchFlag的存在保证了子代是一个数组
,此时执行有key的diff算法
。else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// unkeyed
patchUnkeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
return
}
}
全都没有key
,直接进行没key的diff算法
。// children has 3 possibilities: text, array or no children.
// children有三种情况: 文本节点, 数组节点, 或者没有节点
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// text children fast path
// 文本节点快速方式
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
}
if (c2 !== c1) {
hostSetElementText(container, c2 as string)
}
}
没有patchFlag或者patchFlag <= 0
,那么子节点有三种情况
,分别为文本节点、数组节点、空节点
。第一次先会根据新节点的shapeFlag标识
判断子节点是不是文本节点
,如果条件满足,就会去判断旧节点的shapeFlag标识
,判断旧节点的子节点是不是数组节点
,如果旧的是数组,新的是文本,直接把旧节点卸载
。如果旧节点的子节点不是数组节点,那要么是空节点,要么是文本节点,此时判断新旧节点是否全等
,如果不等,就直接执行更新或插入新的文本节点
。 else {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// prev children was array
// 旧VNode的子节点是数组节点
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// two arrays, cannot assume anything, do full diff
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// no new children, just unmount old
// 没有新节点, 只是卸载掉旧节点
unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
}
} else {
// prev children was text OR null
// new children is array OR null
// 旧的VNode的子节点是文本节点或者空节点
// 新的VNode的子节点是数组节点或者空节点
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(container, '')
}
// mount new if array
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
}
}
如果新节点的子节点不是文本节点
,那就分情况判断,如果旧节点的子节点是数组节点并且新节点的子节点也是数组节点
,就会直接执行有key的diff方式
。如果旧节点的子节点是数组节点,但是新节点的子节点不是数组节点
,说明新节点是空节点
,直接卸载掉旧节点。
如果旧节点的子节点不是数组节点
,那么旧节点只可能为文本或空节点
,新节点只可能为数组或空节点
,如果旧节点为文本节点
,直接将文本变为空的,这时再去判断新节点的子节点是不是数组节点
,如果是的话就重新创建一个数组节点
,如果不是的话,旧的文本节点依旧变成空节点了也就不需要做其它操作了。
在这整个过程中,最重要的是两个方法patchUnkeyedChildren
和patchKeyedChildren
,分别是没key的diff算法
和有key时的diff算法
。
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
c1 = c1 || EMPTY_ARR
c2 = c2 || EMPTY_ARR
const oldLength = c1.length
const newLength = c2.length
const commonLength = Math.min(oldLength, newLength)
let i
for (i = 0; i < commonLength; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
patch(
c1[i],
nextChild,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
if (oldLength > newLength) {
// remove old
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength
)
} else {
// mount new
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
commonLength
)
}
}
无key时
的diff算法,它会直接按照新旧节点较小的长度
进行遍历,然后一项一项进行对比,其中optimized
的作用是标识了渲染时是否进行了优化
(比如可能某些节点做了静态提升,那么optimized为true时就会去判断当前节点是否进行了静态提升,如果有静态提升就会克隆之前的节点进行复用)。遍历对比完之后去判断新旧节点的长度
,如果旧的更长,说明需要进行删除操作
,如果新的更长,说明需要进行新增节点的操作
。const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 1 // next ending index
有key时
的diff算法,先声明了一些变量以及传入参数。// 1. sync from start
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
i++
}
有key时
第一个判断,从头部依次对比
,第一个不同时,指针就会向后移对比第二个,直到不满足条件退出循环。这时,每次匹配到一个相同的节点
就会进行patch
。// 2. sync from end
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
e1--
e2--
}
有key时
第二个判断,从尾部依次对比
,最后一个不同时,指针就会向前移对比倒数第二个,直到不满足条件退出循环。这时,每次匹配到一个相同的节点
就会进行patch
。// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) {
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}
有key时
的第三种判断,这时根据规律,当e1 < i <= e2
时,说明新旧节点有公共序列并且需要新增节点
,这时就会把新节点多出来的去和空节点进行patch,相当于新增了。// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
有key时
的第四种判断,这时根据规律,当e2 < i
时,说明新旧节点有公共序列并且需要删除旧节点
,这时就会把旧节点多出来的节点进行遍历,然后依次删除。// 5. unknown sequence
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
const s1 = i // prev starting index
const s2 = i // next starting index
// 5.1 build key:index map for newChildren
// 建立一个Map集合, 这个集合的key是VNode的key, value为索引值
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (nextChild.key != null) {
if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
warn(
`Duplicate keys found during update:`,
JSON.stringify(nextChild.key),
`Make sure keys are unique.`
)
}
keyToNewIndexMap.set(nextChild.key, i)
}
}
无序时
,首先第一步,会建立一个Map集合
,里面会将新节点的所有子节点的key和索引
作为key:value
的形式存储进该集合。// 5.2 loop through old children left to be patched and try to patch
// matching nodes & remove nodes that are no longer present
let j
let patched = 0
const toBePatched = e2 - s2 + 1
let moved = false
// used to track whether any node has moved
let maxNewIndexSoFar = 0
// works as Map
// Note that oldIndex is offset by +1
// and oldIndex = 0 is a special value indicating the new node has
// no corresponding old node.
// used for determining longest stable subsequence
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
if (patched >= toBePatched) {
// 已经patch过的 >= 准备要patch的,说明都被patch过了,那当前节点就是多余的了,直接进行删除
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
// 设置遍历寻找新节点中该节点的索引
let newIndex
// 如果旧的节点的key不为空,直接去新节点的`key:value`表中去查找新索引
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 旧的节点没有key,尝试去新节点中找到和旧节点类型一样的那个节点,然后拿到新的索引
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
newIndex = j
break
}
}
}
// 如果新节点中没有当前节点的索引,说明新节点中当前节点已经不存在了,直接删除即可
if (newIndex === undefined) {
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 找到新索引之后,更新当前节点新索引对应的旧索引
// 比如拿官方例子来说
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, s1 = i s2 = i,此时第一次循环,s1 = 2, s2 = 2,i = 2,e1[i] = c
// 找到c在新节点的索引为4,那么newIndexToOldIndexMap[4-2] = 2 + 1(arr[2] = 3)
// 意思就是说需要更新的这一部分列表,第二项的数据对应的是旧节点的第3个数据
// 很多人在这里有疑问,c不是旧节点的第二个数据吗?怎么会变成第三个?为什么要i + 1?
// 这是因为创建newIndexToOldIndexMap数组时,给每一项默认赋值了0,当前项的值为0时,说明还没有建立映射关系
// 因此使用i+1的方式建立对应关系,我们可以理解为newIndexToOldIndexMap[2] = 2,只是存储时在旧节点的索引值上加了1
newIndexToOldIndexMap[newIndex - s2] = i + 1
// 如果新节点的索引>=maxNewIndexSoFar,就更新maxNewIndexSoFar的值,否则就需要进行移动
// 比如第一次循环,c的newIndex为4,那么maxNewIndexSoFar就会被更新成4
// 第二次循环,d的newIndex为3,3 < 4,说明在新节点中,d的位置在c之前,需要进行移动
// 如果说新节点的顺序也是c、d、e、h,那么第一次循环,maxNewIndexSoFar的值就是2,
// 第二次循环,d的newIndex是3,3>2说明d排在c之后,和旧节点情况一样,不用移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
// 标记为需要移动
moved = true
}
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
patched++
}
}
跳过那些已经patch过的节点
,对其它没有patch过的节点进行patch,并且会移除那些已经不存在的旧节点
。这里面首先会创建一个新节点的索引和旧节点索引对照关系的一个数组
,用于获取无序序列的最长递增子序列
。然后遍历旧节点
,找到在新节点中存在的节点
,进行复用,执行patch,并且更新索引对应关系表
,如果有多余的节点,就进行删除。// 5.3 move and mount
// generate longest stable subsequence only when nodes have moved
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// looping backwards so that we can use last patched node as anchor
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// mount new
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) {
// move if:
// There is no stable subsequence (e.g. a reverse)
// OR current node is not among the stable sequence
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
需要移动
,就会生成一个最长递增子序列
,然后遍历需要patch的序列
,看看序列中有没有对应的旧节点索引值为0的
,如果有,说明这些节点在旧节点中不存在,就需要执行新增节点的操作
。如果存在,并且需要移动
,就会将这些节点移动到对应的位置。至此,整个diff算法完成。加入旧节点子元素列表的key
为[1,2,3,4,5],经过数据变化,新的节点顺序变动,key
变成了[1,3,5,2,4],如果我们不进行任何优化,那么只有1是可复用的,我们需要把2、3、4、5
四个节点分别移动到对应位置
,需要移动4次
。如果我们算出来了新节点列表的最长递增子序列
([1,2,4]),那么我们可以保持这三个元素不做变动
,只将3、5
两个元素进行移动,只需要移动2次
。这就是为什么最长递增子序列
可以减少操作dom的次数
,从而达到优化的原因。
在Vue2中,diff算法采用的是双指针进行头头相比、尾尾相比、头尾相比
,最终通过映射关系
来确认可复用的节点,进行更新。
在Vue3中,diff算法分为有key和无key和快速diff三种方式
,快速diff
通过静态标记,对一些文本
、空节点
进行快速更新,无key方式
简单粗暴对比每一项,判断是否可以复用节点,有key的方式
依旧采用双指针,但是只进行头头相比、尾尾相比
,最终会根据求取无序列表的最长递增子序列
的方式,对能复用的节点进行patch,需要移动的节点进行移动节点
,最终完成diff更新。
Vue3
的diff算法优化点如下:
静态提升
:在模板编译时
,会将没有用到动态变量
的节点或属性(class、style这些元素属性)
进行静态提升
,在进行render时,直接复用旧节点。而在Vue2中
,无论元素是否使用了动态变量
,每次更新都会重新创建
,这也是为什么Vue3
最好使用template
而不是render函数,因为模板编译时会帮我们做优化;预字符串化
:当编译器遇到大量的静态节点时
,会将这一整部分变成字符串,减少VNode的创建
,渲染为静态节点
,而在Vue2中,则会将这些节点一个个变成虚拟节点;缓存事件处理函数
:在Vue3中,会将dom元素绑定的事件进行缓存
,在进行patch的时候会使用缓存中的事件处理函数;Block Tree
:在Vue3中,Block
用于提取那些动态属性
的节点,从而在进行更新时,可以精准的比较Block中的内容
,只更新那些使用动态节点的节点;patchFlags
:patchFlags
是编译器生成的优化提示
,它标记了节点的哪些属性是动态的
,从而在进行更新时,精确的对某些属性进行更新;shapeFlags
:shapeFlags
也是一个标识,它标识了当前虚拟节点的类型,从而可以在进行diff时能够省去类型判断
,对不同类型做不同的更新处理。$nextTick
是 Vue.js提供的一个异步更新DOM的方法。因为Vue的更新是异步
的,如果你想在改变某个属性之后立即去操作DOM,可能结果并不是你想要的,而nextTick
允许你在当前 DOM 更新循环结束之后执行一个回调函数,这样可以确保在回调函数中操作的DOM是最新的
。
Vue2和Vue3中使用
nextTick的方式和实现的原理
都不一样。
在Vue2中,我们可以直接使用this.$nextTick
去使用该函数。
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// In problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (
!isIE &&
typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
// Use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, Android 4.4
// (#6466 MutationObserver is unreliable in IE11)
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Technically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e: any) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
timerFunc()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
从Vue2的nextTick的源码实现中我们可以看到,传入nextTick的回调函数,会放入callbacks数组中
,然后会遍历callbacks数组
,依次执行其中的函数。具体在什么时机执行呢?如果支持Promise
,就使用Promise.then
,否则就使用MutationObserver
,这两种是微任务
,如果都不支持,会判断是否支持setImmediate
(Node中),如果不支持则会使用setTimeout
的方式来进行异步执行。
如何确保nextTick的内容一定会在
数据更新之后执行呢?
这个其实是无法完全保证的,我们对数据的更新也是通过nextTick添加到异步任务队列
进行异步更新的,当我们在数据更新之后手动调用nextTick
时,我们的代码就会放在数据更新之后
进行执行了。比如我们先改变了数据,Vue内部的任务队列此时会有[a]
一个任务,这个任务就是准备更新数据的任务,此时我们手动调用nextTick
,这时任务队列就会有[a,b]
两个任务,到时候执行时先执行a,然后才会去执行我们的回调任务。
Vue3中,由于无法访问this
,因此在使用时变成了导入nextTick函数
进行调用,并且该函数返回一个Promise对象
,我们可以使用await或者.then
来获取异步操作的结果。
export function nextTick<T = void, R = void>(
this: T,
fn?: (this: T) => R
): Promise<Awaited<R>> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
由此可见,Vue3中的nextTick
只是利用了promise
来实现的(我感觉是因为Vue3已经打算放弃兼容那些低版本浏览器了,因此才会这么做),而nextTick的回调
其实就像相当于放在Promise.then()
中来执行的。当然还有各种细节,有兴趣的可以去看下源码(在runtime-core文件夹下的src文件夹中的scheduler.ts文件)。
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
if (isReadonly(target)) {
return target
}
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap
)
}
从源码可以看出,reactive
需要传入一个对象
,如果是一个只读的对象,那就返回原对象,否则就调用createReactiveObject()
函数,返回一个经过Proxy代理的对象
。
function createRef(rawValue: unknown, shallow: boolean) {
if (isRef(rawValue)) {
return rawValue
}
return new RefImpl(rawValue, shallow)
}
class RefImpl<T> {
private _value: T
private _rawValue: T
public dep?: Dep = undefined
public readonly __v_isRef = true
constructor(
value: T,
public readonly __v_isShallow: boolean
) {
this._rawValue = __v_isShallow ? value : toRaw(value)
this._value = __v_isShallow ? value : toReactive(value)
}
get value() {
trackRefValue(this)
return this._value
}
set value(newVal) {
const useDirectValue =
this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)
newVal = useDirectValue ? newVal : toRaw(newVal)
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal
this._value = useDirectValue ? newVal : toReactive(newVal)
triggerRefValue(this, newVal)
}
}
}
从源码可以看出,创建一个ref变量
,如果传入的变量已经是一个ref变量了,就直接返回这个变量,如果不是就会返回一个new RefImpl()
,new这个类又做了什么呢?其实就是把我们传入的变量进行toReactive
然后赋值给this._value
,当我们访问value
的时候,通过get函数,给我们返回了this._value
。说白了就是给我们的变量包裹了一层对象
,然后转变成了reactive
对象。
既然都是用reactive
实现的,为什么不都用reactive
呢?这就要说到它们的区别了。
Proxy
的,而Proxy只能代理对象
,如果我们要实现一个基本数据类型的响应式
怎么办呢?只能通过将它变成对象的方式
;reactive
只能传入一个对象,而ref
可以传入任何类型;ref
声明的变量,我们在访问时,除了模板之外,必须使用xxx.value
,而reactive不用
;reactive
声明的变量可能会造成响应式丢失
,这也是为什么官方更推荐使用ref的原因
;<script setup>
import { reactive } from 'vue'
let obj = reactive({
a: 1,
b: 2
})
obj = {
a: 2,
b: 1
}
script>
乍一看这种方式挺正常的,但是这种方式会引起响应式丢失
,第一次使用reactive
创建对象obj
时,obj是一个正常的被Proxy代理的对象
,它是响应式的。但是当我们给obj重新赋值
时,相当于改变了obj的内存地址
,此时obj变成了一个非响应式的普通对象
,于是造成了响应式丢失
。
为什么ref
不会产生这种问题呢?
<script setup>
import { ref } from 'vue'
let obj = ref({
a: 1,
b: 2
})
obj.value = {
a: 2,
b: 1
}
script>
当我们使用ref
创建响应式变量时,其实是类似这样的:
<script setup>
import { reactive } from 'vue'
let obj = reactive({
value: {
a: 1,
b: 2
}
})
obj.value = {
a: 2,
b: 1
}
script>
这时我们改变的其实是obj对象的value属性
,并没有更改整个对象,于是不会造成响应式丢失
。
如果想使用reactive
,一定要确保修改对象时修改的是对象的某个属性
,如果想对reactive对象重新赋值
,就必须要再次包裹一层reactive
,但是不建议这么做。
使用reactive
时,进行解构操作,也会丢失响应式
,这是因为在解构赋值中,如果是原始类型
就是按照值传递
,如果是引用数据类型
就会按照引用类型地址传递
,因此解构出来的值并不是一个响应式的。
Vuex
是Vue2
官方提供的一个状态管理工具;而pinia
是Vue3
官方推荐的一个状态管理工具。
公共属性和方法
的一个工具;响应式的
,但是刷新页面会丢失数据,因此需要进行持久化处理
;Vuex
一共有五个属性state
、getter
、mutation
、action
、module
。
state
:主要是用于管理store的一个容器
,可以在这里面定义一些公用属性
;getter
:类似于Vue组件实例的computed
,主要用于做一些计算,也可以用于访问state中的属性
;mutation
:主要用于对state的内容
进行同步修改,是修改state内容
唯一被官方推荐使用的手段,可以使用commit('mutation')
来调用mutation
;action
:主要进行一些异步操作
,然后通过mutation
来该变state的内容
,调用action
的方法时使用dispatch
;module
:用于将store分模块,否则所有属性存在一个store中时会造成冗余,而且某些场景下,可能不同数据属于不同的业务,将其分为多模块的方式比较好。pinia
在Vuex
的基础上去掉了mutation
,将action
作为同步和异步
共用的操作方法,并且去掉了module
属性,因为每定义一个store就相当于一个模块。因此它一共有三个属性
:state
、getter
、action
。
pinia
的各个属性和Vuex
类似。
pinia
和vuex
是两个不同的库,因此在使用方式上有些细微差别
;pinia
支持compositionApi的格式,更加贴合Vue3;pinia
的语法和使用方式更加简洁,调用action
的方法时无需使用dispatch
;SPA页面
首页白屏的原因是因为所有资源
都需要在首页加载,因此优化首页白屏就是要优化首页资源的加载。
按需引入
就采用按需引入,如果不行可以采取CDN
的方式引入;长耗时的同步任务
阻塞了页面的渲染;script标签
,使用defer异步加载
或者放到body
之后;实现一个简单的响应式
,首先我们要知道响应式其实就是通过Proxy中的handler参数中的get/set来实现的(vue2是通过Object.defineProperty())
,然后在get中收集哪些函数使用了当前变量
,然后在set中在变量更新时重新执行这些记录的函数
,让它们用最新的值再次执行一遍
。
// 存储每个响应式对象以及对应的依赖 key: 响应式对象, value: Map()(Map中的key是响应式对象的属性,value是对应属性的依赖)
const targetDep = new WeakMap()
// 存储当前需要被收集的依赖
let activeEffect = undefined
// 定义一个方法,传入一个对象,返回一个该对象的代理,并且给代理对象设置get/set
function reactive(target) {
if (target === null || typeof target !== 'object') {
console.log('请传入一个对象')
return
}
const objProxy = new Proxy(target, {
get(target, key, receiver) {
// 收集依赖
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, newValue, receiver) {
if (newValue !== target[key]) {
// 派发依赖
trigger(target, key)
}
return Reflect.set(target, key, newValue, receiver)
}
})
return objProxy
}
// 定义一个函数收集依赖
function track(target, key) {
if (!activeEffect) return
let dep = targetDep.get(target)
if (!dep) {
dep = new Map()
targetDep.set(target, dep)
}
let propertyDep = dep.get(key)
if (!propertyDep) {
propertyDep = new Set()
dep.set(key, propertyDep)
}
propertyDep.add(activeEffect)
}
// 定义一个函数派发依赖
function trigger(target, key) {
const dep = targetDep.get(target)
if (!dep) return
const propertyDep = dep.get(key)
if (!propertyDep) return
// 在Vue中,并不会立即将函数立即执行,而是在内部维护了一个任务队列,将需要更新的任务放进任务队列,然后在微任务中统一执行,这样有两个好处,
// 第一个是如果是同步执行,setter函数还没有返回值,此时对象的属性还没有更新完成,拿到的还是旧值。
// 第二个是如果是同步执行,如果在一个函数中多次修改同一变量时,会触发多次派发依赖。
propertyDep.forEach(item => {
// 这里简单模拟,不添加异步队列了
item && Promise.resolve().then(() => item())
})
}
// 定义一个函数,接收一个函数,将接收的函数赋值给全局的activeEffect变量,用于依赖收集,然后执行一遍该函数,触发里面用到变量的一些get,最后将全局变量置空
function effect(fn) {
activeEffect = fn
fn()
activeEffect = undefined
}
// example
const obj = reactive({
count: 1
})
effect(() => {
console.log(`count:`, obj.count) //count: 1, count: 2 第一次是在effect函数中执行(为了触发obj的get),打印1; 第二次是set时触发了依赖更新,打印2
})
obj.count = 2
我们可能在一个方法中更新多次数据
,如果是同步执行,那么可能会有许多次更新操作,开销会很大,使用异步更新
,能够将一次事件循环内的多次数据更改合并成一次,减少更新操作。
Vue.use
的作用是给Vue实例
注册插件。
export function initUse(Vue: GlobalAPI) {
Vue.use = function (plugin: Function | any) {
const installedPlugins =
this._installedPlugins || (this._installedPlugins = [])
if (installedPlugins.indexOf(plugin) > -1) {
return this
}
// additional parameters
const args = toArray(arguments, 1)
args.unshift(this)
if (isFunction(plugin.install)) {
plugin.install.apply(plugin, args)
} else if (isFunction(plugin)) {
plugin.apply(null, args)
}
installedPlugins.push(plugin)
return this
}
}
在Vue2的Vue.use
的源码中我们可以看到,Vue.use的参数是一个函数或者一个any类型
,然后会拿到已经注册的插件
,看传入的插件是否已经被注册,如果已经被注册就会直接返回this
,如果没有被注册,就会看参数是否有install属性,并且该属性是否为一个函数
,或者直接判断参数是不是一个函数
,如果满足这两种情况,会调用参数或参数中的install方法注册插件
。最后返回this
。
而这里返回的this
就是Vue
,因此我们调用完Vue.use
,它的返回值还是一个Vue,我们又可以通过.use
去注册下一个插件,这样就可以实现了链式调用。
因为Vue
每个实例上有$emit
、$on
、$off
、$once
等方法,我们的每个组件都是一个Vue实例
,因此它们是不可以共享的,但是当创建一个公共的Vue实例时,将此实例导入我们需要使用事件总线
的文件,然后调用实例上的$emit
方法,抛出事件,在需要接收事件的页面调用$on
方法,这两个页面访问的是同一个实例上的方法
,事件和数据就可以共享了。
事件总线是发布订阅者模式
的一个场景。实现事件总线,有几个关键点,需要创建一个集合去记录订阅者和发布者的映射关系
。on
方法用于监听事件
,也就是订阅者
,emit
用于派发事件
,也就是发布者
,然后可以通过off
去取消监听
。
function eventBus() {
// 创建map集合,记录事件名称以及该事件触发的回调集合
const map = new Map()
return {
// 订阅事件
on(eventName, callback) {
// 获取到该事件名对应的回调
const handlers = map.get(eventName)
if (handlers) {
handlers.push(callback)
} else {
map.set(eventName, [callback])
}
},
// 发布事件
emit(eventName, param) {
// 获取到该事件名对应的所有回调
const handlers = map.get(eventName)
if (handlers) {
handlers.forEach(handler => {
handler(param)
})
}
},
// 取消某个回调
off(eventName, callback) {
// 获取到该事件名对应的回调
const handlers = map.get(eventName)
if (handlers) {
if (callback) {
// 判断准备取消的回调是否在记录中
const index = handlers.indexOf(callback)
if (index < 0) {
return new Error('回调不存在')
}
// 删除该回调
handlers.splice(index, 1)
} else {
map.set(eventName, [])
}
}
}
}
}
今天开始
Vue2
就要离我们远去,缅怀。