谈谈你对 Vue 的理解?为什么使用 Vue 进行开发?
根据官方说法,Vue 是一套用于构建用户界面的渐进式框架。Vue 的设计受到了 MVVM 的启发。Vue 的两个核心是数据驱动和组件系统。
我为什么使用 Vue,有以下几个原因:
-
Vue 对于前端初学者比较友好。一个 Vue 文件的结构和原生 HTML 保持了高度相似,分为静态页面,用于放置 html 标签,和 script,用于处理用户操作和业务逻辑,最后是 style 样式,用于书写 CSS 代码,这种写法可以让初学者感到习惯。
-
Vue 提供了许多 JS 定制化的操作,比如 v-bind 和事件监听的 @ 符号,开发者可以直接使用,从而减少一些重复代码的书写。
-
Vue 提供一套高效的响应式的系统用于更新 DOM,可以让开发者专注于处理业务而非技术实现。
-
Vue 鼓励组件化开发,开发者可以将部分界面提取为组件,尤其是复用性较高的界面,使代码更加模块化、可维护性更高。
-
Vue 在国内拥有庞大且活跃的社区,开发者可以获得丰富的文档、教程、插件和支持。
这个问题是开放性问题,具体如何回答可以自由发挥。
刚才提到了 MVVM,可以介绍一下吗?
MVVM,即 Model–View–ViewModel,是一种软件架构模式。
-
Model即模型,是指代表真实状态内容的领域模型(面向对象),或指代表内容的数据访问层(以数据为中心)。
-
View即视图,是用户在屏幕上看到的结构、布局和外观(UI)。
-
ViewModel即视图模型,是暴露公共属性和命令的视图的抽象。用于把
Model和View关联起来。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。
在 MVVM 架构下,View 和 Model 之间并没有直接的联系,而是通过 ViewModel 进行交互,Model 和 ViewModel 之间的交互是双向的,View 数据的变化会同步到 Model 中,而 Model 数据的变化也会立即反应到 View 上。
因此开发者只需关注业务逻辑,不需要手动操作 DOM,不需要关注数据状态的同步问题,复杂的数据状态维护完全由 MVVM 来统一管理。
Vue 3 有什么主要变化?
- 引入了 Composition API(组合式 API):这种方式允许开发者使用函数而不是选项对象来组织组件的代码,通过组合式 API 可以更灵活地组织和复用组件逻辑,使得代码更具可读性和维护性。
-
支持多根组件:可以直接在 template 中使用多个根级别的元素,而不需要额外的包装元素。这样更方便地组织组件的结构。
-
引入了 Teleport(传送):可以将组件的内容渲染到指定 DOM 节点的新特性。一般用于创建全局弹窗和对话框等组件。
-
数据劫持方法升级:从
Object.defineProperty升级到 ES6 原生的 Proxy,不需要初始化遍历所有属性,就可以监听新增和删除的属性。 -
编译优化:重写了虚拟 DOM,提升了渲染速度。diff 时静态节点会被直接跳过。
-
体积优化:移除了一些非必要的特性,如
filter和.native修饰符。另外,所有模块也将会被按需引入,仅打包用到的模块,从而减小了打包体积。 -
打包优化:更强的 Tree Shaking,可以过滤不使用的模块,没有使用到的组件,比如过渡(transition)组件,则打包时不会包含它。
-
通过 monorepo 方式维护:Vue 3 根据功能的不同将模块拆分成不同的
package,拆分后的模块更细化,职责划分更明确,开发人员更容易阅读和理解源码。并且,一些package(比如reactivity响应式库)是可以独立于Vue使用的,这样用户如果只想使用 Vue 的响应式能力,可以单独引入这个响应式库。
Composition API(组合式 API)与 Options API(选项式 API)有什么区别?
-
Options API 会将组件中的同一逻辑相关的代码拆分到不同选项,比如
data、props、methods等,而使用 Composition API 较为灵活,开发者可以将同一个逻辑的相关代码放在一起。 -
Composition API 通过 Vue 3.x 新增的 setup 选项进行使用,该选项会在组件创建之前执行,第一个参数
props,第二个参数context,return 的所有内容都会暴露给组件的其余部分 (计算属性、方法、生命周期钩子等等) 以及组件的模板。 -
Composition API 上的生命周期钩子与 Options API 基本相同,但需要添加前缀
on,比如onMounted、onUpdated等。
为什么 Vue 3 使用 Proxy 代替 Object.defineProperty() ?
-
Proxy 专门被设计用于代理对象,可以直接监听整个对象,而
Object.defineProperty()需要遍历对象的每个属性(所以只能代理已存在的属性)再去进行监听。 -
因为数组也是对象,
typeof [] === 'object',所以 Proxy 还可以代理数组,而在 Vue 2 中则是通过重写数组的以下七种方法实现的。-
push()(将一个或多个元素添加到数组的末尾,并返回该数组的新长度) -
pop()(移除并返回数组的最后一个元素) -
unshift()(将一个或多个元素添加到数组的开头,并返回该数组的新长度) -
shift()(移除并返回数组的第一个元素) -
splice()(删除数组中的一个或多个元素,并将其返回) -
sort()(对数组进行排序) -
reverse()(对数组进行反转)
-
-
Object.defineProperty()的本质是在一个对象上定义一个新属性,或者修改一个现有属性。const cat = { name: 'Tom', } Object.defineProperty(cat, 'name', { get() { console.log(`我被读取了`) }, set(value) { console.log(`我被设置成了 ${value}`) }, }) cat.name // expected output: 我被读取了 cat.name = 'Kitty' // expected output: 我被设置成了 Kitty -
Proxy 有 13 种拦截操作的方法,是
Object.defineProperty()不具备的。-
apply()(拦截函数的调用) -
construct()(拦截构造函数的调用) -
defineProperty()(拦截属性的定义) -
deleteProperty()(拦截属性的删除) -
get()(拦截对象属性的读取) -
getOwnPropertyDescriptor()(拦截对象属性的描述) -
getPrototypeOf()(拦截对象的原型) -
has()(拦截对象属性的检查) -
isExtensible()(拦截对象是否可扩展的检查) -
ownKeys()(拦截对象的属性列表) -
preventExtensions()(拦截对象是否可扩展的设置) -
set()(拦截对象属性的设置) -
setPrototypeOf()(拦截对象的原型的设置)
-
Vue 3 响应式系统的原理
响应式系统是 Vue 最核心的功能。所有组件的状态都是由 Vue 响应式的对象组成的。当更改它们时,视图会随即自动更新。
在 JavaScript 中有两种劫持 property 访问的方式:getter / setters 和 Proxy。Vue 3 中使用 Proxy 用于 reactive 及相关 API 创建响应式对象,使用 getter / setter 用于 ref 及相关 API 劫持基本类型。
实现 reactive 的核心代码:
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
}
})
}
实现 ref 的核心代码:
function ref(value) {
const refObject = {
get value() {
track(refObject, 'value')
return value
},
set value(newValue) {
value = newValue
trigger(refObject, 'value')
}
}
return refObject
}
如代码所示,Vue 3 的响应式系统在设置属性时通过 track 跟踪并收集依赖,在设置属性时通过 trigger 触发更新,来实现响应性。
🔔 面试官可能会继续追问,或者根据实际情况主动回答:
track 和 trigger 是如何实现的?
track() 用于跟踪并收集依赖。在 track() 内部会检查当前是否有正在运行的 effect(通常用于更新视图)。如果有,则会查找追踪该属性的订阅者的集合(Set,3.4 之后改为了 Map),然后将当前这个 effect 作为新订阅者添加到该集合中。
// 这会在一个副作用就要运行之前被设置
// 我们会在后面处理它
let activeEffect
function track(target, key) {
if (activeEffect) {
// 查找相应属性订阅的 effect 集合,如果没有则新建
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}
trigger() 用于执行 effect。在 trigger() 内部会查找在 track() 时保存的该属性的所有订阅者的 effect,然后执行它们。
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}
Vue 是如何实现数据双向绑定的?v-model 的原理?
Vue 组件可以通过使用 v-model 指令以实现双向绑定。
v-model 是 vue 的一个语法糖,它用于监听数据的改变并将数据更新。以 input 元素为例:
<el-input v-model="foo" />
其实就等价于
<input :value="searchText" @input="searchText = $event.target.value" />
如何在组件中实现 v-model ?
在 Vue 2 组件中实现 v-model,只需定义 model 属性即可。
export default {
model: {
prop: 'value', // 属性
event: 'input', // 事件
},
}
在 Vue 3 组合式 API 实现 v-model,需要定义 modelValue 参数,和 emits 方法。
defineProps({
modelValue: { type: String, default: '' },
})
const emits = defineEmits(['update:modelValue'])
function onInput(val) {
emits('update:modelValue', val)
}
当数据改变时,Vue 是如何更新 DOM 的?(Diff 算法和虚拟 DOM)
当我们修改了某个数据时,如果直接重新渲染到真实 DOM,开销是很大的。Vue 为了减少开销和提高性能采用了 Diff 算法。当数据发生改变时,Observer 会通知所有 Watcher,Watcher 就会调用 patch() 方法(Diff 的具体实现),把变化的内容更新到真实的 DOM,俗称打补丁。
Diff 算法会对新旧节点进行同层级比较,当两个新旧节点是相同节点的时候,再去比较他们的子节点(如果是文本则直接更新文本内容),逐层比较然后找到最小差异部分,进行 DOM 更新。如果不是相同节点,则删除之前的内容,重新渲染。
patch() 方法先根据真实 DOM 生成一颗虚拟 DOM,保存到变量 oldVnode,当某个数据改变后会生成一个新的 Vnode,然后 Vnode 和 oldVnode 进行对比,发现有不一样的地方就直接修改在真实 DOM 上,最后再返回新节点作为下次更新的 oldVnode。
什么是虚拟 DOM?有什么用?
虚拟 DOM(Virtual DOM)就是将真实 DOM 的属性使用 JavaScript 对象描述,用于优化 DOM 操作。比如真实 DOM 如下:
<div id="hello">
<h1>Page Title</h1>
</div>
对应的虚拟 DOM 就是(伪代码):
const vnode = {
tag: 'div',
props: {
id: 'hello',
},
children: [
{
tag: 'h1',
innerText: 'Page Title',
},
],
}
虚拟 DOM 的主要目的是提高性能和减少实际 DOM 操作的次数,从而改善用户界面的渲染速度和响应性。
Vue 3 对 diff 算法进行了哪些优化?
静态内容缓存
在 Vue 2 中,每当数据发生变化时,Vue 会创建一个新的虚拟 DOM 树,并对整个虚拟 DOM 树进行递归比较,即使其中大部分内容是静态的,最后再找到不同的节点,然后进行更新。
Vue 3 引入了静态标记的概念,通过静态标记,Vue 3 可以将模板中的静态内容和动态内容区分开来。这样,在更新过程中,Vue 3 只会关注动态部分的比较,而对于静态内容,它将跳过比较的步骤,从而避免了不必要的比较,提高了性能和效率。
<div>
<div>foo</div> <!-- 需缓存 -->
<div>bar</div> <!-- 需缓存 -->
<div>{{ dynamic }}</div>
</div>
事件监听缓存
在 Vue 2 中,当元素绑定了一些事件时,每次渲染该元素时,都会重新给此元素绑定事件,即使组件的状态没有变化,这不仅浪费资源,还可能导致内存泄漏。
<template>
<button @click="handleClick">Click me!</button>
</template>
<script setup>
const handleClick = () => {
console.log('Button clicked!');
};
</script>
为了解决这个问题,Vue 3 引入了事件监听缓存,在第一次渲染时注册并缓存事件监听器,并在后续渲染时复用这些监听器,除非显式更新它们。
Vue 中的 key 有什么用?
-
在 Vue 中,key 被用来作为 VNode 的唯一标识。
-
key 主要用在虚拟 DOM Diff 算法,在新旧节点对比时作为识别 VNode 的一个线索。如果新旧节点中提供了 key,能更快速地进行比较及复用。反之,Vue 会尽可能复用相同类型元素。
<ul> <li v-for="item in items" :key="item.id">{{ item.name }}</li> </ul> -
手动改变 key 值,可以强制 DOM 进行重新渲染。
<transition> <span :key="text">{{ text }}</span> </transition>
Vue 实例的生命周期钩子都有哪些?
生命周期钩子是指一个组件实例从创建到卸载(销毁)的全过程,例如,设置数据监听、编译模板、将实例挂载到 DOM 并在数据变化时更新 DOM 等。在这个过程中会运行一些叫做生命周期钩子的函数,从而可以使开发者们在不同阶段处理不同的业务。
Vue 3 选项式 API 的钩子和 Vue 2 大致是一样的,有以下钩子:
-
beforeCreate
实例初始化之前,
$el和data都为undefined。 -
created
实例创建完成,
data已经绑定。但$el不可用。 -
beforeMount
将
<template>和data生成虚拟DOM节点,可以访问到$el,但还没有渲染到html上。 -
mounted
实例挂载完成,渲染到
html页面中。 -
beforeUpdate
data更新之前,虚拟DOM重新渲染之前。 -
updated
由于
data更新导致的虚拟DOM重新渲染之后。 -
beforeDestroy(Vue 2) → beforeUnmount(Vue 3)
实例销毁之前(实例仍然可用)。
-
destroyed(Vue 2) → beforeUnmount(Vue 3)
实例销毁之后。所有的事件监听器会被移除,所有的子实例也会被销毁,但
DOM节点依旧存在。该钩子在服务器端渲染期间不被调用。 -
activated
keep-alive专用,实例被激活时调用。 -
deactivated
keep-alive专用,实例被移除时调用。 -
errorCaptured
在捕获了后代组件传递的错误时调用。
第一次页面加载会触发这四个钩子:
-
beforeCreate
-
created
-
beforeMount
-
mounted
Vue 3 组合式 API 有以下钩子:
-
onBeforeMount()
在组件被挂载之前被调用。
-
onMounted()
在组件挂载完成后执行。
-
onBeforeUpdate()
在组件即将因为响应式状态变更而更新其 DOM 树之前调用。
-
onUpdated()
在组件因为响应式状态变更而更新其 DOM 树之后调用。
-
onBeforeUnmount()
在组件实例被卸载之前调用。
-
onUnmounted()
在组件实例被卸载之后调用。相当于 Vue 2 的
destroyed。 -
onErrorCaptured()
在捕获了后代组件传递的错误时调用。
-
onRenderTracked()
当组件渲染过程中追踪到响应式依赖时调用。只在开发环境生效。
-
onRenderTriggered()
当响应式依赖的变更触发了组件渲染时调用。只在开发环境生效。
-
onActivated()
keep-alive专用,当组件被插入到 DOM 中时调用。 -
onDeactivated()
keep-alive专用,当组件从 DOM 中被移除时调用。 -
onServerPrefetch()
在组件实例在服务器上被渲染之前调用。只在 SSR 模式下生效。
keep-alive 的作用和原理
作用
<keep-alive> 可以缓存包裹在其中的组件(只能有一个直接组件),用于保存组件状态或者避免重新渲染。
<keep-alive>
<!-- 该组件会被缓存 -->
<MyComponent></MyComponent>
</keep-alive>
原理
在 Vue 中,当路由切换时,Vue 会自动销毁当前页面组件,并加载一个新的页面组件,而被 <keep-alive> 缓存的组件则会保留在内存中不会被销毁,因为其内部维护了一个 cache 对象,并将组件的虚拟 DOM 和实例添加到 cache 对象中。当被缓存的组件(通过路由导航或其他方式)被再次访问时,Vue 会从缓存中取出之前的组件实例,重新激活它们,而不是重新创建新的实例。所以一般情况下,被缓存的组件不会触发 onBeforeUnmount 和 onUnmounted 钩子,取而代之的是 onActivated 和 onDeactivated。
可以指定组件被缓存或不被缓存吗
可以,<keep-alive> 默认会缓存所有经过的组件,但可以通过 include 和 exclude 来指定和排除一些组件:
指定:
<keep-alive include="a, b">
<router-view :is="view" />
</keep-alive>
排除:
<keep-alive exclude="a, b">
<router-view :is="view" />
</keep-alive>
其中,include 和 exclude 接收的参数为组件的 name 属性(支持字符串、数组和正则),所以组件想要被识别,必须声明 name 属性。
<script setup>
defineOptions({ name: 'my-component' })
</script>
nextTick 的使用场景和原理
使用场景
nextTick 是在下次 DOM 更新循环结束之后执行的一个方法。一般在修改数据之后使用这个方法操作更新后的 DOM。
export default {
data() {
return {
message: 'Hello Vue!',
}
},
methods: {
example() {
// 修改数据
this.message = 'changed'
// DOM 尚未更新
this.$nextTick(() => {
// DOM 现在更新了
console.log('DOM 现在更新了')
})
},
},
}
原理
在 Vue2 当中,nextTick 可以理解为就是收集异步任务到队列当中并且开启异步任务去执行它们。它可以同时收集组件渲染的任务,以及用户手动放入的任务。组件渲染的任务是由 watcher 的 update 触发,并且将回调函数包装为异步任务,最后推到 nextTick 的队列里,等待执行。
而在 Vue3 当中,nextTick 则是利用 promise 的链式调用,将用户放入的回调放在更新视图之后的 then 里面调用,用户调用多少次 nextTick,就接着多少个 then。
为什么 Vue 组件中的 data 必须是函数?
因为在 Vue 中组件是可以被复用的,组件复用其实就是创建多个 Vue 实例,实例之间共享 prototype.data 属性,当 data 的值引用的是同一个对象时,改变其中一个就会影响其他组件,造成互相污染,而改用函数的形式将数据 return 出去,则每次复用都是崭新的对象。
这里我们举个例子:
function Component() {}
Component.prototype.data = {
name: 'vue',
language: 'javascript',
}
const A = new Component()
const B = new Component()
A.data.language = 'typescript'
console.log(A.data) // { name: 'vue', language: 'typescript' }
console.log(B.data) // { name: 'vue', language: 'typescript' }
此时,A 和 B 的 data 都指向了同一个内存地址,language 都变成了 'typescript'。
我们改成函数式的写法,就不会有这样的问题了。
function Component() {
this.data = this.data()
}
Component.prototype.data = function () {
return { name: 'vue', language: 'javascript' }
}
const A = new Component()
const B = new Component()
A.data.language = 'typescript'
console.log(A.data) // { name: 'vue', language: 'typescript' }
console.log(B.data) // { name: 'vue', language: 'javascript' }
所以组件的 data 选项必须是一个函数,该函数返回一个独立的拷贝,这样就不会出现数据相互污染的问题。
Vue 组件之间如何通信?
传送门:Vue 3 组件之间如何通信
Vue 项目中做过哪些性能优化?
-
UI 库按需加载,减小打包体积,以 ElementUI 为例:
// main.js import { Button, Select } from 'element-ui' Vue.use(Button) Vue.use(Select) -
路由按需加载
// router.js export default new VueRouter({ routes: [ { path: '/', component: () => import('@/components/Home') }, { path: '/about', component: () => import('@/components/About') }, ], }) -
组件销毁后把同时销毁全局变量和移除事件监听和清除定时器,防止内存泄漏
beforeDestroy() { clearInterval(this.timer) window.removeEventListener('resize', this.handleResize) }, -
合理使用 v-if 和 v-show
Vue 和 React 的区别?
传送门:对比其他框架 — Vue.js
v-for 和 v-if 可以同时使用吗
可以同时使用,但不推荐,具体原因参考官方说明。
在 Vue 3 中,当 v-if 和 v-for 同时存在于一个节点上时,v-if 比 v-for 的优先级更高,此时 v-if 无法访问 v-for 中的对象。
当确实需要条件遍历渲染的话,有以下几个方法:
- fitler
<li v-for="todo in todos.filter(todo => !todo.isDone)">{{ todo.name }}</li>
使用数组的 filter 的方法可以提前对不需要的数据进行过滤,根源上解决这个问题。
- 使用 v-show
<li v-for="todo in todos" v-show="!todo.isDone">{{ todo.name }}</li>
v-show 和 v-if 都可以用于隐藏某个元素,但 v-if 用于决定是否渲染,而 v-show 则使用 display 属性决定是否显示。此时可以避免 v-if 和 v-for 同时使用造成的的渲染问题。
- 添加额外的标签
<template v-for="todo in todos">
<li v-if="!todo.isComplete">{{ todo.name }}</li>
</template>
添加额外的标签,根据层级的不同,可以自己决定 v-if 和 v-for 的优先级,这种方法更加灵活也更容易理解,但会有更深的代码结构。
v-for 只能遍历数组吗?都支持遍历哪些数据类型?
除了常见的数组,v-for 还可以用来遍历对象,遍历的顺序会基于对该对象调用 Object.values() 的返回值来决定。
<template>
<ul>
<li v-for="(value, key) in myObject">
{{ key }}: {{ value }}
</li>
</ul>
</template>
<script setup>
const myObject = {
title: 'How to do lists in Vue',
author: 'Jane Doe',
publishedAt: '2016-04-10'
}
</script>
还可以直接接受一个整数值,但初始值是从 1 开始而非 0。
<span v-for="n in 10">{{ n }}</span>
指令是什么?封装过指令吗?
在 Vue 中,指令用于在组件或元素上完成一些快捷操作,指令以 v- 开头,是元素的行内属性,每一个指令都可以实现一些特殊的功能,比如 Vue 内置的一些指令:
-
v-if:用于决定某个元素是否渲染。 -
v-model:用于双向数据绑定。 -
v-show:用于改变元素的可见性。
更多 Vue 内置指令点击:内置指令
在 Vue 3.x 中注册自定义指令有三种情况,以一个自动聚焦的指令为例:
全局注册指令
// main.js
import { createApp } from 'vue'
const app = createApp({})
// 注册
app.directive('my-directive', {
mounted: el => el.focus(),
})
组合式 API 注册指令:
在组合式 API 中,任何以 v 开头的驼峰式命名的变量都可以被用作一个自定义指令。
<template>
<input v-focus />
</template>
<script setup>
const vFocus = {
mounted: el => el.focus(),
}
</script>
选项式 API 注册指令:
export default {
directives: {
focus: {
mounted: el => el.focus(),
},
},
}
Vue 3 常用的 watch、watchEffect 和 computed 的共同点和区别是什么?
watch、watchEffect 和 computed 都可以用来追踪响应式数据源并执行一些操作,不同的是:
追踪方式不同:
-
watch:需要明确指定追踪的数据源,指定哪些就监听哪些。 -
watchEffect:无需指定,自动根据回调函数进行追踪,所有用到的响应式数据源都会被追踪。 -
computed:和watchEffect一样会自动追踪,区别是它必须 return 一个返回值。这是因为watchEffect注重的是 effect,数据变化后的副作用,而computed注重的是计算值。
访问范围不同:
watch 可以访问改变前的值,而 watchEffect 和 computed 只能访问到改变后的值。
执行时机不同:
-
watch默认不会立即执行,在其追踪的响应式数据改变后才会执行。如果需要立即执行,需要传入immediate: true。 -
watchEffect和computed会立即执行。
返回值不同:
-
watch和watchEffect都会返回一个stop方法,用于随时停止追踪。 -
computed返回一个只读的响应式 ref 对象。
import { ref, watch, watchEffect, computed } from 'vue'
const count = ref(1)
count.value++
const w = watch(
count,
(newValue, oldValue) => {
console.log(newValue === oldValue) // -> false
},
{ immediate: true }
)
w.stop() // w 停止追踪
const e = watchEffect(() => console.log(count.value)) // -> 2
e.stop() // e 停止追踪
const c = computed(() => count.value + 1)
console.log(c.value) // -> 3
参考资料: