【面试必备】Vue基础知识

43 阅读14分钟

写在前面的话:最近复习过程中积累的笔记,本文内容主要是以 Vue3 为主的常见面试题。内容来源五花八门,如有不妥或错误欢迎指出。

Vue2 与 Vue3 的区别

  1. 写法上,Vue2 是选项式 API,Vue3 是组合式 API 且支持多个根标签。
  2. 类型安全上,Vue3 使⽤ TS 重写,更加类型安全。
  3. 生命周期上,Vue2 的 beforeDestroydestroyed 被重命名为 beforeUnmountunmounted,且 mounted() 等 API 被重命名为 onMounted()
  4. 在响应式原理上,Vue2 使用 Object.defineProperty 进行数据劫持,Vue3 使用 Proxy 进行数据劫持

响应式原理和双向绑定

Vue 的核心在于响应式原理和双向绑定,二者都依赖于数据劫持和发布订阅模式来实现,区别在于:

  • 响应式指的是数据驱动视图更新。例如,通过 ref()reactive() 定义响应式数据。
  • 双向绑定是在响应式基础上扩展的机制,除了数据驱动视图外,视图的变化也会反过来更新数据,即数据和视图的双向同步。例如 v-model 实现双向绑定,v-model:value@input 的语法糖。:v-bind 的简写,用于数据绑定,@v-on 的简写,用于监听 DOM 事件。

数据劫持和发布订阅模式在 Vue2 和 Vue3 的实现有所不同:

Vue2 使用 Object.defineProperty 进行数据劫持。 Vue2 的数据劫持实际上是遍历对象的每个属性并对每个属性定义 getset。在 Vue2 中,不能直接监听到对象属性的增删,为了增删对象属性,需要使用 Vue.set / this.$setVue.delete / this.$delete 方法。在 Vue2 中,新增数组索引、修改数组 length 不会触发响应式,因为没有监听数组的所有索引(会有严重的性能问题),为了实现数组的响应式,建议更改整个数组的引用或者使用 push/pop/shift/unshift/splice/sort/reverse 七个 Vue2 提供的变异方法。

Vue3 使用 Proxy 进行数据劫持(第一个参数是对象,第二个参数是 handler 方法),直接代理对象,可以监听到数组和对象属性的增删。

数据的变化并不等同于视图的变化,因此需要建立数据依赖它的副作用函数(读取响应式数据并在数据变化时执行) 之间的映射关系,收集依赖(track) 是当 effect 读取某个属性时,把它登记到属性的依赖集合里,派发更新(trigger) 是当属性变化时,从依赖集合里找到所有的 effect 并依次重新执行。

生命周期钩子

Vue 的生命周期是指从一个 Vue 实例创建、初始化、运行、销毁的过程。

阶段VUE 2 生命周期钩子VUE 3 生命周期钩子
实例创建阶段beforeCreate, createdbeforeCreate, created
模板挂载阶段beforeMount, mountedbeforeMount, mounted
运行更新阶段beforeUpdate, updatedbeforeUpdate, updated
销毁阶段beforeDestroy, destroyedbeforeUnmount, unmounted

onMounted() 在组件挂载之后执行。通常用于执行需要访问组件所渲染的 DOM 树相关的副作用,或是在服务端渲染应用中用于确保 DOM 相关代码仅在客户端执行。

onUpdated() 注册一个回调函数,在组件因为响应式状态变更而更新其 DOM 树之后调用。

onBeforeUnmount() 用于清理一些副作用,例如清理监听器、定时器等。onUnmounted() 用于清理清理不依赖组件状态的副作用(如全局事件总线、第三方库的销毁)。

ref 与 reactive

在组合式 API 中, ref()reactive() 都可以用来声明响应式,底层靠 Proxy 拦截读写 + 依赖收集 + 触发副作用实现。

ref() 可以持有任何类型的值,包括深层嵌套的对象、数组或者基本数据类型。由于 Proxy 只能代理对象,对于 ref(),实际上是代理了一个含有 value 属性的对象,因此需要用 .value 来访问这个响应式变量(在模板中会自动解包因此不需要 .value)。我们替换掉 ref 里的基本类型或者对象,实际上只是替换掉 value 属性,响应式不会丢失。

reactive() 是只能用于对象类型而不能用于基本类型,Proxy 会直接代理对象,如果替换掉对象就会失去响应式。

推荐使用 ref() 来声明响应式状态。因为使用 reactive() 的局限是:1. 只能用于对象类型(对象、数组和如 MapSet 这样的集合类型),不能用于基本数据类型。2. 只能改变对象的属性,而不能替换整个对象,因为会改变引用从而失去响应式。3. 对解构操作不友好。

可以通过 shallow ref 来放弃深层响应性。对于浅层 ref,只有 .value 的访问会被追踪。浅层 ref 可以用于避免对大型数据的响应性开销来优化性能、或者有外部库管理其内部状态的情况。

模板语法渲染原理

Vue 不是直接操作真实 DOM,而是使用虚拟 DOM,虚拟 DOM 在本质上是 JS 对象,通过比较新旧虚拟 DOM 之间的差异,也就是比较 JS 对象之间的差异,计算出最小的操作量来实现这些差异。

Vue 的核心渲染原理是编译时+运行时

首先 Vue 的编译器会将模板解析为 AST,遍历 AST 并标记静态节点,之后将优化后的 AST 转换为可执行的渲染函数 h()

当组件首次渲染时,会执行渲染函数 h(),生成一棵虚拟 DOM 树,然后调用 patch() 函数,将 VNode 递归地创建为真实的 DOM 节点。当组件内响应式数据发生变化时,会触发组件的更新效应,重新执行渲染函数执行 h() 创建 VNode(虚拟 DOM 树),接着给 patch() 函数传入前两个参数 oldVnodeVnode(分别代表新的节点和之前的旧节点)并执行,执行 patch() 函数时会通过 Diff 算法比较新旧 VNode 树之间的差异,计算最小更新并批量将差异更新到真实 DOM。这样减少了对真实 DOM 的操作次数,节省性能开销。

h() 的三个参数分别是1. 一个 HTML 标签名、一个组件、一个异步组件、或一个函数式组件,必须。2. props,可选。3. 子 VNodes,可选。

Diff 算法

React / Vue2 / Vue3 的 Diff 算法有共同点也有不同点。共同点在于:1. 同层级比较。正常 Diff 两个树的时间复杂度是 O(n^3),通过只对同一层级的节点进行比较,时间复杂度可以降低到 O(n),如果节点跨层级移动,会被先删除再创建。2. 会通过 key 来标识可复用节点。

React 使用双指针算法,在新旧列表中寻找相同 Key 的节点,并尝试复用。

Vue 使用双端算法,在头尾有新旧一共四个指针,会进行头头比较、尾尾比较、头尾比较、尾头比较,以上都不匹配的话则会使用类似 React 的方式通过 Key 在一个旧节点的映射表中查找新节点。Vue 3 在 Vue 2 的基础上引入了最长递增子序列算法。

template 与 component

<template> 的内容会被渲染,但其自身不会被渲染为DOM。常用于:

条件渲染:可以使用 v-if、v-else-if、v-else、v-show 等指令与 <template> 结合来进行条件渲染。这样当需要对多个元素进行条件渲染时,不需要创建一个实际的 DOM 容器。

<template v-if="ok">
  <h1>Title</h1>
  <p>Paragraph 1</p>
  <p>Paragraph 2</p>
</template>

列表渲染:与模板上的 v-if 类似, <template>v-for 指令一起使用时,<template> 可以用来渲染列表中的每一项,而不需要为列表中的每个项目创建一个实际的 DOM 容器。

<ul>
  <template v-for="item in items">
    <li>{{ item.msg }}</li>
    <li class="divider" role="presentation"></li>
  </template>
</ul>

具名插槽:有时在一个组件中包含多个插槽出口是很有用的。对于这种场景,<slot> 元素可以有一个特殊的属性 name,这类带 name 的插槽被称为具名插槽 (named slots)。没有提供 name<slot> 出口会隐式地命名为“default”。

<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

在父元素使用该组件时,需要使用一个含 v-slot# 指令的 <template> 元素,并将目标插槽的名字传给该指令。

<BaseLayout>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>
​
  <!-- 隐式的默认插槽 -->
  <p>A paragraph for the main content.</p>
  <p>And another one.</p>
​
  <template #footer>
    <p>Here's some contact info</p>
  </template>
</BaseLayout>

<component><slot><template> 具有类似组件的特性,也是模板语法的一部分。但它们并非真正的组件,同时在模板编译期间会被编译掉。因此,它们通常在模板中用小写字母书写。

<component> 是用于渲染动态组件或元素的“元组件”。要渲染的实际组件由 is prop 决定。<slot> 表示模板中的插槽内容出口。<template> 当我们想要使用内置指令而不在 DOM 中渲染元素时,<template> 标签可以作为占位符使用。常与 v-ifv-for 等内置指令一起使用,如果这些指令都不存在,那么它将被渲染成一个原生的<template>元素。

computed 与 watch

computedwatch 是 Vue 中用于响应式数据处理的两种方式。

使用场景不同,当模板中的某个值需要由响应式数据计算得到时,就用 computed,当需要监听某个值,如果值改变则执行回调函数,就用 watch

computed(计算属性):依赖响应式数据,当依赖的响应式数据发生变化时,自动计算衍生值,具有缓存功能。computed() 是通过 getter / setter 和依赖收集来实现的,当我们获取计算属性的值,就调用了 get() 方法,当我们修改计算属性的值,就调用了 set() 方法。如果我们传入一个函数,就会被视为 get() 方法,此时默认是只读的。如果传入一个对象,就会分别提取 get() 方法和 set() 方法,可读写。

<script setup>
import { ref, computed } from 'vue'const firstName = ref('John')
const lastName = ref('Doe')
​
const fullName1 = computed(() => { return firstName.value + ' ' + lastName.value })
const fullName = computed({
  get() {
    return firstName.value + ' ' + lastName.value
  },
  set(newValue) {
    [firstName.value, lastName.value] = newValue.split(' ')
  }
})
</script>

watch(侦听器):依赖响应式数据,当依赖的响应式数据发生变化时,执行回调函数,没有缓存功能。可以给 watch 的第一个参数传入一个或多个响应式数据,还可以给 watch 设置 deep 和 immediate 选项,用于实现深度监听和立即执行回调函数。watch 适⽤于需要细粒度控制响应式数据变化的场景,⽐如需要⼿动停⽌监听或访问数据变化前后的值。

import { ref, watch } from 'vue';
​
const count = ref(0);
const double = ref(0);
​
// 创建单个数据源的侦听器
watch(count, (newVal, oldVal) => {
  console.log(`count变化:新值 - ${newVal}, 旧值 - ${oldVal}`);
});
​
// 创建多个数据源的侦听器
watch([count, double], ([newCount, newDouble], [oldCount, oldDouble]) => {
  console.log(`count变化:新值 - ${newCount}, 旧值 - ${oldCount}`);
  console.log(`double变化:新值 - ${newDouble}, 旧值 - ${oldDouble}`);
});

vue3 还引入了 watchEffect,无需显式指定侦听的数据源。watchEffect 会自动深度监听、不管数据是否变化都会立即执行,不能配置 immediate 和 deep 选项,不能获取新值和旧值。watchEffect 更适合⽤于⾃动追踪响应式数据并触发副作⽤的场景,如执⾏异步操作或更新 UI。

v-for 与 v-if 的冲突

二者不建议一起使用,因为 v-ifv-for 的优先级更高。这意味着 v-if 的条件将无法访问到 v-for 作用域内定义的变量别名。

<!-- 这会抛出一个错误,因为属性 todo 此时没有在该实例上定义 -->
<li v-for="todo in todos" v-if="!todo.isComplete">
  {{ todo.name }}
</li>

解决方案:在外先包装一层 <template> 再在其上使用 v-for (这也更加明显易读)。

<template v-for="todo in todos">
  <li v-if="!todo.isComplete">
    {{ todo.name }}
  </li>
</template>

v-if 与 v-show 的区别

二者都用于动态显示 DOM 元素,但 v-if 会添加或者删除 DOM,v-show 是控制 DOM 的 display 属性。

初始和切换状态的编译情况上,v-if 如果初始条件为假则不会编译,当条件变为 true 时才开始局部编译,而且在切换过程中会有局部编译和卸载的过程。v-show 无论初始条件如何都被编译,之后根据条件是否为 true 来改变 css 属性,在切换过程中 DOM 始终保留。

性能消耗上,v-if 有更⾼的切换消耗,v-show 有更⾼的初始渲染消耗。

使⽤场景上,v-if 适合条件大多不会改变的场景,v-show 适合需要频繁切换的场景。

nextTick

在 Vue 中,存在基于微任务队列的批处理(Batching) 机制,也就是多个状态更新会合并为一次渲染。因此,在一次状态更新后,立即去访问 DOM 可能会获取到未更新的数据。nextTick 的作用是确保在一次渲染完成之后,对最新的状态进行操作。实际上,在渲染完成之后,Vue 自身会调用一次 nextTick 来刷新 DOM,开发者追加的 nextTick 与其落在同一轮微任务队列,因此能保证 DOM 已渲染完。

<script setup>
import { ref, nextTick } from 'vue';
​
const message = ref("Hello");
​
function updateMessage() {
  message.value = "Vue 3 updated!";
  
  nextTick(() => {
    console.log("DOM 已更新:", document.querySelector("#text").innerText); 
  });
​
  console.log("DOM 还未更新:", document.querySelector("#text").innerText);
}
</script>
​
// 执行顺序:
// DOM 还未更新: Hello
// DOM 已更新: Vue 3 updated!

组件通信

父组件通过 props 向子组件传递数据,子组件只传递不修改。子组件通过 emits 将事件传递给父组件。v-model 双向绑定。跨越多个层级的组件通信使用 Provide / Inject。

透传 Attributes

透传指的是将没有被显式声明为 props 的属性或方法传递给一个组件

1. 属性透传

在 vue 中,如果子组件是单根节点组件,那么父组件传递的未在 props 中声明的属性会自动透传。最常见的例子就是 classstyleid。如果子组件是多根节点组件,子组件中接收透传元素的节点上需要显式绑定 $attrs ,否则会抛出运行时警告。

在 vue 中,可以通过设置 inheritAttrs: false 来禁止自动透传。

<!-- 父组件 -->
<CustomLayout id="custom-layout" @click="changeValue" />
​
<!-- 子组件有多个根节点,需要指定哪个根结点接收父节点透传的属性 -->
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>

在 React 中没有自动透传机制,但可以实现类似效果,如使用扩展运算符。

function ChildComponent(props) {
  const { title, count, className, style, onClick, ...otherAttrs } = props;
  return (<div>any</div>)
}

2. 方法透传

在 vue 中,父组件用 @事件 绑定方法,子组件用 $emit 触发。在 react 中,方法也作为 props 从父组件传递到子组件。

3. 数据透传

provideinject

在 vue 中,父组件向子组件传递数据,如果组件链路非常长,可能会影响到更多这条路上的组件。provide 和 inject 可以帮助我们解决逐级透传的问题。一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

provide() 函数接收两个参数。第一个参数被称为注入名,可以是一个字符串或是一个 Symbol。第二个参数是提供的值,值可以是任意类型,包括响应式的状态。

provide('key', count)

子组件通过 inject() 注入父组件提供的数据。如果有多个父组件提供了相同键的数据,注入将解析为组件链上最近的父组件所注入的值。第二个参数是默认值。第三个参数表示默认值应该被当作一个工厂函数。

const value = inject('message', '这是默认值');
const value = inject('key', () => new ExpensiveClass(), true);

在 React 中,在数据透传时常使用 useContext 钩子。

复杂场景下,使用状态管理工具 Vuex & Pinia(vue)、Zustand & Redux(react)。

样式穿透

一般我们会在 <style> 标签添加 scoped 属性进行样式隔离,这样当前组件的样式只会在当前样式生效,避免全局污染。原理是最后标签与样式都会带上类似于 [data-v-xxxx] 的属性。

通过深度选择器 v-deep 可以实现样式穿透,样式穿透是用来打破样式隔离的,常用来修改第三方库的样式。

<style scoped lang="scss">
.card {
  ::v-deep(.ant-btn) {
    background: #42b983;
  }
}
</style>

React 没有样式穿透这个说法,那是因为 React 的样式就是全局生效,所以 React 更常见的情况是要显式采用 CSS-Modules、CSS-in-JS 或约定命名空间来样式隔离。

Vuex 与 Pinia

Vuex 适合大型项目,而 Pinia 更加轻量,适合中小型项目,且 Pinia 具有更好的 TypeScript 支持。

Vuex 通过以下四个核心概念来管理状态:

  1. state:储应用的状态,类似于 Vue 组件的 data,但它是全局共享的。
  2. getters:从 state 中派生出状态的计算属性,用来在组件中获取数据。
  3. mutations:同步修改 state 的唯一方式,负责状态的改变。
  4. actions:处理业务逻辑或异步操作,调用 mutations 来修改状态。

Pinia 原理在于在 Vue 应用上下文内维护一个 Map 结构,键是 store 的 ID,值是完整的响应式 store 实例。通过 provide/inject 依赖注入机制,所有组件都可以访问到同一个 store 实例,从而实现状态的共享和响应式更新。

选项式写法,Option store 的三个属性是 stateactionsgetters

组合式写法,Setup store 中:ref() 就是 state 属性;computed() 就是 getters 属性;function() 就是 actions 属性。

内置组件

内置组件无需注册便可以直接在模板中使用。在渲染函数中使用它们时,需要显式导入。

  1. <Transition> 为单个元素或组件提供动画过渡效果。

  2. <TransitionGroup> 为列表中的多个元素或组件提供过渡效果。

  3. <keep-alive> 缓存包裹在其中的动态切换组件。keepAlive 通过 LRU 缓存策略⼯作,它在 created 函数调用时将需要缓存的组件实例保存在缓存中,当组件被再次激活且存在于缓存中,则复用缓存中的组件实例。

  4. <Teleport>将其插槽内容渲染到 DOM 中的另一个位置。

  5. <Suspense> 用于协调对组件树中嵌套的异步依赖的处理。<Suspense> 组件有两个插槽:#default#fallback。两个插槽都只允许一个直接子节点。

    <Suspense> 组件会触发三个事件:pendingresolvefallbackpending 事件是在进入挂起状态时触发。resolve 事件是在 default 插槽完成获取新内容时触发。fallback 事件则是在 fallback 插槽的内容显示时触发。

    我们常常会将 <Suspense><keep-alive><Transition> 等组件结合。要保证这些组件都能正常工作,嵌套的顺序非常重要。

    <RouterView v-slot="{ Component }">
      <template v-if="Component">
        <Transition mode="out-in">
          <KeepAlive>
            <Suspense>
              <!-- 主要内容 -->
              <component :is="Component"></component>
    ​
              <!-- 加载中状态 -->
              <template #fallback>
                正在加载...
              </template>
            </Suspense>
          </KeepAlive>
        </Transition>
      </template>
    </RouterView>
    

Babel

Babel 主要用于将采用 ECMAScript 2015+ 语法编写的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。Babel 本质上就是在操作 AST 来完成代码的转译。AST是抽象语法树(Abstract Syntax Tree, AST)。

MVC 与 MVVM

MVC (Model-View-Controller)模式下,页面更新经过数据改变、获取 DOM、更新 DOM、视图更新的过程,也就是说需要操作 DOM 结构更新试图。MVC 模式下, Vue 等框架帮助我们实现了获取 DOM、更新 DOM的过程,开发者只需关注业务逻辑,无需手动操作 DOM。

MVVM(Model-View-ViewModel),即数据和视图双向绑定。Model 层代表数据模型,View 代表UI组件,ViewModelViewModel 层的桥梁,数据会绑定到 viewModel 层并自动将数据渲染到页面中,视图变化的时候会通知 viewModel 层更新数据。

SPA 与 MPA

单页应用(SPA)只有一个 HTML 页面,通过 JS 动态更新内容,页面切换无刷新。典型框架如 React、Vue、Angular 等。可能存在 SEO 和首屏加载问题。

多页应用(MPA)每次跳转都会加载新的 HTML 页面。SEO 友好,屏加载快,但页面切换慢。

SSR、CSR、SSG

客户端渲染(CSR)是浏览器从一个空 HTML 开始,用 JS 动态生成内容。常见于 SPA。

服务端渲染(SSR)是服务端生成完整的 HTML 发送到浏览器,利于 SEO 和首屏加载。

SSG(静态渲染)适合官网。

同构渲染(CSR + SSR)的典型框架如 Next.js 与 Nuxt.js

以 Nuxt 为例,首次访问(SSR阶段) :当用户浏览器首次请求一个页面时,服务器接收到请求匹配到对应的页面,执行异步数据获取函数(如 asyncData, fetch)并渲染页面,生成完整的 HTML 字符串,发送到客户端。注水阶段:浏览器接收到服务器发送的 HTML,会立即解析并渲染显示出来,同时浏览器也会加载 Nuxt 应用打包出的客户端 JavaScript bundle,之后由 Vue 接管,Vue 会重新执行渲染函数,并检查虚拟 DOM 节点与现有的静态 DOM 节点是否匹配(但不会重新创建 DOM 元素),并为这些已有的 DOM 元素添加事件监听器、激活响应式数据等。后续导航(CSR阶段) :一旦Hydration完成,就变成了一个标准的单页应用(SPA),此后,用户在应用内点击 <NuxtLink> 进行导航时会在客户端完成路由切换,也可以通过设置强制每一次导航都是 SSR,通常不建议这么做。

Next 与之最大的区别在于 SSR 阶段渲染页面时,会将 React 组件树渲染为 RSC Payload 格式,其中包含了 HTML 结构和客户端组件的占位符。

Nuxt 请求类型

useFetch 是对 useAsyncData + $fetch 的封装,useLazyFetch 是对 useLazyAsyncData + $fetch 的封装,都是用于在服务端发起请求。一般用于需要 SSR 的页面。

$fetch 可能会导致在服务端、客户端中重复请求。为了防止重复请求,需要判断环境。一般用 $fetch 来做绑定在dom中的方法(比如点击后发起删除请求),这样就只会在客户端执行。在 onMounted 中调用 $fetch 也可以令请求只在客户端执行。或者把不需要 SSR 的页面设置为 ssr: false,这样就不会经过服务端渲染。

Nuxt 支持流式 SSR,也就是服务端异步请求渲染页面,并分段发送 HTML 给客户端,直到拼接成一整个 HTML 页面才会被爬虫引擎爬取。在客户端那里,暂时没有完成请求渲染并接收到的 HTML 片段会展示 fallback 的内容,这样比起传统 SSR 就避免了加载最初显示空白的问题。

补充:流式 SSR 与 SSE 不同,后者是持久连接的,客户端通过 EventSource API 与服务端建立长连接,并通过 ReadableStream API 读取服务端响应的数据(类型应该是 'Content-Type': 'text/event-stream' ),客户端每监听到 EventSource 的 message 事件,就会将新收到的数据块更新在 DOM 中。document.getElementById('ai-response').innerText += data.token;

水合 hydration

水合/注水(hydration)是同构渲染的步骤,即服务端渲染后,客户端接管并为静态 DOM 绑定事件、监听器,激活响应式数据,让静态页面变得可交互。需要保持服务端与客户端渲染结果一致。hydration中出现的 mismatch 可能出于以下原因:

  • 服务端和客户端的初始数据不同步(如时间戳、随机数)。解决方法:合理使用使用Nuxt的 useFetchuseAsyncData$fetch 请求方法,避免重复请求;把调用浏览器 API 等行为放在 onMounted 中,避免在服务端渲染的过程中运行。
  • 浏览器环境API在服务端未处理(如window、document)。解决方法:使用 <ClientOnly> 包裹只在浏览器中使用的组件。