十五、Vue相关面试题讲解

郁子大约 32 分钟约 9679 字笔记渡一教育笔面试题甄选袁进

(一)组件通信总结

面试题:vue 组件之间有哪些通信方式?

1.父子组件通信

绝大部分 vue 本身提供的通信方式,都是父子组件通信

  • prop
  • event
  • style & class
  • attribute
  • native 修饰符
  • $listeners
  • v-model
  • sync 修饰符
  • $parent & $children
  • $slots & $scopedSlots
  • ref

2.prop

  • 最常见的组件通信方式之一
  • 由父组件传递到子组件

3.event

  • 最常见的组件通信方式之一
  • 当子组件发生了某些事,可以通过 event 通知父组件

4.style 和 class

  • 父组件可以向子组件传递 style 和 class
  • 会合并到 子组件的根元素

1)父组件

<template>
  <div id="app">
    <HelloWorld style="color:red" class="hello" msg="Welcome to Your Vue.js App" />
  </div>
</template>

<script>
  import HelloWorld from "./components/HelloWorld.vue";

  export default {
    components: {
      HelloWorld,
    },
  };
</script>

2)子组件

<template>
  <div class="world" style="text-align:center">
    <h1>{{ msg }}</h1>
  </div>
</template>

<script>
  export default {
    name: "HelloWorld",
    props: {
      msg: String,
    },
  };
</script>

3)渲染结果

<div id="app">
  <div class="hello world" style="color:red; text-align:center">
    <h1>Welcome to Your Vue.js App</h1>
  </div>
</div>

5.attribute

  • 如果父组件传递了一些属性到子组件,但子组件并没有声明这些属性
  • 则它们称为 attribute,这些属性会直接附着在 子组件的根元素

不包括 style 和 class,它们会被特殊处理

1)父组件

<template>
  <div id="app">
    <!-- 除 msg 外,其他均为 attribute -->
    <HelloWorld data-a="1" data-b="2" msg="Welcome to Your Vue.js App" />
  </div>
</template>

<script>
  import HelloWorld from "./components/HelloWorld.vue";

  export default {
    components: {
      HelloWorld,
    },
  };
</script>

2)子组件

<template>
  <div>
    <h1>{{ msg }}</h1>
  </div>
</template>

<script>
  export default {
    name: "HelloWorld",
    props: {
      msg: String,
    },
    created() {
      console.log(this.$attrs); // 得到: { "data-a": "1", "data-b": "2" }
    },
  };
</script>

3)渲染结果

<div id="app">
  <div data-a="1" data-b="2">
    <h1>Welcome to Your Vue.js App</h1>
  </div>
</div>
  • 子组件可以通过 inheritAttrs: false 配置
  • 禁止将 attribute 附着在子组件的根元素上,但不影响通过 $attrs 获取

6.native 修饰符

  • 在注册事件时,父组件可以使用 native 修饰符
  • 将事件注册到 子组件的根元素

1)父组件

<template>
  <div id="app">
    <HelloWorld @click.native="handleClick" />
  </div>
</template>

<script>
  import HelloWorld from "./components/HelloWorld.vue";

  export default {
    components: {
      HelloWorld,
    },
    methods: {
      handleClick() {
        console.log(1);
      },
    },
  };
</script>

2)子组件

<template>
  <div>
    <h1>Hello World</h1>
  </div>
</template>

3)渲染结果

<div id="app">
  <!-- 点击该 div,会输出 1 -->
  <div>
    <h1>Hello World</h1>
  </div>
</div>

7.$listeners

  • 子组件可以通过 $listeners 获取父组件传递过来的所有事件处理函数

8.v-model

  • 针对一个数据进行双向绑定

9.sync 修饰符

  • 和 v-model 的作用类似,用于双向绑定
  • 不同点在于 v-model 只能针对一个数据进行双向绑定,而 sync 修饰符没有限制

1)子组件

<template>
  <div>
    <p>
      <button @click="$emit(`update:num1`, num1 - 1)">-</button>
      {{ num1 }}
      <button @click="$emit(`update:num1`, num1 + 1)">+</button>
    </p>
    <p>
      <button @click="$emit(`update:num2`, num2 - 1)">-</button>
      {{ num2 }}
      <button @click="$emit(`update:num2`, num2 + 1)">+</button>
    </p>
  </div>
</template>

<script>
  export default {
    props: ["num1", "num2"],
  };
</script>

2)父组件

<template>
  <div id="app">
    <Numbers :num1.sync="n1" :num2.sync="n2" />
    <!-- 等同于 -->
    <Numbers :num1="n1" :num2="n2" @update:num1="n1 = $event" @update:num2="n2 = $event" />
  </div>
</template>

<script>
  import Numbers from "./components/Numbers.vue";

  export default {
    components: {
      Numbers,
    },
    data() {
      return {
        n1: 0,
        n2: 0,
      };
    },
  };
</script>

10.$parent 和 $children

  • 在组件内部,可以通过 $parent 和 $children 属性
  • 分别得到当前组件的父组件和子组件实例

11.$slots 和 $scopedSlots

12.ref

  • 父组件可以通过 ref 获取到子组件的实例

13.跨组件通信

  • Provide & Inject
  • router
  • vuex
  • store 模式
  • eventbus

14.Provide 和 Inject

// 父级组件提供 'foo'
var Provider = {
  provide: {
    foo: "bar",
  },
  // ...
};

// 组件注入 'foo'
var Child = {
  inject: ["foo"],
  created() {
    console.log(this.foo); // => "bar"
  },
  // ...
};

详见:https://cn.vuejs.org/v2/api/?#provide-injectopen in new window

15.router

  • 如果一个组件改变了地址栏,所有监听地址栏的组件都会做出相应反应
  • 最常见的场景就是通过点击 router-link 组件改变了地址,router-view 组件就渲染其他内容

16.vuex

  • 适用于大型项目的数据仓库

17.store 模式

  • 适用于中小型项目的数据仓库
// store.js
const store = {
  loginUser: ...,
  setting: ...
}

// compA
const compA = {
  data(){
    return {
      loginUser: store.loginUser
    }
  }
}

// compB
const compB = {
  data(){
    return {
      setting: store.setting,
      loginUser: store.loginUser
    }
  }
}

18.eventBus

  • 组件通知事件总线发生了某件事
  • 事件总线通知其他监听该事件的所有组件运行某个函数

(二)虚拟 DOM 详解

面试题:请你阐述一下对 vue 虚拟 dom 的理解

1.什么是虚拟 dom?

  • 虚拟 dom 本质上就是一个普通的 JS 对象,用于描述视图的界面结构
  • 在 vue 中,每个组件都有一个 render 函数,每个 render 函数都会返回一个虚拟 dom 树
    • 即:每个组件都对应一棵虚拟 DOM 树

2.为什么需要虚拟 dom?

  • 在 vue 中,渲染视图会调用 render 函数
  • 这种渲染不仅发生在 组件创建 时,同时发生在 视图依赖的数据更新
  • 如果在渲染时,直接使用真实 DOM,由于真实 DOM 的创建、更新、插入等操作会带来大量的性能损耗,就会极大地降低渲染效率
  • 因此, vue 在渲染时,使用虚拟 dom 来替代真实 dom,主要是为了解决渲染效率的问题

3.虚拟 dom 是如何转换为真实 dom 的?

  • 在一个组件实例首次被渲染时,先生成虚拟 dom 树
    • 然后根据虚拟 dom 树创建真实 dom,并把真实 dom 挂载到页面中合适的位置
    • 此时,每个虚拟 dom 便会对应一个真实的 dom
  • 如果一个组件受响应式数据变化的影响,需要重新渲染时,仍然会重新调用 render 函数
    • 创建出一个新的虚拟 dom 树,用新树和旧树对比
    • 通过对比,vue 会找到最小更新量,然后更新必要的虚拟 dom 节点
    • 最后,这些更新过的虚拟节点,会去修改它们对应的真实 dom
  • 这样一来,就保证了对真实 dom 达到最小的改动

4.模板和虚拟 dom 的关系

  • vue 框架中有一个 compile 模块,主要负责将模板转换为 render 函数,而 render 函数调用后将得到虚拟 dom
  • 编译的过程分两步
    • 将模板字符串转换成为抽象语法树 AST
    • AST 转换为 render 函数
  • 如果使用传统的引入方式,则编译时间发生在 组件第一次加载 时【运行时编译】
  • 如果是在 vue-cli 的默认配置下,编译发生在 打包 时【模板预编译】
  • 编译是一个极其耗费性能的操作,预编译可以有效的提高运行时的性能
  • 由于运行的时候已不需要编译,vue-cli 在打包时会排除掉 vue 中的 compile 模块,以减少打包体积
  • 模板的存在,仅仅是为了让开发人员更加方便的书写界面代码

警告

vue 最终运行的时候,最终需要的是 render 函数而不是模板,因此,模板中的各种语法在虚拟 dom 中都是不存在的,都会变成虚拟 dom 的配置

(三)v-model

面试题:请阐述一下 v-model 的原理

1.作用范围

  • 可以作用于表单元素,又可作用于自定义组件
  • 无论是哪一种情况都是一个语法糖,最终会生成一个属性和一个事件

2.作用于表单元素时

  • vue 会根据作用的表单元素类型而生成合适的属性和事件
  • 作用于普通文本框时,会生成 value 属性和 input 事件
  • 作用于单选框或多选框时,会生成 checked 属性和 change 事件

3.作用于自定义组件时

  • 默认情况下,会生成一个 value 属性和 input 事件
<Comp v-model="data" />

<!-- 等效于 -->
<Comp :value="data" @input="data = $event" />

4.改变属性和事件

  • 可以通过组件的 model 配置来改变生成的属性和事件
// Comp
const Comp = {
  model: {
    prop: "number", // 默认为 value
    event: "change", // 默认为 input
  },
  // ...
};
<Comp v-model="data" />

<!-- 等效于 -->
<Comp :number="data" @change="data = $event" />

(四)数据响应原理

面试题:请阐述 vue2 响应式原理

vue 官方阐述:https://cn.vuejs.org/v2/guide/reactivity.htmlopen in new window

  • 响应式数据的最终目标
    • 当对象本身或对象属性发生变化时,将会运行一些函数
    • 最常见的就是 render 函数
  • 在具体实现上,vue 用到了几个核心部件
    • Observer
    • Dep
    • Watcher
    • Scheduler

1.Observer

  • Observer 要 把一个普通的对象转换为响应式的对象
  • 为了实现这一点,Observer 把对象的每个属性通过 Object.defineProperty 转换为带有 gettersetter 的属性
  • 这样一来,当访问或设置属性时, vue 就有机会做一些别的事情

  • Observer 是 vue 内部的构造器,可以通过 Vue 提供的静态方法间接的使用该功能
Vue.observable(object);
  • 在组件生命周期中,这件事发生在 beforeCreate 之后,created 之前
  • 会递归遍历对象的所有属性,以完成深度的属性转换
  • 由于遍历时只能遍历到对象的当前属性,因此无法监测到将来动态增加或删除的属性
  • 因此 vue 提供了 $set$delete 两个实例方法,让开发者通过这两个实例方法对已有响应式对象添加或删除属性
  • 对于数组,vue 会更改它的 隐式原型
    • 因为 vue 需要监听那些可能改变数组内容的方法

  • 总之,Observer 的目标,就是要让一个对象,它属性的读取、赋值,内部数组的变化都要能够被 vue 感知到

2.Dep

  • Dep 需要解决两个问题
    • 读取属性时要做什么事
    • 属性变化时要做什么事
  • Dep 的含义是 Dependency,表示依赖
  • Vue 会为响应式对象中的每个属性、对象本身、数组本身创建一个 Dep 实例
  • 每个 Dep 实例都有能力做以下两件事
    • 记录依赖:是谁在用我
    • 派发更新:我变了,我要通知那些用到我的人
  • 当读取响应式对象的某个属性时,会进行依赖收集:有人用到了我
  • 当改变某个属性时,会派发更新:那些用我的人听好了,我变了

3.Watcher

  • Watcher 要解决一个问题,就是 Dep 如何知道是谁在用我
  • 当某个函数执行的过程中,用到了响应式数据,响应式数据是无法知道是哪个函数在用自己的
    • 因此,vue 通过一种巧妙的办法来解决这个问题
    • 不要直接执行函数,而是把函数交给一个叫做 watcher 的东西去执行
    • watcher 是一个对象,每个这样的函数执行时都应该创建一个 watcher,通过 watcher 去执行
  • watcher 会设置一个全局变量,让全局变量记录当前负责执行的 watcher 等于自己,然后再去执行函数
  • 在函数的执行过程中,如果发生了依赖记录 dep.depend(),那么 Dep 就会把这个全局变量记录下来,表示:有一个 watcher 用到了我这个属性
  • 当 Dep 进行派发更新时,会通知之前记录的所有 watcher:我变了

  • 每一个 vue 组件实例,都至少对应一个 watcher,该 watcher 中记录了该组件的 render 函数
  • watcher 首先会把 render 函数运行一次以收集依赖
    • 于是那些在 render 中用到的响应式数据就会记录这个 watcher
  • 当数据变化时,dep 就会通知该 watcher,而 watcher 将重新运行 render 函数
    • 从而让界面重新渲染同时重新记录当前的依赖

4.Scheduler

  • 现在还剩下最后一个问题,就是 Dep 通知 watcher 之后,如果 watcher 执行重运行对应的函数,就有可能导致函数频繁运行,从而导致效率低下

试想,如果一个交给 watcher 的函数,它里面用到了属性 a、b、c、d,那么 a、b、c、d 属性都会记录依赖,于是下面的代码将触发 4 次更新

state.a = "new data";
state.b = "new data";
state.c = "new data";
state.d = "new data";

这样显然是不合适的

  • 因此,watcher 收到派发更新的通知后,实际上不是立即执行对应函数,而是把自己交给一个叫调度器的东西
  • 调度器维护一个执行队列,该队列同一个 watcher 仅会存在一次
  • 队列中的 watcher 不是立即执行,会通过一个叫做 nextTick 的工具方法
    • 把这些需要执行的 watcher 放入到事件循环的微队列中
    • nextTick 的具体做法是通过 Promise 完成的

nextTick 通过 this.$nextTick 暴露给开发者

nextTick 的具体处理方式见:https://cn.vuejs.org/v2/guide/reactivity.html#%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0%E9%98%9F%E5%88%97open in new window

  • 也就是说,当响应式数据变化时, render 函数的执行是 异步 的,并且在 微队列

5.总体流程

(五)Diff 算法

面试题:请阐述 vue 的 diff 算法

  1. 当组件创建和更新时,vue 均会执行内部的 update 函数,该函数使用 render 函数生成的虚拟 dom 树,将新旧两树进行对比,找到差异点,最终更新到真实 dom
  2. 对比差异的过程叫 diff,vue 在内部通过一个叫 patch 的函数完成该过程
  3. 在对比时,vue 采用深度优先、同层比较的方式进行比对
  4. 在判断两个节点是否相同时,vue 是通过虚拟节点的 key 和 tag 来进行判断的
  • 首先对根节点进行对比,如果相同则将旧节点关联的真实 dom 的引用挂到新节点上,然后根据需要更新属性到真实 dom,然后再对比其子节点数组
  • 如果不相同,则按照新节点的信息递归创建所有真实 dom,同时挂到对应虚拟节点上,然后移除掉旧的 dom
  1. 在对比其子节点数组时,vue 对每个子节点数组使用了两个指针,分别指向头尾,然后不断向中间靠拢来进行对比,这样做的目的是尽量复用真实 dom,尽量少的销毁和创建真实 dom
  2. 如果发现相同,则进入和根节点一样的对比流程,如果发现不同,则移动真实 dom 到合适的位置
  3. 这样一直递归的遍历下去,直到整棵树完成对比

1.Diff 的时机

  • 当组件创建时,以及依赖的属性或数据变化时,会运行一个函数,该函数会做两件事
    • 运行 _render 生成一棵新的虚拟 dom 树(vnode tree)
    • 运行 _update,传入虚拟 dom 树的根节点,对新旧两棵树进行对比,最终完成对真实 dom 的更新
// vue构造函数
function Vue() {
  // ... 其他代码
  var updateComponent = () => {
    this._update(this._render());
  };
  new Watcher(updateComponent);
  // ... 其他代码
}
  • diff 就发生在 _update 函数的运行过程中

2._update 函数在干什么

  • _update 函数接收到一个 vnode 参数,这就是 新生成 的虚拟 dom 树
  • _update 函数通过当前组件的 _vnode 属性,拿到 的虚拟 dom 树

1)_update 函数首先会给组件的 _vnode 属性重新赋值,让它指向新树

2)然后会判断旧树是否存在

  • 不存在:说明这是第一次加载组件,于是通过内部的 patch 函数直接遍历新树,为每个节点生成真实 DOM,挂载到每个节点的 elm 属性上

  • 存在:说明之前已经渲染过该组件,于是通过内部的 patch 函数对新旧两棵树进行对比,以达到下面两个目标:
    • 完成对所有真实 dom 的最小化处理
    • 让新树的节点对应合适的真实 dom

3.patch 函数的对比流程

术语解释
相同指两个虚拟节点的标签类型、key 值均相同,但 input 元素还要看 type 属性
新建元素指根据一个虚拟节点提供的信息,创建一个真实 dom 元素,同时挂载到虚拟节点的 elm 属性上
销毁元素vnode.elm.remove()
更新指对两个虚拟节点进行对比更新,仅发生 在两个虚拟节点相同的情况下
对比子节点指对两个虚拟节点的子节点进行对比

1)根节点比较

  • patch函数首先对根节点进行比较
  • 如果两个节点 相同 ,进入 更新 流程
    • 将旧节点的真实 dom 赋值到新节点
      • newVnode.elm = oldVnode.elm
    • 对比新节点和旧节点的属性,有变化的更新到真实 dom 中
    • 当前两个节点处理完毕,开始 对比子节点
  • 如果两个节点 不相同
    • 新节点递归 新建元素
    • 旧节点 销毁元素

2)对比子节点

  • vue 一切的出发点,都是为了
    • 尽量啥也别做
    • 不行的话,尽量仅改动元素属性
    • 还不行的话,尽量移动元素,而不是删除和创建元素
    • 还不行的话,删除和创建元素

(六)生命周期详解

面试题:new Vue 之后,发生了什么?数据改变后,又发生了什么?

1.创建 vue 实例和创建组件的流程基本一致

1)首先做一些初始化的操作,主要是设置一些私有属性到实例中

2) 运行生命周期钩子函数 beforeCreate

3)进入注入流程

  • 处理属性、computed、methods、data、provide、inject,最后使用代理模式将它们挂载到实例中

4)运行生命周期钩子函数 created

5)生成 render 函数

  • 如果有配置,直接使用配置的 render
  • 如果没有,使用运行时编译器,把模板编译为 render

6)运行生命周期钩子函数 beforeMount

7)创建一个 Watcher

  • 传入一个函数 updateComponent,该函数会运行 render,把得到的 vnode 再传入 _update 函数执行
  • 在执行 render 函数的过程中,会收集所有依赖,将来依赖变化时会重新运行 updateComponent 函数
  • 在执行 _update 函数的过程中,触发 patch 函数,由于目前没有旧树,因此直接为当前的虚拟 dom 树的每一个普通节点生成 elm 属性,即真实 dom
  • 如果遇到创建一个组件的 vnode,则会进入组件实例化流程,该流程和创建 vue 实例流程基本相同,最终会把创建好的组件实例挂载 vnode 的 componentInstance 属性中,以便复用

8)运行生命周期钩子函数 mounted

2.重渲染

1)数据变化后,所有依赖该数据的 Watcher 均会重新运行

  • 这里仅考虑 updateComponent 函数对应的 Watcher

2)Watcher 会被调度器放到 nextTick 中运行

  • 也就是微队列中
  • 为了避免多个依赖的数据同时改变后被多次执行

3)运行生命周期钩子函数 beforeUpdate

4)updateComponent 函数重新执行

  • 在执行 render 函数的过程中,会去掉之前的依赖,重新收集所有依赖,将来依赖变化时会重新运行 updateComponent 函数
  • 在执行 _update 函数的过程中,触发 patch 函数
  • 新旧两棵树进行对比
    • 普通 html 节点的对比会导致真实节点被创建、删除、移动、更新
    • 组件节点的对比会导致组件被创建、删除、移动、更新
  • 当新组件需要创建时,进入实例化流程
  • 当旧组件需要删除时,会调用旧组件的 $destroy 方法删除组件
    • 该方法会先触发 生命周期钩子函数 beforeDestroy
    • 然后递归调用 子组件的 $destroy 方法
    • 然后触发 生命周期钩子函数 destroyed
  • 当组件属性更新时,相当于组件的 updateComponent 函数被重新触发执行,进入重渲染流程

5)运行生命周期钩子函数 updated

(七)你不知道的 computed

面试题:computed 和 methods 有什么区别

1.标准而浅显的回答

  • 在使用时,computed 当做属性使用,而 methods 则当做方法调用
  • computed 可以具有 getter 和 setter,因此可以赋值,而 methods 不行
  • computed 无法接收多个参数,而 methods 可以
  • computed 具有缓存,而 methods 没有

2.更接近底层原理的回答

  • vue 对 methods 的处理比较简单,只需要遍历 methods 配置中的每个属性,将其对应的函数使用 bind 绑定当前组件实例后复制其引用到组件实例中即可
  • 而 vue 对 computed 的处理会稍微复杂一些
  • 当组件实例触发生命周期函数 beforeCreate 后,会做一系列事情,其中就包括对 computed 的处理
    • 会遍历 computed 配置中的所有属性,为每一个属性创建一个 Watcher 对象,并传入一个函数,该函数的本质其实就是 computed 配置中的 getter,这样一来,getter 运行过程中就会收集依赖
    • 但是和渲染函数不同,为计算属性创建的 Watcher 不会立即执行,因为要考虑到该计算属性是否会被渲染函数使用,如果没有使用,就不会得到执行。因此,在创建 Watcher 的时候,使用了 lazy 配置,lazy 配置可以让 Watcher 不会立即执行
  • 受到 lazy 的影响,Watcher 内部会保存两个关键属性来实现缓存,一个是 value,一个是 dirty
    • value 属性用于保存 Watcher 运行的结果,受 lazy 的影响,该值在最开始是 undefined
    • dirty 属性用于指示当前的 value 是否已经过时了,即是否为脏值,受 lazy 的影响,该值在最开始是 true
  • Watcher 创建好后,vue 会使用代理模式,将计算属性挂载到组件实例中
  • 当读取计算属性时,vue 检查其对应的 Watcher 是否是脏值,如果是,则运行函数,计算依赖,并得到对应的值,保存在 Watcher 的 value 中,然后设置 dirty 为 false,然后返回
  • 如果 dirty 为 false,则直接返回 watcher 的 value
  • 巧妙的是,在依赖收集时,被依赖的数据不仅会收集到计算属性的 Watcher,还会收集到组件的 Watcher
  • 当计算属性的依赖变化时,会先触发计算属性的 Watcher 执行,此时,它只需设置 dirty 为 true 即可,不做任何处理
  • 由于依赖同时会收集到组件的 Watcher,因此组件会重新渲染,而重新渲染时又读取到了计算属性,由于计算属性目前已为 dirty,因此会重新运行 getter 进行运算
  • 而对于计算属性的 setter,则极其简单,当设置计算属性时,直接运行 setter 即可

(八)Filter 过滤器

参见 官网文档open in new window

1.App.vue

<template>
  <div id="app">
    <input v-model.number="money" type="number" />
    <p>
      {{ money | formatMoney(" ") }}
    </p>
  </div>
</template>

<script>
  import { formatMoney } from "./filter/moneyFilter";
  export default {
    data() {
      return {
        money: 1000,
      };
    },
    filters: {
      formatMoney,
    },
  };
</script>

<style>
  #app {
    font-family: Avenir, Helvetica, Arial, sans-serif;
    text-align: center;
  }
</style>

2.filter/moneyFilter.js

/**
 * 过滤器格式化金额
 * @param {Number} money
 * @param {String} split
 */
export function formatMoney(money, split = ",", fixedDigit = 2) {
  const str = money.toFixed(fixedDigit).toString();
  const parts = str.split(".");
  let part1 = parts[0]; // 左边整数
  const part2 = parts[1]; // 右边小数
  part1 = part1.replace(/(?=(\d{3})+$)/g, split);
  return `${part1}.${part2}`;
}

(九)作用域插槽

参见 官网文档open in new window

1.属性

1)$slots

  • 用于访问父组件传递的普通插槽中的 vnode

2)$scopedSlots

  • 用于访问父组件传递的所有用于生成 vnode 的函数
  • 包括默认插槽在内

2.App.vue

<template>
  <div id="app">
    <async-content :contentPromise="fetchProducts()">
      <template #loading>加载中...</template>
      <template v-slot:default="{ content }">
        <ul>
          <li v-for="item in content" :key="item.id">商品名:{{ item.name }} 库存:{{ item.stock }}</li>
        </ul>
      </template>
      <template v-slot:error="{ error }">
        <p style="color:red">{{ error.message }}</p>
      </template>
    </async-content>
  </div>
</template>

<script>
  import AsyncContent from "./components/AsyncContent.vue";
  // ajax模拟函数
  function getProducts() {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (Math.random() < 0.5) {
          resolve([
            { id: 1, name: "xiaomi", stock: 50 },
            { id: 2, name: "iphone", stock: 70 },
            { id: 3, name: "huawei", stock: 60 },
          ]);
        } else {
          reject(new Error("not found"));
        }
      }, 1000);
    });
  }

  export default {
    components: { AsyncContent },
    methods: {
      async fetchProducts() {
        return await getProducts();
      },
    },
  };
</script>

2.components/AsyncContent.vue

<template>
  <div>
    <slot v-if="isLoading" name="loading">默认加载中的效果...</slot>
    <slot v-else-if="error" :error="error" name="error">{{ error }}</slot>
    <!-- 通过 v-bind 将子组件的插槽数据绑定到了父组件插槽的位置 -->
    <slot v-else :content="content" name="default">{{ content }}</slot>
  </div>
</template>

<script>
  export default {
    props: {
      contentPromise: Promise,
    },
    data() {
      return {
        isLoading: true,
        content: null,
        error: null,
      };
    },
    async created() {
      try {
        this.content = await this.contentPromise;
        this.error = null;
      } catch (err) {
        this.content = null;
        this.error = err;
      } finally {
        this.isLoading = false;
      }
    },
    mounted() {
      console.log("$slots", this.$slots);
      console.log("$scopedSlots", this.$scopedSlots);
    },
  };
</script>

(十)过渡和动画

内置组件 Transition 官网详细文档:https://cn.vuejs.org/v2/guide/transitions.htmlopen in new window

1.时机

  • Transition 组件会监控 slot 中 唯一根元素 的出现和消失
  • 并会在其出现和消失时应用过渡效果

1)具体的监听内容

  • 对新旧两个虚拟节点进行对比
    • 如果旧节点被销毁,则应用消失效果
    • 如果新节点是新增的,则应用进入效果
  • 如果不是上述情况,则会对比新旧节点,观察其 v-show 是否变化
    • true -> false 应用消失效果
    • false -> true 应用进入效果

2.流程

1)类名规则

  • 如果 transition 上没有定义 name,则类名为 v-xxxx
  • 如果 transition 上定义了 name,则类名为 ${name}-xxxx
  • 如果指定了类名,直接使用指定的类名

指定类名见:自定义过渡类名open in new window

2)进入效果

3)消失效果

3.过渡组

  • Transition 可以监控其内部的 单个 dom 元素 的出现和消失,并为其附加样式
  • 如果要监控一个 dom 列表,就需要使用 TransitionGroup 组件
    • 对列表的新增元素应用进入效果
    • 对列表的删除元素应用消失效果
    • 对列表被移动的元素应用 v-move 样式

被移动的元素之所以能够实现过渡效果,是因为 TransitionGroup 内部使用了 Flip 过渡方案

(十一)优化

1.使用 key

  • 对于通过循环生成的列表,应给每个列表项一个稳定且唯一的 key
  • 有利于在列表变动时,尽量少的删除、新增、改动元素

2.使用冻结的对象

  • 冻结的对象不会被响应化
<template>
  <div id="app">
    <button @click="loadNormalDatas">load normal datas</button>
    <button @click="loadFrozenDatas">load frozen datas</button>
    <h1>normal datas count: {{ normalDatas.length }}</h1>
    <h1>freeze datas count: {{ freezeDatas.length }}</h1>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        normalDatas: [],
        freezeDatas: [],
      };
    },
    methods: {
      loadNormalDatas() {
        this.normalDatas = this.getDatas();
        console.log("normalDatas", this.normalDatas);
      },
      loadFrozenDatas() {
        this.freezeDatas = Object.freeze(this.getDatas());
        console.log("freezeDatas", this.freezeDatas);
      },
      getDatas() {
        const result = [];
        for (var i = 0; i < 1000000; i++) {
          result.push({
            id: i,
            name: `name${i}`,
            address: {
              city: `city${i}`,
              province: `province${i}`,
            },
          });
        }
        return result;
      },
    },
  };
</script>

<style>
  #app {
    text-align: center;
  }
</style>

3.使用函数式组件

1)components/FunctionalComp.vue

<template functional>
  <h1>NormalComp: {{ props.count }}</h1>
</template>

<script>
  export default {
    functional: true,
    props: {
      count: Number,
    },
  };
</script>

2)App.vue

<template>
  <div id="app">
    <button @click="normalCount = 10000">生成10000个普通组件</button>
    <button @click="functionalCount = 10000">生成10000个函数组件</button>

    <div class="container">
      <div class="item">
        <NormalComp v-for="n in normalCount" :key="n" :count="n"></NormalComp>
      </div>
      <div class="item">
        <FunctionalComp v-for="n in functionalCount" :key="n" :count="n"></FunctionalComp>
      </div>
    </div>
  </div>
</template>

<script>
  import NormalComp from "./components/NormalComp";
  import FunctionalComp from "./components/FunctionalComp";
  export default {
    components: {
      NormalComp,
      FunctionalComp,
    },
    data() {
      return {
        functionalCount: 0,
        normalCount: 0,
      };
    },
    mounted() {
      window.vm = this;
    },
  };
</script>

<style>
  #app {
    text-align: center;
  }
  .container {
    width: 90%;
    display: flex;
    margin: 0 auto;
  }
  .item {
    padding: 30px;
    border: 1px solid #ccc;
    margin: 1em;
    flex: 1 1 auto;
  }
</style>

4.使用计算属性

  • 如果模板中某个数据会使用多次,并且该数据是通过计算得到的
  • 应该使用计算属性以缓存它们

5.非实时绑定的表单项

  • 当使用 v-model 绑定一个表单项时,当用户 改变表单项的状态 时,数据也会随之改变
    • 从而导致 vue 发生 重渲染(rerender),这会带来一些性能的开销
  • 特别是当用户改变表单项时,页面有一些动画正在进行中,由于 JS 执行线程和浏览器渲染线程是互斥的,最终会导致动画出现卡顿
  • 可以通过使用 lazy 或不使用 v-model 的方式解决该问题
    • 这样可能会导致在某一个时间段内数据和表单项的值是不一致的

6.保持对象引用稳定

  • 在绝大部分情况下,vue 触发 rerender 的时机是其 依赖的数据发生变化
  • 若数据没有发生变化,哪怕给数据重新赋值了,vue 也不会做出任何处理
  • vue 判断数据 没有变化 的源码
// value 为旧值, newVal 为新值
if (newVal === value || (newVal !== newVal && value !== value)) {
  return;
}
  • 因此只要能保证组件的依赖数据不发生变化,组件就不会重新渲染
    • 对于原始数据类型,保持其值不变 即可
    • 对于对象类型,保持其引用不变 即可
  • 由于可以通过保持属性引用稳定来避免子组件的重渲染,应该 细分组件 来尽量避免多余的渲染
<template>
  <div id="app">
    <div class="container">
      <button @click="handleAdd1">添加:点击后重新获取数据</button>
      <user-comment v-for="item in comments1" :key="item.id" :comment="item"></user-comment>
    </div>
    <div class="container">
      <button @click="handleAdd2">添加:点击后向当前数据插入数据</button>
      <user-comment v-for="item in comments2" :key="item.id" :comment="item"></user-comment>
    </div>
  </div>
</template>

<script>
  import axios from "axios";
  import UserComment from "./components/UserComment.vue";

  // api
  async function getComments() {
    return await axios.get("/api/comment").then((resp) => resp.data);
  }

  async function addComment() {
    return await axios.post("/api/comment").then((resp) => resp.data);
  }

  export default {
    components: { UserComment },
    data() {
      return {
        comments1: [],
        comments2: [],
      };
    },
    async created() {
      this.comments1 = await getComments();
      this.comments2 = await getComments();
    },
    methods: {
      async handleAdd1() {
        await addComment();
        this.comments1 = await getComments();
      },
      async handleAdd2() {
        const cmt = await addComment();
        this.comments2.unshift(cmt);
      },
    },
  };
</script>

<style>
  #app {
    width: 800px;
    margin: 0 auto;
    display: flex;
  }
  .container {
    margin: 1em;
    flex: 1 0 auto;
    padding: 10px;
    border: 1px solid #ccc;
  }
</style>

7.使用 v-show 替代 v-if

  • 对于 频繁切换显示状态 的元素,使用 v-show 可以保证虚拟 dom 树的稳定
  • 避免频繁的新增和删除元素,特别是对于那些 内部包含大量 dom 元素 的节点

8.使用延迟装载(defer)

  • 首页白屏时间主要受到两个因素的影响

1)打包体积过大

  • 巨型包需要消耗大量的传输时间
  • 导致 JS 传输完成前页面只有一个 div ,没有可显示的内容
  • 需要 自行优化打包体积

2)需要立即渲染的内容太多

  • JS 传输完成后,浏览器开始执行 JS 构造页面
  • 但可能一开始要渲染的组件太多,不仅 JS 执行的时间很长,而且执行完后浏览器要渲染的元素过多,从而导致页面白屏
  • 延迟装载组件,让组件按照指定的先后顺序依次一个一个渲染出来

延迟装载是一个思路,本质上就是利用 requestAnimationFrame 事件分批渲染内容,具体实现多种多样

3)延迟装载组件

  • App.vue
<template>
  <div class="container">
    <div v-for="n in 21" v-if="defer(n)" class="block">
      <heavy-comp></heavy-comp>
    </div>
  </div>
</template>

<script>
  import HeavyComp from "./components/HeavyComp.vue";
  import defer from "./mixin/defer";
  export default {
    mixins: [defer(21)],
    components: { HeavyComp },
  };
</script>

<style scoped>
  .container {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-gap: 1em;
  }
  .block {
    border: 3px solid #f40;
  }
</style>
  • mixin/defer.js
export default function (maxFrameCount) {
  return {
    data() {
      return {
        frameCount: 0,
      };
    },
    mounted() {
      const refreshFrameCount = () => {
        requestAnimationFrame(() => {
          this.frameCount++;
          if (this.frameCount < maxFrameCount) {
            refreshFrameCount();
          }
        });
      };
      refreshFrameCount();
    },
    methods: {
      defer(showInFrameCount) {
        return this.frameCount >= showInFrameCount;
      },
    },
  };
}

9.使用 keep-alive

10.长列表优化

(十二)keep-alive

面试题:请阐述 keep-alive 组件的作用和原理

  • keep-alive 组件是 vue 的内置组件,用于 缓存内部组件实例
  • keep-alive 内部的组件切回时,不用重新创建组件实例
  • 直接使用缓存中的实例

1.作用

  • 能够避免创建组件带来的开销
  • 还可以保留组件的状态

2.属性

1)include 和 exclude 属性

  • 可以控制哪些组件进入缓存

2)max 属性

  • 可以设置最大缓存数
  • 当缓存的实例超过该数时,vue 会移除最久没有使用的组件缓存

3.生命周期函数

  • 内部所有嵌套的组件都具有两个生命周期钩子函数

1)activated

  • 在组件 激活 时触发

2)deactivated

  • 在组件 失活 时触发

4.原理

  • 第一次 activated 触发是在 mounted 之后
// keep-alive 内部的生命周期函数
created () {
  this.cache = Object.create(null)
  this.keys = []
}
  • keep-alive 在内部维护了一个 key 数组和一个缓存对象
  • key 数组记录目前缓存的组件 key 值,如果组件没有指定 key 值,则会为其自动生成一个唯一的 key 值
  • cache 对象以 key 值为键,vnode 为值,用于 缓存组件对应的虚拟 DOM
  • 在 keep-alive 的渲染函数中,其基本逻辑是
    • 判断当前渲染的 vnode 是否有对应的缓存
    • 如果有,从缓存中读取到对应的组件实例
    • 如果没有则将其缓存
  • 当缓存数量超过 max 数值时,keep-alive 会移除掉 key 数组的第一个元素
render(){
  const slot = this.$slots.default; // 获取默认插槽
  const vnode = getFirstComponentChild(slot); // 得到插槽中的第一个组件的vnode
  const name = getComponentName(vnode.componentOptions); //获取组件名字
  const { cache, keys } = this; // 获取当前的缓存对象和key数组
  const key = ...; // 获取组件的key值,若没有,会按照规则自动生成
  if (cache[key]) {
    // 有缓存
    // 重用组件实例
    vnode.componentInstance = cache[key].componentInstance
    remove(keys, key); // 删除key
    // 将key加入到数组末尾,这样是为了保证最近使用的组件在数组中靠后,反之靠前
    keys.push(key);
  } else {
    // 无缓存,进行缓存
    cache[key] = vnode
    keys.push(key)
    if (this.max && keys.length > parseInt(this.max)) {
      // 超过最大缓存数量,移除第一个key对应的缓存
      pruneCacheEntry(cache, keys[0], keys, this._vnode)
    }
  }
  return vnode;
}

(十三)长列表优化

vue-virtual-scrolleropen in new window

1.App.vue

<template>
  <div id="app">
    <RecycleScroller v-slot="{ item }" :items="items" :itemSize="54" class="scroller">
      <ListItem :item="item" />
    </RecycleScroller>
  </div>
</template>

<script>
  import ListItem from "./components/ListItem.vue";
  // import RecycleScroller from './components/RecycleScroller';
  import { RecycleScroller } from "vue-virtual-scroller";
  import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
  var items = [];
  for (var i = 0; i < 10000; i++) {
    items.push({
      id: i + 1,
      count: i + 1,
    });
  }
  export default {
    components: { ListItem, RecycleScroller },
    data() {
      return {
        items,
      };
    },
  };
</script>

<style>
  #app {
    width: 100%;
    margin: 0 auto;
  }
  .scroller {
    width: 500px;
    margin: 0 auto;
    height: 500px;
  }
</style>

2.RecycleScroller.vue 实现原理

<template>
  <div @scroll="setPool" class="recycle-scroller-container" ref="container">
    <div :style="{ height: `${totalSize}px` }" class="recycle-scroller-wrapper">
      <div
        v-for="poolItem in pool"
        :key="poolItem.item[keyField]"
        :style="{
          transform: `translateY(${poolItem.position}px)`,
        }"
        class="recycle-scroller-item"
      >
        <slot :item="poolItem.item"></slot>
      </div>
    </div>
  </div>
</template>

<script>
  var prev = 10,
    next = 10;
  export default {
    props: {
      // 数据的数组
      items: {
        type: Array,
        default: () => [],
      },
      // 每条数据的高度
      itemSize: {
        type: Number,
        default: 0,
      },
      keyField: {
        // 给我的items数组中,每个对象哪个属性代表唯一且稳定的编号
        type: String,
        default: "id",
      },
    },
    data() {
      return {
        // { item: 原始数据, position: 该数据对应的偏移位置 }
        pool: [], // 渲染池,保存当前需要渲染的数据
      };
    },
    mounted() {
      this.setPool();
      window.vm = this;
    },
    computed: {
      totalSize() {
        return this.items.length * this.itemSize; // 总高度
      },
    },
    methods: {
      setPool() {
        const scrollTop = this.$refs.container.scrollTop;
        const height = this.$refs.container.clientHeight;
        let startIndex = Math.floor(scrollTop / this.itemSize);
        let endIndex = Math.ceil((scrollTop + height) / this.itemSize);
        startIndex -= prev;
        if (startIndex < 0) {
          startIndex = 0;
        }
        endIndex += next;
        const startPos = startIndex * this.itemSize;
        this.pool = this.items.slice(startIndex, endIndex).map((it, i) => ({
          item: it,
          position: startPos + i * this.itemSize,
        }));
      },
    },
  };
</script>

<style>
  .recycle-scroller-container {
    overflow: auto;
  }
  .recycle-scroller-wrapper {
    position: relative;
  }
  .recycle-scroller-item {
    position: absolute;
    width: 100%;
    left: 0;
    top: 0;
  }
</style>

(十四)其他 API

  • main.js
import Vue from "vue";
import App from "./App.vue";

Vue.config.productionTip = false;
Vue.config.optionMergeStrategies.created = function (parent, child) {
  if (!parent) {
    return child;
  }
  if (!child) {
    return parent;
  }
  return [child];
};

new Vue({
  render: (h) => h(App),
}).$mount("#app");

(十五)模式和环境变量

详见 vue-cli 官网 模式和环境变量open in new window

1.读取环境变量

  • 首先读取当前机器的环境变量
  • 读取.env 文件

2.定义生产环境变量

  • .env.production
VUE_APP_SERVERBASE=http://www.duyiservice.com

3.定义开发环境变量

  • .env.development
VUE_APP_SERVERBASE=http://www.test.com

4.使用环境变量

  • vue-cli 在打包时,会将 process.env.XXX 进行替换

1)api/request.js

export default function (url, ...args) {
  return fetch(`${process.env.VUE_APP_SERVERBASE}${url}`, ...args);
}

2)api/news.js

import request from "./request";

export async function getNews() {
  /* 
    开发环境:http://www.test.com
    生产环境:http://www.duyiservice.com
  */
  // let baseUrl;
  // if (process.env.NODE_ENV === 'development') {
  //   baseUrl = 'http://www.test.com';
  // } else {
  //   baseUrl = 'http://www.duyiservice.com';
  // }
  // await fetch(baseUrl + '/api/news');
  // console.log('正在请求', process.env.VUE_APP_SERVERBASE);
  // console.log('VUE_APP_ABC', process.env.VUE_APP_ABC);
  // console.log('VUE_APP_BCD', process.env.VUE_APP_BCD);

  return await request("/api/news");
}

(十六)更多配置

vue-cli 配置open in new window

1.vue.config.js

  • devServer
  • publicPath
  • outputDir
  • runtimeCompiler
  • transpileDependencies
  • configureWebpack
  • css.requireModuleExtension
// vue-cli的配置文件
module.exports = {
  devServer: {
    proxy: {
      "/api": {
        target: "http://test.my-site.com",
      },
    },
  },
  publicPath: "/news",
  // runtimeCompiler: true,
  // transpileDependencies: []
  // configureWebpack: {
  //   // webpack配置
  // }
  // css: {
  //   requireModuleExtension: false,
  // },
};

2.babel 配置

  • 写到项目根目录下的 babel.config.js
module.exports = {
  presets: ["@vue/cli-plugin-babel/preset"],
};

3.ESLint

  • 可以通过 .eslintrcpackage.json 中的 eslintConfig 字段来配置

1).eslintrc.js

module.exports = {
  root: true,
  env: {
    node: true,
  },
  extends: ["plugin:vue/essential", "@vue/airbnb"],
  parserOptions: {
    parser: "babel-eslint",
  },
  rules: {
    "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
    "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
    "import/no-extraneous-dependencies": 0,
    "implicit-arrow-linebreak": 0,
  },
};

2)eslintConfig

[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100

4.postcss

  • 写到 postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {
      purge: ["./src/**/*.vue"],
    },
    autoprefixer: {},
  },
};

(十七)更多命令

(十八)嵌套路由

  • router/index.js
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    component: Home,
  },
  {
    path: "/about",
    component: () => import("../views/About.vue"),
  },
  {
    path: "/user",
    component: () => import("../views/user/Layout.vue"),
    children: [
      {
        path: "", // 匹配的 /user
        component: () => import("../views/user/Profile.vue"),
      },
      {
        path: "address", // /user/address
        component: () => import("../views/user/Address.vue"),
      },
      {
        path: "security",
        component: () => import("../views/user/Security.vue"),
      },
      {
        path: "friends",
        component: () => import("../views/user/Friends.vue"),
      },
    ],
  },
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes,
});

export default router;

(十九)路由切换动画

  • views/user/Layout.vue
<template>
  <div class="flex">
    <nav class="w-50 flex-none border-r p-10 flex flex-col leading-loose text-gray-500">
      <router-link exact to="/user" active-class="text-yellow-600">profile</router-link>
      <router-link to="/user/address" active-class="text-yellow-600">address</router-link>
      <router-link to="/user/security" active-class="text-yellow-600">security</router-link>
      <router-link to="/user/friends" active-class="text-yellow-600">friends</router-link>
    </nav>
    <div class="flex-auto">
      <transition mode="out-in">
        <router-view></router-view>
      </transition>
    </div>
  </div>
</template>

<style scoped>
  .v-leave-active {
    transition: 0.2s;
    opacity: 0;
  }
  .v-enter {
    opacity: 0;
  }
  .v-enter-active {
    transition: 0.2s;
  }
</style>
  • App.vue
<template>
  <div>
    <div class="fixed bg-gray-900 text-white w-full leading-10 flex justify-center h-10">
      <router-link active-class="text-yellow-600" exact class="mx-5" to="/">Home</router-link>
      <router-link active-class="text-yellow-600" class="mx-5" to="/about">About</router-link>
      <router-link active-class="text-yellow-600" class="mx-5" to="/user">User</router-link>
    </div>
    <div class="h-10"></div>
    <div class="min-h-screen py-10 lg:w-1/2 h-10 mx-auto">
      <transition :name="routerSwitchInfo.direction">
        <router-view />
      </transition>
    </div>
    <div class="border-solid border-t mt-10 text-center py-10 bg-gray-100">Footer</div>
  </div>
</template>

<script>
  import store from "./store";

  export default {
    data() {
      return {
        routerSwitchInfo: store.routerSwitch,
      };
    },
  };
</script>

<style scoped>
  .right-leave-active {
    position: absolute;
    width: 100%;
    transition: 0.5s;
    transform: translateX(-50%);
    opacity: 0;
  }
  .right-enter {
    transform: translateX(50%);
    opacity: 0;
  }
  .right-enter-active {
    transition: 0.5s;
  }

  .left-leave-active {
    position: absolute;
    width: 100%;
    transition: 0.5s;
    transform: translateX(50%);
    opacity: 0;
  }
  .left-enter {
    transform: translateX(-50%);
    opacity: 0;
  }
  .left-enter-active {
    transition: 0.5s;
  }
</style>
上次编辑于: