Code前端首页关于Code前端联系我们

202X Vue3中高级前端面试题必备核心考点

terry 8小时前 阅读数 146 #Vue

最近身边好多准备跳槽的前端小伙伴都在吐槽,现在Vue3的面试题越来越难搞了,不再是只会背个生命周期、双向绑定原理就能过的阶段,而是开始考察实际项目里怎么用、原理底层能挖到哪一步、性能优化有没有落地经验,刚好我这段时间整理了不少真实大厂的面试题,也自己动手翻了翻源码、补了补实战踩坑的笔记,今天就挑几个高频且容易答偏答浅的问题,用大家能听懂的日常话聊聊——不会给你列一堆死记硬背的序号知识点,而是带着场景、带着原理、带着踩坑点展开,看完不仅能应付面试,说不定回头还能优化自己手里的项目。

Composition API 到底比 Options API 好在哪?别只会说“逻辑复用更灵活”

很多人一碰到这个问题,第一反应就是蹦出这句标准答案,然后就卡壳了,面试官再追问一句“具体怎么体现灵活?在哪些场景下比 Options API 好用?它有没有缺点?”就懵了,其实逻辑复用只是其中一个点,而且不是最核心的痛点——Vue2其实也有mixins、scoped slots这些复用逻辑的方式,但它们都有各自的硬伤。

先说说Vue2复用逻辑的老问题吧,踩过mixins坑的应该都懂:命名冲突是家常便饭,比如你引入的两个mixins都有一个叫handleClick的方法,或者都在data里定义了count,根本不知道最后生效的是谁的;还有就是依赖关系不透明,看组件代码的时候,你根本不知道某个data属性或者方法是从哪个mixins里来的,也不知道这个mixins会不会依赖其他mixins,后期维护简直是噩梦;最后就是类型推导差,TypeScript在Vue2里用起来特别别扭,要么得写一堆$refs、$data的类型断言,要么就得用Vue.extend这种繁琐的写法,很多团队为了省事干脆不用TS。

再看Composition API是怎么解决这些问题的,逻辑复用用的是自定义Hook,比如你可以把“获取用户信息、处理加载状态、处理错误状态”封装成一个叫useUser的Hook,哪个组件需要就直接引入调用,不会有命名冲突——因为Hook里的变量都是作用域内的,你想重命名的话直接在组件里解构的时候改就行,比如const { userInfo: currentUser, loading: userLoading } = useUser(id);然后依赖关系非常透明,看组件代码的setup函数(现在的script setup更方便),你扫一眼引入的Hook和里面的代码逻辑,就能知道这个组件用了哪些功能、这些功能之间是怎么关联的;最后就是类型推导完美,TS在Vue3的script setup里几乎不需要写断言,定义接口的时候直接用就行,编辑器的智能提示也特别丝滑,开发体验提升不止一个档次。

但Composition API也不是万能的,它有两个比较明显的缺点:第一个是学习曲线相对陡峭,特别是习惯了Vue2 Options API那种按“data、methods、computed”分类写代码的开发者,刚开始可能会觉得“setup函数里怎么堆了这么多代码,乱糟糟的”——其实这是因为你还没学会拆分自定义Hook,拆分之后代码会比Options API更清晰;第二个是小型组件写起来可能有点啰嗦,比如一个只有一个count变量、一个add方法的简单计数器,用Options API可能只需要写5行代码,用Composition API的script setup可能也要写4-5行,但如果组件稍微复杂一点,Composition API的优势就立刻显现出来了。

Vue3的响应式原理和Vue2有什么区别?Proxy真的比Object.defineProperty强很多吗?

这个问题绝对是Vue3面试的必考题,没有之一!很多人知道Vue2用的是Object.defineProperty,Vue3用的是Proxy,但具体强在哪,又说不出个所以然——比如只知道Proxy能监听数组下标、能监听新增删除属性,但其实还有很多其他的优势。

先快速回顾一下Vue2的响应式原理,避免大家混淆:Vue2在初始化组件的时候,会遍历data里的所有属性,用Object.defineProperty把它们转换成getter和setter,getter里收集依赖(也就是谁用了这个属性),setter里触发更新;但Object.defineProperty有三个致命的缺陷:第一个就是不能监听数组的下标变化和length属性的变化——虽然Vue2给数组重写了push、pop、shift、unshift、splice、sort、reverse这7个常用方法,能间接实现响应式,但如果你直接用arr[0] = 'newValue'或者arr.length = 0,Vue2是检测不到的;第二个就是不能监听对象的新增和删除属性——虽然Vue2提供了$set$delete这两个API来解决,但总觉得有点麻烦,而且容易忘;第三个就是遍历深层嵌套对象的时候性能差——Vue2需要递归遍历data里的所有对象,把每个属性都转换成getter和setter,如果data里的对象特别深、特别大,初始化组件的时候会有一定的性能损耗。

再看Vue3的响应式原理,用的是ES6的Proxy和Reflect:Proxy是一个对象的代理,你可以理解成给目标对象套了一层“壳”,所有对目标对象的操作(读取、写入、新增、删除、遍历、调用函数等等)都会先经过这层“壳”,你可以在“壳”里做一些拦截处理;Reflect是一个内置对象,它提供了一系列与Proxy拦截器对应的方法,用来操作目标对象,而且这些方法的返回值都是标准的,更符合ES规范。

那Proxy到底比Object.defineProperty强在哪呢?刚才说的Vue2的三个缺陷,Proxy都能完美解决:第一个是可以监听数组的所有操作,包括下标变化、length属性变化、甚至是遍历数组的for...of循环;第二个是可以监听对象的所有操作,包括新增、删除属性,不需要再用$set$delete了;第三个是遍历深层嵌套对象的时候用了“懒代理”——也就是只有当你访问到深层嵌套对象的时候,才会把它转换成响应式对象,而不是一开始就递归遍历所有对象,这样初始化组件的性能会好很多,特别是data里有大对象的时候。

还有两个容易被忽略的优势:第一个是Proxy可以拦截更多的操作,比如读取对象的属性描述符、检查对象是否有某个属性、拦截函数的调用和构造函数的实例化等等——这些在某些高级场景下特别有用,比如你可以用Proxy实现一个带撤销功能的状态管理,或者一个带缓存的函数;第二个是Proxy的性能在现代浏览器里已经和Object.defineProperty差不多了,甚至在某些场景下(比如大数组的操作)还要更快一些,毕竟Proxy是ES6原生的API,浏览器厂商已经做了很多优化。

不过Proxy也有一个小问题:它不兼容IE11及以下的浏览器——虽然现在IE11的市场份额已经非常低了,但如果你的项目还需要兼容IE11,那还是得用Vue2,或者用Vue3的兼容模式(不过兼容模式本质上还是用的Object.defineProperty,会失去Proxy的很多优势)。

Pinia和Vuex有什么区别?为什么Vue3官方推荐用Pinia?

Vuex作为Vue2官方推荐的状态管理工具,曾经是很多项目的标配,但Vue3出来之后,官方直接把Pinia列为了“官方推荐的下一代状态管理工具”,而且Vuex5的规划也是完全基于Pinia的——这说明Pinia肯定有它的过人之处,不然官方不会这么快就“抛弃”Vuex。

先说说Vuex的缺点吧,特别是在Vue3和TypeScript的环境下,这些缺点会被无限放大:第一个是概念太多,学起来麻烦——Vuex有state、getters、mutations、actions、modules这五个核心概念,mutations和actions的区别很多初学者搞不清楚,modules的命名空间、模块拆分、模块访问也很繁琐;第二个是类型推导差,和Vue2的问题一样,TypeScript在Vuex里用起来特别别扭,要么得写一堆类型断言,要么就得用Vuex提供的那些复杂的类型工具,很多团队为了省事干脆不用TS,或者只把state定义成any;第三个是没有官方的devtools插件支持Vue3的Composition API——虽然有第三方的,但用起来总觉得不如官方的顺手;第四个是mutations必须是同步的,actions虽然可以是异步的,但只能通过commit mutations来修改state,代码写起来有点冗余。

再看Pinia是怎么解决这些问题的,Pinia的概念非常少,只有state、getters、actions这三个核心概念——没有mutations了!你可以直接在actions里修改state,不管是同步还是异步都可以,代码写起来非常简洁;Pinia的类型推导完美,甚至不需要你写太多的类型定义,TS就能自动推导出state、getters、actions的类型,编辑器的智能提示也特别丝滑;Pinia有官方的devtools插件支持Vue3,不管是Options API还是Composition API都能用,而且功能比Vuex的devtools还要强大;Pinia天生支持Composition API,你可以在组件里直接用useStore函数获取store,也可以在自定义Hook里用,非常灵活;还有,Pinia支持模块化,但它的模块化非常简单,没有命名空间的概念——你可以创建多个store,每个store都是独立的,你想怎么组合就怎么组合,不会有命名冲突;Pinia的体积非常小,压缩之后只有1KB左右,比Vuex小很多。

举个简单的例子对比一下吧,比如我们要写一个计数器的状态管理:

用Vuex的话,代码大概是这样的:

// store/index.js
import { createStore } from 'vuex'
export default createStore({
  state: {
    count: 0
  },
  getters: {
    doubleCount: (state) => state.count * 2
  },
  mutations: {
    increment(state) {
      state.count++
    },
    decrement(state) {
      state.count--
    },
    setCount(state, payload) {
      state.count = payload
    }
  },
  actions: {
    asyncIncrement({ commit }) {
      setTimeout(() => {
        commit('increment')
      }, 1000)
    }
  }
})

然后在组件里用的话,还得用mapState、mapGetters、mapMutations、mapActions这些辅助函数,或者直接用$store,代码比较啰嗦,而且类型推导差。

用Pinia的话,代码大概是这样的:

// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    async asyncIncrement() {
      setTimeout(() => {
        this.count++
      }, 1000)
    }
  }
})

然后在组件里用的话,直接引入useCounterStore函数调用就行,不管是Options API还是Composition API都能用,而且类型推导完美:

<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
  <div>
    <p>Count: {{ counter.count }}</p>
    <p>Double Count: {{ counter.doubleCount }}</p>
    <button @click="counter.increment">Increment</button>
    <button @click="counter.decrement">Decrement</button>
    <button @click="counter.asyncIncrement">Async Increment</button>
  </div>
</template>

是不是比Vuex简洁太多了?而且代码可读性也更好。

Vue3的生命周期钩子函数和Vue2有什么区别?script setup里怎么用?

这个问题也是Vue3面试的高频题,而且很多初学者容易把Vue2的生命周期钩子函数和Vue3的Composition API里的钩子函数搞混——比如Vue2里的beforeCreatecreated,在Vue3的Composition API里是没有对应的钩子函数的,因为setup函数本身就是在beforeCreate之后、created之前执行的。

先给大家列一下Vue2和Vue3生命周期钩子函数的对应关系,方便大家记忆:

  • Vue2的beforeCreate → Vue3的setup函数(执行时机)
  • Vue2的created → Vue3的setup函数(执行时机)
  • Vue2的beforeMount → Vue3的onBeforeMount
  • Vue2的mounted → Vue3的onMounted
  • Vue2的beforeUpdate → Vue3的onBeforeUpdate
  • Vue2的updated → Vue3的onUpdated
  • Vue2的beforeDestroy → Vue3的onBeforeUnmount
  • Vue2的destroyed → Vue3的onUnmounted
  • Vue2的activated → Vue3的onActivated
  • Vue2的deactivated → Vue3的onDeactivated
  • Vue2的errorCaptured → Vue3的onErrorCaptured
  • Vue3新增的钩子函数:onRenderTrackedonRenderTriggeredonServerPrefetch

这里重点说一下Vue3新增的两个钩子函数:onRenderTrackedonRenderTriggered——这两个钩子函数主要是用来调试响应式系统的,onRenderTracked会在响应式依赖被收集的时候触发,onRenderTriggered会在响应式依赖被触发更新的时候触发,它们接收一个DebuggerEvent对象作为参数,这个对象里包含了依赖的类型、目标对象、键名等等信息,非常有用。

然后说一下script setup里怎么用生命周期钩子函数:你需要从vue里引入对应的钩子函数,比如import { onMounted, onUnmounted } from 'vue';直接在script setup里调用这些钩子函数,传入一个回调函数就行;这些钩子函数可以多次调用,回调函数会按照调用的顺序依次执行。

举个简单的例子,比如我们要在组件挂载的时候启动一个定时器,在组件卸载的时候清除这个定时器:

<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
const count = ref(0)
let timer = null
onMounted(() => {
  timer = setInterval(() => {
    count.value++
  }, 1000)
})
onUnmounted(() => {
  clearInterval(timer)
})
</script>
<template>
  <div>Count: {{ count }}</div>
</template>

是不是非常简单?而且这个逻辑你还可以封装成一个自定义Hook,比如useInterval,哪个组件需要就直接引入调用:

// hooks/useInterval.js
import { onMounted, onUnmounted, ref } from 'vue'
export function useInterval(callback, delay) {
  const savedCallback = ref(callback)
  let timer = null
  // 更新回调函数,避免闭包陷阱
  function updateCallback(newCallback) {
    savedCallback.value = newCallback
  }
  onMounted(() => {
    timer = setInterval(() => {
      savedCallback.value()
    }, delay)
  })
  onUnmounted(() => {
    clearInterval(timer)
  })
  return { updateCallback }
}

然后在组件里用的话,直接引入调用就行:

<script setup>
import { ref } from 'vue'
import { useInterval } from '@/hooks/useInterval'
const count = ref(0)
// 这里可以直接传箭头函数,也可以传普通函数
useInterval(() => {
  count.value++
}, 1000)
</script>
<template>
  <div>Count: {{ count }}</div>
</template>

这样是不是更灵活、更易复用?

Vue3的性能优化有哪些实用的方法?别只会说“用v-once、v-memo”

很多人一碰到Vue3性能优化的问题,第一反应就是蹦出“用v-once、v-memo、用computed缓存、避免v-for和v-if同用”这些老生常谈的方法,但其实这些方法很多时候在Vue3里已经不是必须的了——因为Vue3本身已经做了很多性能优化,比如静态提升、事件监听缓存、SSR优化等等,而且v-for和v-if同用的优先级也变了(Vue3里v-if的优先级比v-for高,Vue2里刚好相反)。

那Vue3里还有哪些实用的、容易被忽略的性能优化方法呢?今天就给大家聊几个:

第一个是合理使用refreactive——很多初学者不知道什么时候用ref,什么时候用reactive,其实很简单:基本数据类型(比如string、number、boolean、null、undefined)用ref,引用数据类型(比如object、array、function)用reactive;不过如果你想给一个引用数据类型重新赋值,那最好用ref,因为reactive创建的响应式对象不能直接重新赋值,否则会失去响应式;还有,如果你创建的响应式对象非常大,而且里面只有少数几个属性是经常变化的,那最好把经常变化的属性单独拿出来用ref,剩下的用shallowReactive或者markRaw——shallowReactive只会把对象的第一层属性转换成响应式,深层嵌套的对象不会转换;markRaw会标记一个对象永远不会被转换成响应式,这样可以减少性能损耗。

第二个是合理使用computedwatch——computed是带缓存的,只有当它的依赖发生变化的时候才会重新计算,所以如果你需要根据其他响应式数据计算出一个新的数据,而且这个新的数据会被多次使用,那最好用computedwatch是用来监听响应式数据的变化的,只有当你需要在响应式数据变化的时候执行一些副作用(比如发送网络请求、操作DOM、修改其他数据)的时候才用;还有,Vue3里新增了watchEffect,它会自动收集依赖,不需要你手动指定监听的目标,如果你需要在依赖变化的时候执行一些副作用,而且你不想手动指定依赖,那可以用watchEffect;不过要注意,watchEffect的执行时机和watch不一样,watchEffect会在组件挂载的时候先执行一次,而watch只有当依赖变化的时候才会执行(除非你设置了immediate: true)。

第三个是合理使用teleport——teleport是Vue3新增的一个内置组件,它可以把组件的内容渲染到DOM树的任意位置,而不是组件的父级容器里;这在某些场景下特别有用,比如你要写一个弹窗、一个下拉菜单、一个提示框,这些组件的内容如果渲染在父级容器里,可能会受到父级容器的position: relativeoverflow: hidden等样式的影响,导致显示不正常;用teleport把它们渲染到body标签下,就可以避免这些问题;而且teleport不会影响组件的逻辑和状态,组件的状态还是和原来一样的,只是DOM结构变了。

第四个是合理使用<Suspense>和异步组件——<Suspense>是Vue3新增的一个内置组件,它可以在异步组件加载完成之前显示一个加载状态,比如loading动画、骨架屏等等;异步组件是Vue3官方推荐的一种组件加载方式,它可以把组件的代码分割成单独的chunk,只有当组件被使用的时候才会加载,这样可以减少首屏加载的时间;Vue3里创建异步组件的方法非常简单,用defineAsyncComponent函数就行:

import { defineAsyncComponent } from 'vue'
const AsyncComponent = defineAsyncComponent(() => import('./AsyncComponent.vue'))

然后和<Suspense>一起用:

<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <div>Loading...</div>
    </template>
  </Suspense>
</template>

是不是非常简单?而且<Suspense>还可以嵌套使用,比如你可以在一个异步组件里再放一个<Suspense>,加载另一个异步组件。

第五个是合理使用v-memo——很多人觉得v-memov-once差不多,其实不然;v-once只会渲染一次,之后不管依赖怎么变化都不会重新渲染;v-memo可以传入一个依赖数组,只有当依赖数组里的元素发生变化的时候才会重新渲染,否则会直接复用之前的DOM;v-memo特别适合用在有大量列表项的场景下,比如你有一个10000条数据的列表,每条数据里只有少数几个属性是经常变化的,那你可以用v-memo传入这些经常变化的属性作为依赖,这样只有当这些属性变化的时候,对应的列表项才会重新渲染,其他的列表项会直接复用之前的DOM,性能会提升很多;不过要注意,v-memo的依赖数组不能太长,否则比较依赖数组的时间会超过重新渲染的时间,反而会降低性能。

第六个是避免不必要的响应式——很多初学者不管什么数据都用ref或者reactive转换成响应式,其实有些数据根本不需要响应式,比如组件里的一些常量、一些只在初始化的时候用一次的临时变量、一些不需要在模板里显示的变量等等;你可以直接用普通的变量定义这些数据,这样可以减少响应式系统的负担,提升性能;还有,如果你有一个大的配置对象,而且这个配置对象不需要响应式,那你可以用markRaw标记它,这样Vue3就不会把它转换成响应式了。

第七个是合理使用虚拟列表——如果你有一个非常大的列表,比如10000条数据以上,那不管你用v-memo还是其他优化方法,性能都会很差,因为浏览器需要渲染太多的DOM节点;这时候你就需要用虚拟列表了,虚拟列表只会渲染可视区域内的列表项,其他的列表项会被暂时移除,这样可以大大减少DOM节点的数量,提升性能;Vue3官方没有提供虚拟列表的内置组件,但有很多优秀的第三方库可以用,比如vue-virtual-scrollervue-virtual-scroll-list等等,这些库的使用方法都非常简单,而且性能也很好。

除了上面这些,还有一些其他的性能优化方法,比如开启生产模式、压缩代码、Tree Shaking、CDN加速等等,这些都是前端通用的性能优化方法,这里就不展开说了。

最后给大家提几个面试的小建议

聊了这么多核心考点,最后给大家提几个面试的小建议,希望能帮到大家:

第一个是不要死记硬背知识点,要理解原理,最好能结合实际项目的经验来说——比如面试官问你Composition API比Options API好在哪,你不要只说“逻辑复用更灵活”,你可以举一个你实际项目里用自定义Hook解决mixins命名冲突或者依赖关系不透明的例子,这样面试官会觉得你是真的懂,而不是死记硬背的。

第二个是要诚实,不会的就说不会,不要瞎编——面试官都是身经百战的,你瞎编的话很容易被识破;而且你可以说“这个问题我之前没遇到过,不过我可以说说我的思路”,这样面试官会觉得你是一个有思考能力的人,反而会给你加分。

第三个是要主动展示自己的优势——比如你可以主动说你做过哪些Vue3的项目,用过哪些Vue3的新特性,踩过哪些坑,怎么解决的,这样面试官会更了解你,也更容易发现你的亮点。

第四个是要提前了解面试公司的技术栈和业务——比如面试公司用的是Vue3+TypeScript+Pinia,那你就要重点准备这些方面的知识点;比如面试公司做的是电商项目,那你就要重点准备性能优化、状态管理、表单处理这些方面的知识点。

第五个是要保持自信,不要紧张——面试只是一个双向选择的过程,你在选公司,公司也在选你;保持自信,正常发挥,就一定能找到心仪的工作。

好了,今天的分享就到这里了,希望这篇文章能帮到正在准备Vue3面试的小伙伴们;如果大家还有其他的Vue3面试题或者疑问,欢迎在评论区留言交流。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门