前端一面常见vue面试题汇总

Posted bbxiaxia1998

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了前端一面常见vue面试题汇总相关的知识,希望对你有一定的参考价值。

说说你对 proxy 的理解,Proxy 相比于 defineProperty 的优势

Object.defineProperty() 的问题主要有三个:

  • 不能监听数组的变化 :无法监控到数组下标的变化,导致通过数组下标添加元素,不能实时响应
  • 必须遍历对象的每个属性 :只能劫持对象的属性,从而需要对每个对象,每个属性进行遍历,如果属性值是对象,还需要深度遍历。Proxy 可以劫持整个对象,并返回一个新的对象
  • 必须深层遍历嵌套的对象

Proxy的优势如下:

  • 针对对象: 针对整个对象,而不是对象的某个属性 ,所以也就不需要对 keys 进行遍历
  • 支持数组:Proxy 不需要对数组的方法进行重载,省去了众多 hack,减少代码量等于减少了维护成本,而且标准的就是最好的
  • Proxy的第二个参数可以有 13 种拦截方:不限于applyownKeysdeletePropertyhas等等是Object.defineProperty不具备的
  • Proxy返回的是一个新对象,我们可以只操作新的对象达到目的,而Object.defineProperty只能遍历对象属性直接修改
  • Proxy作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利

proxy详细使用点击查看(opens new window)

Object.defineProperty的优势如下:

兼容性好,支持 IE9,而 Proxy 的存在浏览器兼容性问题,而且无法用 polyfill 磨平

defineProperty的属性值有哪些

Object.defineProperty(obj, prop, descriptor)

// obj 要定义属性的对象
// prop 要定义或修改的属性的名称
// descriptor 要定义或修改的属性描述符

Object.defineProperty(obj,"name",
  value:"poetry", // 初始值
  writable:true, // 该属性是否可写入
  enumerable:true, // 该属性是否可被遍历得到(for...in, Object.keys等)
  configurable:true, // 定该属性是否可被删除,且除writable外的其他描述符是否可被修改
  get: function() ,
  set: function(newVal) 
)

相关代码如下

import  mutableHandlers  from "./baseHandlers"; // 代理相关逻辑
import  isObject  from "./util"; // 工具方法

export function reactive(target) 
  // 根据不同参数创建不同响应式对象
  return createReactiveObject(target, mutableHandlers);

function createReactiveObject(target, baseHandler) 
  if (!isObject(target)) 
    return target;
  
  const observed = new Proxy(target, baseHandler);
  return observed;


const get = createGetter();
const set = createSetter();

function createGetter() 
  return function get(target, key, receiver) 
    // 对获取的值进行放射
    const res = Reflect.get(target, key, receiver);
    console.log("属性获取", key);
    if (isObject(res)) 
      // 如果获取的值是对象类型,则返回当前对象的代理对象
      return reactive(res);
    
    return res;
  ;

function createSetter() 
  return function set(target, key, value, receiver) 
    const oldValue = target[key];
    const hadKey = hasOwn(target, key);
    const result = Reflect.set(target, key, value, receiver);
    if (!hadKey) 
      console.log("属性新增", key, value);
     else if (hasChanged(value, oldValue)) 
      console.log("属性值被修改", key, value);
    
    return result;
  ;

export const mutableHandlers = 
  get, // 当获取属性时调用此方法
  set, // 当修改属性时调用此方法
;

Proxy只会代理对象的第一层,那么Vue3又是怎样处理这个问题的呢?

判断当前Reflect.get的返回值是否为Object,如果是则再通过reactive方法做代理, 这样就实现了深度观测。

监测数组的时候可能触发多次get/set,那么如何防止触发多次呢?

我们可以判断key是否为当前被代理对象target自身属性,也可以判断旧值与新值是否相等,只有满足以上两个条件之一时,才有可能执行trigger

组件通信

组件通信的方式如下:

(1) props / $emit

父组件通过props向子组件传递数据,子组件通过$emit和父组件通信

1. 父组件向子组件传值
  • props只能是父组件向子组件进行传值,props使得父子组件之间形成了一个单向下行绑定。子组件的数据会随着父组件不断更新。
  • props 可以显示定义一个或一个以上的数据,对于接收的数据,可以是各种数据类型,同样也可以传递一个函数。
  • props属性名规则:若在props中使用驼峰形式,模板中需要使用短横线的形式
// 父组件
<template>
  <div id="father">
    <son :msg="msgData" :fn="myFunction"></son>
  </div>
</template>

<script>
import son from "./son.vue";
export default 
  name: father,
  data() 
    msgData: "父组件数据";
  ,
  methods: 
    myFunction() 
      console.log("vue");
    ,
  ,
  components:  son ,
;
</script>

// 子组件
<template>
  <div id="son">
    <p> msg </p>
    <button @click="fn">按钮</button>
  </div>
</template>
<script>
export default  name: "son", props: ["msg", "fn"] ;
</script>

2. 子组件向父组件传值
  • $emit绑定一个自定义事件,当这个事件被执行的时就会将参数传递给父组件,而父组件通过v-on监听并接收参数。
// 父组件
<template>
  <div class="section">
    <com-article
      :articles="articleList"
      @onEmitIndex="onEmitIndex"
    ></com-article>
    <p> currentIndex </p>
  </div>
</template>

<script>
import comArticle from "./test/article.vue";
export default 
  name: "comArticle",
  components:  comArticle ,
  data() 
    return  currentIndex: -1, articleList: ["红楼梦", "西游记", "三国演义"] ;
  ,
  methods: 
    onEmitIndex(idx) 
      this.currentIndex = idx;
    ,
  ,
;
</script>

//子组件
<template>
  <div>
    <div
      v-for="(item, index) in articles"
      :key="index"
      @click="emitIndex(index)"
    >
       item 
    </div>
  </div>
</template>

<script>
export default 
  props: ["articles"],
  methods: 
    emitIndex(index) 
      this.$emit("onEmitIndex", index); // 触发父组件的方法,并传递参数index
    ,
  ,
;
</script>

(2)eventBus事件总线($emit / $on

eventBus事件总线适用于父子组件非父子组件等之间的通信,使用步骤如下: (1)创建事件中心管理组件之间的通信

// event-bus.js

import Vue from \'vue\'
export const EventBus = new Vue()

(2)发送事件 假设有两个兄弟组件firstComsecondCom

<template>
  <div>
    <first-com></first-com>
    <second-com></second-com>
  </div>
</template>

<script>
import firstCom from "./firstCom.vue";
import secondCom from "./secondCom.vue";
export default  components:  firstCom, secondCom  ;
</script>

firstCom组件中发送事件:

<template>
  <div>
    <button @click="add">加法</button>
  </div>
</template>

<script>
import  EventBus  from "./event-bus.js"; // 引入事件中心

export default 
  data() 
    return  num: 0 ;
  ,
  methods: 
    add() 
      EventBus.$emit("addition",  num: this.num++ );
    ,
  ,
;
</script>

(3)接收事件secondCom组件中发送事件:

<template>
  <div>求和:  count </div>
</template>

<script>
import  EventBus  from "./event-bus.js";
export default 
  data() 
    return  count: 0 ;
  ,
  mounted() 
    EventBus.$on("addition", (param) => 
      this.count = this.count + param.num;
    );
  ,
;
</script>

在上述代码中,这就相当于将num值存贮在了事件总线中,在其他组件中可以直接访问。事件总线就相当于一个桥梁,不用组件通过它来通信。

虽然看起来比较简单,但是这种方法也有不变之处,如果项目过大,使用这种方式进行通信,后期维护起来会很困难。

(3)依赖注入(provide / inject)

这种方式就是Vue中的依赖注入,该方法用于父子组件之间的通信。当然这里所说的父子不一定是真正的父子,也可以是祖孙组件,在层数很深的情况下,可以使用这种方法来进行传值。就不用一层一层的传递了。

provide / inject是Vue提供的两个钩子,和datamethods是同级的。并且provide的书写形式和data一样。

  • provide 钩子用来发送数据或方法
  • inject钩子用来接收数据或方法

在父组件中:

provide()  
    return      
        num: this.num  
    ;


在子组件中:

inject: [\'num\']

还可以这样写,这样写就可以访问父组件中的所有属性:

provide() 
 return 
    app: this
  ;

data() 
 return 
    num: 1
  ;


inject: [\'app\']
console.log(this.app.num)

注意: 依赖注入所提供的属性是非响应式的。

(3)ref / $refs

这种方式也是实现父子组件之间的通信。

ref: 这个属性用在子组件上,它的引用就指向了子组件的实例。可以通过实例来访问组件的数据和方法。

在子组件中:

export default 
  data () 
    return 
      name: \'JavaScript\'
    
  ,
  methods: 
    sayHello () 
      console.log(\'hello\')
    
  


在父组件中:

<template>
  <child ref="child"></component-a>
</template>
<script>
import child from "./child.vue";
export default 
  components:  child ,
  mounted() 
    console.log(this.$refs.child.name); // JavaScript
    this.$refs.child.sayHello(); // hello
  ,
;
</script>

(4)$parent / $children

  • 使用$parent可以让组件访问父组件的实例(访问的是上一级父组件的属性和方法)
  • 使用$children可以让组件访问子组件的实例,但是,$children并不能保证顺序,并且访问的数据也不是响应式的。

在子组件中:

<template>
  <div>
    <span> message </span>
    <p>获取父组件的值为:  parentVal </p>
  </div>
</template>

<script>
export default 
  data() 
    return  message: "Vue" ;
  ,
  computed: 
    parentVal() 
      return this.$parent.msg;
    ,
  ,
;
</script>

在父组件中:

// 父组件中
<template>
  <div class="hello_world">
    <div> msg </div>
    <child></child>
    <button @click="change">点击改变子组件值</button>
  </div>
</template>

<script>
import child from "./child.vue";
export default 
  components:  child ,
  data() 
    return  msg: "Welcome" ;
  ,
  methods: 
    change() 
      // 获取到子组件
      this.$children[0].message = "JavaScript";
    ,
  ,
;
</script>

在上面的代码中,子组件获取到了父组件的parentVal值,父组件改变了子组件中message的值。 需要注意:

  • 通过$parent访问到的是上一级父组件的实例,可以使用$root来访问根组件的实例
  • 在组件中使用$children拿到的是所有的子组件的实例,它是一个数组,并且是无序的
  • 在根组件#app上拿$parent得到的是new Vue()的实例,在这实例上再拿$parent得到的是undefined,而在最底层的子组件拿$children是个空数组
  • $children 的值是数组,而$parent是个对象

(5)$attrs / $listeners

考虑一种场景,如果A是B组件的父组件,B是C组件的父组件。如果想要组件A给组件C传递数据,这种隔代的数据,该使用哪种方式呢?

如果是用props/$emit来一级一级的传递,确实可以完成,但是比较复杂;如果使用事件总线,在多人开发或者项目较大的时候,维护起来很麻烦;如果使用Vuex,的确也可以,但是如果仅仅是传递数据,那可能就有点浪费了。

针对上述情况,Vue引入了$attrs / $listeners,实现组件之间的跨代通信。

先来看一下inheritAttrs,它的默认值true,继承所有的父组件属性除props之外的所有属性;inheritAttrs:false 只继承class属性 。

  • $attrs:继承所有的父组件属性(除了prop传递的属性、class 和 style ),一般用在子组件的子元素上
  • $listeners:该属性是一个对象,里面包含了作用在这个组件上的所有监听器,可以配合 v-on="$listeners" 将所有的事件监听器指向这个组件的某个特定的子元素。(相当于子组件继承父组件的事件)

A组件(APP.vue):

<template>
  <div id="app">
    //此处监听了两个事件,可以在B组件或者C组件中直接触发
    <child1
      :p-child1="child1"
      :p-child2="child2"
      @test1="onTest1"
      @test2="onTest2"
    ></child1>
  </div>
</template>
<script>
import Child1 from "./Child1.vue";
export default 
  components:  Child1 ,
  methods: 
    onTest1() 
      console.log("test1 running");
    ,
    onTest2() 
      console.log("test2 running");
    ,
  ,
;
</script>

B组件(Child1.vue):

<template>
  <div class="child-1">
    <p>props:  pChild1 </p>
    <p>$attrs:  $attrs </p>
    <child2 v-bind="$attrs" v-on="$listeners"></child2>
  </div>
</template>
<script>
import Child2 from "./Child2.vue";
export default 
  props: ["pChild1"],
  components:  Child2 ,
  inheritAttrs: false,
  mounted() 
    this.$emit("test1"); // 触发APP.vue中的test1方法
  ,
;
</script>

C 组件 (Child2.vue):

<template>
  <div class="child-2">
    <p>props:  pChild2 </p>
    <p>$attrs:  $attrs </p>
  </div>
</template>
<script>
export default 
  props: ["pChild2"],
  inheritAttrs: false,
  mounted() 
    this.$emit("test2"); // 触发APP.vue中的test2方法
  ,
;
</script>

在上述代码中:

  • C组件中能直接触发test的原因在于 B组件调用C组件时 使用 v-on 绑定了$listeners 属性
  • 在B组件中通过v-bind 绑定$attrs属性,C组件可以直接获取到A组件中传递下来的props(除了B组件中props声明的)

(6)总结

(1)父子组件间通信

  • 子组件通过 props 属性来接受父组件的数据,然后父组件在子组件上注册监听事件,子组件通过 emit 触发事件来向父组件发送数据。
  • 通过 ref 属性给子组件设置一个名字。父组件通过 $refs 组件名来获得子组件,子组件通过 $parent 获得父组件,这样也可以实现通信。
  • 使用 provide/inject,在父组件中通过 provide提供变量,在子组件中通过 inject 来将变量注入到组件中。不论子组件有多深,只要调用了 inject 那么就可以注入 provide中的数据。

(2)兄弟组件间通信

  • 使用 eventBus 的方法,它的本质是通过创建一个空的 Vue 实例来作为消息传递的对象,通信的组件引入这个实例,通信的组件通过在这个实例上监听和触发事件,来实现消息的传递。
  • 通过 $parent/$refs 来获取到兄弟组件,也可以进行通信。

(3)任意组件之间

  • 使用 eventBus ,其实就是创建一个事件中心,相当于中转站,可以用它来传递事件和接收事件。

如果业务逻辑复杂,很多组件之间需要同时处理一些公共的数据,这个时候采用上面这一些方法可能不利于项目的维护。这个时候可以使用 vuex ,vuex 的思想就是将这一些公共的数据抽离出来,将它作为一个全局的变量来管理,然后其他组件就可以对这个公共数据进行读写操作,这样达到了解耦的目的。

mixin 和 mixins 区别

mixin 用于全局混入,会影响到每个组件实例,通常插件都是这样做初始化的。

Vue.mixin(
  beforeCreate() 
    // ...逻辑        // 这种方式会影响到每个组件的 beforeCreate 钩子函数
  ,
);

虽然文档不建议在应用中直接使用 mixin,但是如果不滥用的话也是很有帮助的,比如可以全局混入封装好的 ajax 或者一些工具函数等等。

mixins 应该是最常使用的扩展组件的方式了。如果多个组件中有相同的业务逻辑,就可以将这些逻辑剥离出来,通过 mixins 混入代码,比如上拉下拉加载数据这种逻辑等等。
另外需要注意的是 mixins 混入的钩子函数会先于组件内的钩子函数执行,并且在遇到同名选项的时候也会有选择性的进行合并。

Vue 的父子组件生命周期钩子函数执行顺序

  • 渲染顺序 :先父后子,完成顺序:先子后父
  • 更新顺序 :父更新导致子更新,子更新完成后父
  • 销毁顺序 :先父后子,完成顺序:先子后父

加载渲染过程

beforeCreate->父 created->父 beforeMount->子 beforeCreate->子 created->子 beforeMount->子 mounted->父 mounted子组件先挂载,然后到父组件

子组件更新过程

beforeUpdate->子 beforeUpdate->子 updated->父 updated

父组件更新过程

beforeUpdate->父 updated

销毁过程

beforeDestroy->子 beforeDestroy->子 destroyed->父 destroyed

之所以会这样是因为Vue创建过程是一个递归过程,先创建父组件,有子组件就会创建子组件,因此创建时先有父组件再有子组件;子组件首次创建时会添加mounted钩子到队列,等到patch结束再执行它们,可见子组件的mounted钩子是先进入到队列中的,因此等到patch结束执行这些钩子时也先执行。

function patch (oldVnode, vnode, hydrating, removeOnly)  
    if (isUndef(vnode))  
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode) return 
    
    let isInitialPatch = false 
    const insertedVnodeQueue = [] // 定义收集所有组件的insert hook方法的数组 // somthing ... 
    createElm( 
        vnode, 
        insertedVnodeQueue, oldElm._leaveCb ? null : parentElm, 
        nodeOps.nextSibling(oldElm) 
    )// somthing... 
    // 最终会依次调用收集的insert hook 
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
    return vnode.elm


function createElm ( vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index )  
    // createChildren 会递归创建儿子组件 
    createChildren(vnode, children, insertedVnodeQueue) // something... 
 
// 将组件的vnode插入到数组中 
function invokeCreateHooks (vnode, insertedVnodeQueue)  
    for (let i = 0; i < cbs.create.length; ++i)  
        cbs.create[i](emptyNode, vnode) 
    
    i = vnode.data.hook // Reuse variable 
    if (isDef(i))  
        if (isDef(i.create)) i.create(emptyNode, vnode) 
        if (isDef(i.insert)) insertedVnodeQueue.push(vnode) 
     
 
// insert方法中会依次调用mounted方法 
insert (vnode: MountedComponentVNode)  
    const  context, componentInstance  = vnode 
    if (!componentInstance._isMounted)  
        componentInstance._isMounted = true 
        callHook(componentInstance, \'mounted\') 
     

function invokeInsertHook (vnode, queue, initial)  
    // delay insert hooks for component root nodes, invoke them after the // element is really inserted 
    if (isTrue(initial) && isDef(vnode.parent))  
        vnode.parent.data.pendingInsert = queue 
     else  
        for (let i = 0; i < queue.length; ++i)  
            queue[i].data.hook.insert(queue[i]); // 调用insert方法 
         
     


Vue.prototype.$destroy = function ()  
    callHook(vm, \'beforeDestroy\') 
    // invoke destroy hooks on current rendered tree 
    vm.__patch__(vm._vnode, null) // 先销毁儿子 
    // fire destroyed hook 
    callHook(vm, \'destroyed\') 

在Vue中使用插件的步骤

  • 采用ES6import ... from ...语法或CommonJSrequire()方法引入插件
  • 使用全局方法Vue.use( plugin )使用插件,可以传入一个选项对象Vue.use(MyPlugin, someOption: true )

Vue中diff算法原理

DOM操作是非常昂贵的,因此我们需要尽量地减少DOM操作。这就需要找出本次DOM必须更新的节点来更新,其他的不更新,这个找出的过程,就需要应用diff算法

vuediff算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式+双指针(头尾都加指针)的方式进行比较。

简单来说,Diff算法有以下过程

  • 同级比较,再比较子节点(根据keytag标签名判断)
  • 先判断一方有子节点和一方没有子节点的情况(如果新的children没有子节点,将旧的子节点移除)
  • 比较都有子节点的情况(核心diff)
  • 递归比较子节点
  • 正常Diff两个树的时间复杂度是O(n^3),但实际情况下我们很少会进行跨层级的移动DOM,所以VueDiff进行了优化,从O(n^3) -> O(n),只有当新旧children都为多个子节点时才需要用核心的Diff算法进行同层级比较。
  • Vue2的核心Diff算法采用了双端比较的算法,同时从新旧children的两端开始进行比较,借助key值找到可复用的节点,再进行相关操作。相比ReactDiff算法,同样情况下可以减少移动节点次数,减少不必要的性能损耗,更加的优雅
  • 在创建VNode时就确定其类型,以及在mount/patch的过程中采用位运算来判断一个VNode的类型,在这个基础之上再配合核心的Diff算法,使得性能上较Vue2.x有了提升

vue3中采用最长递增子序列来实现diff优化

回答范例

思路

  • diff算法是干什么的
  • 它的必要性
  • 它何时执行
  • 具体执行方式
  • 拔高:说一下vue3中的优化

回答范例

  1. Vue中的diff算法称为patching算法,它由Snabbdom修改而来,虚拟DOM要想转化为真实DOM就需要通过patch方法转换
  2. 最初Vue1.x视图中每个依赖均有更新函数对应,可以做到精准更新,因此并不需要虚拟DOMpatching算法支持,但是这样粒度过细导致Vue1.x无法承载较大应用;Vue 2.x中为了降低Watcher粒度,每个组件只有一个Watcher与之对应,此时就需要引入patching算法才能精确找到发生变化的地方并高效更新
  3. vuediff执行的时刻是组件内响应式数据变更触发实例执行其更新函数时,更新函数会再次执行render函数获得最新的虚拟DOM,然后执行patch函数,并传入新旧两次虚拟DOM,通过比对两者找到变化的地方,最后将其转化为对应的DOM操作
  4. patch过程是一个递归过程,遵循深度优先、同层比较的策略;以vue3patch为例
  • 首先判断两个节点是否为相同同类节点,不同则删除重新创建
  • 如果双方都是文本则更新文本内容
  • 如果双方都是元素节点则递归更新子元素,同时更新元素属性
  • 更新子节点时又分了几种情况
    • 新的子节点是文本,老的子节点是数组则清空,并设置文本;
    • 新的子节点是文本,老的子节点是文本则直接更新文本;
    • 新的子节点是数组,老的子节点是文本则清空文本,并创建新子节点数组中的子元素;
    • 新的子节点是数组,老的子节点也是数组,那么比较两组子节点,更新细节blabla
  • vue3中引入的更新策略:静态节点标记等

vdom中diff算法的简易实现

以下代码只是帮助大家理解diff算法的原理和流程

  1. vdom转化为真实dom
const createElement = (vnode) => 
  let tag = vnode.tag;
  let attrs = vnode.attrs || ;
  let children = vnode.children || [];
  if(!tag) 
    return null;
  
  //创建元素
  let elem = document.createElement(tag);
  //属性
  let attrName;
  for (attrName in attrs) 
    if(attrs.hasOwnProperty(attrName)) 
      elem.setAttribute(attrName, attrs[attrName]);
    
  
  //子元素
  children.forEach(childVnode => 
    //给elem添加子元素
    elem.appendChild(createElement(childVnode));
  )

  //返回真实的dom元素
  return elem;

  1. 用简易diff算法做更新操作
function updateChildren(vnode, newVnode) 
  let children = vnode.children || [];
  let newChildren = newVnode.children || [];

  children.forEach((childVnode, index) => 
    let newChildVNode = newChildren[index];
    if(childVnode.tag === newChildVNode.tag) 
      //深层次对比, 递归过程
      updateChildren(childVnode, newChildVNode);
     else 
      //替换
      replaceNode(childVnode, newChildVNode);
    
  )

参考 前端进阶面试题详细解答

v-model实现原理

我们在 vue 项目中主要使用 v-model 指令在表单 inputtextareaselect 等元素上创建双向数据绑定,我们知道 v-model 本质上不过是语法糖(可以看成是value + input方法的语法糖),v-model 在内部为不同的输入元素使用不同的属性并抛出不同的事件:

  • texttextarea 元素使用 value 属性和 input 事件
  • checkboxradio 使用 checked 属性和 change 事件
  • select 字段将 value 作为 prop 并将 change 作为事件

所以我们可以v-model进行如下改写:

<input v-model="sth" />
<!-- 等同于 -->
<input :value="sth" @input="sth = $event.target.value" />

当在input元素中使用v-model实现双数据绑定,其实就是在输入的时候触发元素的input事件,通过这个语法糖,实现了数据的双向绑定

  • 这个语法糖必须是固定的,也就是说属性必须为value,方法名必须为:input
  • 知道了v-model的原理,我们可以在自定义组件上实现v-model
//Parent
<template>
  num
  <Child v-model="num">
</template>
export default 
  data()
    return 
      num: 0
    
  


//Child
<template>
  <div @click="add">Add</div>
</template>
export default 
  props: [\'value\'], // 属性必须为value
  methods:
    add()
      // 方法名为input
      this.$emit(\'input\', this.value + 1)
    
  

原理

会将组件的 v-model 默认转化成value+input

const VueTemplateCompiler = require(\'vue-template-compiler\'); 
const ele = VueTemplateCompiler.compile(\'<el-checkbox v-model="check"></el- checkbox>\'); 

// 观察输出的渲染函数:
// with(this)  
//     return _c(\'el-checkbox\',  
//         model:  
//             value: (check), 
//             callback: function ($$v)  check = $$v , 
//             expression: "check" 
//          
//     ) 
// 
// 源码位置 core/vdom/create-component.js line:155

function transformModel (options, data: any)  
    const prop = (options.model && options.model.prop) || \'value\' 
    const event = (options.model && options.model.event) || \'input\' 
    ;(data.attrs || (data.attrs = ))[prop] = data.model.value 
    const on = data.on || (data.on = ) 
    const existing = on[event] 
    const callback = data.model.callback 
    if (isDef(existing))  
        if (Array.isArray(existing) ? existing.indexOf(callback) === -1 : existing !== callback ) 
            on[event] = [callback].concat(existing) 
         
     else  
        on[event] = callback 
     

原生的 v-model,会根据标签的不同生成不同的事件和属性

const VueTemplateCompiler = require(\'vue-template-compiler\'); 
const ele = VueTemplateCompiler.compile(\'<input v-model="value"/>\');

// with(this)  
//     return _c(\'input\',  
//         directives: [ name: "model", rawName: "v-model", value: (value), expression: "value" ], 
//         domProps:  "value": (value) ,
//         on: "input": function ($event)  
//             if ($event.target.composing) return;
//             value = $event.target.value
//         
//         
//     )
// 

编译时:不同的标签解析出的内容不一样 platforms/web/compiler/directives/model.js

if (el.component)  
    genComponentModel(el, value, modifiers) // component v-model doesn\'t need extra runtime 
    return false 
 else if (tag === \'select\')  
    genSelect(el, value, modifiers) 
 else if (tag === \'input\' && type === \'checkbox\')  
    genCheckboxModel(el, value, modifiers) 
 else if (tag === \'input\' && type === \'radio\')  
    genRadioModel(el, value, modifiers) 
 else if (tag === \'input\' || tag === \'textarea\')  
    genDefaultModel(el, value, modifiers) 
 else if (!config.isReservedTag(tag))  
    genComponentModel(el, value, modifiers) // component v-model doesn\'t need extra runtime 
    return false 

运行时:会对元素处理一些关于输入法的问题 platforms/web/runtime/directives/model.js

inserted (el, binding, vnode, oldVnode)  
    if (vnode.tag === \'select\')  // #6903 
    if (oldVnode.elm && !oldVnode.elm._vOptions)  
        mergeVNodeHook(vnode, \'postpatch\', () =>  
            directive.componentUpdated(el, binding, vnode) 
        ) 
     else  
        setSelected(el, binding, vnode.context) 
    
    el._vOptions = [].map.call(el.options, getValue) 
     else if (vnode.tag === \'textarea\' || isTextInputType(el.type))  
        el._vModifiers = binding.modifiers 
        if (!binding.modifiers.lazy)  
            el.addEventListener(\'compositionstart\', onCompositionStart) 
            el.addEventListener(\'compositionend\', onCompositionEnd) 
            // Safari < 10.2 & UIWebView doesn\'t fire compositionend when 
            // switching focus before confirming composition choice 
            // this also fixes the issue where some browsers e.g. iOS Chrome
            // fires "change" instead of "input" on autocomplete. 
            el.addEventListener(\'change\', onCompositionEnd) /* istanbul ignore if */ 
            if (isIE9)  
                el.vmodel = true 
            
        
    

请说明Vue中key的作用和原理,谈谈你对它的理解

  • key是为Vue中的VNode标记的唯一id,在patch过程中通过key可以判断两个虚拟节点是否是相同节点,通过这个key,我们的diff操作可以更准确、更快速
  • diff算法的过程中,先会进行新旧节点的首尾交叉对比,当无法匹配的时候会用新节点的key与旧节点进行比对,然后检出差异
  • 尽量不要采用索引作为key
  • 如果不加key,那么vue会选择复用节点(Vue的就地更新策略),导致之前节点的状态被保留下来,会产生一系列的bug
  • 更准确 :因为带 key 就不是就地复用了,在 sameNode 函数 a.key === b.key 对比中可以避免就地复用的情况。所以会更加准确。
  • 更快速key的唯一性可以被Map数据结构充分利用,相比于遍历查找的时间复杂度O(n)Map的时间复杂度仅仅为O(1),比遍历方式更快。

源码如下:

function createKeyToOldIdx (children, beginIdx, endIdx) 
  let i, key
  const map = 
  for (i = beginIdx; i <= endIdx; ++i) 
    key = children[i].key
    if (isDef(key)) map[key] = i
  
  return map

回答范例

分析

这是一道特别常见的问题,主要考查大家对虚拟DOMpatch细节的掌握程度,能够反映面试者理解层次

思路分析:

  • 给出结论,key的作用是用于优化patch性能
  • key的必要性
  • 实际使用方式
  • 总结:可从源码层面描述一下vue如何判断两个节点是否相同

回答范例:

  1. key的作用主要是为了更高效的更新虚拟DOM
  2. vuepatch过程中 判断两个节点是否是相同节点是key是一个必要条件 ,渲染一组列表时,key往往是唯一标识,所以如果不定义key的话,vue只能认为比较的两个节点是同一个,哪怕它们实际上不是,这导致了频繁更新元素,使得整个patch过程比较低效,影响性能
  3. 实际使用中在渲染一组列表时key必须设置,而且必须是唯一标识,应该避免使用数组索引作为key,这可能导致一些隐蔽的bugvue中在使用相同标签元素过渡切换时,也会使用key属性,其目的也是为了让vue可以区分它们,否则vue只会替换其内部属性而不会触发过渡效果
  4. 从源码中可以知道,vue判断两个节点是否相同时主要判断两者的key标签类型(如div)等,因此如果不设置key,它的值就是undefined,则可能永远认为这是两个相同节点,只能去做更新操作,这造成了大量的dom更新操作,明显是不可取的

如果不使用 keyVue 会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法。key 是为 Vuevnode 的唯一标记,通过这个 key,我们的 diff 操作可以更准确、更快速

diff程可以概括为:oldChnewCh各有两个头尾的变量StartIdxEndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldChnewCh至少有一个已经遍历完了,就会结束比较,这四种比较方式就是旧尾新头旧头新尾

相关代码如下

// 判断两个vnode的标签和key是否相同 如果相同 就可以认为是同一节点就地复用
function isSameVnode(oldVnode, newVnode) 
  return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key;


// 根据key来创建老的儿子的index映射表  类似 \'a\':0,\'b\':1 代表key为\'a\'的节点在第一个位置 key为\'b\'的节点在第二个位置
function makeIndexByKey(children) 
  let map = ;
  children.forEach((item, index) => 
    map[item.key] = index;
  );
  return map;

// 生成的映射表
let map = makeIndexByKey(oldCh);

Vuex中actions和mutations有什么区别

题目分析

  • mutationsactionsvuex带来的两个独特的概念。新手程序员容易混淆,所以面试官喜欢问。
  • 我们只需记住修改状态只能是mutationsactions只能通过提交mutation修改状态即可

回答范例

  1. 更改 Vuexstore 中的状态的唯一方法是提交 mutationmutation 非常类似于事件:每个 mutation 都有一个字符串的类型 (type)和一个 回调函数 (handler) 。Action 类似于 mutation,不同在于:Action可以包含任意异步操作,但它不能修改状态, 需要提交mutation才能变更状态
  2. 开发时,包含异步操作或者复杂业务组合时使用action;需要直接修改状态则提交mutation。但由于dispatchcommit是两个API,容易引起混淆,实践中也会采用统一使用dispatch action的方式。调用dispatchcommit两个API时几乎完全一样,但是定义两者时却不甚相同,mutation的回调函数接收参数是state对象。action则是与Store实例具有相同方法和属性的上下文context对象,因此一般会解构它为commit, dispatch, state,从而方便编码。另外dispatch会返回Promise实例便于处理内部异步结果
  3. 实现上commit(type)方法相当于调用options.mutations[type](state)dispatch(type)方法相当于调用options.actions[type](store),这样就很容易理解两者使用上的不同了

实现

我们可以像下面这样简单实现commitdispatch,从而辨别两者不同

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)
    

对Vue SSR的理解

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

SSR也就是服务端渲染,也就是将 Vue 在客户端把标签渲染成 HTML 的工作放在服务端完成,然后再把 html 直接返回给客户端

  • 优点SSR 有着更好的 SEO、并且首屏加载速度更快
    • 因为 SPA 页面的内容是通过 Ajax 获取,而搜索引擎爬取工具并不会等待 Ajax 异步完成后再抓取页面内容,所以在 SPA 中是抓取不到页面通过 Ajax获取到的内容;而 SSR 是直接由服务端返回已经渲染好的页面(数据已经包含在页面中),所以搜索引擎爬取工具可以抓取渲染好的页面
    • 更快的内容到达时间(首屏加载更快): SPA 会等待所有 Vue 编译后的 js 文件都下载完成后,才开始进行页面的渲染,文件下载等需要一定的时间等,所以首屏渲染需要一定的时间;SSR 直接由服务端渲染好页面直接返回显示,无需等待下载 js 文件及再去渲染等,所以 SSR 有更快的内容到达时间
  • 缺点 : 开发条件会受到限制,服务器端渲染只支持 beforeCreatecreated 两个钩子,当我们需要一些外部扩展库时需要特殊处理,服务端渲染应用程序也需要处于 Node.js 的运行环境。服务器会有更大的负载需求
    • 在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用CPU资源 (CPU-intensive - CPU 密集),因此如果你预料在高流量环境 ( high traffic ) 下使用,请准备相应的服务器负载,并明智地采用缓存策略

其基本实现原理

  • app.js 作为客户端与服务端的公用入口,导出 Vue 根实例,供客户端 entry 与服务端 entry 使用。客户端 entry 主要作用挂载到 DOM 上,服务端 entry 除了创建和返回实例,还进行路由匹配与数据预获取。
  • webpack 为客服端打包一个 Client Bundle ,为服务端打包一个 Server Bundle
  • 服务器接收请求时,会根据 url,加载相应组件,获取和解析异步数据,创建一个读取 Server BundleBundleRenderer,然后生成 html 发送给客户端。
  • 客户端混合,客户端收到从服务端传来的 DOM 与自己的生成的 DOM 进行对比,把不相同的 DOM 激活,使其可以能够响应后续变化,这个过程称为客户端激活 。为确保混合成功,客户端与服务器端需要共享同一套数据。在服务端,可以在渲染之前获取数据,填充到 stroe 里,这样,在客户端挂载到 DOM 之前,可以直接从 store里取数据。首屏的动态数据通过 window.__INITIAL_STATE__发送到客户端

Vue SSR 的实现,主要就是把 Vue 的组件输出成一个完整 HTML, vue-server-renderer 就是干这事的

Vue SSR需要做的事多点(输出完整 HTML),除了complier -> vnode,还需如数据获取填充至 HTML、客户端混合(hydration)、缓存等等。相比于其他模板引擎(ejs, jade 等),最终要实现的目的是一样的,性能上可能要差点

怎么实现路由懒加载呢

这是一道应用题。当打包应用时,JavaScript 包会变得非常大,影响页面加载。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问时才加载对应组件,这样就会更加高效

// 将
// import UserDetails from \'./views/UserDetails\'
// 替换为
const UserDetails = () => import(\'./views/UserDetails\')
​
const router = createRouter(
  // ...
  routes: [ path: \'/users/:id\', component: UserDetails ],
)

回答范例

  1. 当打包构建应用时,JavaScript 包会变得非常大,影响页面加载。利用路由懒加载我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,这样会更加高效,是一种优化手段
  2. 一般来说,对所有的路由都使用动态导入是个好主意
  3. component选项配置一个返回 Promise 组件的函数就可以定义懒加载路由。例如: path: \'/users/:id\', component: () => import(\'./views/UserDetails\')
  4. 结合注释 () => import(/* webpackChunkName: "group-user" */ \'./UserDetails.vue\') 可以做webpack代码分块

你觉得vuex有什么缺点

分析

相较于reduxvuex已经相当简便好用了。但模块的使用比较繁琐,对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使用感觉也没简化

回答范例

  1. vuex利用响应式,使用起来已经相当方便快捷了。但是在使用过程中感觉模块化这一块做的过于复杂,用的时候容易出错,还要经常查看文档
  2. 比如:访问state时要带上模块key,内嵌模块的话会很长,不得不配合mapState使用,加不加namespaced区别也很大,gettersmutationsactions这些默认是全局,加上之后必须用字符串类型的path来匹配,使用模式不统一,容易出错;对ts的支持也不友好,在使用模块时没有代码提示。
  3. 之前Vue2项目中用过vuex-module-decorators的解决方案,虽然类型支持上有所改善,但又要学一套新东西,增加了学习成本。pinia出现之后使用体验好了很多,Vue3 + pinia会是更好的组合

原理

下面我们来看看vuexstore.state.x.y这种嵌套的路径是怎么搞出来的

首先是子模块安装过程:父模块状态parentState上面设置了子模块名称moduleName,值为当前模块state对象。放在上面的例子中相当于:store.state[\'x\'] = moduleX.state。此过程是递归的,那么store.state.x.y安装时就是:store.state[\'x\'][\'y\'] = moduleY.state

//源码位置 https://github1s.com/vuejs/vuex/blob/HEAD/src/store-util.js#L102-L115
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
    )

v-if和v-show区别

  • v-show隐藏则是为该元素添加css--display:nonedom元素依旧还在。v-if显示隐藏是将dom元素整个添加或删除
  • 编译过程:v-if切换有一个局部编译/卸载的过程,切换过程中合适地销毁和重建内部的事件监听和子组件;v-show只是简单的基于css切换
  • 编译条件:v-if是真正的条件渲染,它会确保在切换过程中条件块内的事件监听器和子组件适当地被销毁和重建。只有渲染条件为假时,并不做操作,直到为真才渲染
  • v-showfalse变为true的时候不会触发组件的生命周期
  • v-iffalse变为true的时候,触发组件的beforeCreatecreatebeforeMountmounted钩子,由true变为false的时候触发组件的beforeDestorydestoryed方法
  • 性能消耗:v-if有更高的切换消耗;v-show有更高的初始渲染消耗

v-show与v-if的使用场景

  • v-ifv-show 都能控制dom元素在页面的显示
  • v-if 相比 v-show 开销更大的(直接操作dom节点增加与删除)
  • 如果需要非常频繁地切换,则使用 v-show 较好
  • 如果在运行时条件很少改变,则使用 v-if 较好

v-show与v-if原理分析

  1. v-show原理

不管初始条件是什么,元素总是会被渲染

我们看一下在vue中是如何实现的

代码很好理解,有transition就执行transition,没有就直接设置display属性

// https://github.com/vuejs/vue-next/blob/3cd30c5245da0733f9eb6f29d220f39c46518162/packages/runtime-dom/src/directives/vShow.ts
export const vShow: ObjectDirective<VShowElement> = 
  beforeMount(el,  value ,  transition ) 
    el._vod = el.style.display === \'none\' ? \'\' : el.style.display
    if (transition && value) 
      transition.beforeEnter(el)
     else 
      setDisplay(el, value)
    
  ,
  mounted(el,  value ,  transition ) 
    if (transition && value) 
      transition.enter(el)
    
  ,
  updated(el,  value, oldValue ,  transition ) 
    // ...
  ,
  beforeUnmount(el,  value ) 
    setDisplay(el, value)
  

  1. v-if原理

v-if在实现上比v-show要复杂的多,因为还有else else-if 等条件需要处理,这里我们也只摘抄源码中处理 v-if 的一小部分

返回一个node节点,render函数通过表达式的值来决定是否生成DOM

// https://github.com/vuejs/vue-next/blob/cdc9f336fd/packages/compiler-core/src/transforms/vIf.ts
export const transformIf = createStructuralDirectiveTransform(
  /^(if|else|else-if)$/,
  (node, dir, context) => 
    return processIf(node, dir, context, (ifNode, branch, isRoot) => 
      // ...
      return () => 
        if (isRoot) 
          ifNode.codegenNode = createCodegenNodeForBranch(
            branch,
            key,
            context
          ) as IfConditionalExpression
         else 
          // attach this branch\'s codegen node to the v-if root.
          const parentCondition = getParentCondition(ifNode.codegenNode!)
          parentCondition.alternate = createCodegenNodeForBranch(
            branch,
            key + ifNode.branches.length - 1,
            context
          )
        
      
    )
  
)

Vue路由的钩子函数

首页可以控制导航跳转,beforeEachafterEach等,一般用于页面title的修改。一些需要登录才能调整页面的重定向功能。

  • beforeEach主要有3个参数tofromnext
  • toroute即将进入的目标路由对象。
  • fromroute当前导航正要离开的路由。
  • nextfunction一定要调用该方法resolve这个钩子。执行效果依赖next方法的调用参数。可以控制网页的跳转

vue-router 路由钩子函数是什么 执行顺序是什么

路由钩子的执行流程, 钩子函数种类有:全局守卫路由守卫组件守卫

  1. 导航被触发。
  2. 在失活的组件里调用 beforeRouteLeave 守卫。
  3. 调用全局的 beforeEach 守卫。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。
  5. 在路由配置里调用 beforeEnter
  6. 解析异步路由组件。
  7. 在被激活的组件里调用 beforeRouteEnter
  8. 调用全局的 beforeResolve 守卫 (2.5+)。
  9. 导航被确认。
  10. 调用全局的 afterEach 钩子。
  11. 触发 DOM 更新。
  12. 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入

Composition API 与 Options API 有什么不同

分析

Vue3最重要更新之一就是Composition API,它具有一些列优点,其中不少是针对Options API暴露的一些问题量身打造。是Vue3推荐的写法,因此掌握好Composition API应用对掌握好Vue3至关重要

What is Composition API?(opens new window)

  • Composition API出现就是为了解决Options API导致相同功能代码分散的现象

体验

Composition API能更好的组织代码,下面用composition api可以提取为useCount(),用于组合、复用

compositon api提供了以下几个函数:

  • setup
  • ref
  • reactive
  • watchEffect
  • watch
  • computed
  • toRefs
  • 生命周期的hooks

回答范例

  1. Composition API是一组API,包括:Reactivity API生命周期钩子依赖注入,使用户可以通过导入函数方式编写vue组件。而Options API则通过声明组件选项的对象形式编写组件
  2. Composition API最主要作用是能够简洁、高效复用逻辑。解决了过去Options APImixins的各种缺点;另外Composition API具有更加敏捷的代码组织能力,很多用户喜欢Options API,认为所有东西都有固定位置的选项放置代码,但是单个组件增长过大之后这反而成为限制,一个逻辑关注点分散在组件各处,形成代码碎片,维护时需要反复横跳,Composition API则可以将它们有效组织在一起。最后Composition API拥有更好的类型推断,对ts支持更友好,Options API在设计之初并未考虑类型推断因素,虽然官方为此做了很多复杂的类型体操,确保用户可以在使用Options API时获得类型推断,然而还是没办法用在mixinsprovide/inject
  3. Vue3首推Composition API,但是这会让我们在代码组织上多花点心思,因此在选择上,如果我们项目属于中低复杂度的场景,Options API仍是一个好选择。对于那些大型,高扩展,强维护的项目上,Composition API会获得更大收益

可能的追问

  1. Composition API能否和Options API一起使用?

可以在同一个组件中使用两个script标签,一个使用vue3,一个使用vue2写法,一起使用没有问题

<!-- vue3 -->
<script setup>
  // vue3写法
</script>

<!-- 降级vue2 -->
<script>
  export default 
    data() ,
    methods: 
  
</script>

子组件可以直接改变父组件的数据么,说明原因

这是一个实践知识点,组件化开发过程中有个单项数据流原则,不在子组件中修改父组件是个常识问题

思路

  • 讲讲单项数据流原则,表明为何不能这么做
  • 举几个常见场景的例子说说解决方案
  • 结合实践讲讲如果需要修改父组件状态应该如何做

回答范例

  1. 所有的 prop 都使得其父子之间形成了一个单向下行绑定:父级 prop以上是关于前端一面常见vue面试题汇总的主要内容,如果未能解决你的问题,请参考以下文章

    前端面试题汇总

    前端面试题汇总(主要为 Vue)

    前端面试题汇总-Vue篇

    前端面试题汇总-Vue篇

    阿里前端常考vue面试题汇总

    前端面试题vue-element汇总