本篇文章给大家总结分享29+个Vue经典面试题(附源码级详解),带你梳理基础知识,增强Vue知识储备,值得收藏,快来看看吧!
还是问新特性,陈述典型新特性,分析其给你带来的变化即可。(学习视频分享:vue视频教程)
从以下几方面分门别类阐述:易用性、性能、扩展性、可维护性、开发体验等
Vue3的最大设计目标是替代Vue2(皮一下),为了实现这一点,Vue3在以下几个方面做了很大改进,如:易用性、框架性能、扩展性、可维护性、开发体验等
易用性方面主要是API简化,比如v-model
在Vue3中变成了Vue2中v-model
和sync
修饰符的结合体,用户不用区分两者不同,也不用选择困难。类似的简化还有用于渲染函数内部生成VNode的h(type, props, children)
,其中props
不用考虑区分属性、特性、事件等,框架替我们判断,易用性大增。
开发体验方面,新组件Teleport
传送门、Fragments
、Suspense
等都会简化特定场景的代码编写,SFC Composition API
语法糖更是极大提升我们开发体验。
扩展性方面提升如独立的reactivity
模块,custom renderer
API等
可维护性方面主要是Composition API
,更容易编写高复用性的业务逻辑。还有对TypeScript支持的提升。
性能方面的改进也很显著,例如编译期优化、基于Proxy
的响应式系统
。。。
Proxy
和defineProperty
有什么不同?这是一道综合实践题目,写过一定数量的代码之后小伙伴们自然会开始关注一些优化方法,答得越多肯定实践经验也越丰富,是很好的题目。
根据题目描述,这里主要探讨Vue代码层面的优化
我这里主要从Vue代码编写层面说一些优化手段,例如:代码分割、服务端渲染、组件缓存、长列表优化等
最常见的路由懒加载:有效拆分App尺寸,访问时才异步加载
const router = createRouter({ routes: [ // 借助webpack的import()实现异步组件 { path: '/foo', component: () => import('./Foo.vue') } ] })
keep-alive
缓存页面:避免重复创建组件实例,且能保留缓存组件状态
<router-view v-slot="{ Component }"> <keep-alive> <component :is="Component"></component> </keep-alive> </router-view>
使用v-show
复用DOM:避免重复创建组件
<template> <div class="cell"> <!-- 这种情况用v-show复用DOM,比v-if效果好 --> <div v-show="value" class="on"> <Heavy :n="10000"/> </div> <section v-show="!value" class="off"> <Heavy :n="10000"/> </section> </div> </template>
v-for
遍历避免同时使用 v-if
:实际上在Vue3中已经是个错误写法
<template> <ul> <li v-for="user in activeUsers" <!-- 避免同时使用,vue3中会报错 --> <!-- v-if="user.isActive" --> :key="user.id"> {{ user.name }} </li> </ul> </template> <script> export default { computed: { activeUsers: function () { return this.users.filter(user => user.isActive) } } } </script>
v-once和v-memo:不再变化的数据使用v-once
<!-- single element --> <span v-once>This will never change: {{msg}}</span> <!-- the element have children --> <div v-once> <h1>comment</h1> <p>{{msg}}</p> </div> <!-- component --> <my-component v-once :comment="msg"></my-component> <!-- `v-for` directive --> <ul> <li v-for="i in list" v-once>{{i}}</li> </ul>
按条件跳过更新时使用v-momo
:下面这个列表只会更新选中状态变化项
<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]"> <p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p> <p>...more child nodes</p> </div>
https://vuejs.org/api/built-in-directives.html#v-memo
长列表性能优化:如果是大数据长列表,可采用虚拟滚动,只渲染少部分区域的内容
<recycle-scroller class="items" :items="items" :item-size="24" > <template v-slot="{ item }"> <FetchItemView :item="item" @vote="voteItem(item)" /> </template> </recycle-scroller>
一些开源库:
- vue-virtual-scroller:https://github.com/Akryum/vue-virtual-scroller
- vue-virtual-scroll-grid:https://github.com/rocwang/vue-virtual-scroll-grid
事件的销毁:Vue 组件销毁时,会自动解绑它的全部指令及事件监听器,但是仅限于组件本身的事件。
export default { created() { this.timer = setInterval(this.refresh, 2000) }, beforeUnmount() { clearInterval(this.timer) } }
图片懒加载
对于图片过多的页面,为了加速页面加载速度,所以很多时候我们需要将页面内未出现在可视区域内的图片先不做加载, 等到滚动到可视区域后再去加载。
<img v-lazy="/static/img/1.png">
参考项目:https://github.com/hilongjw/vue-lazyload
第三方插件按需引入
像element-plus
这样的第三方组件库可以按需引入避免体积太大。
import { createApp } from 'vue'; import { Button, Select } from 'element-plus'; const app = createApp() app.use(Button) app.use(Select)
子组件分割策略:较重的状态组件适合拆分
<template> <div> <ChildComp/> </div> </template> <script> export default { components: { ChildComp: { methods: { heavy () { /* 耗时任务 */ } }, render (h) { return h('div', this.heavy()) } } } } </script>
但同时也不宜过度拆分组件,尤其是为了所谓组件抽象将一些不需要渲染的组件特意抽出来,组件实例消耗远大于纯dom节点。参考:https://vuejs.org/guide/best-practices/performance.html#avoid-unnecessary-component-abstractions
服务端渲染/静态网站生成:SSR/SSG
如果SPA应用有首屏渲染慢的问题,可以考虑SSR、SSG方案优化。参考:https://vuejs.org/guide/scaling-up/ssr.html
这题现在有些落伍,vue3
已经不用一个根了。因此这题目很有说头!
vue2直接报错,test-v2.html
new Vue({ components: { comp: { template: ` <div>root1</div> <div>root2</div> ` } } }).$mount('#app')
vue3中没有问题,test-v3.html
Vue.createApp({ components: { comp: { template: ` <div>root1</div> <div>root2</div> ` } } }).mount('#app')
vue3
解决方法原理vue2
中组件确实只能有一个根,但vue3
中组件已经可以多根节点了。vdom
是一颗单根树形结构,patch
方法在遍历的时候从根节点开始遍历,它要求只有一个根节点。组件也会转换为一个vdom
,自然应该满足这个要求。vue3
中之所以可以写多个根节点,是因为引入了Fragment
的概念,这是一个抽象的节点,如果发现组件是多根的,就创建一个Fragment节点,把多个根节点作为它的children。将来patch的时候,如果发现是一个Fragment节点,则直接遍历children创建或更新。patch方法接收单根vdom:
https://github1s.com/vuejs/core/blob/HEAD/packages/runtime-core/src/renderer.ts#L354-L355
// 直接获取type等,没有考虑数组的可能性 const { type, ref, shapeFlag } = n2
patch方法对Fragment的处理:
https://github1s.com/vuejs/core/blob/HEAD/packages/runtime-core/src/renderer.ts#L1091-L1092
// a fragment can only have array children // since they are either generated by the compiler, or implicitly created // from arrays. mountChildren(n2.children as VNodeArrayChildren, container, ...)
https://vuex.vuejs.org/zh/guide/modules.html
const moduleA = { state: () => ({ ... }), mutations: { ... }, actions: { ... }, getters: { ... } } const moduleB = { state: () => ({ ... }), mutations: { ... }, actions: { ... } } const store = createStore({ modules: { a: moduleA, b: moduleB } }) store.state.a // -> moduleA 的状态 store.state.b // -> moduleB 的状态 store.getters.c // -> moduleA里的getters store.commit('d') // -> 能同时触发子模块中同名mutation store.dispatch('e') // -> 能同时触发子模块中同名action
概念和必要性
怎么拆
使用细节
优缺点
用过module,项目规模变大之后,单独一个store对象会过于庞大臃肿,通过模块方式可以拆分开来便于维护
可以按之前规则单独编写子模块代码,然后在主文件中通过modules
选项组织起来:createStore({modules:{...}})
不过使用时要注意访问子模块状态时需要加上注册时模块名:store.state.a.xxx
,但同时getters
、mutations
和actions
又在全局空间中,使用方式和之前一样。如果要做到完全拆分,需要在子模块加上namespace
选项,此时再访问它们就要加上命名空间前缀。
很显然,模块的方式可以拆分代码,但是缺点也很明显,就是使用起来比较繁琐复杂,容易出错。而且类型系统支持很差,不能给我们带来帮助。pinia显然在这方面有了很大改进,是时候切换过去了。
用过pinia吗?都做了哪些改善?
这是一道应用题。当打包应用时,JavaScript 包会变得非常大,影响页面加载。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问时才加载对应组件,这样就会更加高效。
// 将 // import UserDetails from './views/UserDetails' // 替换为 const UserDetails = () => import('./views/UserDetails') const router = createRouter({ // ... routes: [{ path: '/users/:id', component: UserDetails }], })
参考:https://router.vuejs.org/zh/guide/advanced/lazy-loading.html
必要性
何时用
怎么用
使用细节
当打包构建应用时,JavaScript 包会变得非常大,影响页面加载。利用路由懒加载我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,这样会更加高效,是一种优化手段。
一般来说,对所有的路由都使用动态导入是个好主意。
给component
选项配置一个返回 Promise 组件的函数就可以定义懒加载路由。例如:
{ path: '/users/:id', component: () => import('./views/UserDetails') }
结合注释() => import(/* webpackChunkName: "group-user" */ './UserDetails.vue')
可以做webpack代码分块
vite中结合rollupOptions定义分块
路由中不能使用异步组件
component
(和 components
) 配置如果接收一个返回 Promise 组件的函数,Vue Router 只会在第一次进入页面时才会获取这个函数,然后使用缓存数据。
https://github1s.com/vuejs/router/blob/HEAD/src/navigationGuards.ts#L292-L293
这是Vue3
数据响应式中非常重要的两个概念,自然的,跟我们写代码关系也很大。
ref:https://vuejs.org/api/reactivity-core.html#ref
const count = ref(0) console.log(count.value) // 0 count.value++ console.log(count.value) // 1
reactive:https://vuejs.org/api/reactivity-core.html#reactive
const obj = reactive({ count: 0 }) obj.count++
两者概念
两者使用场景
两者异同
使用细节
ref
接收内部值(inner value)返回响应式Ref
对象,reactive
返回响应式代理对象
从定义上看ref
通常用于处理单值的响应式,reactive
用于处理对象类型的数据响应式
两者均是用于构造响应式数据,但是ref
主要解决原始值的响应式问题
ref返回的响应式数据在JS中使用需要加上.value
才能访问其值,在视图中使用会自动脱ref,不需要.value
;ref可以接收对象或数组等非原始值,但内部依然是reactive
实现响应式;reactive内部如果接收Ref对象会自动脱ref;使用展开运算符(...)展开reactive返回的响应式对象会使其失去响应性,可以结合toRefs()将值转换为Ref对象之后再展开。
reactive内部使用Proxy代理传入对象并拦截该对象各种操作(trap),从而实现响应式。ref内部封装一个RefImpl类,并设置get value/set value,拦截用户对值的访问,从而实现响应式。
reactive实现响应式:
https://github1s.com/vuejs/core/blob/HEAD/packages/reactivity/src/reactive.ts#L90-L91
ref实现响应式:
https://github1s.com/vuejs/core/blob/HEAD/packages/reactivity/src/ref.ts#L73-L74
我们经常性需要侦测响应式数据的变化,vue3中除了watch之外又出现了watchEffect,不少同学会混淆这两个api。
watchEffect
立即运行一个函数,然后被动地追踪它的依赖,当这些依赖改变时重新执行该函数。
Runs a function immediately while reactively tracking its dependencies and re-runs it whenever the dependencies are changed.
const count = ref(0) watchEffect(() => console.log(count.value)) // -> logs 0 count.value++ // -> logs 1
watch
侦测一个或多个响应式数据源并在数据源变化时调用一个回调函数。
Watches one or more reactive data sources and invokes a callback function when the sources change.
const state = reactive({ count: 0 }) watch( () => state.count, (count, prevCount) => { /* ... */ } )
给出两者定义
给出场景上的不同
给出使用方式和细节
原理阐述
watchEffect
立即运行一个函数,然后被动地追踪它的依赖,当这些依赖改变时重新执行该函数。watch
侦测一个或多个响应式数据源并在数据源变化时调用一个回调函数。
watchEffect(effect)
是一种特殊watch
,传入的函数既是依赖收集的数据源,也是回调函数。如果我们不关心响应式数据变化前后的值,只是想拿这些数据做些事情,那么watchEffect
就是我们需要的。watch更底层,可以接收多种数据源,包括用于依赖收集的getter函数,因此它完全可以实现watchEffect的功能,同时由于可以指定getter函数,依赖可以控制的更精确,还能获取数据变化前后的值,因此如果需要这些时我们会使用watch。
watchEffect
在使用时,传入的函数会立刻执行一次。watch
默认情况下并不会执行回调函数,除非我们手动设置immediate
选项。
从实现上来说,watchEffect(fn)
相当于watch(fn,fn,{immediate:true})
watchEffect
定义:https://github1s.com/vuejs/core/blob/HEAD/packages/runtime-core/src/apiWatch.ts#L80-L81
export function watchEffect( effect: WatchEffect, options?: WatchOptionsBase ): WatchStopHandle { return doWatch(effect, null, options) }
watch
定义如下:https://github1s.com/vuejs/core/blob/HEAD/packages/runtime-core/src/apiWatch.ts#L158-L159
export function watch<T = any, Immediate extends Readonly<boolean> = false>( source: T | WatchSource<T>, cb: any, options?: WatchOptions<Immediate> ): WatchStopHandle { return doWatch(source as any, cb, options) }
很明显watchEffect
就是一种特殊的watch
实现。
我们现在编写的Vue、React和Angular应用大多数情况下都会在一个页面中,点击链接跳转页面通常是内容切换而非页面跳转,由于良好的用户体验逐渐成为主流的开发模式。但同时也会有首屏加载时间长,SEO不友好的问题,因此有了SSR,这也是为什么面试中会问到两者的区别。
两者概念
两者优缺点分析
使用场景差异
其他选择
SPA(Single Page Application)即单页面应用。一般也称为 客户端渲染(Client Side Render), 简称 CSR。SSR(Server Side Render)即 服务端渲染。一般也称为 多页面应用(Mulpile Page Application),简称 MPA。
SPA应用只会首次请求html文件,后续只需要请求JSON数据即可,因此用户体验更好,节约流量,服务端压力也较小。但是首屏加载的时间会变长,而且SEO不友好。为了解决以上缺点,就有了SSR方案,由于HTML内容在服务器一次性生成出来,首屏加载快,搜索引擎也可以很方便的抓取页面信息。但同时SSR方案也会有性能,开发受限等问题。
在选择上,如果我们的应用存在首屏加载优化需求,SEO需求时,就可以考虑SSR。
但并不是只有这一种替代方案,比如对一些不常变化的静态网站,SSR反而浪费资源,我们可以考虑预渲染(prerender)方案。另外nuxt.js/next.js中给我们提供了SSG(Static Site Generate)静态网站生成方案也是很好的静态站点解决方案,结合一些CI手段,可以起到很好的优化效果,且能节约服务器资源。
内容生成上的区别:
SSR
SPA
部署上的区别
这是一道工具类的原理题目,相当有深度,具有不错的人才区分度。
使用官方提供的SFC playground可以很好的体验vue-loader
。
有了vue-loader
加持,我们才可以以SFC的方式快速编写代码。
<template> <div class="example">{{ msg }}</div> </template> <script> export default { data() { return { msg: 'Hello world!', } }, } </script> <style> .example { color: red; } </style>
vue-loader
是什么东东vue-loader
是做什么用的vue-loader
何时生效vue-loader
如何工作vue-loader
是用于处理单文件组件(SFC,Single-File Component)的webpack loader
因为有了vue-loader
,我们就可以在项目中编写SFC格式的Vue组件,我们可以把代码分割为<template>、<script>和<style>,代码会异常清晰。结合其他loader我们还可以用Pug编写<template>,用SASS编写<style>,用TS编写<script>。我们的<style>还可以单独作用当前组件。
webpack打包时,会以loader的方式调用vue-loader
vue-loader
被执行时,它会对SFC中的每个语言块用单独的loader链处理。最后将这些单独的块装配成最终的组件模块。
1、vue-loader
会调用@vue/compiler-sfc
模块解析SFC源码为一个描述符(Descriptor),然后为每个语言块生成import代码,返回的代码类似下面:
// source.vue被vue-loader处理之后返回的代码 // import the <template> block import render from 'source.vue?vue&type=template' // import the <script> block import script from 'source.vue?vue&type=script' export * from 'source.vue?vue&type=script' // import <style> blocks import 'source.vue?vue&type=style&index=1' script.render = render export default script
2、我们想要script块中的内容被作为js处理(当然如果是<script lang="ts">
被作为ts处理),这样我们想要webpack把配置中跟.js匹配的规则都应用到形如source.vue?vue&type=script
的这个请求上。例如我们对所有*.js配置了babel-loader,这个规则将被克隆并应用到所在Vue SFC的
import script from 'source.vue?vue&type=script'
将被展开为:
import script from 'babel-loader!vue-loader!source.vue?vue&type=script'
类似的,如果我们对.sass
文件配置了style-loader
+ css-loader
+ sass-loader
,对下面的代码:
<style scoped lang="scss">
vue-loader
将会返回给我们下面结果:
import 'source.vue?vue&type=style&index=1&scoped&lang=scss'
然后webpack会展开如下:
import 'style-loader!css-loader!sass-loader!vue-loader!source.vue?vue&type=style&index=1&scoped&lang=scss'
1)当处理展开请求时,vue-loader
将被再次调用。这次,loader将会关注那些有查询串的请求,且仅针对特定块,它会选中特定块内部的内容并传递给后面匹配的loader。
2)对于<script>
块,处理到这就可以了,但是<template>
和 <style>
还有一些额外任务要做,比如:
<style scoped>
中的CSS做后处理(post-process),该操作在css-loader之后但在style-loader之前实现上这些附加的loader需要被注入到已经展开的loader链上,最终的请求会像下面这样:
// import 'vue-loader/template-loader!pug-loader!source.vue?vue&type=template' // <style scoped lang="scss"> import 'style-loader!vue-loader/style-post-loader!css-loader!sass-loader!vue-loader!source.vue?vue&type=style&index=1&scoped&lang=scss'
这是一道API题,我们可能写的自定义指令少,但是我们用的多呀,多举几个例子就行。
定义一个包含类似组件生命周期钩子的对象,钩子函数会接收指令挂钩的dom元素:
const focus = { mounted: (el) => el.focus() } export default { directives: { // enables v-focus in template focus } } <input v-focus />
<input v-focus />
定义
何时用
如何用
常用指令
vue3变化
Vue有一组默认指令,比如v-mode
l或v-for
,同时Vue也允许用户注册自定义指令来扩展Vue能力
自定义指令主要完成一些可复用低层级DOM操作
使用自定义指令分为定义、注册和使用三步:
我在项目中常用到一些自定义指令,例如:
vue3中指令定义发生了比较大的变化,主要是钩子的名称保持和组件一致,这样开发人员容易记忆,不易犯错。另外在v3.2之后,可以在setup中以一个小写v开头方便的定义自定义指令,更简单了!
编译后的自定义指令会被withDirective函数装饰,进一步处理生成的vnode,添加到特定属性中。
https://sfc.vuejs.org/#eyJBcHAudnVlIjoiPHNjcmlwdCBzZXR1cD5cbmltcG9ydCB7IHJlZiB9IGZyb20gJ3Z1ZSdcblxuY29uc3QgbXNnID0gcmVmKCdIZWxsbyBXb3JsZCEnKVxuXG5jb25zdCB2Rm9jdXMgPSB7XG4gIG1vdW50ZWQoZWwpIHtcbiAgICAvLyDojrflj5ZpbnB1dO+8jOW5tuiwg+eUqOWFtmZvY3VzKCnmlrnms5VcbiAgICBlbC5mb2N1cygpXG4gIH1cbn1cbjwvc2NyaXB0PlxuXG48dGVtcGxhdGU+XG4gIDxoMT57eyBtc2cgfX08L2gxPlxuICA8aW5wdXQgdi1tb2RlbD1cIm1zZ1wiIHYtZm9jdXM+XG48L3RlbXBsYXRlPiIsImltcG9ydC1tYXAuanNvbiI6IntcbiAgXCJpbXBvcnRzXCI6IHtcbiAgICBcInZ1ZVwiOiBcImh0dHBzOi8vc2ZjLnZ1ZWpzLm9yZy92dWUucnVudGltZS5lc20tYnJvd3Nlci5qc1wiXG4gIH1cbn0ifQ==
API考察,但$attrs和$listeners是比较少用的边界知识,而且vue3有变化,$listeners已经移除,还是有细节可说的。
这两个api的作用
使用场景分析
使用方式和细节
vue3变化
一个包含组件透传属性的对象。
An object that contains the component's fallthrough attributes.
<template> <child-component v-bind="$attrs"> 将非属性特性透传给内部的子组件 </child-component> </template>
我们可能会有一些属性和事件没有在props中定义,这类称为非属性特性,结合v-bind指令可以直接透传给内部的子组件。
这类“属性透传”常常用于包装高阶组件时往内部传递属性,常用于爷孙组件之间传参。比如我在扩展A组件时创建了组件B组件,然后在C组件中使用B,此时传递给C的属性中只有props里面声明的属性是给B使用的,其他的都是A需要的,此时就可以利用v-bind="$attrs"透传下去。
最常见用法是结合v-bind做展开;$attrs本身不是响应式的,除非访问的属性本身是响应式对象。
vue2中使用attrs中,使用起来更简单了。
查看透传属性foo和普通属性bar,发现vnode结构完全相同,这说明vue3中将分辨两者工作由框架完成而非用户指定:
<template> <h1>{{ msg }}</h1> <comp foo="foo" bar="bar" /> </template>
<template> <div> {{$attrs.foo}} {{bar}} </div> </template> <script setup> defineProps({ bar: String }) </script>
_createVNode(Comp, { foo: "foo", bar: "bar" })
https://sfc.vuejs.org/#eyJBcHAudnVlIjoiPHNjcmlwdCBzZXR1cD5cbmltcG9ydCB7IHJlZiB9IGZyb20gJ3Z1ZSdcbmltcG9ydCBDb21wIGZyb20gJy4vQ29tcC52dWUnXG5jb25zdCBtc2cgPSByZWYoJ0hlbGxvIFdvcmxkIScpXG48L3NjcmlwdD5cblxuPHRlbXBsYXRlPlxuICA8aDE+e3sgbXNnIH19PC9oMT5cbiAgPGNvbXAgZm9vPVwiZm9vXCIgYmFyPVwiYmFyXCIgLz5cbjwvdGVtcGxhdGU+IiwiaW1wb3J0LW1hcC5qc29uIjoie1xuICBcImltcG9ydHNcIjoge1xuICAgIFwidnVlXCI6IFwiaHR0cHM6Ly9zZmMudnVlanMub3JnL3Z1ZS5ydW50aW1lLmVzbS1icm93c2VyLmpzXCJcbiAgfVxufSIsIkNvbXAudnVlIjoiPHRlbXBsYXRlPlxuXHQ8ZGl2PlxuICAgIHt7JGF0dHJzLmZvb319IHt7YmFyfX1cbiAgPC9kaXY+XG48L3RlbXBsYXRlPlxuPHNjcmlwdCBzZXR1cD5cbmRlZmluZVByb3BzKHtcbiAgYmFyOiBTdHJpbmdcbn0pXG48L3NjcmlwdD4ifQ==
v-once
是Vue中内置指令,很有用的API,在优化方面经常会用到,不过小伙伴们平时可能容易忽略它。
仅渲染元素和组件一次,并且跳过未来更新
Render the element and component once only, and skip future updates.
<!-- single element --> <span v-once>This will never change: {{msg}}</span> <!-- the element have children --> <div v-once> <h1>comment</h1> <p>{{msg}}</p> </div> <!-- component --> <my-component v-once :comment="msg"></my-component> <!-- `v-for` directive --> <ul> <li v-for="i in list" v-once>{{i}}</li> </ul>
v-once
是什么
什么时候使用
如何使用
扩展v-memo
探索原理
v-once
是vue的内置指令,作用是仅渲染指定组件或元素一次,并跳过未来对其更新。
如果我们有一些元素或者组件在初始化渲染之后不再需要变化,这种情况下适合使用v-once
,这样哪怕这些数据变化,vue也会跳过更新,是一种代码优化手段。
我们只需要作用的组件或元素上加上v-once即可。
vue3.2之后,又增加了v-memo
指令,可以有条件缓存部分模板并控制它们的更新,可以说控制力更强了。
编译器发现元素上面有v-once时,会将首次计算结果存入缓存对象,组件再次渲染时就会从缓存获取,从而避免再次计算。
下面例子使用了v-once:
<script setup> import { ref } from 'vue' const msg = ref('Hello World!') </script> <template> <h1 v-once>{{ msg }}</h1> <input v-model="msg"> </template>
我们发现v-once出现后,编译器会缓存作用元素或组件,从而避免以后更新时重新计算这一部分:
// ... return (_ctx, _cache) => { return (_openBlock(), _createElementBlock(_Fragment, null, [ // 从缓存获取vnode _cache[0] || ( _setBlockTracking(-1), _cache[0] = _createElementVNode("h1", null, [ _createTextVNode(_toDisplayString(msg.value), 1 /* TEXT */) ]), _setBlockTracking(1), _cache[0] ), // ...
递归组件我们用的比较少,但是在Tree、Menu这类组件中会被用到。
组件通过组件名称引用它自己,这种情况就是递归组件。
An SFC can implicitly refer to itself via its filename.
<template> <li> <div> {{ model.name }}</div> <ul v-show="isOpen" v-if="isFolder"> <!-- 注意这里:组件递归渲染了它自己 --> <TreeItem class="item" v-for="model in model.children" :model="model"> </TreeItem> </ul> </li> <script> export default { name: 'TreeItem', // ... } </script>
如果某个组件通过组件名称引用它自己,这种情况就是递归组件。
实际开发中类似Tree、Menu这类组件,它们的节点往往包含子节点,子节点结构和父节点往往是相同的。这类组件的数据往往也是树形结构,这种都是使用递归组件的典型场景。
使用递归组件时,由于我们并未也不能在组件内部导入它自己,所以设置组件name
属性,用来查找组件定义,如果使用SFC,则可以通过SFC文件名推断。组件内部通常也要有递归结束条件,比如model.children这样的判断。
查看生成渲染函数可知,递归组件查找时会传递一个布尔值给resolveComponent
,这样实际获取的组件就是当前组件本身。
递归组件编译结果中,获取组件时会传递一个标识符 _resolveComponent("Comp", true)
const _component_Comp = _resolveComponent("Comp", true)
就是在传递maybeSelfReference
export function resolveComponent( name: string, maybeSelfReference?: boolean ): ConcreteComponent | string { return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name }
resolveAsset中最终返回的是组件自身:
if (!res && maybeSelfReference) { // fallback to implicit self-reference return Component }
https://github1s.com/vuejs/core/blob/HEAD/packages/runtime-core/src/helpers/resolveAssets.ts#L22-L23 https://github1s.com/vuejs/core/blob/HEAD/packages/runtime-core/src/helpers/resolveAssets.ts#L110-L111 https://sfc.vuejs.org/#eyJBcHAudnVlIjoiPHNjcmlwdCBzZXR1cD5cbmltcG9ydCB7IHJlZiB9IGZyb20gJ3Z1ZSdcbmltcG9ydCBjb21wIGZyb20gJy4vQ29tcC52dWUnXG5jb25zdCBtc2cgPSByZWYoJ+mAkuW9kue7hOS7ticpXG5jb25zdCBtb2RlbCA9IHtcbiAgbGFiZWw6ICdub2RlLTEnLFxuICBjaGlsZHJlbjogW1xuICAgIHtsYWJlbDogJ25vZGUtMS0xJ30sXG4gICAge2xhYmVsOiAnbm9kZS0xLTInfVxuICBdXG59XG48L3NjcmlwdD5cblxuPHRlbXBsYXRlPlxuICA8aDE+e3sgbXNnIH19PC9oMT5cbiAgPGNvbXAgOm1vZGVsPVwibW9kZWxcIj48L2NvbXA+XG48L3RlbXBsYXRlPiIsImltcG9ydC1tYXAuanNvbiI6IntcbiAgXCJpbXBvcnRzXCI6IHtcbiAgICBcInZ1ZVwiOiBcImh0dHBzOi8vc2ZjLnZ1ZWpzLm9yZy92dWUucnVudGltZS5lc20tYnJvd3Nlci5qc1wiXG4gIH1cbn0iLCJDb21wLnZ1ZSI6Ijx0ZW1wbGF0ZT5cbiAgPGRpdj5cbiAgICB7e21vZGVsLmxhYmVsfX1cbiAgPC9kaXY+XG4gIDxDb21wIHYtZm9yPVwiaXRlbSBpbiBtb2RlbC5jaGlsZHJlblwiIDptb2RlbD1cIml0ZW1cIj48L0NvbXA+XG4gIDxjb21wMj48L2NvbXAyPlxuPC90ZW1wbGF0ZT5cbjxzY3JpcHQ+XG5cdGV4cG9ydCBkZWZhdWx0IHtcbiAgICBuYW1lOiAnQ29tcCcsXG4gICAgcHJvcHM6IHtcbiAgICAgIG1vZGVsOiBPYmplY3RcbiAgICB9LFxuICAgIGNvbXBvbmVudHM6IHtcbiAgICAgIGNvbXAyOiB7XG4gICAgICAgIHJlbmRlcigpe31cbiAgICAgIH1cbiAgICB9XG4gIH1cbjwvc2NyaXB0PiJ9
因为异步路由的存在,我们使用异步组件的次数比较少,因此还是有必要两者的不同。
大型应用中,我们需要分割应用为更小的块,并且在需要组件时再加载它们。
In large applications, we may need to divide the app into smaller chunks and only load a component from the server when it's needed.
import { defineAsyncComponent } from 'vue' // defineAsyncComponent定义异步组件 const AsyncComp = defineAsyncComponent(() => { // 加载函数返回Promise return new Promise((resolve, reject) => { // ...可以从服务器加载组件 resolve(/* loaded component */) }) }) // 借助打包工具实现ES模块动态导入 const AsyncComp = defineAsyncComponent(() => import('./components/MyComponent.vue') )
异步组件作用
何时使用异步组件
使用细节
和路由懒加载的不同
在大型应用中,我们需要分割应用为更小的块,并且在需要组件时再加载它们。
我们不仅可以在路由切换时懒加载组件,还可以在页面组件中继续使用异步组件,从而实现更细的分割粒度。
使用异步组件最简单的方式是直接给defineAsyncComponent指定一个loader函数,结合ES模块动态导入函数import可以快速实现。我们甚至可以指定loadingComponent和errorComponent选项从而给用户一个很好的加载反馈。另外Vue3中还可以结合Suspense组件使用异步组件。
异步组件容易和路由懒加载混淆,实际上不是一个东西。异步组件不能被用于定义懒加载路由上,处理它的是vue框架,处理路由组件加载的是vue-router。但是可以在懒加载的路由组件中使用异步组件。
defineAsyncComponent定义了一个高阶组件,返回一个包装组件。包装组件根据加载器的状态决定渲染什么内容。
https://github1s.com/vuejs/core/blob/HEAD/packages/runtime-core/src/apiAsyncComponent.ts#L43-L44
这是一个综合应用题目,在项目中我们常常需要将App的异常上报,此时错误处理就很重要了。
这里要区分错误的类型,针对性做收集。
然后是将收集的的错误信息上报服务器。
首先区分错误类型
根据错误不同类型做相应收集
收集的错误是如何上报服务器的
应用中的错误类型分为"接口异常
"和“代码逻辑异常
”
我们需要根据不同错误类型做相应处理:接口异常
是我们请求后端接口过程中发生的异常,可能是请求失败,也可能是请求获得了服务器响应,但是返回的是错误状态。以Axios为例,这类异常我们可以通过封装Axios,在拦截器中统一处理整个应用中请求的错误。代码逻辑异常
是我们编写的前端代码中存在逻辑上的错误造成的异常,vue应用中最常见的方式是使用全局错误处理函数app.config.errorHandler
收集错误。
收集到错误之后,需要统一处理这些异常:分析错误,获取需要错误信息和数据。这里应该有效区分错误类型,如果是请求错误,需要上报接口信息,参数,状态码等;对于前端逻辑异常,获取错误名称和详情即可。另外还可以收集应用名称、环境、版本、用户信息,所在页面等。这些信息可以通过vuex存储的全局状态和路由信息获取。
axios拦截器中处理捕获异常:
// 响应拦截器 instance.interceptors.response.use( (response) => { return response.data; }, (error) => { // 存在response说明服务器有响应 if (error.response) { let response = error.response; if (response.status >= 400) { handleError(response); } } else { handleError(null); } return Promise.reject(error); }, );
vue中全局捕获异常:
import { createApp } from 'vue' const app = createApp(...) app.config.errorHandler = (err, instance, info) => { // report error to tracking services }
处理接口请求错误:
function handleError(error, type) { if(type == 1) { // 接口错误,从config字段中获取请求信息 let { url, method, params, data } = error.config let err_data = { url, method, params: { query: params, body: data }, error: error.data?.message || JSON.stringify(error.data), }) } }
处理前端逻辑错误:
function handleError(error, type) { if(type == 2) { let errData = null // 逻辑错误 if(error instanceof Error) { let { name, message } = error errData = { type: name, error: message } } else { errData = { type: 'other', error: JSON.strigify(error) } } } }
这个题目很有难度,首先思考vuex
解决的问题:存储用户全局状态并提供管理状态API。
vuex
需求分析官方说vuex
是一个状态管理模式和库,并确保这些状态以可预期的方式变更。可见要实现一个vuex
:
Store
存储全局状态commit(type, payload)
, dispatch(type, payload)
实现Store
时,可以定义Store类,构造函数接收选项options,设置属性state对外暴露状态,提供commit和dispatch修改属性state。这里需要设置state为响应式对象,同时将Store定义为一个Vue插件。
commit(type, payload)
方法中可以获取用户传入mutations
并执行它,这样可以按用户提供的方法修改状态。 dispatch(type, payload)
类似,但需要注意它可能是异步的,需要返回一个Promise给用户以处理异步结果。
Store的实现:
class Store { constructor(options) { this.state = reactive(options.state) this.options = options } commit(type, payload) { this.options.mutations[type].call(this, this.state, payload) } }
Vuex中Store的实现:https://github1s.com/vuejs/vuex/blob/HEAD/src/store.js#L19-L20
mutations
和actions
是vuex
带来的两个独特的概念。新手程序员容易混淆,所以面试官喜欢问。
我们只需记住修改状态只能是mutations
,actions
只能通过提交mutation
修改状态即可。
看下面例子可知,Action
类似于 mutation
,不同在于:
Action
提交的是 mutation
,而不是直接变更状态。Action
可以包含任意异步操作。const store = createStore({ state: { count: 0 }, mutations: { increment (state) { state.count++ } }, actions: { increment (context) { context.commit('increment') } } })
给出两者概念说明区别
举例说明应用场景
使用细节不同
简单阐述实现上差异
官方文档说:更改 Vuex 的 store 中的状态的唯一方法是提交 mutation
,mutation
非常类似于事件:每个 mutation
都有一个字符串的类型 (type)和一个 回调函数 (handler) 。Action
类似于 mutation
,不同在于:Action
可以包含任意异步操作,但它不能修改状态, 需要提交mutation
才能变更状态。
因此,开发时,包含异步操作或者复杂业务组合时使用action;需要直接修改状态则提交mutation。但由于dispatch和commit是两个API,容易引起混淆,实践中也会采用统一使用dispatch action的方式。
调用dispatch和commit两个API时几乎完全一样,但是定义两者时却不甚相同,mutation的回调函数接收参数是state对象。action则是与Store实例具有相同方法和属性的上下文context对象,因此一般会解构它为{commit, dispatch, state}
,从而方便编码。另外dispatch会返回Promise实例便于处理内部异步结果。
实现上commit(type)方法相当于调用options.mutations[type](state)
;dispatch(type)
方法相当于调用options.actions[type](store)
,这样就很容易理解两者使用上的不同了。
我们可以像下面这样简单实现commit
和dispatch
,从而辨别两者不同:
class Store { constructor(options) { this.state = reactive(options.state) this.options = options } commit(type, payload) { // 传入上下文和参数1都是state对象 this.options.mutations[type].call(this.state, this.state, payload) } dispatch(type, payload) { // 传入上下文和参数1都是store本身 this.options.actions[type].call(this, this, payload) } }
企业级项目中渲染大量数据的情况比较常见,因此这是一道非常好的综合实践题目。
描述大数据量带来的问题
分不同情况做不同处理
总结一下
在大型企业级项目中经常需要渲染大量数据,此时很容易出现卡顿的情况。比如大数据量的表格、树。
处理时要根据情况做不通处理:
v-once
方式只渲染一次v-for
使用,避免数据变化时不必要的VNode创建总之,还是要看具体需求,首先从设计上避免大数据获取和渲染;实在需要这样做可以采用虚表的方式优化渲染;最后优化更新,如果不需要更新可以v-once处理,需要更新可以v-memo进一步优化大数据更新性能。其他可以采用的是交互方式优化,无线滚动、懒加载等方案。
vuex数据状态是响应式的,所以状态变视图跟着变,但是有时还是需要知道数据状态变了从而做一些事情。
既然状态都是响应式的,那自然可以watch
,另外vuex也提供了订阅的API:store.subscribe()
。
我知道几种方法:
watch选项方式,可以以字符串形式监听$store.state.xx
;subscribe方式,可以调用store.subscribe(cb),回调函数接收mutation对象和state对象,这样可以进一步判断mutation.type是否是期待的那个,从而进一步做后续处理。
watch方式简单好用,且能获取变化前后值,首选;subscribe方法会被所有commit行为触发,因此还需要判断mutation.type,用起来略繁琐,一般用于vuex插件中。
watch方式
const app = createApp({ watch: { '$store.state.counter'() { console.log('counter change!'); } } })
subscribe方式:
store.subscribe((mutation, state) => { if (mutation.type === 'add') { console.log('counter change in subscribe()!'); } })
vue-router中两个重要组件router-link
和router-view
,分别起到导航作用和内容渲染作用,但是回答如何生效还真有一定难度哪!
router-link
和router-view
,分别起到路由导航作用和组件内容渲染作用https://github1s.com/vuejs/router/blob/HEAD/src/RouterLink.ts#L184-L185
https://github1s.com/vuejs/router/blob/HEAD/src/RouterView.ts#L43-L44
vue-router导航有两种方式:声明式导航
和编程方式导航
声明式导航
<router-link to="/about">Go to About</router-link>
编程导航
// literal string path router.push('/users/eduardo') // object with path router.push({ path: '/users/eduardo' }) // named route with params to let the router build the url router.push({ name: 'user', params: { username: 'eduardo' } })
声明式导航
和编程方式导航
router-link
组件,添加to属性导航;编程方式导航更加灵活,可传递调用router.push(),并传递path字符串或者RouteLocationRaw对象,指定path、name、params等信息https://github1s.com/vuejs/router/blob/HEAD/src/RouterLink.ts#L240-L241
routerlink点击跳转,调用的是navigate方法
navigate内部依然调用的push
vue3在设计时有几个目标:更小、更快、更友好,这些多数适合性能相关,因此可以围绕介绍。
通过playground体验编译优化:sfc.vuejs.org
为什么基于Proxy更快了:初始化时懒处理,用户访问才做拦截处理,初始化更快:
https://github1s.com/vuejs/core/blob/HEAD/packages/reactivity/src/baseHandlers.ts#L136-L137
轻量的依赖关系保存:利用WeakMap、Map和Set保存响应式数据和副作用之间的依赖关系
https://github1s.com/vuejs/core/blob/HEAD/packages/reactivity/src/effect.ts#L19-L20
Vue3中最重大的更新之一就是响应式模块reactivity
的重写。主要的修改就是Proxy
替换defineProperty
实现响应式。
此变化主要是从性能方面考量。
defineProperty
的原因是,2013年时只能用这种方式。由于该API存在一些局限性,比如对于数组的拦截有问题,为此vue需要专门为数组响应式做一套实现。另外不能拦截那些新增、删除属性;最后defineProperty
方案在初始化时需要深度递归遍历待处理的对象才能对它进行完全拦截,明显增加了初始化的时间。Proxy属性拦截的原理:利用get、set、deleteProperty这三个trap实现拦截
function reactive(obj) { return new Proxy(obj, { get(target, key) {}, set(target, key, val) {}, deleteProperty(target, key){} }) }
Object.defineProperty属性拦截原理:利用get、set这两个trap实现拦截
function defineReactive(obj, key, val) { Object.defineReactive(obj, key, { get(key) {}, set(key, val) {} }) }
很容易看出两者的区别!
vue-router有3个模式,其中两个更为常用,那便是history和hash。
两者差别主要在显示形式和部署上。
vue-router4.x中设置模式已经变化:
const router = createRouter({ history: createWebHashHistory(), // hash模式 history: createWebHistory(), // history模式 })
用起来一模一样
<router-link to="/about">Go to About</router-link>
区别只在url形式
// hash // 浏览器里的形态:http://xx.com/#/about // history // 浏览器里的形态:http://xx.com/about
hash是一种特殊的history实现:
https://github1s.com/vuejs/router/blob/HEAD/src/history/hash.ts#L31-L32
应用的有些界面是由多层级组件组合而来的,这种情况下,url各部分通常对应某个嵌套的组件,vue-router中就可以使用嵌套路由表示这种关系:router.vuejs.org/guide/essen…
定义嵌套路由,对应上图嵌套关系:
const routes = [ { path: '/user/:id', component: User, children: [ { // UserProfile 会被渲染在 User 组件中的 <router-view> 里 path: 'profile', component: UserProfile, }, { // UserPosts 会被渲染在 User 组件中的 <router-view> 里 path: 'posts', component: UserPosts, }, ], }, ]
children
属性组织嵌套关系router-view获取自己所在的深度:默认0,加1之后传给后代,同时根据深度获取匹配路由。
这是一道应用题目,很容易想到使用localStorage
或数据库存储并还原状态。
但是如何优雅编写代码还是能体现认知水平。
可以从localStorage
中获取作为状态初始值:
const store = createStore({ state () { return { count: localStorage.getItem('count') } } })
业务代码中,提交修改状态同时保存最新值:虽说实现了,但是每次还要手动刷新localStorage不太优雅
store.commit('increment') localStorage.setItem('count', store.state.count)
可以看一下vuex-persist内部确实是利用subscribe实现的
https://github.com/championswimmer/vuex-persist/blob/master/src/index.ts#L277
相较于redux,vuex已经相当简便好用了。但模块的使用比较繁琐,对ts支持也不好。
使用模块:用起来比较繁琐,使用模式也不统一,基本上得不到类型系统的任何支持
const store = createStore({ modules: { a: moduleA } }) store.state.a // -> 要带上 moduleA 的key,内嵌模块的话会很长,不得不配合mapState使用 store.getters.c // -> moduleA里的getters,没有namespaced时又变成了全局的 store.getters['a/c'] // -> 有namespaced时要加path,使用模式又和state不一样 store.commit('d') // -> 没有namespaced时变成了全局的,能同时触发多个子模块中同名mutation store.commit('a/d') // -> 有namespaced时要加path,配合mapMutations使用感觉也没简化
下面我们来看看vuex中store.state.x.y
这种嵌套的路径是怎么搞出来的。
首先是子模块安装过程:父模块状态parentState
上面设置了子模块名称moduleName
,值为当前模块state
对象。放在上面的例子中相当于:store.state['x'] = moduleX.state
。此过程是递归的,那么store.state.x.y
安装时就是:store.state['x']['y'] = moduleY.state
。
if (!isRoot && !hot) { // 获取父模块state const parentState = getNestedState(rootState, path.slice(0, -1)) // 获取子模块名称 const moduleName = path[path.length - 1] store._withCommit(() => { // 把子模块state设置到父模块上 parentState[moduleName] = module.state }) }
这下大家明白了吧!
源码地址:https://github1s.com/vuejs/vuex/blob/HEAD/src/store-util.js#L102-L115
Vue3最重要更新之一就是Composition API,它具有一些列优点,其中不少是针对Options API暴露的一些问题量身打造。是Vue3推荐的写法,因此掌握好Composition API应用对掌握好Vue3至关重要。
https://vuejs.org/guide/extras/composition-api-faq.html
Composition API能更好的组织代码,下面这个代码用options api实现
如果用composition api可以提取为useCount(),用于组合、复用
Composition API
是一组API,包括:Reactivity API、生命周期钩子、依赖注入,使用户可以通过导入函数方式编写vue组件。而Options API
则通过声明组件选项的对象形式编写组件。Composition API
最主要作用是能够简洁、高效复用逻辑。解决了过去Options API
中mixins
的各种缺点;另外Composition API
具有更加敏捷的代码组织能力,很多用户喜欢Options API
,认为所有东西都有固定位置的选项放置代码,但是单个组件增长过大之后这反而成为限制,一个逻辑关注点分散在组件各处,形成代码碎片,维护时需要反复横跳,Composition API
则可以将它们有效组织在一起。最后Composition API
拥有更好的类型推断,对ts支持更友好,Options API
在设计之初并未考虑类型推断因素,虽然官方为此做了很多复杂的类型体操,确保用户可以在使用Options API
时获得类型推断,然而还是没办法用在mixins和provide/inject上。Composition API
,但是这会让我们在代码组织上多花点心思,因此在选择上,如果我们项目属于中低复杂度的场景,Options API
仍是一个好选择。对于那些大型,高扩展,强维护的项目上,Composition API
会获得更大收益。Composition API
能否和Options API
一起使用?路由保护在应用开发过程中非常重要,几乎每个应用都要做各种路由权限管理,因此相当考察使用者基本功。
全局守卫:
const router = createRouter({ ... }) router.beforeEach((to, from) => { // ... // 返回 false 以取消导航 return false })
路由独享守卫:
const routes = [ { path: '/users/:id', component: UserDetails, beforeEnter: (to, from) => { // reject the navigation return false }, }, ]
组件内的守卫:
const UserDetails = { template: `...`, beforeRouteEnter(to, from) { // 在渲染该组件的对应路由被验证前调用 }, beforeRouteUpdate(to, from) { // 在当前路由改变,但是该组件被复用时调用 }, beforeRouteLeave(to, from) { // 在导航离开渲染该组件的对应路由时调用 }, }
runGuardQueue(guards)链式的执行用户在各级别注册的守卫钩子函数,通过则继续下一个级别的守卫,不通过进入catch流程取消原本导航。
【相关视频教程推荐:web前端】