2024前端年末备战面试总结——框架篇(Vue)

框架篇(Vue)

本文是本人根据Vue官网上的内容或者网上一些其他朋友的文章以及自己的理解,整理归纳出来的一篇Vue方面的面试题及知识点,其中不免有许多漏掉的问题或是答案,发现错误的或者是有什么问题需要补充的朋友们,可以在评论区留言,大家虚心交流,一起进步。

1. Vue是什么

Vue是一款基于MVVM架构的渐进式框架,它主要用于构建单页面应用(spa),它的特点有声明式渲染响应式两大点。

(1)什么是MVVM

MVVM就是Model-View-ViewModel的缩写,它是一种架构模式,是MVC(Model-View-Controller)的改进版。

  • Model:指的就是负责应用的数据处理以及整体业务逻辑(相当于后端);
  • View:指的就是展示给用户的界面(相当于HTML页面);
  • MVVM中的ViewModel:指的就是视图模型,它是ViewModule沟通的桥梁,用于展示数据、处理用户交互、并且更新模型,并且视图会与视图模型进行关联,将视图中的一些方法和属性封装在视图模型中,然后通过视图模型模型之间获取、更新数据,然后将真实的数据反映到视图中。

2024前端年末备战面试总结——框架篇(Vue)_第1张图片

MVVM的架构的优点有:

  • 低耦合:视图(View)可以独立于Model变化和修改一个ViewModel可以绑定到不同的View上,当View变化的时候Model可以不变,当Model变化的时候View也可以不变
  • 可复用:可以将一个ViewModule的逻辑给多个View使用;
  • 独立开发:开发人员可以专注于业务逻辑和数据的开发(ViewModel),设计人员可以专注于页面设计;
  • 可测试:因为ViewModel是独立于界面的,因此测试人员可以专注于业务逻辑,而无需依赖具体的页面实现;

(2)什么是渐进式框架

渐进式框架指的就是一种框架概念,一般来说,使用渐进式框架时,无需引入其所有功能,而是需要什么就用什么,就拿Vue来说,我们可以引入一个vue.js的文件,然后在其它框架中去使用Vue,也可以使用它的脚手架,来进行构建一个Vue项目,这完全取决于用户想怎么使用,而框架为我们提供了多种使用方式以及各个模块的功能

渐进式框架优点有:

  • 灵活:开发者可以按需引入框架的各个功能;
  • 可维护性:开发者可以先少量引入框架部分功能,然后在需要的时候引入其它功能,防止项目从一开始就变得结构复杂难以维护

(3)什么是SPA页面

SPA就是单页面应用,这是一种网站的设计模式,它的意思就是一个网站中,只有一个HTML文件,用户在进行页面交互时,或者刷新页面时,只是利用JavaScript动态变换HTML的内容,而并非真正意义上的变换页面

SPA应用的优点:

  • 良好的交互体验:因为用户在交互时,只是动态刷新局部内容,并不用请求新的HTML文件,因此也就不会造成长时间的页面白屏
  • 良好的工作模式:更好的实现前后端分离,让不同岗位的工程师专注于自己的领域,提升代码的性能以及复用性;
  • 路由:使用前端路由,通过浏览器的API来模拟前进后退操作,让用户在使用感知上并无变化。

SPA应用的缺点:

  • 首页开销大:因为所有的资源都需要在首页进行加载,因此资源过多时会产生白屏问题
  • 内存占用较大:在SPA中,一旦页面加载完成,所有的页面内容和状态都保存在内存中,如果页面过于复杂或用户长时间停留在页面上,可能导致内存占用较大,影响设备性能;
  • 不利于SEO:SPA页面在初始化时只有HTML的基本骨架,其它内容需要依赖于JavaScript的异步加载,浏览器不能完整的捕获到页面内容,不利于SEO。

(4)什么是声明式渲染

声明式渲染,就是你只需要告诉框架你的目的,至于它内部如何达成你的目的,你无需关心。与之对应的是命令式渲染,你需要一步步操作,让框架执行你的操作,最终达成你的目的。

比如原生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强大的模板编译以及响应式

2. Vue的生命周期

(1)Vue2

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类似,只是它代表当前组件处于非活跃状态

(2)Vue3

  • Vue3中,去掉了beforeCreatecreated两个生命周期函数,用setup来替代(也就是说在setup中写的代码,相当于之前在这两个函数中写的代码);
  • Vue3中,将beforeDestroydestroyed两个生命周期函数更名为onBeforeUnmountonUnmounted
  • Vue3中,其它生命周期函数并没有改变,只是在每个生命周期函数前面加上了一个on

3. keep-alive的作用

keep-alive是一个内置组件,它不会被渲染到dom树中,普通的组件在被替换之后,会销毁组件,组件中的一些状态都会被销毁,当这个组件再被创建时,组件内的所有属性和方法都是初始状态的。而keep-alive的作用就是,用keep-alive包裹某个组件,在这个组件被替换时,保存当前组件的状态,并触发deactivated函数,当该组件再次被激活时,组件内的属性和方法都是上次被替换时的状态,也就是说它的作用就是缓存组件

(1)使用方法

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算法)。

(2)传入的a、b分别是什么

在给keep-aliveinclude、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

4. keep-alive的原理

keep-alive是一个内置的组件,因此它其实和普通组件类似,只是在keep-alive组件的不同生命周期函数中做了一些特殊的处理,可以缓存我们的组件,主要是通过createddestroyedmountedrender四个函数进行处理。

(1)created阶段

created阶段,keep-alive组件会创建一个空对象,用于存放需要缓存的组件的信息。

(2)render阶段

render阶段,就会拿插槽中的内容,然后获取插槽中的第一个组件,这也是为什么在keep-alive中写多个组件时,只会缓存第一个的原因。

拿到第一个组件之后,就会去获取当前组件的name,然后和include/exclude中的内容匹配,如果判断该组件不需要缓存,就直接返回组件VNode信息,如果需要缓存,就执行下一步操作。

缓存时,会看组件的VNode信息中有没有key,如果没有key,就会根据组件标签(tag)和cid拼接成一个key,然后判断key是否已经存在于缓存中,如果不存在,就将key和当前VNode作为一组数据进行缓存,如果存在,就会把当前VNode的组件实例替换为缓存中当前key的组件实例,最后返回VNode,也就是说当我们访问一个组件时,如果命中了缓存,那么访问的其实就是缓存中上一次该组件的实例信息,并且每次命中缓存时,都会调整该组件在缓存中的位置,会移动到末尾。这时如果设置了max,当缓存内容超出这个范围时,就会删掉缓存中的第一项(因为最近访问的组件缓存都被移动到后面了,所以排在第一位的肯定就是最久未访问的了)。

(3)mounted阶段

mounted阶段,主要对include/exclude的值进行了监听,监听到值有变化时,说明需要被缓存的组件有改动,这时就会去遍历已缓存的组件,判断哪些已经不需要被缓存,然后将它们移除出缓存数据。

(4)destroyed阶段

destroyed阶段,说明keep-alive组件要销毁了,那它组件实例上的一些方法和属性也都会被销毁,因此在这个阶段就会清除所有已缓存的组件实例

5. Vue中常见的指令

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-showv-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一起使用,来达到优化的效果;

6. v-if和v-show的区别

  • v-if会根据判断条件控制是否渲染元素,如果不满足条件,元素不会被渲染,节约内存;
  • v-show只是根据条件修改元素的display属性,元素还是会被渲染,只是控制在页面是否展示;
  • 在一个元素频繁的在展示和隐藏之间切换时,使用v-show性能更好,因为可以节约每次重新渲染带来的开销;
  • 在一个元素永久性隐藏或者展示时,使用v-if性能更好,因为可以减少dom结构。

7. Vue中常见的修饰符

(1)事件修饰符

  • .stop:阻止事件冒泡;
  • .prevent:阻止默认事件;
  • .self:只有事件在当前元素自身触发时,才会调用函数;
  • .capture:捕获模式,内部元素的事件在被内部元素处理之前,先被外部元素处理
  • .once:该事件只会触发一次;
  • .passive:一般用于触摸事件的监听器,可以用来改善移动端设备的滚动性能

(2)按键修饰符

  • .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键,不同机器上键位不同。

(3)鼠标按键修饰符

  • .left:鼠标左键触发;
  • .right:鼠标右键触发;
  • .middle:鼠标中键触发;

.sync修饰符

.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>

8. Vue中组件的传参

(1)父子组件传参

v-bind & props

这种方式主要场景是父组件向子组件传参父组件通过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>
$emit/emit

这种方式主要场景是子组件向父组件传参子组件通过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>
p a r e n t / parent/ parent/children(Vue3被移除)

<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>

(2)多级组件传参

provide/inject

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>
a t t r s / attrs/ attrs/listeners(Vue3中被移除)

$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)

Vuexpinia都是状态管理工具,通常在Vue2中使用Vuex,在Vue3中作者更推崇我们使用pinia,同时pinia也兼容了Vue2

什么是状态管理工具呢,我们可以把它看作是一个仓库(store),当有一些公共的属性和方法时,我们可以存储到这个store中,不管是父子、爷孙,甚至毫无关系的两个组件,都可以向store中存储数据,而其它组件想使用这些数据时,直接从store获取,就实现了跨层级的数据共享和传参

事件总线(event bus)

Vue2中,我们可以通过新建一个文件夹,然后new一个Vue实例,去实现一个事件总线,它可以在任何组件之间通过$emit发送事件$on监听事件$once监听一次事件$off停止监听事件,即使是两个毫无关系的组件,想进行消息传递时,只需要其中一个组件使用$emit发送一个事件,在另一个组件中使用$on/$once监听该事件,然后执行相应的逻辑,在组件销毁或非激活态的时候使用$off停止监听,就可以做到跨层级的通信

Vue3中,移除了事件总线,但是依然有一些第三方库实现了该功能,比如Mitt,使用方式也和Vue2中的相似。

之所以移除了事件总线,是因为在使用该功能时,很容易导致代码的混乱,不同的组件抛出各种事件,这些事件又被各种组件接收,太多时就会产生难以溯源的情况,不知道这些事件都是从哪个组件发出来的,让代码变得难以维护。并且有些开发者在使用时,不遵守规范,只使用$on,不使用$off关闭,浪费性能。

9. options API中的provide/inject

在使用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

Vue.observableVue2.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>

使用computed

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>

10. v-if和v-for谁的优先级更高

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>

11. v-for的key是做什么的

key的作用就是标识当前VNode节点,一般用于v-for中。使用key进行标识的元素,在进行更新操作时会将更新前后两个key相同的元素视作同一元素,进行对比,然后进行相应的更新操作,如果没有key,就只能按顺序进行对比,在合理的场景使用合理的key可以提升更新时的渲染性能

12. 为什么不建议使用index作为v-for的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的原因,因为它并没有对更新时的渲染起到任何优化作用

13. data为什么必须是一个函数

data是一个组件的私有属性,但是一个组件可以被其它多个组件使用,之所以必须是一个函数,是因为函数作用域是私有作用域,保证变量不会被污染。如果我们返回一个普通对象,在多个组件使用该组件时,如果都对data中的某个属性进行了修改,所有使用该组件的组件都会被影响,而使用函数则每次都会创建一个新的对象,保证当前的data不会被其它组件所影响。

在每一次创建组件实例时,Vue都会去初始化这个组件的状态。

  • 查看组件有没有data属性;
  • 如果有data属性,会查看data是不是一个函数,如果是函数,就直接调用这个函数,并将函数返回的对象赋值给组件实例的data属性(这就是为什么使用函数不会造成变量污染,因为每次都会调用这个函数,生成新的对象);
  • 如果不是函数,会看之前组件实例上的data是不是空值,如果不是,就用之前的,如果是就赋值一个空对象(这就是为什么直接写成对象会造成变量污染,因为每次创建组件实例时都会使用之前的data)。
  • Vue内部也帮我们做了异常处理,当我们的data不是一个函数时,会抛出异常

14. v-model的原理是什么

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

在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

在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的回调

15. Vue中computed和watch的作用是什么,有什么区别?

computed

computed称为计算属性,它必须拥有返回值,它的值可以是固定的,也可以是依赖其它响应式数据计算出来的,它进行一些同步运算处理,当它依赖其它值时,只有当其它值改变时,它才会触发更新。

watch

watch称为监听,它需要监听一个响应式数据,当它监听的数据改变时,它会处理回调任务,可以在回调中做一些异步操作

区别

  • computed是有缓存的,读取computed属性时,如果依赖的值没有变化,就会读取缓存的内容,而watch没有缓存,只要数据改动,就会触发回调;
  • computed必须要有返回值,watch不需要有返回值;
  • computed在初始化时就会执行一次,而watch初始化时默认不会执行,如果我们想让它执行,可以设置它的immediate属性为true
  • computed相当于创建了一个新的响应式属性,而watch相当于监听原有的响应式属性,然后执行回调;
  • computed中处理的是同步操作,而watch可以处理异步任务

16. Vue2和Vue3怎样挂载全局属性

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

17. vue-router

SPA单页面应用其中特点之一就是更改HTML显示内容,来模拟页面跳转。而vue-router可以帮助我们做到这一点。

(1)路由模式

vue-router提供了三种路由模式,分别是Hash模式History模式以及在Vue-Router4.x新增的Memory模式(一般不会用于浏览器环境),开发者可以通过设置mode(vue-router4.x之后为history)属性,去选择不同的路由模式。

(2)Hash模式实现原理

Hash模式下,URL的样子是这样的https://xxx.com#123

Hash模式是vue-router的默认使用方式。这种方式是利用了URL的hashURL的hash就是#,也可以称之为锚点,只改变URL中#之后的部分其实是相当于更改了网页的位置,也相当于更改了window.location.hash的属性值,但是网页地址还是没有改变,因此不会进行刷新网页,在进行请求接口时,也只会向#之前的地址进行请求,并不会带上#之后的内容,因此不会对服务端产生影响。

每次改变#之后的内容时,都会在浏览器的历史记录中增加一个记录,使用浏览器的后退/前进功能可以回到上一次或下一次的页面位置,而根据#之后的值,我们就可以渲染不同的页面,从而达到模拟页面跳转的操作,而且可以通过hashchange监听URL-hash的变化。

(3)History模式实现原理

Hash模式下,URL会出现一个#符号,影响美观,而History模式不会有这个符号,因此当我们不想要这个符号时,可以设置为History模式

History模式是利用了HTML5中新增的history API来进行URL管理,主要利用pushState()replaceState()来改变URL,使用这两种方法,可以给浏览器的历史记录添加一条新纪录或者替换记录,但是这两种方法改变URL时,不会立即向服务器发送请求,只有在执行history.back()history.forward()history.go()的时候才会向服务器发送请求,可以通过监听popstate事件,来监听浏览器的前进回退操作,然后进行路由的匹配

History模式需要服务端的支持,因为它在进行浏览器前进/回退操作时会发送请求,如果这时没有匹配到资源,就会出现404,因此需要服务端对这种场景做处理。

(4)vue-router的使用和配置

// 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')

(5)路由跳转方法

vue-router提供了router-link标签来实现路由的跳转,相当于一个a标签,但是可以使用vue-router提供的一些方法;

vue-router还提供了pushreplacegoforwardback等常用方法进行编程式导航

  • push:用于跳转下一个页面,会在历史记录添加一条新纪录;
  • replace:和push相同,只是会将当前历史记录替换掉;
  • go:可以传入一个数字,如果是负数则代表后退几条历史记录,如果是正数则代表前进;
  • forward:相当于go(1);
  • back:相当于go(-1);

此外,在Vue2中,可以通过this.$router去执行相应的方法,而在Vue3中,则需要先引入useRouter组合式API,然后调用该方法得到一个router对象,才能够执行相应的方法。

(6)路由的传参

路由的传参方式一共有两种,一种是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-router4.14版本之前,是可以访问到params传参内容的,但是这种传参方式一直是不被提倡的,因为刷新页面会使参数丢失。虽然这种情况可以通过vuex或pinia等工具进行缓存,在刷新页面时重新赋值,但是这种操作也会带来一定的风险。于是在4.14版本之后,如果没有使用动态路由,直接使用params传参,参数是不会被下个页面接收的。

(7)动态路由

动态路由就是路由的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的方式给下个页面传递参数,那么必须使用动态路由,也就是说必须先将要传递的参数定义在路由中。

(8)路由中的props

每个路由对象中可以设置props属性,默认为false,设置为true表示通过params传参的参数会作为组件的props传入

但是这种通过路由传递过来的参数,类型都会变成string,因此在使用时要注意。

此外props属性还可以被设置成函数或者其它一些特殊场景的处理,可以看Vue-router的官网示例。

(9) r o u t e r 和 router和 routerroute的区别

$router在Vue3中以useRouter的方式使用,$route在Vue3中以useRoute的方式使用。

这是$router包含的内容:

2024前端年末备战面试总结——框架篇(Vue)_第2张图片

这是$route包含的内容:

2024前端年末备战面试总结——框架篇(Vue)_第3张图片

从中可以看到,$router中包含了路由的一些公共方法,比如跳转路由拦截等,而$route则包含了一些当前路由的属性,比如paramsqueryhash等。

  • 我们可以把$router视为全局的路由对象,操作路由一些方法时,使用$router
  • 我们可以把$route视为当前活跃的路由对象,当访问当前路由信息的时候,使用$route

(10)一个页面使用多个路由

可以使用多个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

(11)设置404页面

Vue2中,vue-router为我们提供了一个*通配符,可以匹配到那些未定义的路由,从而可以让我们对这种场景进行一些处理,在Vue3中,删掉了*通配符,具体使用方法可以看官网。

(12)路由的懒加载

路由懒加载就是将路由组件的静态导入变为动态导入,从而实现只有在第一次进入页面时,才会进行加载,后续将使用缓存。就拿我们上面定义的路由文件来说,它们可以通过懒加载改造成这样:

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

(13)路由的导航守卫

路由的导航守卫可以分为全局前置守卫全局解析守卫全局后置钩子路由独享守卫组件内的守卫等几大类。

全局前置守卫

全局前置守卫就是router.beforeEach,从名字来看,它的含义代表在进入路由之前。它一共有三个参数tofromnext(可选)

  • 当一个导航触发时,全局前置守卫按照创建顺序调用。守卫是异步解析执行,此时导航在所有守卫 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的时候才会被触发。

路由独享守卫可以接收多个函数,可以将一个函数数组传递给该属性,触发守卫时数组中所有函数都会被触发。

组件内的守卫

组件内的守卫一共有三种,分别为beforeRouteEnterbeforeRouteUpdatebeforeRouteLeave,用法类似于组件的生命周期,只是在这些守卫里访问不到组件实例,因为守卫发生在导航确认之前。但是可以在它的next中传入一个回调函数,回调函数接收组件实例作为参数,等到组件实例可以被访问时触发该回调,做一些相应的处理。

  • beforeRouteEnter不能访问组件实例,其它两个可以;
  • beforeRouteUpdate会在组件实例复用时触发,比如动态路由,因为只是参数不同,但是会将路由复用。
完整的导航解析流程
  • 导航被触发;
  • 在失活的组件里调用beforeRouteLeave守卫;
  • 调用全局的beforeEach守卫;
  • 在重用的组件里调用beforeRouteUpdate守卫;
  • 在路由配置里调用beforeEnter守卫;
  • 解析异步路由组件;
  • 在被激活的组件里调用beforeRouteEnter守卫;
  • 调用全局的beforeResolve守卫;
  • 导航被确认;
  • 调用全局afterEach钩子;
  • 触发dom更新;
  • 调用beforeRouteEnter守卫中next的回调函数,创建好的组件实例会作为回调函数的参数传入。

18. Vue3有哪些变动

  • 响应式原理的变动,由Object.defineProperty变为Proxy
  • diff算法优化;
  • 删除filtersmixin,用methodshooks替代;
  • options API转变为composition API
  • 新增Fragment内置组件,可以在不引入多余DOM的情况下,包裹并渲染多个子元素,这也是为什么vue3的模板不需要有一个根节点了;
  • 新增Teleport内置组件,它可以将内容绑定到指定的节点之下,比如有些弹窗,我们想让它作为body的子元素,而不是嵌套太深,就可以使用该组件包裹,然后设置to="body";
  • 新增Suspense内置组件,该组件的作用是等待,该组件内部有两个插槽,默认插槽为default,另一个为fallback,我们可以将即将展示的东西放入默认插槽,在它还没有值时,展示fallback插槽的内容,这目前还是一项实验性特性
  • 移除了$children、$listeners等属性;
  • v-model可以传参;
  • 更好的支持TypeScript;

19. setup语法糖的一些常用API

在使用setup语法糖(script setup)时,已经不能像Vue2和setup函数那样去定义props或者emits了,取而代之的是各种各样的

  • defineProps:用于定义组件的props
  • defineEmits:用于定义组件的emits
  • defineOptions:用于定义组件的一些信息name等;
  • withDefaults:用于定义props的默认值
  • defineExposescript setup的组件默认是关闭的(外界访问不到实例),需要通过该方法导出那些需要被外界访问的属性
  • useSlots、useAttrs:相当于Vue2的$slots$attrs,它们是真实的函数,在setup函数中也可以使用;

script setup的优点

  • 更简洁的代码;
  • 和ts更好的融合;
  • 更好的运行时性能;
  • 更好的ide类型推导;

20. Vue2和Vue3的响应式原理

什么是响应式

响应式就是在我们修改数据之后,无需手动触发视图更新,视图会自动更新。

Vue2响应式实现

Vue2中,响应式系统是通过依次遍历data返回的对象,将里面每一个属性通过Object.defineProperty进行定义,然后在属性描述符中添加get/set,实现getter/setter方法,在访问属性时,在getter函数中收集依赖(记录哪些方法或变量在使用这个属性),在修改属性时,在setter函数中派发依赖(将收集到的依赖依次更新),从而达到响应式

Vue3响应式实现

Vue3中,响应式系统是通过ES6中的Proxy实现对一个对象的代理,然后设置handler.get/handler.set,在对代理对象进行操作时,可以触发get/set,和Object.defineProperty类似,get中实现收集依赖,在set中实现派发依赖,从而达到响应式的效果。

21. Vue3为什么要改为Proxy实现响应式?

既然Vue3要更改响应式的实现方式,那么说明Vue2的响应式实现一定是有缺点的

Object.defineProperty()实现响应式的缺点

  1. Object.defineProperty只能对对象的属性进行监听,也就是说当我们想对某个对象进行监听时,必须将这个对象遍历,然后对其中的每一个属性进行监听。如果说对象中的某个属性又是一个对象,那就需要递归遍历,将每一层都进行监听,这样的性能肯定是比较低的。
  2. Object.defineProperty只能对已有属性进行监听,也就是说,在Vue2中,created()阶段Vue内部已经帮我们把data中的属性遍历完毕并且对每个属性进行监听了,如果在之后的阶段我们给某个对象使用obj.xx的方式给对象添加了一个新属性,这个属性就不再是响应式了,这也是为什么我们在添加新属性时,需要使用this.$set的方式。
  3. Object.defineProperty不能监听数组长度的改变,这也就造成了我们在使用一些影响原数组的数组方法时,它监听不到,比如我们使用pop、shift、push等,这也是为什么Vue2要重写部分数组原型方法

Object.defineProperty不能监听数组长度变化,但是它是可以监听数组内容变化的,前提是我们需要像对象一样,把数组进行遍历,然后对每一个索引值进行监听。之所以Vue2没有对数组的每一项进行监听,是因为数组的长度有可能会很长,一般来说对象的属性值并不会有太多,而数组中的数据可能长达上万甚至数十万,如果对数组进行遍历监听每一项,代价无疑是巨大的。

Proxy改变了什么

  1. 首先针对Object.defineProperty的第一个缺点,Proxy的作用是返回一个代理对象,因此它不需要再遍历/深度遍历一个对象,而是只需要将原对象作为参数传入,就可以返回该对象的代理对象,并且它的第二个参数handler提供了13种方法,能够监听代理对象的各种操作。
  2. 针对Object.defineProperty的第二个缺点,当我们对代理对象使用obj.xx的方式添加一个新属性时,它依旧能够对新添加的数据进行监听。
  3. 针对Object.defineProperty的第三个缺点,Proxy不仅可以监听数组索引值的变化,还能够监听原型方法(pop、push)等。

22. this.$set()的实现原理

$set解决的就是对象/数组添加新属性不是响应式的问题,因此它的核心就是调用此方法,vue内部帮助我们把添加的属性变成响应式

首先这个方法接收三个参数

  • target:需要添加属性的对象;
  • key:需要添加/修改的对象key值;
  • val:需要修改的value值;

执行过程

  1. 判断传入的对象是否为数组,并且key是否为一个正确的索引,如果是,就会修改数组长度为key和原数组长度的最大值,然后调用数组的splice方法进行更新数组,我们知道splice方法也是不能被defineProperty监听的,为什么这里要调用此方法呢?这是因为Vue内部帮我们重写了数组原型的该方法。
  2. 如果传入的是一个对象,那么就判断传入的key值是否在对象中,并且不是在对象的原型上,如果已经在对象中的话,不管当前对象是否为响应式对象,直接通过target[key] = val修改属性值就行了(如果原对象是响应式,那么它已有的属性肯定是响应式的,如果不是,那它已有的属性也不需要是响应式)。
  3. 如果传入的对象是vue实例,那么就会抛出警告;
  4. 最后通过target.__ob__判断传入的对象是否为响应式的,如果不是响应式,那么给非响应式对象添加属性时,也不需要是响应式,直接使用target[key]=val就行了,如果对象是一个响应式的,那么给它添加新属性,也必须要变成响应式,于是就会调用defineReactive方法将该属性添加getter/setter(本质就是使用Object.defineProperty)将其变成响应式。

23. Vue2重写了哪些数组方法

Vue2一共重写了7个数组原型上的方法,这些方法都会改变原数组。分别是poppushunshiftshiftsplicesortreverse

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
  })
})

从源码可以看出,在调用数组这七个方法时,依旧会调用原来的这些方法,只是在调用完成之后会触发依赖更新,如果是pushunshiftsplice这些可能会新增属性的,会将新增的属性变为响应式,然后触发依赖更新。

24. Vue2和Vue3的diff算法有什么不同?

diff算法就是比较新旧虚拟节点(VNode),对旧节点进行更新、删除、新增操作,然后更新到真实的DOM上。

  • diff算法只会同层对比,不会跨层级,比如一个旧节点不会与新节点的子节点去进行对比;
  • diff算法一般都是从两端开始对比,逐渐向中间收拢;

(1)Vue2的diff算法流程

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
  1. 设置各种变量,记录当前的头尾索引值和新旧节点的信息,如新旧节点的头节点、新旧节点的尾节点、新旧节点的长度等;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (isUndef(oldStartVnode)) {
    oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
  } else if (isUndef(oldEndVnode)) {
    oldEndVnode = oldCh[--oldEndIdx]
  }
  1. 旧节点头部索引值 <= 旧节点总长度并且新节点头部当前索引值 <= 新节点总长度时,执行以下代码,第一个判断,如果当旧节点头部索引值的VNode是空,那就将头部的VNode变为第1项(如果第一项还是空,那就变成第二项…依此类推),如果当旧节点尾部索引值的VNode为空,旧将尾部的VNode变为前一项
else if (sameVnode(oldStartVnode, newStartVnode)) {
    patchVnode(
      oldStartVnode,
      newStartVnode,
      insertedVnodeQueue,
      newCh,
      newStartIdx
    )
    oldStartVnode = oldCh[++oldStartIdx]
    newStartVnode = newCh[++newStartIdx]
}
  1. 如果旧节点头部节点和新节点头部节点相同,那么就会执行patch,进行更新,然后指针变化,开始第二个节点的对比;
else if (sameVnode(oldEndVnode, newEndVnode)) {
    patchVnode(
      oldEndVnode,
      newEndVnode,
      insertedVnodeQueue,
      newCh,
      newEndIdx
    )
    oldEndVnode = oldCh[--oldEndIdx]
    newEndVnode = newCh[--newEndIdx]
} 
  1. 如果头部不同,就会判断旧节点尾部节点和新节点尾部节点是否相同,如果相同,就执行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]
  }
  1. 如果头头、尾尾都不相同,接下来就会判断旧的头部和新的尾部是否相同,如果相同,就会将旧头和新尾进行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]
  }
  1. 如果旧头部和新尾部还不相同,就会对比旧尾部和新头部,如果相同,就将旧尾和新头进行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]
  }
  1. 如果上述场景都不满足,就要进行最复杂的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)
}
  1. while循环结束之后,判断如果oldStartIdx > oldEndIdx成立说明旧的VNode节点先遍历完成了(遍历完成之后,最后一次++index会让startIdx > endIdx),那么就说明旧的VNode节点更少,然后就把新节点多出来的节点进行创建然后添加到真实dom中,反之说明旧的VNode节点更多,需要删除节点,那就把旧节点多出来的从真实dom进行删除。至此,整个diff算法结束。

(2)Vue3的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
  }
  1. 首先会根据新节点的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
  }
}
  1. 如果子代全都没有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)
  }
} 
  1. 如果没有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
      )
    }
  }
}
  1. 如果新节点的子节点不是文本节点,那就分情况判断,如果旧节点的子节点是数组节点并且新节点的子节点也是数组节点,就会直接执行有key的diff方式。如果旧节点的子节点是数组节点,但是新节点的子节点不是数组节点,说明新节点是空节点,直接卸载掉旧节点。

    如果旧节点的子节点不是数组节点,那么旧节点只可能为文本或空节点,新节点只可能为数组或空节点,如果旧节点为文本节点,直接将文本变为空的,这时再去判断新节点的子节点是不是数组节点,如果是的话就重新创建一个数组节点,如果不是的话,旧的文本节点依旧变成空节点了也就不需要做其它操作了。

  2. 在这整个过程中,最重要的是两个方法patchUnkeyedChildrenpatchKeyedChildren,分别是没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
      )
    }
  }
  1. 这是无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
  1. 这是有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++
}
  1. 有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--
}
  1. 有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++
    }
  }
}
  1. 有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++
  }
}
  1. 有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)
    }
}
  1. 当节点无序时,首先第一步,会建立一个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++
    }
}
  1. 第二步,会跳过那些已经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--
      }
    }
}
  1. 最后一步,如果需要移动,就会生成一个最长递增子序列,然后遍历需要patch的序列,看看序列中有没有对应的旧节点索引值为0的,如果有,说明这些节点在旧节点中不存在,就需要执行新增节点的操作。如果存在,并且需要移动,就会将这些节点移动到对应的位置。至此,整个diff算法完成。

(3)最长递增子序列为什么可以优化

加入旧节点子元素列表的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的次数,从而达到优化的原因。

(4)总结

在Vue2中,diff算法采用的是双指针进行头头相比、尾尾相比、头尾相比,最终通过映射关系来确认可复用的节点,进行更新。

在Vue3中,diff算法分为有key和无key和快速diff三种方式快速diff通过静态标记,对一些文本空节点进行快速更新,无key方式简单粗暴对比每一项,判断是否可以复用节点,有key的方式依旧采用双指针,但是只进行头头相比、尾尾相比,最终会根据求取无序列表的最长递增子序列的方式,对能复用的节点进行patch,需要移动的节点进行移动节点,最终完成diff更新。

25. Vue3在diff阶段都优化了哪些?

Vue3的diff算法优化点如下:

  • 静态提升:在模板编译时,会将没有用到动态变量节点或属性(class、style这些元素属性)进行静态提升,在进行render时,直接复用旧节点。而在Vue2中,无论元素是否使用了动态变量,每次更新都会重新创建,这也是为什么Vue3最好使用template而不是render函数,因为模板编译时会帮我们做优化;
  • 预字符串化:当编译器遇到大量的静态节点时,会将这一整部分变成字符串,减少VNode的创建,渲染为静态节点,而在Vue2中,则会将这些节点一个个变成虚拟节点;
  • 缓存事件处理函数:在Vue3中,会将dom元素绑定的事件进行缓存,在进行patch的时候会使用缓存中的事件处理函数;
  • Block Tree:在Vue3中,Block用于提取那些动态属性的节点,从而在进行更新时,可以精准的比较Block中的内容,只更新那些使用动态节点的节点;
  • patchFlagspatchFlags是编译器生成的优化提示,它标记了节点的哪些属性是动态的,从而在进行更新时,精确的对某些属性进行更新;
  • shapeFlagsshapeFlags也是一个标识,它标识了当前虚拟节点的类型,从而可以在进行diff时能够省去类型判断,对不同类型做不同的更新处理。

26. $nextTick的作用是什么,原理是什么?

$nextTick是 Vue.js提供的一个异步更新DOM的方法。因为Vue的更新是异步的,如果你想在改变某个属性之后立即去操作DOM,可能结果并不是你想要的,而nextTick允许你在当前 DOM 更新循环结束之后执行一个回调函数,这样可以确保在回调函数中操作的DOM是最新的

Vue2和Vue3中使用nextTick的方式和实现的原理都不一样。

(1)Vue2

在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,然后才会去执行我们的回调任务。

(2)Vue3

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文件)。

27. Vue3中ref和reactive的区别是什么?

(1)reactive

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代理的对象

(2)ref

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对象。

(3)区别

既然都是用reactive实现的,为什么不都用reactive呢?这就要说到它们的区别了。

  • Vue3的响应式原理是基于Proxy的,而Proxy只能代理对象,如果我们要实现一个基本数据类型的响应式怎么办呢?只能通过将它变成对象的方式
  • reactive只能传入一个对象,而ref可以传入任何类型;
  • ref声明的变量,我们在访问时,除了模板之外,必须使用xxx.value,而reactive不用
  • reactive声明的变量可能会造成响应式丢失,这也是为什么官方更推荐使用ref的原因

(4)reactive响应式丢失

<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时,进行解构操作,也会丢失响应式,这是因为在解构赋值中,如果是原始类型就是按照值传递,如果是引用数据类型就会按照引用类型地址传递,因此解构出来的值并不是一个响应式的。

28. Vuex和pinia的使用和区别

VuexVue2官方提供的一个状态管理工具;而piniaVue3官方推荐的一个状态管理工具。

  • 它们都是用于储存和读取公共属性和方法的一个工具;
  • 它们中存取的数据都是响应式的,但是刷新页面会丢失数据,因此需要进行持久化处理

(1)Vuex

Vuex一共有五个属性stategettermutationactionmodule

  • state:主要是用于管理store的一个容器,可以在这里面定义一些公用属性
  • getter:类似于Vue组件实例的computed,主要用于做一些计算,也可以用于访问state中的属性
  • mutation:主要用于对state的内容进行同步修改,是修改state内容唯一被官方推荐使用的手段,可以使用commit('mutation')来调用mutation
  • action:主要进行一些异步操作,然后通过mutation来该变state的内容,调用action的方法时使用dispatch
  • module:用于将store分模块,否则所有属性存在一个store中时会造成冗余,而且某些场景下,可能不同数据属于不同的业务,将其分为多模块的方式比较好。

(2)pinia

piniaVuex的基础上去掉了mutation,将action作为同步和异步共用的操作方法,并且去掉了module属性,因为每定义一个store就相当于一个模块。因此它一共有三个属性stategetteraction

pinia的各个属性和Vuex类似。

(3)区别

  • 使用方式不同,piniavuex是两个不同的库,因此在使用方式上有些细微差别
  • pinia支持compositionApi的格式,更加贴合Vue3;
  • pinia的语法和使用方式更加简洁,调用action的方法时无需使用dispatch

29. SPA页面首页白屏如何优化

SPA页面首页白屏的原因是因为所有资源都需要在首页加载,因此优化首页白屏就是要优化首页资源的加载。

  • 第三方库如果能进行按需引入就采用按需引入,如果不行可以采取CDN的方式引入;
  • 尽量减少图片资源,使用字体图标或精灵图,对大图使用TinyPng对图片资源进行压缩,并且使用CDN引入图片;
  • 代码层面,检查首页代码是否有长耗时的同步任务阻塞了页面的渲染;
  • 开启gzip压缩;
  • 打包出来的index.html文件中的script标签,使用defer异步加载或者放到body之后;
  • 利用webpack等打包工具进行分包,避免首页一次性加载太多资源;

30. 可以实现一个响应式吗?

实现一个简单的响应式,首先我们要知道响应式其实就是通过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

31. 为什么Vue的数据更新要是异步的

我们可能在一个方法中更新多次数据,如果是同步执行,那么可能会有许多次更新操作,开销会很大,使用异步更新,能够将一次事件循环内的多次数据更改合并成一次,减少更新操作。

32. Vue.use怎么实现的链式调用

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去注册下一个插件,这样就可以实现了链式调用。

33. 为什么new一个Vue实例可以使用事件总线

因为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就要离我们远去,缅怀。

2024前端年末备战面试总结——框架篇(Vue)_第4张图片

你可能感兴趣的:(前端,面试,vue.js)