前端核心面试题:Vue框架

2,603 阅读6分钟

说说你对Vue的理解

  • MVVM:VM层相当于MVC中的C层中的一部分特定职能的分离,即实时监控和读取数据层的修改并将其映射到视图层,所以VM层专干数据驱动视图的活;
  • 响应式:与VM本质相同提法有区别,还是数据驱动视图,声明响应式的数据,当数据变化时自动驱动视图更新;
  • 组件化开发:将页面分割成可以独立复用的小块(组件),每个组件都有有自己的模板、样式、数据与逻辑,主要目的是为了方便复用;
  • 虚拟DOM:虚拟DOM是将真实DOM描述为JS对象,以便在数据状态变化时在内存中迅速递归比较出发生变化的节点(比较过程即diff算法),大致思路如下:
// 真实DOM
<div class="wrapper">
    <h3>hello</h3>
</div>

// 虚拟DOM
{
    type:"div",
    attrs:{
        class:"wrapper"
    },
    children:[
        {
            type:"h3",
            attrs:{},
            children:[
                {
                    type:"text",
                    value:"hello"
                }
            ]
        }
    ]
}
  • diff算法的作用:当数据变化时实现差量渲染,最大化使用上次渲染的缓存,但是需要提前比较出新老两个虚拟DOM的不同之处,在暴力递归比较的基础上做了很多优化,例如使用key去唯一标识特定的节点;
  • SPA框架/单页面应用开发框架,当浏览器中的路由地址发生变化时,在一个单页面中切换组件,实现整站效果;

列表渲染中key的作用

  • 用于diff算法的性能优化;
  • 当新老虚拟DOM节点(vnode)的Key相同时,直接认定其整个DOM结构未发生变化,直接复用老的虚拟DOM的渲染结果,而不对该vnode进行深度递归比较
  • 当新老虚拟DOM节点(vnode)的Key不同时,也不对该vnode进行深度递归比较,而是直接干掉旧的节点,根据数据重新渲染新的节点;

computed与watch的区别

  • 共同点:都是侦听一手数据状态的变化,从而触发相应逻辑的手段;
  • computed专注于基于一手数据状态去换算得到特定的二手数据,即计算属性;
  • 当computed依赖的一手数据状态没有发生变化时,在组件更新时会直接复用上一次渲染的缓存而不会重新计算,以提升性能;
  • computed中不应该产生副作用;
  • 而watch是专门侦听特定的数据变化,去触发相应的副作用逻辑;

Vue生命周期有哪些

  • 创建实例阶段(init options):beforeCreate + created
  • 挂载阶段(渲染DOM):beforeMount + mounted
  • 更新阶段(数据驱动视图变化):befoereUpdate + updated
  • 卸载阶段(页面切换或渲染条件不再成立):beforeUnmount + unmounted
  • Vue2中的卸载阶段:beforeDestroy + destroyed
  • 最好能绘制下图:

image.png

父子组件的生命周期联动(不甚高频)

  • 大原则:父组件搭台 + 子组件唱戏
  • 创建与挂载阶段:父组件创建实例完毕并预备挂载 => 子组件创建实例并一一挂载完毕 => 父组件宣布整体挂载完毕;
parent: beforeCreate + created + beforeMount
child:  beforeCreate + created + beforeMount + mounted
parent: mounted
  • 更新阶段:父组件宣布预备更新 => 子组件一一更新完毕 => 父组件宣布整体更新完毕;
parent: beforeUpdate
child:  beforeUpdate + updated
parent: updated
  • 卸载阶段:父组件宣布预备卸载 => 子组件一一卸载 => 父组件宣布整体卸载完毕;
parent: beforeUnmount
child:  beforeUnmount + unmounted
parent: unmounted

Vue有哪些常用的修饰符(不甚高频)

  • 事件修饰符:stop,prevent,once,capture,self
  • 键盘修饰符: enter,esc,ctrl,meta,shift
  • 系统修饰符:exact
  • vmodel修饰符: lazy,number,trim
  • 自定义vmodel修饰符
  • 自定义指令中的修饰符

vif与vshow的区别

  • 当渲染条件不成立时,vif不渲染(DOM压根不存在),vshow不显示(display:none)
  • 适合用vshow的场景:频繁显隐;
  • 适合vif的场景:一锤子买卖(非VIP用户不给看【爽片专区】)

说说你对nexttick的理解(高频)

  • 数据变化立刻完成,渲染跟进需要时间(异步)
  • 数据变化后无法立即拿到DOM的更新状态
  • nexttick(()=>/在这里就可以拿了/)
  • nexttick中的callback在DOM渲染完毕的时候回调,此时就能拿到DOM的最新状态了

什么是具名插槽和作用域插槽

  • 具名插槽:插槽位有很多,想要具体指定把哪个内容插在哪个插槽中,就需要指名道姓;
  • 作用域插槽:父组件在自己的模板上部署子组件及其插槽内容时,只能访问到父组件自身作用域内的数据;要想将子组件自己的数据部署在插槽中,就需要用到作用域插槽;
子组件:
子组件暴露出去的数据会形成一个对象{myname,myage}
<slot name="a" :myname="子组件自己的myname数据" :myage="子组件自己的myage数据"></slot>

父组件:
<Child>
    <!-- 将子组件暴露出来的数据对象起一个别名sp -->
    <template #a="sp">
        {{sp.myname}}/{{sp.myage}}
    </template>
</Child>

Vue组件有哪些通信方式

  • 父传子:props-down
  • 子传父:event-up
  • 祖传孙:provide + inject
  • 主动暴露数据给父组件使用: expose
子组件
export default {
    expose:{
        myname,
        myage,
        sayHelloFn
    }
}

父组件
<son ref="sonRef"/>
this.$refs.sonRef.sayHelloFn()
  • 使用全局状态管理自由通信:需要共享的数据状态都在【具有响应式的中央状态仓库】(Vuex/pinia)中,并对外提供状态的CRUD接口,任何组件去修改数据状态,其它用到该状态的组件都具有响应式;根本不必刻意去通信;

Vue性能优化

缓存系列/最大程度使用缓存

  • computed
  • 动态组件与keepalive
  • 本地缓存网络数据
  • 服客双方配合使用HTTP的强缓与协缓;

偷懒系列

  • 使用异步组件:需要用到某组件的时候再去加载
  • 防抖节流(减少事件处理)
  • v-model.lazy="xxx"(本质上不理input事件只理会change事件)
  • 合理选用vif与vshow(能不渲染就不渲染)
  • 避免产生无效回流(不要连续对一个DOM元素连做多次样式操作,集合起来做一次)

减少渲染与diff压力

  • DOM结构尽量扁平化(幽灵标签)
  • 数据结构尽量扁平化(可能涉及到深度侦听)
  • 避免不必要的深度侦听
  • 列表渲染时别忘了加key(diff时减少很多深度递归比较)

节约内存

  • 避免内存泄露,组件卸载时将自创的全局变量、DOM事件侦听器、定时器、闭包统统释放;

提升算法水平,少造BUG...

对vmodel双向数据绑定的理解

  • 在一对多通信中我们更推崇单向数据流,简单清晰好管理;
  • 在一对一通信中使用双向数据流不存在复杂逻辑和难管理问题,因此合理使用能带来极大的遍历;
  • 表单元素的v-model双向数据绑定实际上等价于父子通信(props-down)子父通信(events-up)的集成,其本质是一种语法糖;
    <input type="text" v-model="text" />

    <!-- 上面代码等价于↓ -->
    <input :value="text" @input="e => text = e.target.value" />
  • 当v-model作用在组件上时等价于传递props modelValue + 处理自定义事件update:modelValue
    <MyComponent v-model="searchText" />

    <!-- 上面代码等价于↓ -->
    <MyComponent 
    :modelValue="searchText" 
    @update:modelValue="newValue => searchText = newValue" 
    />
  • 自定义vmodel参数时等价于传递props xxx + 处理自定义事件update:xxx
    <MyComponent v-model:xxx="bookTitle" />

对Props单向数据流的理解

  • 父子通信中数据只能由父组件流向子组件,子组件不应直接修改父组件的数据(可以做到但会报警告),因为多个子组件同时修改父组件的数据会造成结构上的混乱;
  • 子组件确实需要修改父组件数据时,应向父组件发送自定义事件,由父组件自己来修改该数据(子组件会获得同步响应);
  • 在一对多通信中我们推崇单向数据流,简单清晰好管理;
  • 特定数据的修改入口唯一化是科学的编程理念,政出多门,编程大忌、致乱之由也;

Vue有哪些逻辑复用方式

  • Vue2中主要是mixin
  • Vue3中主要是自定义hook
  • mixin基本就是个垃圾,全局和局部到处都可以定义,相互冲突和覆盖,难以追踪和调试,因此并不好用;
  • 自定义Hook很好很强大,是Vue3升级的最大初衷之一(或没有之一);
  • 另外还有自定义指令自定义插件

生命周期的父子联动

  • 父组件搭台,子组件唱戏;
  • 所有子组件都唱完,才算整体(父组件)唱完;
  • parent: beforeCreate + created + beforeMount;//父组件搭台
  • children: beforeCreate + created + beforeMount + mounted;//子组件把戏唱完
  • parent: mounted;//整体唱戏完毕

  • parent: beforeUpdate;//父组件搭台
  • children: beforeUpdate + updated;//子组件把戏唱完
  • parent: updated;//整体唱戏完毕

  • parent: beforeUnmount;//父组件搭台
  • children: beforeUnmount + unmounted;//子组件把戏唱完
  • parent: unmounted;//整体唱戏完毕

为什么data选项是一个函数

  • 为了形成封闭的闭包/作用域;
  • 以确保每个组件的数据都相互独立,互不冲突,都不污染全局;

谈谈对Vuex的理解

  • 实现数据和视图分离;
  • 实现组件间的数据共享和自由通信;
  • 实现全局数据缓存(缓在localStorage)(需要配置persistedState数据持久化插件);
  • 单向数据流架构:组件派发action编辑数据=>action异步通信获取最新数据=>action提交mutation令其同步更新数据=>mutation同步更新state(devtool会知道)=>订阅state的组件自动被更新;
  • 在一(中央数据仓库)对多(组件)的通信场景中,单向数据流简单清晰好管理;
  • Vuex单向数据流图示:

image.png

Vue怎么缓存当前组件?缓存后想更新怎么办?

  • keepalive包裹动态组件,参与动态替换的组件实例不会卸载;
  • 可以在keepalive身上配置include(正选)、exclude(反选)
  • 可以在keepalive身上配置max(最大缓存实例数),其底层使用LRU缓存算法,即当缓存实例数满时,最近最少使用的(LeastRecentUse)组件实例会被踢出缓存;
  • 被切走的组件会回调deactived(失活)生命周期,被切入的组件会回调activated(激活)生命周期,分别可用于缓存和更新数据;

有没有封装过axios

  • 必须有;
  • 经典三层封装模型:实例层 => CRUD层=> 应用层,视图层只直接与应用层打交道;
  • 实例层封装了axios实例(包含baseUrl,timeout等基础配置)+ 实例拦截器;
  • 在请求拦截器中可以注入通用配置,例如登录token;(headers:{authorization:"Bearer tokenvalue"});
  • 在响应拦截器中可以统一过滤数据,例如直接提取response.data;
  • 拦截器还可以用于记录接口访问日志、统计数据与错误等;
  • CRUD层封装了通用的POST/DELETE/PUT/GET请求,应用层只需要注入url,data,config即可;
  • CRUD层通常还用于统一处理错误;
  • 应用层调用CRUD层封装的增删改查方法,对视图层直接放出业务API,例如
    • addGoods(goods)
    • deleteGoods(gid)
    • updateGoods(goods)
    • getGoods(gid)
    • getGoodsList(page)

Vue3有哪些更新?

@底层响应式原理更新

  • 使用Proxy替代Vue2的数据劫持侦听/Object.defineProperty;
  • Proxy机制:数据的每个叶子节点背后都是一个代理对象,访问数据一律通过其代理,这样一旦数据被更新,代理会直接通知相应的视图更新,而无需递归比较整棵数据树,从而极大地提升了响应式性能;
  • 数据劫持机制的缺点:
    • 性能垃圾,因为需要递归比较新老数据的所有叶子节点;
    • 只能监听数据的get与set操作,这样当数据地址不发生变化时(例如删除对象的一个key,原地修改数组)数据的修改是无法被侦听的;(做了很多复杂的周边处理才填上这个坑)
  • 底层响应式原理的替换并不直接体现在编码上;

@逻辑复用机制更新

  • Vue2的主要(跨组件)逻辑复用方式是mixin;
  • mixin就是个垃圾,全局和局部到处都可以定义,相互冲突和覆盖,难以追踪和调试,因此并不好用;
  • Vue3的组合式API(compositionAPI)可以将响应式数据的定义逻辑、生命周期回调逻辑、数据侦听逻辑全部组合到【自定义Hook】中,轻松在组件间共享各种业务逻辑而不产生任何副作用;
  • 独立的响应式数据的定义:ref定义单个地址、reactive定义响应式对象、toRefs将响应式对象打散为多个ref集合在一起的普通对象;
  • 独立的生命周期逻辑:setup本身充当创建阶段,另外六大生命周期回调都以普通回调函数的方式存在:例如onMounted(()=>{组件挂载时做什么}),它们分别是:
    • onBeforeMount/onMounted
    • onBeforeUpdate/onUpdated
    • onBeforeUnmount/onUnmounted;
  • PS:在Vue2中卸载阶段的两个生命周期分别是beforeDestroy/destroyed;
  • PS:注意keepalive管理的缓存实例有激活/失活两个生命周期activated/deactivated,它们在Vue3中对应的名字为: onActivated/onDeactivated;
  • 独立的数据侦听逻辑:
    • const xxx = computed(()=>基于一手数据换算而来的二手数据) 用于创建独立存在的计算属性;
    • const unwatch = watch(()=>侦听项,(nv,ov)=>副作用逻辑,{config})watchEffect(()=>自动收集依赖的副作用逻辑) 用于创建独立的数据变化侦听和副作用逻辑;

什么是自定义hook

  • Vue3强大的跨组件逻辑复用方式;
  • 通过将【响应式数据创建逻辑 + 生命周期回调逻辑 + 数据侦听逻辑】集成为一个函数,最终对外返回一组响应式数据;
  • 通过这种方式,轻松在组件间共享业务逻辑,且没有任何额外副作用;
  • 典型手写案例:useMouse, useCountDown, useAxios, useScroll...

什么是自定义指令

  • Vue在元素上逻辑复用的主要方式;
  • Vue指令格式:v-指令名:指令参数.指令修饰符="指令值";
  • 指令定义方式:
app.directive(name,{
    onMounted(el,binding){...},
    onUpdated(el,binding){...},
})

app.directive(name,(el,binding)=>{
    // 在元素的宿主组件onMounted和onUpdated时做什么
})
  • 典型手写案例:v-pin, v-change...

案例:点击在组件外部

当使用Vue 3重构自定义指令时,需要按照新的Vue 3指令语法和API进行修改。下面是将上述click-outside指令重构为Vue 3风格的示例:

// 创建一个自定义指令对象
export default const clickOutsideDirective = {

  // 使用 `mounted` 钩子来替代 Vue 2 中的 `bind` 钩子
  mounted(el, binding) {
    const handleClickOutside = event => {
      if (!el.contains(event.target)) {
        binding.value();
      }
    };

    document.addEventListener('click', handleClickOutside);

    // 在元素销毁前移除事件监听器
    el._ClickOutsideEvent = handleClickOutside;
  },
  
  // 使用 `unmounted` 钩子来替代 Vue 2 中的 `unbind` 钩子
  unmounted(el) {
    document.removeEventListener('click', el._ClickOutsideEvent);
    delete el._ClickOutsideEvent;
  }
  
};

全局注册指令

import COD from "@directives/ClickOutsideDirective"

app.directive("click-outside", COD);

在上面的示例中,我们使用Directive类从自定义指令对象clickOutsideDirective中创建了一个指令实例,并导出该指令实例。在实例中,我们使用了新的钩子函数mountedunmounted来替代Vue 2中的bindunbind钩子。

使用指令实例时,可以在模板中使用v-click-outside指令,并将要执行的方法作为指令的值传递给它:

<template>
  <div v-click-outside="closePopup"></div>
</template>

需要注意的是,在Vue 3中,指令不再支持修饰符,但可以使用参数来扩展指令的功能。

案例:触底加载下一页

先复习几个与滚动有关的概念

image.png

此处一个典型的自定义指令案例是实现无限滚动加载的指令。这个指令可以用于当滚动到页面底部时,自动加载更多数据。下面是一个基于Vue 3的示例:

// 在 Vue 3 中,需要单独导入 Directive 类
import { Directive } from 'vue';

// 创建一个自定义指令对象
const infiniteScrollDirective = {
  // 使用 `mounted` 钩子来替代 Vue 2 中的 `bind` 钩子
  mounted(el, binding) {
    const handleScroll = () => {
      const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
      
      // 滚动到页面底部时,触发绑定的处理函数
      if (scrollTop + clientHeight >= scrollHeight) {
        binding.value();
      }
    };

    window.addEventListener('scroll', handleScroll);

    // 在元素销毁前移除事件监听器
    el._InfiniteScrollEvent = handleScroll;
  },
  // 使用 `unmounted` 钩子来替代 Vue 2 中的 `unbind` 钩子
  unmounted(el) {
    window.removeEventListener('scroll', el._InfiniteScrollEvent);
    delete el._InfiniteScrollEvent;
  }
};

// 创建一个指令实例,并导出
export const InfiniteScrollDirective = Directive.from(infiniteScrollDirective);

在上面的示例中,我们创建了一个名为infiniteScrollDirective的自定义指令对象。在mounted钩子中,我们使用addEventListener监听scroll事件,即滚动事件。每当页面滚动时,都会执行handleScroll函数。

handleScroll函数中,我们通过获取document.documentElementscrollTopscrollHeightclientHeight属性来判断是否滚动到页面底部。当滚动到底部时,我们触发自定义指令绑定的处理函数,即binding.value(),来加载更多数据。

使用这个自定义指令时,可以在需要实现无限滚动加载的元素上使用v-infinite-scroll指令,并将要执行的加载数据的方法作为指令的值传递给它:

<template>
  <div v-infinite-scroll="loadMoreData"></div>
</template>

通过这个自定义指令,我们可以轻松地实现无限滚动加载数据的功能,提升用户体验和页面的交互性。用户只需滚动到页面底部,就可以自动加载更多数据,无需点击或其他操作。这种指令在处理大量数据时非常实用。

案例:鼠标覆盖改变样式

const hoverHandler = {

    // v-hover="{ backgroundColor: 'green', color: 'white' }"
    mounted(el, binding, vnode) {
        // 存储原来的样式
        const originalStyle = window.getComputedStyle(el)
        const obj = {}
        for (const key in binding.value) {
            obj[key] = originalStyle[key]
        }

        // 鼠标覆盖时 使用binding定义的样式
        el._mouseover = e => {
            for (const key in binding.value) {
                el.style[key] = binding.value[key]
            }
        }

        // 鼠标移出时 恢复原来的样式
        el._mouseout = e => {
            for (const key in obj) {
                el.style[key] = obj[key]
            }
        }

        el.addEventListener(
            "mouseover",
            el._mouseover
        )

        el.addEventListener(
            "mouseout",
            el._mouseout
        )
    },

    unmounted(el) {
        el.removeEventListener(
            "mouseover",
            el._mouseover
        )

        el.removeEventListener(
            "mouseout",
            el._mouseout
        )

        delete el._mouseover
        delete el._mouseout
    }
}

export default hoverHandler
<div class="box1" v-hover="{ backgroundColor: 'green', color: 'white' }">
  失败乃你之母
</div>
import hoverHandler from './directives/hover'
app.directive("hover", hoverHandler)

执行效果

Video_2023-07-20_153626 00_00_00-00_00_30.gif


你怎么理解Vue插件的

  • 形式上是一个注册在app身上的带有install方法的对象;
  • 在app被初始化的时候调用该对象的install方法可以成体系地做很多事情:
const i18nPlugin = {
    install(app,options){
        // 在app身上注册自定义指令
        // 在app身上全局透传数据或配置
        // 在app身上使用其它插件
        // 为所有组件实例注入全局API
        // app.config.globalProperties.$translate = ()=>{...}
    }
}
app.use(i18nPlugin,options)
  • Vuex、VueRouter以及各种Vue组件库都是以插件形式存在的;
  • 在自定义组件库场景中尤为常见;
  • 经典手写案例:i18nPlugin...

Vue有哪些路由模式,区别如何?

  • 主要有Hash哈希模式(baseUrl/#/path)和H5历史记录模式(baseUrl/path);
  • 哈希模式优点:浏览器直接识别为前端页面内跳转,没有额外的前后端通信过程,不对服务端产生额外压力;
  • 哈希模式缺点:丑陋;不利于搜索引擎优化(SEO=SearchEnginOptimization);(搜索引擎爬虫认为各path与首页就是同一个页面,在爬完首页信息后就扬长而去)
  • H5/历史记录模式优点:漂亮;利于SEO优化;
  • H5/历史记录模式缺点:形成额外的前后端通信过程,一定程度上降低了性能,给服务端造成一定的额外负担;
  • 历史记录模式相对来说更主流一些;
  • 历史记录模式下服务端需要一点额外配置,以把前端路由打回给前端自己处理,以Nginx为例:
location / {
  # 看看服务端有没有对应的API 
  # 如果没有则定性为前端路由(一脚踹回给/index.html即前端页面本身去处理)
  # $uri(即/path)会回到前端的路由表进行前端路由匹配
  try_files $uri $uri/ /index.html;
}

什么是路由懒加载

  • 又叫路由的异步加载;
  • 等到用户访问path时才历史去加载/下载对应的组件;
  • 这样用户首次访问时只需要加载/下载首页组件即可,极大地提升了首屏加载性能;
  • 路由异步加载/懒加载基本已经是日常项目开发的默认选项了;
  • 其配置如下:
routes:[
    {
      path: "/about",
      name: "about",

      // 同步加载 客户端浏览器需要先完成组件内容的下载
      // component: AboutView

      /* 
      - 异步路由/路由的懒加载=用户什么时候访问/about就什么时候加载about组件对应的内容
      - 首次用户浏览器只加载home组件
      - 提高了首次加载的性能 
      */
      component: () => import("../views/AboutView.vue"),
    },
]

什么是动态路由

  • 路由中包含动态参数段,例如:/film/<电影id>
  • 其配置如下:
routes:[
    {
      // 规定电影id为若干数字 例如/film/1234
      path: "/film/:id(\\d+)",
      name: "detail",
      component: () => import("../views/DetailView.vue"),
    },
]
  • 组件内获取动态路由参数
this.$route.params["id"]
import {useRoute} from "vue-router"
const route = useRoute()
route.params["id"]

如何封装自己的组件

  • 大前提:把什么都写死就没有复用价值可言
  • 组件需要展示的数据由父组件通过props注入
  • 当用户在子组件中交互(点击)时,交互的处理权限交还于父组件自行处理,通过向父组件发送自定义事件events实现
  • 允许父组件通过覆盖插槽的方式给子组件注入内容和样式(子DOM)
  • 子组件自己可以暴露一些便捷操作API供父组件调用,例如轮播图组件可以暴露prev(),next(),startAutoPlay(),stopAutoPlay(),主要用到exposeref两个语法;

子组件对外暴露API

expose:["prev","next","startAutoPlay","stopAutoPlay"]

父组件通过ref得到子组件实例,再进一步调用其暴露出来的API

swiperRef.value.startAutoPlay()
  • 巧用双向数据绑定v-model:propName="dataName"可以简化props+events使用流程;

如何做自己的组件库

请参见 Vue发布自定义组件库

权限控制

参考文章