写在前面的话:最近复习过程中积累的笔记,本文内容主要是以 Vue3 为主的常见面试题。内容来源五花八门,如有不妥或错误欢迎指出。
Vue2 与 Vue3 的区别
- 写法上,Vue2 是选项式 API,Vue3 是组合式 API 且支持多个根标签。
- 类型安全上,Vue3 使⽤ TS 重写,更加类型安全。
- 生命周期上,Vue2 的
beforeDestroy和destroyed被重命名为beforeUnmount和unmounted,且mounted()等 API 被重命名为onMounted()。 - 在响应式原理上,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 的数据劫持实际上是遍历对象的每个属性并对每个属性定义 get 和 set。在 Vue2 中,不能直接监听到对象属性的增删,为了增删对象属性,需要使用 Vue.set / this.$set 和 Vue.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, created | beforeCreate, created |
| 模板挂载阶段 | beforeMount, mounted | beforeMount, mounted |
| 运行更新阶段 | beforeUpdate, updated | beforeUpdate, updated |
| 销毁阶段 | beforeDestroy, destroyed | beforeUnmount, 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. 只能用于对象类型(对象、数组和如 Map、Set 这样的集合类型),不能用于基本数据类型。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() 函数传入前两个参数 oldVnode 和 Vnode(分别代表新的节点和之前的旧节点)并执行,执行 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-if、v-for 等内置指令一起使用,如果这些指令都不存在,那么它将被渲染成一个原生的<template>元素。
computed 与 watch
computed 和 watch 是 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-if 比 v-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 中声明的属性会自动透传。最常见的例子就是 class、style 和 id。如果子组件是多根节点组件,子组件中接收透传元素的节点上需要显式绑定 $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. 数据透传
provide、inject
在 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 通过以下四个核心概念来管理状态:
state:储应用的状态,类似于 Vue 组件的data,但它是全局共享的。getters:从state中派生出状态的计算属性,用来在组件中获取数据。mutations:同步修改state的唯一方式,负责状态的改变。actions:处理业务逻辑或异步操作,调用mutations来修改状态。
Pinia 原理在于在 Vue 应用上下文内维护一个 Map 结构,键是 store 的 ID,值是完整的响应式 store 实例。通过 provide/inject 依赖注入机制,所有组件都可以访问到同一个 store 实例,从而实现状态的共享和响应式更新。
选项式写法,Option store 的三个属性是 state、actions 与 getters 。
组合式写法,Setup store 中:ref() 就是 state 属性;computed() 就是 getters 属性;function() 就是 actions 属性。
内置组件
内置组件无需注册便可以直接在模板中使用。在渲染函数中使用它们时,需要显式导入。
-
<Transition>为单个元素或组件提供动画过渡效果。 -
<TransitionGroup>为列表中的多个元素或组件提供过渡效果。 -
<keep-alive>缓存包裹在其中的动态切换组件。keepAlive 通过 LRU 缓存策略⼯作,它在 created 函数调用时将需要缓存的组件实例保存在缓存中,当组件被再次激活且存在于缓存中,则复用缓存中的组件实例。 -
<Teleport>将其插槽内容渲染到 DOM 中的另一个位置。 -
<Suspense>用于协调对组件树中嵌套的异步依赖的处理。<Suspense>组件有两个插槽:#default和#fallback。两个插槽都只允许一个直接子节点。<Suspense>组件会触发三个事件:pending、resolve和fallback。pending事件是在进入挂起状态时触发。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组件,ViewModel 是 View 和 Model 层的桥梁,数据会绑定到 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的
useFetch、useAsyncData、$fetch请求方法,避免重复请求;把调用浏览器 API 等行为放在onMounted中,避免在服务端渲染的过程中运行。 - 浏览器环境API在服务端未处理(如window、document)。解决方法:使用
<ClientOnly>包裹只在浏览器中使用的组件。