简答Vue相关的高频问题

2,616 阅读14分钟

业精于勤,荒于嬉;

学习Vue源码也有段时间,一直想去分享这块的东西,但一直没想好怎么写这一块的内容,这次我们可以通过Vue中常见的面试题来作为切入口,深入源码。将源码中学习到的提炼成答案来回答面试官,让自己给到的答案不那么空泛。

v-for/v-if优先级

对于v-for/v-if的优先级也是有调整的, 在Vue2中v-for优先级高于v-if,在Vue3的更新之后v-if的优先级是高于v-for的

项目中一般有两种场景:

  1. 前置条件成立才去循环渲染列表数据,从而渲染出每个数据对应的「节点」
  2. 循环渲染列表数据,根据某个数据的条件判断要不要渲染对应的「节点」

第一种场景

<template>
 <div id="app">
    <div v-for="i in arr" v-if="isRenderList">
      {{i.text}}
    </div>
	</div>
</template>
<script>
  const app = new Vue({
    el: '#app',
    data: {
      isRenderList: false,
      arr: [
        {
          text: '1',
        },
        {
          text: '2',
        }
      ]
    }
  })
</script>

上面的代码是过不了检测:Vue会抛出warning,但其实代码还是可以运行的

下方编译后的代码中的_c、_l、_e、_v、_s可以大致理解编译过程中的帮助方法

_c -> 渲染dom节点
_l -> 渲染列表
_e -> 渲染空注释
_v -> 渲染本文节点
_s -> Object.prototype.toString

更多详情查看后面章节Vue实例原型链上的方法

编译后的代码

function render() {
  var _vm = this;
  var _h = _vm.$createElement;
  var _c = _vm._self._c || _h;
  return _c('div', {
    attrs: {
      "id": "app"
    }
  }, _vm._l((_vm.arr), function (i) {
    return (_vm.isRenderList) ? _c('div', [_vm._v("\n    " + _vm._s(i.text) +
      "\n  ")]) : _vm._e()
  }), 0) 
}

从编译的render函数中可以看出无论任何都会循环整个数据,再进行判断,根据判断再来考虑渲染代码,所以得出结论:v-for优先级高于v-if,其实循环执行代码次数一次没少,从代码执行结果也可以看出。

一般就是先判断再循环

<div id="app">
   <template v-if="isRenderList">
     <div v-for="i in arr" v-if="isRenderList">
       {{i.text}}
     </div>
   </template>
</div>

编译后的代码

function render() {
  var _vm = this;
  var _h = _vm.$createElement;
  var _c = _vm._self._c || _h;
  return _c('div', {
    attrs: {
      "id": "app"
    }
  }, [(_vm.isRenderList) ? _vm._l((_vm.arr), function (i) {
    return (_vm.isRenderList) ? _c('div', [_vm._v("\n       " + _vm._s(
      i.text) + "\n     ")]) : _vm._e()
  }) : _vm._e()], 2)
}

这个时候可以看到是先进行了判断再考虑要不要循环

第二种场景

<div id="app">
  <div v-for="i in arr">
    <template v-if="i.show">
      {{i.text}}
    </template>
  </div>
</div>

编译后的代码

function render() {
  var _vm = this;
  var _h = _vm.$createElement;
  var _c = _vm._self._c || _h;
  return _c('div', {
    attrs: {
      "id": "app"
    }
  }, _vm._l((_vm.arr), function (i) {
    return _c('div', [(i.show) ? [_vm._v("\n      " + _vm._s(i.text) +
      "\n    ")] : _vm._e()], 2)
  }), 0)
}

循环中根据某个条件判断是否渲染,其实在Vue文档中已经给出了很好的解决方式,就是用computed根据条件进行筛选计算出要渲染的列表,如果项目中遇到了v-for/v-if一起使用,不去进行数据过滤而在模板中用条件语句这种写法其实是有问题的

computed过滤不需要生成节点的数据

<div id="app">
  <div v-for="i in filterArr" :key="i.text">
    {{i.text}}
  </div>
</div>

<script>
  const app = new Vue({
    el: '#app',
    data: {
      isRenderList: false,
      arr: [
        {
          text: '1',
          show: true,
        },
        {
          text: '2',
          show: false
        }
      ]
    },
    computed: {
      filterArr({ arr }) {
        return arr.filter(i => i.show)
      }
    }
  })
</script>

所以在日常写代码中模板中尽量不要出现过长的条件语句,使用computed/filters可以避免大多情况下的条件判断,优化template就是在优化生成render函数中的代码,这也是代码优化的部分

在Vue3中v-if优先级高于v-for

<div id="app">
  <div v-for="i in arr" v-if="i.show">
      {{i.text}}
  </div>
</div>

编译后的代码

import { 
  renderList as _renderList,
  Fragment as _Fragment, 
  openBlock as _openBlock, 
  createElementBlock as _createElementBlock,
  toDisplayString as _toDisplayString,
  createCommentVNode as _createCommentVNode 
} from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", { id: "app" }, [
    // 先进行的条件判断
    (_ctx.i.show)
      ? (_openBlock(true), _createElementBlock(_Fragment, { key: 0 }, _renderList(_ctx.arr, (i) => {
          return (_openBlock(), _createElementBlock("div", null, _toDisplayString(i.text), 1 /* TEXT */))
        }), 256 /* UNKEYED_FRAGMENT */))
      : _createCommentVNode("v-if", true)
  ]))
}

其实从编译后的代码可以看出,上面那段代码写的是有问题的,因为v-if的优先级高于v-for,所以在执行之前会先进行条件判断。这个时候读取的i.show读的是实例中的属性,所以肯定是会报错的

Vue根实例中data可以定义成对象,组件中的data必须要函数形式

<div id="app">
  <h1>{{ title }}</h1>
  <comp></comp>
  <comp></comp>
  <comp></comp>
</div>

<script>
  Vue.component('comp', {
    data: {
      counter: 0
    },
    render(h) {
      let vm = this
      return h('div', {
        on: {
          click: () => vm.counter++
        }
      }, vm.counter)
    },
  })
  
  const app = new Vue({
    el: '#app',
    data: {
      title: '组件中的data为什么要函数形式?'
    }
  })

</script>

上面这段代码执行时铁定是过不了检测,会报warning。我们都知道Vue文档中明确指出Vue组件中的data值必须要定义成函数。为什么呢?

先看下源码流程

  • src\core\instance\index.js中调用 initMixin(Vue)
// ...
import { initMixin } from './render';
// ...

// 构造函数声明
function Vue(options) {
  if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword');
  }
  // 初始化
  this._init(options);
}

// 扩展原型Vue原型对象,初始化提供给实例的方法和属性
initMixin(Vue);
// ...
  • Vue方法中调用this._init(options)
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    // ...
    // merge options
    // init
    vm._self = vm
    initLifecycle(vm) // 生命周期相关的属性初始化
    initEvents(vm) // 事件监听初始化
    initRender(vm) // 插槽处理,$createElm === render(h)
    // 调用生命周期的钩子函数
    callHook(vm, 'beforeCreate')
    // provide/inject
    // 组件数据和状态初始化
    initInjections(vm) // resolve injections before data/props
    initState(vm) // 初始化data/props/methods/computed/watch   => 这一行代码
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')
		// ...
    // 设置了el选项组件,会自动挂载
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}
  • initState(vm)
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
}

结论

从方法中可以知道,data是可以定义成Object|Function,如果在组件中定义成Object类型就会直接使用当前定义的对象,如果使用对象形式定义data,则会导致它们共用一个data对象,那么状态变更将会影响所有组件实例,如果采用函数形式定义,在initData时会将其作为工厂函数返回全新data对象,有效规避多实例之间状态污染问题。而在Vue根实例创建过程中则不存在该限制,也是因为根实例只能有一个,不需要担心这种情况。

并且在new Vue初始化根实例是和组件创建走的分支是不同的,直接跳过了检测

在Vue3中Vue从构造函数变成 了对象,使用的Vue.createApp来创建实例,就更加不存在这个问题

key的作用和原理

key是Vue在patchVnode前用来判断新旧节点时候是否是相同的重要判断依据,在pathVnode前会进行sameVnode(oldVnode, newVnode)

  • sameVnode:比较新旧节点是否是同个节点
  • patchVnode: 给节点打补丁实现节点更新

sameVnode

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

相同节点判断逻辑:

  1. key相同
  2. tag相同
  3. 都是comment
  4. 都定义了data
  5. tag为input, type是否相同「number、password....」
  6. 异步组件情况

patchVnode

patchVnode中涉及到diff算法, 整个过程就是深度优先、同层比较

主要有下面几个操作,比较两个VNode,包括三种类型操作:属性更新、文本更新、子节点更新

  • 深度优先:比较完根节点,同时比较子节点的情况,整个过程是深度优先,从上往下,再从下往上比对同层节点的比较的过程

    1. 新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren
    2. 如果新节点有子节点而老节点没有子节点,先清空老节点的文本内容,然后为其新增子节点。
    3. 当新节点没有子节点而老节点有子节点的时候,则移除该节点的所有子节点。
  • 同层比较:分析当前两个根节点类型

    1. 如果是元素,更新双方属性、特性等
    2. 如果双方是文本,更新文本

深度优先、同层比较

  • 根节点:old、new判断是否有children
  • 红色:oldA、newA判断是否有chidren
  • 黄色:old、new判断是否有children
  • 黄色:A、B都diff完成,往上走
  • 红色:oldB、newB判断是否有children
  • ....

updateChildren

新旧子节点进行对比:主要通过首尾指针移动的方式来高效比对新旧两个Vnode的children得出最小操作补丁。

在这个方法中就能体现key的作用,我们通过例子来看下比对两个子节点集合的过程已及Vue是如何通过首尾指针移动的方式来进行高效的节点比对。

不写key的情况

如果在写列表时不去定义key, 当列表数据变动,patchVnode过程会是怎样的?

<div id="app">
  <p v-for="i of items">{{i}}</p>
</div>

<script>
  const app = new Vue({
    el: '#app',
    data: {
      items: ["a", "b", "c", "d", "e"]
    },
    mounted() {
      setTimeout(() => {
        this.items.splice(2, 0, 'f')
      }, 1000 * 2)
    }
  })
</script>

2秒之后新数组变化,新旧两个子节点集合以及其首尾指针是这样

首尾指针移动算法:头头、尾尾、头尾、尾头

比较节点是否相同有四个判断,每次循环节点都会依次进行下面四个判断

  1. oldStartVnode 和 newStartVnode 比较是否相同
  2. oldEndVnode 和 newEndVnode 比较是否相同
  3. oldStartVnode 和 newEndVnode 比较是否相同
  4. oldEndVnode 和 newStartVnode 比较是否相同

当没有写key的时候Vue在sameVnode比较节点,因为都没有key并且剩下的判断都符合,就会导致在走第一个判断逻辑oldStartVnode和newStartVnode都认为是相同的节点,进而oldStartIdx和newStartIdx游标继续向下走,每次比对都认为是相同的节点

// 首次循环比较 patchVnode(oldA, newA)
oldCh: a b c d e 
newCh: a b f c d e

// 第二次循环比较 patchVnode(oldB, newB)
oldCh: b c d e 
newCh: b f c d e

前面两步没啥问题,站在上帝视角我们看出确实是相同节点

// 第三次循环比较 patchVnode(oldC, newF)
oldCh: c d e 
newCh: f c d e

到这一步因为都没有key,所以仍然认为是相同节点进行pathVnode, 想一下如果c和f节点写了很多根据配置项生成的东西,势必就会造成很多补丁,这就导致整个diff过程变得很不高效,且是一步错,步步错。

// 第四次循环比较 patchVnode(oldD, newC)
oldCh: d e 
newCh: c d e

强行将d和c进行diff打补丁,一直持续到剩下最后一个e, Vue认为它是新节点,创建并追加到最后

动图演示

写key的情况

还是这张图,直接跳到第三次循环

  • 第一种:头头比较(oldStartVnode 和 newStartVnode ) 不符合
  • 第二种:尾尾比较(oldEndVnode 和 newEndVnode ) 符合
// 第三次循环比较(第二种符合) patchVnode(oldE, newE)
oldCh: c d e 
newCh: f c d e

通过这种比较方式,游标完美命中相同节点

  • 第一种:头头比较(oldStartVnode 和 newStartVnode ) 不符合
  • 第二种:尾尾比较(oldEndVnode 和 newEndVnode ) 符合
// 第四次循环比较(第二种符合) patchVnode(oldD, newD)
oldCh: c d 
newCh: f c d

依次往下走剩下最后一个元素f, Vue认为它是新节点,直接创建并追加到c的前面。这里有个疑问,Vue昨知道我要把剩下的f插到c的前面呢?

先看到第五次循环的时候

// 第五次循环比较(第二种符合) patchVnode(oldD, newD)
oldCh: c 
newCh: f c 

走到这一步的时候,在更新了c之后,就没有了打补丁的环节,会有两个操作

  • oldCh结束: 判断newCh中是否还剩下,批量创建
  • newCh结束: 判断oldCh是否还剩下,批量删除

这里很明显是oldCh先结束,这个时候会进行批量创建,剩下了f(newVnode), 然后从刚最后环节中的newEndIndex中可以找到参考元素c, 进行插入

动图演示

结论

  1. key的作用主要是为了高效的更新虚拟DOM,其原理是vue在patch过程中通过key可以精准判断两个节点是否是同一个,从而避免频繁更新不同元素,使得整个patch过程更加高效,减少DOM操作量,提高性能。
  2. 另外,若不设置key还可能在列表更新时引发一些隐蔽的bug
  3. vue中在使用相同标签名元素的过渡切换时,也会使用到key属性,其目的也是为了让vue可以区分它们,否则vue只会替换其内部属性而不会触发过渡效果。

你怎么理解vue中的diff算法?

可以从源码分析以下几个点回答:

  • 必要性

  • 执行方式

  • 高效性

  1. diff算法是虚拟DOM技术的必然产物:通过新旧虚拟DOM作对比(即diff),将变化的地方更新在真实DOM上;另外,也需要diff高效的执行对比过程,从而降低时间复杂度为O(n)。
  2. vue 2.x中为了降低Watcher粒度,每个组件只有一个Watcher与之对应,只有引入diff才能精确找到 发生变化的地方。
  3. vue中diff执行的时刻是组件实例执行其更新函数时,它会比对上一次渲染结果oldVnode和新的渲染 结果newVnode,此过程称为patch。
  4. diff过程整体遵循深度优先、同层比较的策略;两个节点之间比较会根据它们是否拥有子节点或者文本节点做不同操作;比较两组子节点是算法的重点,Vue使用首尾游标的方式做了4次比对尝试,如果没有找到相同节点才按照通用方式遍历查找,查找结束再按情况处理剩下的节点;借助key通常可以非常精确找到相同节点,因此整个patch过程非常高效。

对于这一块如果你看过Vue源码,可发挥的空间还是很大的,可以在回答过程中时不时说下源码中的一些细节引起面试官的注意,如果面试官对于这块也有想探讨的点,那么这个话题将会是你整个面试过程中的一大亮点

对应源码位置

// src/core/vdom/patch.js  
// 更新流程:patch -> patchVnode -> updateChildren -> patchVnode -> 整个的递归patch过程

谈一谈对vue组件化的理解

回答总体思路: 组件化定义、优点、使用场景和注意事项等方面展开陈述,同时要强调vue中组件化的一些特点。

组件定义

// 组件定义 
Vue.component('comp', { 
	template: '<div>this is a component</div>' 
}) 

对应源码位置:src\core\global-api\assets.js

// initAssetRegisters

需要注意的是,我们日常工作中的SFC文件组件(.vue)在进行打包的时候最终也是编译成Vue配置项中的render函数,最终导出的依然是组件配置对象,所以可以理解为我们在写.vue组件就是在写组件配置对象

组件化优点

对应源码位置:src\core\instance\lifecycle.js

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // 组件更新函数声明
  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    // ...
  } else {
    updateComponent = () => {
      // 首先执行render => vdom
      vm._update(vm._render(), hydrating)
    }
  }
	// 通过切割一个组件对应一个watcher,将更新的范围极大缩小
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
}

要清楚:组件、Watcher、渲染函数和更新函数之间的关系

组件化实现

对应源码位置:

构造函数:src\core\global-api\extend.js 
实例化及挂载,src\core\vdom\patch.js - createElm()

要清楚组件创建的过程:new Vue()初始化根实例之后,从上至下创建、从下至上挂载

总结

  1. 组件是独立和可复用的代码组织单元。组件系统是 Vue 核心特性之一,它使开发者使用小型、独 立和通常可复用的组件构建大型应用;
  2. 组件化开发能大幅提高应用开发效率、测试性、复用性等;
  3. 组件使用按分类有:页面组件、业务组件、通用组件;
  4. vue的组件是基于配置的,我们通常编写的组件是组件配置而非组件,框架后续会生成其构造函 数,它们基于VueComponent,扩展于Vue;
  5. vue中常见组件化技术有:属性prop,自定义事件,插槽等,它们主要用于组件通信、扩展等;
  6. 合理的划分组件,有助于提升应用性能;
  7. 组件应该是高内聚、低耦合的;
  8. 遵循单向数据流的原则。

谈一谈对vue设计原则的理解?

在vue的官网上写着大大的定义和特点:渐进式JavaScript框架 易用、灵活和高效;所以阐述此题的整体思路按照这个展开即可。

渐进式JavaScript框架:

与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易 于上手,还便于与第三方库或既有项目整合。另一方面,当与现代化的工具链以及各种支持类库结合使 用时,Vue 也完全能够为复杂的单页应用提供驱动。

易用性

vue提供数据响应式、声明式模板语法和基于配置的组件系统等核心特性。这些使我们只需要关注应用 的核心业务即可,只要会写js、html和css就能轻松编写vue应用。

灵活性

渐进式框架的最大优点就是灵活性,如果应用足够小,我们可能仅需要vue核心特性即可完成功能;随 着应用规模不断扩大,我们才可能逐渐引入路由、状态管理、vue-cli等库和工具,不管是应用体积还是 学习难度都是一个逐渐增加的平和曲线。

高效性

超快的虚拟 DOM 和 diff 算法使我们的应用拥有最佳的性能表现。 追求高效的过程还在继续,vue3中引入Proxy对数据响应式改进以及编译器中对于静态内容编译的改进 ,以及3.2版本让人难以置信的响应式的性能优化。

了解哪些Vue性能优化方法

这里主要探讨Vue代码层面的优化

  • 路由懒加载

  • keep-alive缓存页面

  • 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

  • 长列表性能优化

    1. 如果列表是纯粹的数据展示,不会有任何改变,就不需要做响应化
    export default { 
      data: () => ({ 
        users: [] 
      }), 
      async created() { 
        const users = await axios.get("/api/users");     
        this.users = Object.freeze(users); 
      } 
    }; 
    
    • 如果是大数据长列表,可采用虚拟滚动,只渲染少部分区域的内容
    • 事件销毁:Vue 组件销毁时,会自动解绑它的全部指令及事件监听器,但是仅限于组件本身的事件,用户自己写的事件及及定时器等,需要自行销毁
    • 子组件分割
<template> 
  <div> 
    <ChildComp/>   </div> 
</template> 

<script> 
export default { 
  components: { 
    ChildComp: { 
      methods: { 
        heavy () { /* 耗时任务 */ } 
      }, 
      render (h) { 
        return h('div', this.heavy())       } 
    } 
  } 
} 
</script> 

Vue实例原型链上的方法

这里仅介绍文中用到的在Vue构造函数原型对象挂载的方法初始化的流程

installRenderHelpers生成的一些方法

流程lifecycle.js

  • src\core\instance\index.js中调用 renderMixin(Vue)
// ...
import { renderMixin } from './render';
// ...

// 构造函数声明
function Vue(options) {
  if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword');
  }
  // 初始化
  this._init(options);
}

// 扩展原型Vue原型对象,初始化提供给实例的方法和属性
// ...
renderMixin(Vue);

  • installRenderHelpers方法中调用installRenderHelpers(Vue.prototype)

export function renderMixin (Vue: Class) { // install runtime convenience helpers installRenderHelpers(Vue.prototype)

Vue.prototype.$nextTick = function (fn: Function) { return nextTick(fn, this) } Vue.prototype._render = function (): VNode { // ... } }


- installRenderHelpers(编译时的帮助方法)

```js
export function installRenderHelpers (target: any) {
target._o = markOnce // v-once
target._n = toNumber 
target._s = toString
target._l = renderList // v-for
target._t = renderSlot // 渲染slot
target._q = looseEqual // 是否相等(Object、Array)
target._i = looseIndexOf // 数组中查找对象
target._m = renderStatic 
target._f = resolveFilter
target._k = checkKeyCodes
target._b = bindObjectProps
target._v = createTextVNode
target._e = createEmptyVNode
target._u = resolveScopedSlots  
target._g = bindObjectListeners // v-on
target._d = bindDynamicKeys
target._p = prependModifier
}

写在最后

最后的最后说明一下并不是说一定是要为了面试去学习源码,而是通过学习源码来强化自己对Vue这门响应式更新框架的认知,并且博主工作了一年,公司里接触到的项目都是Vue开发的,对于每天用Vue这门框架的开发者而言,我个人认为学习源码真的是一件技术驱动的过程,当然这也需要你有一点兴趣,总之对个人的帮助是非常大的,通过学习源码的确是让我成长和认知提升不少