成为Vue高手,必须掌握的37个知识点 🔥

2,456 阅读26分钟

前言

  • "这里我明明用了 Vue.set 设置新属性,怎么没有生效啊?"
  • "子组件更新父组件传递的值好麻烦啊,每次都要写个事件,在父组件里面改"
  • "从列表页跳转到详情页,再返回列表,每次页面都刷新了,就不能保留之前的数据停留在之前的位置吗?"
  • "这个样式穿透怎么没有生效啊?我还写了2层穿透"
  • "打的包怎么这么大?"
  • "这个页面打开怎么这么慢?"
  • ...

IMG_5629.GIF

在我们平常开发 Vue 的过程中,是不是会遇到很多各种各样的问题,或者是面试的时候被问到?

大家好,我是疯狂的小波这里我总结了这么多年开发过程中遇到的问题及对应的解决方案,还有一些觉得比较好用的技巧;还有关于部分问题的原理分析,让我们可以更清楚的知道问题产生的原因,轻松应对更复杂的场景。 💪

这里有30个开发技巧、7个常见性能优化手段、还有很多针对问题的扩展思考,实际上远远不止37个知识点。掌握了这些内容,相信我们的 Vue 能力会更上一层楼。 也可以更好的去 吹牛皮 解决工作中遇到的问题啦 😄。

我已经使用这些知识点,帮助同事解决很多问题了。内容较多,长文预警,建议先点赞收藏👍!

小技巧

1、页面刷新

传统刷新

this.$router.go(0)location.reload()

弊端:

  • 刷新整个应用而不是页面:如果有在应用初始化时调用的接口、方法等也会被重新执行,如根据用户信息获取账号权限等,浪费资源,影响体验
  • 页面会有一瞬间的白屏:因为是整个应用的重新加载,会比较慢

新方案一:路由根节点重新渲染

通过 v-if 控制 router-view 的卸载和重新渲染,来达到页面刷新的效果。

如下:在 router-view 标签上添加 v-if,定义方法控制值的切换,再通过 provide/inject 将我们的重新渲染方法注入到子路由中。

// App.vue
<template>
    <router-view v-if="isRouterAlive"/>
</template>

export default {
  provide() {
    return {
      AppReload: this.AppReload
    };
  },
  data() {
    return {
      isRouterAlive: true   // 控制页面刷新字段
    }
  },
  methods: {
    // 刷新页面的方法
    AppReload() {
      this.isRouterAlive = false;
      this.$nextTick( () => {
        this.isRouterAlive = true;
      });
    },
  }
}
// 子路由页面
export default {
  inject: ["AppReload"],
  methods: {
    pageReload() {
      // 调用注入的刷新方法
      this.AppReload();
    }
  }
}

这样我们就可以实现页面刷新的效果,整体的刷新速度会非常快,交互很棒。👍

这里的 provide/inject 也可用 event-busVuex 替换

新方案二:路由重定向

先跳转到一个公共的重定向路由页面(目标地址作为页面参数),再自动跳转到参数中的目标路由页面。因为路由的变更,所以页面也会刷新啦。

这种更适用于菜单类跳转。在点击菜单时可能会跳转到新路由,也可能还是当前路由(用户的习惯如果点击当前路由应该也要刷新页面),而在 Vue 中相同路由的跳转并不会刷新。通过这种重定向的方式就可以解决这一问题。

第一步:路由注册

添加动态路由 路径参数,接收目标路由地址

注意:常规参数只匹配 url 片段之间的字符,用 / 分隔。如果我们想匹配任意路径,可以使用自定义的 路径参数 正则表达式,在 路径参数 后面的括号中加入 正则表达式 :

// redirect 页面路由
{
  // 添加正则(.*),这样我们就可以匹配任意字符的url了,否则url中带'/'就匹配不上了
  path: "/redirect/:url(.*)",
  name: "Redirect",
  component: () => import("@/pages/redirect/index")
}

第二步:创建重定向页面

// redirect 页面内容
<script>
export default {
  beforeCreate() {
    // 获取动态路由参数中的目标路由地址 url,如果有参数也带上
    const { params, query } = this.$route
    const { url } = params
    // 路由的跳转需要使用 replace,返回时可以正常返回上一页,不保留重定向路由的记录
    this.$router.replace({ path: '/' + url, query })
  },
  render: function(h) {
    return h() // avoid warning message
  }
}
</script>

第三步:页面跳转

// 页面跳转时,跳转到 '/redirect' 页面,带上目标页面的参数即可
const { fullPath } = this.$route
// 刷新当前页面时,使用 replace,这样不会产生新的路由记录
this.$router.replace('/redirect' + fullPath)
// 跳转到其他页面时用push,返回时可以正常返回当前页
this.$router.push('/redirect' + fullPath)
// 👆 这里可以直接使用字符串路径,如果使用带path的对象,有query的时候,需要单独处理

// 例:fullPath == '/deal/list' 时
// 先跳转到 👉 '/redirect/deal/list'
// 在自动重定向到 👉 '/deal/list'

这样就可以自动跳转到我们的目标页面啦,就算是相同路由地址的跳转也会自动刷新了。并且这个重定向的过程非常快,我们是感知不到的。

2、相同路由、不同参数,跳转不刷新问题

如:商品详情页之间的跳转, /product/1,跳转到/product/2,跳转后页面不会更新,因为 vue-router 发现这是同一个组件,会复用这个组件。在页面跳转后 createdmounted 生命周期不会重新执行。

这里也可以上面提到的路由重定向方案,不过这种需要我们自己来判断,在做相同路由跳转的时候使用重定向,其他的正常跳转,会麻烦一点。所以针对这种相同路由、不同参数,跳转不刷新的问题,我们可以采用更好的解决方案。

方案一:监听 $route

监听 $route 的变化,变化后执行我们想要的操作,如重新获取数据。

watch: {
    $route: function(to, from) {
      // doSomeThing
    }
},

这样,在商品详情页间跳转时,我们就可以监听路由变化后重新获取页面数据。

  • 优点: 可以自定义路由变化时要做的操作,可控性较强;并且路由本身会复用,不会有页面刷新的感觉,比较无感。
  • 缺点: 如果这种页面比较多的话需要每个页面单独处理,后续新增的页面也容易遗忘。

方案二:router-view 添加 key

router-view 添加一个路由路径的 key 值,只要 url 变化,就会重新创建组件,重新获取数据

<router-view :key="$route.fullPath"></router-view>
  • 优点: 全局统一处理,比较简单方便。
  • 缺点: 相同路由的页面组件不会复用,相当于先销毁再重新创建、跳转到一个新页面,会有一个刷新的感觉。

上面的2种方案,都可以解决这一个问题,我们可以根据实际情况自由选择

3、Vue.set 常用场景、失败场景

向响应式对象中添加一个 新的 响应式的 property,且触发视图更新

为什么使用

由于 Vue 无法检测到对象属性的添加或删除。而 Vue 会在实例初始化时会对 data 对象属性执行 getter/setter 转化,所以初始化时 data 对象中存在数据才为响应式数据。那如果我们想要给对象添加新的属性并更新视图,就需要使用 Vue.set

使用方法:

Vue.set( target, propertyName/index, value )  
// 或 vm.$set
// vm为vue实例对象,如大部分.vue文件中,可直接使用 this.$set 
  • target: 要添加属性的目标对象或数组,不能是 Vue 实例(不允许动态添加根级响应式 property)
  • propertyName/index:新添加的属性名或数组索引
  • value:添加的值

常见场景:

// 初始数据
export default {
  data () {
    return {
      person: {
        name: 'yb'
      },
      list: [
        {
          year: '2020',
          name: 'create'
        },
        {
          year: '2021',
          name: 'grow'
        }
      ]
    }
  }
}

/*
* 对象添加新属性
*/
// ❌ 非响应式,不触发视图更新
this.person.age = 18

// ✅ 响应式设置
this.$set(this.person, 'age', 18) // 响应式,视图更新
this.person.age = 20 // set之后,数据为响应式,直接赋值也可触发更新

/*
* 数组的操作
*/
// ❌ 非响应式设置:利用索引直接设置一个数组项时
this.list[0] = {}

// ✅ 响应式设置
this.$set(this.list, 0, {})
// 或
this.list.splice(0, 1, {})
// 直接给数组的某一项设置新属性时,同理
this.$set(this.list[0], 'time', 1)
// 在数组中使用下面这些原生方法时,也可以触发视图更新
// push、pop、shift、unshift、splice、sort、reverse

// ✅ 响应式数据的直接赋值,也会触发视图更新,并且新值也为响应式;
// 相当于重新赋值了person对象,并进行 getter/setter 化
this.person = Object.assign({}, this.person, { a: 1, b: 2 })

set 失败场景

在使用 set 的过程中,有时会出现明明按照 api 设置了新属性,但是却没有更新视图的情况。有的时候会感觉到莫名其妙,甚至一度以为是 Vue 的 bug,我身边的同事已经遇到过好几次了 😂。

如果对 set 方法的原理理解的话,我们就可以很快发现为什么会出现这种情况了。

set 方法基本原理

set 方法执行时,内部会先判断 target 中是否已存在 property 属性,如果已存在,则是直接对 targetproperty 进行简单赋值。否则就是添加新属性,并 getter/setter 化。

// .set内部执行机制(这里只是简单说明,实际会更复杂)
function set(target, property, value) {
    if (target[property]) {
        // 已经存在的属性,会被认为是 响应式属性,直接进行赋值操作
        target[property] = value
    } else {
        // 执行新属性的赋值同时getter/setter化,设为响应式数据
        ...
    }
}

看到这,我们大概就能够想到,为什么会出现 set 不更新视图的情况了,就是因为我们的属性在 set 之前已经被赋值了,并且是非响应式方式赋值的。这种赋值通常都是我们非主动、无意识下的赋值。

如下,一个简单的示例:person 对象初始化时无 age 属性,先直接进行了赋值操作,后续再执行 setset 则只会进行简单赋值,不会将数据 getter/setter 化,导致不会更新视图。

// 某些操作中,直接给对象设置新属性
this.person.age = 18

// 后续再执行set,则age依旧为非响应式 😭
this.$set(this.person, 'age', 18) 

上面的 🌰 可能太简单,大家会觉得日常开发中也不会这么操作,明显不对啊。实际上我们在开发中有时也会遇到类似的情况,只不过场景可能更复杂一点。好,那接下来我们来看一个隐藏款。🙈

v-model 绑定的属性更新时视图未更新

v-model 绑定对象形式属性时(如 a.b ),在更改值的回调事件中,内部会默认调用 set 方法来对值进行更新;

// html
<input v-model="a.b" />

// 输入框内容变更时,默认是调用的set方法进行值的更新
_vm.$set(_vm.a, "b", value)

所以我们通常通过 v-model 绑定响应式对象不存在的属性时,给我们的感觉也是响应式的。在大部分情况下这是没有问题的。但是如果在 v-model 更改值的回调前,手动赋值了 a.b 数据,后续表单值再更改也不会触发视图更新(即上面的 set 失败场景)。如下:

// data初始化
data {
  person: {}
}

// html
<input v-model="person.age" />

// 如果在表单值更改前,已经执行了直接赋值,则表单更改时不会再触发视图更新。
// 比如在页面初始化后,获取了age参数,并进行赋值,此时再去修改input值,并不会触发视图更新
this.person.age = 18

总结: 如果下次再遇到 set 失败的情况,就可以考虑这方面的原因了,基本上都是这个原因导致的,场景可能不同,原理都基本一样。

4、非响应式数据的 '伪视图更新'

  • "我这个属性值变更,怎么一会儿可以更新视图,一会儿不能更新视图啊?"
  • "这个 a 值更改时,视图中的 a 并没有更新;而当 b 值更改时,视图中的 a、b 都变成最新的了"

在非响应式数据变更时,事件循环中当前组件有响应式数据变更,则视图中非响应式数据也会更新。为了便于理解,我把这种场景定义为 非响应式数据的 '伪视图更新'

下面来看一个简单的 🌰

<template>
  <div>
    {{c}}
    {{a.b}}
  </div>
</template>

data {
  a: {},
  c: 1
}

// setValue 方法执行前 4 次,视图中 c、a.b 数据都会更新;😊
// 4次后 a.b 再更新时,视图中数据不再更新。 😭
setValue() {
  if (this.c < 5) {
    this.c += 1;
  }
  this.a.b = this.a.b ? this.a.b + 1 : 1;
}

很明显,最终的执行结果并不符合我们的预期,只有当c更新时,页面数据都会更新,否则a.b单独变更不会更新视图。我们可以来思考一下为什么会出现这种情况?

原因分析

在 Vue 中,每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把 "接触" 过的数据 property 记录为依赖(依赖收集)。之后当依赖项数据的 setter 触发时(数据变更),会通知 watcher,从而使它关联的组件重新渲染(派发更新)。

大概流程如下图:

image

而 Vue 在更新 DOM 时是 异步执行 的。只要侦听到数据变化, Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。

然后,在下一个的事件循环 tick 中, Vue 刷新队列并执行实际 (已去重的) 工作。这也是为什么如果要获取更新后的 DOM,通常在 Vue.nextTick() 回调中处理。

简单点来理解:只有响应式数据变更时才会主动触发更新,而更新操作会收集这个事件循环中所有的数据变更(包括非响应式数据),然后再下一个事件循环用最新的数据更新视图。

而上方的例子中,setValue 方法前 4 次执行时,都触发了 c 属性的 setter,将通知当前组件的 watcher,重新渲染组件;并收集到当前事件循环中,a.b 值也变更了,则视图更新时会连同 a.b 一起更新。而 4 次后执行的事件,c 值没有变更, a.b 值并没有被 依赖收集,即使变更了,也不会触发组件的重新渲染,所以视图不会更新。

总结: 所以只要是视图依赖的数据,我们就应该使用响应式数据,这样就能够尽量避免出现这种情况了。如果在某些边界场景遇到,如上面提到的 set 失败,我们也能知道是什么原因,怎么去解决这个问题啦。🎉

其实不光是在 Vue 中,在其他的前端框架,如 React、小程序 中也可能会遇到这种情况,出现问题的原因基本也是类似的。

5、style scoped

作用:CSS 模块化,避免组件间样式互相影响。

scoped 在日常开发中使用的非常多,基本上每个 .vue 文件的样式都会添加该属性,让我们不用再操心组件间样式互相影响的问题。

原理及穿透

scoped 是怎么实现模块化的?为什么添加后父组件样式不能再影响到子组件? 我们下面通过源代码和编译后的代码来对比看看:

// 源代码

// 父组件
<template>
  <div class="a">
    <div class="b">
      <comp />
    </div>
  </div>
</template>

<style scoped>
.a{}
.a .b{}
.a .c{}
.a .d{}
.a >>> .c{}
.a >>> .d{}
.a >>> .c .d{}
</style>

// comp子组件
<template>
  <p class="c">
    <span class="d"></span>
  </p>
</template>
// 编译过后

// 父组件
// 如果组件style无scoped属性,则html、css中不会添加data-v属性
<template>
  <div data-v-6364f53c class="a">
    <div data-v-6364f53c class="b">
      <!--子组件根元素会同时添加父组件、子组件data-v属性-->
      <p data-v-537e2781 data-v-6364f53c class="c">
        <span data-v-537e2781 class="d"></span>
      </p>
    </div>
  </div>
</template>

<style>
  <!-- 组件本身元素样式 -->
  .a{}            →  .a[data-v-6364f53c]{}
  .a .b{}         →  .a .b[data-v-6364f53c] {}
  <!-- 设置子组件样式 -->
  .a .c{}         →  .a .c[data-v-6364f53c] {} <!-- 不加穿透也可控制子组件根元素样式-->
  .a .d{}         →  .a .d[data-v-6364f53c] {} <!-- 无效,无法影响子组件的非根元素-->
  <!-- 设置穿透,可影响子组件所有元素-->
  .a >>> .c{}     →  .a[data-v-6364f53c] .c {} 
  .a >>> .d{}     →  .a[data-v-6364f53c] .d {}
  .a >>> .c .d{}  →  .a[data-v-6364f53c] .c .d {}
</style>

可以看到,在添加了 scoped 属性后每个 css 选择器语句及 html 标签都会添加当前组件的自定义属性 hashdata-v-xx,通过该属性来控制样式影响指定的元素!

css 中,默认是给选择器语句的最后一个选择器添加[data-v-xx]属性,所以只要是当前组件内的元素和子组件的根元素,样式都可以生效(因为对应的 DOM 节点上添加了该属性)。而如果是子组件的非根元素,DOM 节点上没有父组件的 hash 属性,样式就不会生效。就是通过这种方式来实现的样式隔离。

而当在选择器中添加了 >>> 进行样式穿透后,是将 [data-v-xx] 属性移动到了穿透语法 >>> 的前一个选择器,这样只要这个选择器元素在当前组件,就可以成功影响到子组件内部的样式啦。

大家看一下上面编译后的代码结果,就可以很清晰的知道实现隔离和穿透的原理了。

注意事项:

  • 原生 css 使用 >>> 进行样式穿透; scssless 使用 /deep/
  • 不需要嵌套使用穿透语法,在父组件设置穿透可影响所有子孙组件。嵌套使用会导致后续层级穿透语法解析失败,导致出错。
    // 这里 a、b、c 分别是3个组件的节点,多层级组件的样式穿透也只需要在最顶层设置就行
    ❌ 
    .a >>> .c >>> .d{}
    ✅
    .a >>> .c .d{}
    
  • 不要在全局样式中使用穿透语法( Vue 文件中 style 不带 scope 也是全局样式)。全局样式本身就能影响所有组件,不受 scoped 影响,添加后反而会导致穿透语法编译失败,样式无法正常生效。

6、组件通信

Vue 组件通信中几种主要的场景:父子组件、隔代组件、兄弟组件

  1. props / $emit:父子组件
  2. ref$parent / $children:父子组件
    • ref:如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例
    • $parent / $children:访问父 / 子实例
  3. EventBus:均可,通过一个空的 Vue 实例作为事件中心,用它来触发事件和监听事件
  4. $attrs / $listeners:隔代组件
  5. provide / inject:隔代组件。祖先组件通过 provider 提供变量,子孙组件通过 inject 注入变量
  6. Vuex:均可。只保存全局数据,不要滥用,否则反而影响性能。如:父子组件通信使用父子组件通信方式就好

如:父组件调用子组件的方法

通过 $refs 或者 $chilren 来拿到对应的实例,从而操作

<comp ref="comp"></comp>

// 调用子组件方法
this.$refs.comp.eventName()

7、.sync && v-model

更简洁的实现子组件修改父组件传递的props。就像我们最开始的问题一样,子组件修改父组件传递的属性,这个就是一个更简洁的方案。

相同点: 实现 props 的 “双向绑定”,都是语法糖。

不同点: 单个组件可以将 .sync 作用于多个属性,而 v-model 只能使用一个

v-model

作用于自定义组件时,默认会利用名为 valueprop 和名为 input 的事件(可在子组件中通过 model 属性更改属性和事件名)。

// 父组件
<comp v-model="bar.name"></comp>
// 基本等同于 ↓
<comp :value="bar.name" @input="bar.name = $event"></comp>
// 但是不单单只是做了类语法糖的这种处理,还有其他的比如:
// 绑定的如果是对象属性,回调为set的赋值处理等

// 子组件:
<div>{{value}}</div>
// 接收参数
props: ["value"]
// 更新方式
this.$emit("input", newValue)

当作用于表单项时,v-model 在内部为不同的元素使用不同的属性并抛出不同的事件。

  • text、textareavalue 属性、input 事件;
  • checkbox、radiochecked 属性、change 事件;
  • selectvalue 属性、 change 事件。

.sync

实现机制和 v-model 是类似的。当有需要在子组件修改父组件传入的值时这个方法很好用。

// 父组件
<comp :foo.sync="bar.name"></comp>
// 等同于 ↓
<comp :foo="bar.name" @update:foo="bar.name = $event"></comp>

// 子组件内更新方式
this.$emit("update:foo", newValue)

// 同时设置一个对象的全部属性
bar: {
  nav: 1,
  foo: 2
}
<comp v-bind.sync="bar"></comp>
// 等同于 ↓
<comp :nav.sync="bar.nav" :foo.sync="bar.foo"></comp>
// 把 bar 对象中的每一个 property 都作为一个独立的 prop 传进去,然后各自添加用于更新的 v-on 监听器

总结: 相对于传统的 子组件更新父组件传递的值,每次子组件抛出一个事件,父组件监听事件并执行更新值的操作,使用这 2 个 api,显然要简洁的多。

8、常见事件修饰符

.native

用于在某个组件上注册一个原生事件。因为直接通过 @eventName 在组件上注册的事件,触发的是组件内部通过 $emit 传递过来的事件。如下:

// 组件内部没有$emit传递click事件,则点击时事件无法触发。
<vButton @click="clickHandler" @customEvent="customHandler">按钮</vButton> 

// 事件作为原生click事件注册生效,点击时可以正常触发
<vButton @click.native="clickHandler">按钮</vButton>   

而部分UI组件库,在组件上已经做了兼容处理,如 element-uiButton 组件,点击按钮时,会将点击事件传递出来。

// element-ui Button组件内部处理
<button
  class="el-button"
  @click="handleClick"
</button>

...

handleClick: function handleClick(evt) {
  this.$emit('click', evt);
}

所以我们在使用这种组件时,是否添加了 .native,点击事件都能生效。如果是我们自定义的组件,或者未做这种处理的第三方组件,我们在使用时,就要注意添加 .native 修饰符了。

.stop

阻止事件冒泡:子节点触发的事件,不会再触发祖先节点绑定的事件。

.prevent

拦截默认事件:如 a 标签有 href 属性,添加后点击时则不会再触发 href 的跳转效果。

.passive

不拦截默认事件

在每次事件产生时,浏览器都会去查询一下是否有 preventDefault 阻止该次事件的默认动作。加上 .passive 就是为了告诉浏览器,不用查询了,我们没用 preventDefault 阻止默认动作。

常同于滚动监听 @scoll@touchmove 事件,提高效率。因为滚动监听过程中,事件会高频触发,每次都使用内核线程查询 prevent 会使滑动卡顿。我们通过 .passive 将内核线程查询跳过,可以大大提升滑动的流畅度。

<div v-on:scroll.passive="onScroll">...</div>

注:passiveprevent 冲突,不能同时绑定在一个监听器上。

9、生命周期(路由切换,父子组件)

路由切换

如:路由 A -> B

B beforeCreate -> B created -> B beforeMount -> A beforeDestroy -> A destroyed -> B mounted

可能出现的问题

如我之前做过的一次,AB页面初始化时,都需要注册一个 echarts 图表自定义的点击事件,绑定在 window 上,页面离开时销毁事件。代码如下:

// A、B页面中绑定了同名的全局事件;
// A页面跳转到B页面时,需要销毁A中事件,初始化B中事件。

// A页面
beforeMount(){
    window.gotoPageChart = () => {
      ...
    }
}
destroyed() {
    window.gotoPageChart = null;
}

// B页面
beforeMount(){
    window.gotoPageChart = () => {
      ...
    }
}
destroyed() {
    window.gotoPageChart = null;
}

当从 A 跳转到 B 页面后。在 B 页面触发事件时找不到 window.gotoPageChart 事件,当时并没有找到具体的原因,只能把 B 页面中方法名更换。

最后发现,就是因为这里的生命周期顺序,B 页面的 beforeMount 执行赋值后,再执行了 A 页面的 destroyed 方法,再将 window.gotoPageChart 重置为 null,导致与预期不一致。

解决方案:将 beforeMount 生命周期换为 mounted 即可

父子组件

加载渲染过程

父 beforeCreate -> 父 created -> 父 beforeMount -> 子 beforeCreate -> 子 created -> 子 beforeMount -> 子 mounted -> 父 mounted

子组件更新过程

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

父组件更新过程

父 beforeUpdate -> 父 updated

销毁过程

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

总结: 了解了生命周期在不同场景下的执行顺序;我们就能够更好、更快的发现、解决因生命周期顺序导致的问题。

10、动态参数

当一个参数,可能代表不同属性时,我们可以用一个变量代替,再根据实际场景将我们的变量进行赋值。

用于属性

比如我之前遇到的一个需求中,需要根据页面的不同状态,来设置日期选择器的展示内容:

  • 当状态为1时,设置开始日期为当前日期
  • 当状态为2时,设置结束日期为当前日期

在这个需求中,我们只会同时设置开始结束其中一个,不会同时设置。如果我们在这里分别定义2个属性,并根据不同的场景进行赋值的话,会比较麻烦。而如果使用动态参数,就可以很好的实现。如下:

<v-time :[setData]="valueDate"></v-time>

...

data() {
  return {
    valueDate: new Date(),
    setData: "startDate" // 动态的属性名,如果这里的值为 endDate,则为endDate属性绑定值。
  }
}

这里会将 setData 作为 js 表达式动态求值,结果作为最终的参数来使用。上述绑定将等价于 :startDate

用于事件名

同样地,你可以使用动态参数作为事件名绑定处理函数:

<button @[eventName]="handler"></button>

eventName 的值为 focus 时,@[eventName] 将等价于 @focus

动态参数预期结果为字符串。当值为 null 时,可以被显性地用于移除绑定

用于插槽

同样可以适用于插槽绑定,这样我们就可以让相同的内容在不同的状态下展示在不同的插槽内:

<foo>
    <template #[name]>
        Default slot
    </template>
</foo>

动态参数有时在我们处理部分特殊的场景时,会比较有用

11、组件库中未暴露的API

在我们使用第三方组件库时,api 文档内容有时无法满足我们的需求,而为了这部分需求,往往要做大量的兼容工作。

其实除了文档中的属性、方法,部分组件库还有其他支持的属性或方法可以使用,只是没有在文档的体现。遇到这种情况时,我们可以先尝试看看是否有隐藏 api 可以满足我们。

示例

如:element-uidropdown 下拉菜单一个比较常见的需求:我们希望点击菜单项后不自动隐藏菜单,执行自定义操作后再手动隐藏。

比如点击菜单项时调用接口判断是否通过,通过则选中并自动隐藏,否则提示异常;

但是查看api文档中,发现只有一个 hide-on-click 属性,设置为 false 时点击菜单项后不会自动隐藏,但是没有方法主动去隐藏菜单,只能鼠标移开时自动隐藏。这样就无法实现我们校验通过后自动隐藏的效果。

通过 console 打印组件实例后发现有 hide 方法,但是文档中没有,应该是提供给组件内部在使用。测试后发现调用这个实例方法可以实现隐藏。这样就能实现我们的需求了。

// 下拉菜单默认在点击菜单项后会被隐藏,将hide-on-click属性默认为false可以关闭此功能
<el-dropdown ref="dropdown" :hide-on-click="false"></el-dropdown>

...
// 而在执行其他操作后,可调用此方法再将下拉菜单隐藏 😊
this.$refs.dropdown.hide();  // 手动隐藏

// 也有显示方法,在特殊场景下可以使用
// this.$refs.dropdown.show();  // 手动显示

查找方式

  • 可以查看组件源码,如 el-dropdownmethods,通过命名就能大概猜出功能,具体的内部实现可以不关心。代码如下:
methods: {
  show() {
    if (this.triggerElm.disabled) return;
    clearTimeout(this.timeout);
    this.timeout = setTimeout(() => {
      this.visible = true;
    }, this.trigger === 'click' ? 0 : this.showTimeout);
  },
  hide() {
    if (this.triggerElm.disabled) return;
    this.removeTabindex();
    if (this.tabindex >= 0) {
      this.resetTabindex(this.triggerElm);
    }
    clearTimeout(this.timeout);
    this.timeout = setTimeout(() => {
      this.visible = false;
    }, this.trigger === 'click' ? 0 : this.hideTimeout);
  },
  ...
}
  • 查看组件实例对象,如: console.log(this.$refs.dropdown),通过对象属性或方法名查看是否有需要的方法。大部分方法都可以通过名称判断出基本用途

12、别名的多场景使用(template/js/css)

通常我们在配置好文件目录的别名后,在 js 中会大量使用,如最常见的 ./src 目录别名配置 @

容易忽略的是,除了在 js 中, 在 templatecss 中也可以使用别名,帮助我们减少目录层级的书写。

特别是可能进行文件目录的迁移时,内部的相对路径都要一点点改动,并且很容易遗漏。而使用别名就没有这种问题。

// 1、JS文件中,直接使用 @
import chartItem from "@/components/report";

// 2、css(scss等预处理)文件中,使用 ~@ 开头
@import '~@/assets/css/global.less';
background-image: url("~@/assets/img/login/user.png");

// 3、template模板中,@、~@ 均可
<img src="@/assets/img/login/logo.png">

13、computed 的 get、set

通常我们在编写 computed 计算属性时,都是直接 return 一个值。这是大部分场景下的用法。

computed: {
  // 基于指定属性,计算出一个新属性
  customTime() {
    return this.form.time / 1000
  }
},

而当我们在处理一些特殊场景时,computedgetset 会很有用

常用场景

1、在处理传入数据和目标数据格式不一致的时候很有用

如下时间格式,前端展示和设置值时是用的 秒 为单位,而从接口获取及传递时是 毫秒 为单位。如果我们手动去转化格式的话,在获取时和作为参数传递时都需要进行转化,而使用 computedgetset 就会方便很多。

// 前端显示 时间单位为 秒
<input v-model.number="customTime">

...
data() {
    return {
        // 接口返回及逻辑计算 时间单位为 毫秒
        form: {
            time: 3000
        }
    }
}

computed: {
    // 通过一个计算属性就能实现获取与设置时格式转换
    customTime: {
        get() {
            return this.form.time / 1000
        },
        set(val) {
            this.form.time = val * 1000
        }
    }
}

2、在获取数据的同时,监听数据变化,当发生变化时,做一些额外的操作

最经典的用法就是在 v-model 上绑定一个 vuex 值的时候,当input 发生变化时,通过 commit 更新存在 vuex 里面的值。 一步实现 vuex 中值的获取和更新。

<input v-model="name">

...

computed: {
    name: {
        get() {
            return this.$store.state.name
        },
        set(val) {
            this.$store.commit('updateName', val)
        }
    }
}

3、还有如模态框的二次封装,设置显示、隐藏

// 基于 el-dialog 二次封装的组件
<div>
    // 绑定 计算属性 中的值,弹出框内部关闭时会调用 set 方法,同步更新父组件传入的 props
    <el-dialog :visible.sync="dialogVisible">
        // ...
    </el-dialog>
</div>

props: {
    // 模态框是否显示:父组件使用 .sync 修饰符传入
    dialogViewVisible: {
        required: true,
        type: Boolean,
        default: false
    }
}
computed: {
    dialogVisible: {
        // 获取父组件传入的属性
        get() {
            return this.dialogViewVisible;
        },
        // 当值变更时自动更新父组件中传入的 dialogViewVisible 属性
        // 组件内部关闭弹框 也是更改该计算属性值就行
        set(val) {
            this.$emit("update:dialogViewVisible", val);
        }
    }
}

总结: 当我们了解了 getset 的执行时机后,就可以使用它解决更多的问题了。

14、watch 的常用参数

immediate

假设有个列表组件 A,接收一个查询参数 prop:searchText,参数可能有初始值,希望每次searchText 变更时,都重新获取列表。

常规的写法可能是像下面这样:

// bad 😭
created() {
  this.fetchList();
},
watch: {
    searchText: 'fetchList'
}

因为 watch 一个变量,组件初始化时并不会执行回调函数,只有在 watch 的值变更时才会触发,这个时候我们就需要在 created 的时候手动调用一次方法,否则我们就无法初始化时获取列表了。

这里我们可以使用更好的方式,添加 immediate 属性,这样初始化的时候也会触发 watch 的回调了

// good 😊
watch: {
  searchText: {
    handler: 'fetchList',
    immediate: true,
  }
}

deep

假设上面的需求我们再改造一下:现在 A 组件,接收一个查询对象 prop:paramsObj,内部有多个参数,只要任意一个参数变更时,都重新获取列表。

如果还是基于上面的代码,发现内部参数变更时并不会进行更新。

因为当 watch 监听的是一个对象或者数组时,如果对象内部的属性变更,默认是无法被监听到的。

而将 deep 属性设置为true时,它会进行深度监听。这样对象里面任意一项变更都会触发 watch

watch: {
  paramsObj: {
    handler: 'fetchList',
    deep: true,
    immediate: true
  }
}

注意: deep 只有在我们真正需要使用的时候才用,如果滥用可能会导致性能问题。

15、attrs && listeners

组件二次封装的神器,实现属性和方法的透传

属性说明

  • $attrs非prop的attribute。可以通过 v-bind="$attrs" 传入内部组件,手动决定这些 attribute 赋予到哪个元素。通常配合子组件 inheritAttrs 选项一起使用。
  • $listeners:向子组件绑定的 (不含 .native 修饰器的) v-on 事件监听器。可以通过 v-on="$listeners" 传入内部组件。

非prop的attribute:传向一个组件,但是该组件内并没有定义相应 propattribute(不包含classstyle)。

显式定义的 prop 适用于向一个子组件传入信息。而其他非 propattribute 会被添加到这个组件的根元素上(classstyle会和子组件本身的合并,而其他的大部分属性会进行替换),子组件设置inheritAttrs: false,可以阻止这一默认行为,让根元素不自动继承这种 attribute(不影响classstyle

使用场景

当我们基于第三方组件进行二次封装时,可能会加入一些自己的业务逻辑,同时要支持第三方组件原有的配置及事件监听。但是第三方组件本身可能支持几十个配置参数,不可能所有参数都通过自定义 props 传递,并且第三方组件后期也可能加入新参数。这时候我们就可以使用 v-bind="$attrs" 传递所有属性、v-on="$listeners" 传递所有监听事件。

如下:基于element-uiel-pagination组件的简单二次封装。组件内封装公共属性、业务等;并支持传入 el-pagination 的所有属性、监听事件。

// 自定义的pagination组件
<div class="custom-pagination">
    <div>
        ...
    </div>
    // 通过 v-bind="$attrs" 绑定所有 非prop的attribute
    // 通过 v-on="$listeners" 绑定所有 监听事件
    <el-pagination
      background
      :layout="layout"
      v-bind="$attrs"
      v-on="$listeners"
    />
</div>

export default {
    inheritAttrs: false, // 根元素不继承 非prop的attribute
    props: ['custom-prop'],
    data() {
        return {
          layout: 'prev, pager, next, total'
        }
    },
}
// 父组件
// 引入自定义pagination组件
// 除了可以传入自定义的 props
// 还可直接按照el-pagination规则传递参数,绑定监听事件,都会被传递到el-pagination组件上
<pagination
    :custom-prop="_customProp"
    :current-page="currentPage"
    :page-size="limit"
    :total="total"
    @current-change="handleCurrentChange"
    @size-change="handleSizeChange"
/>

如上,我们在自定义的pagination组件中,只显式声明了custom-prop的属性,其他定义的属性如current-pagepage-sizetotal将通过v-bind="$attrs"直接透传到el-pagination组件上,而不用在把这些props全部显式定义一遍。

所有监听事件如 current-change,size-change,也都会通过 v-on="$listeners" 绑定到el-pagination上。

1、$listeners 获取的是所有的监听事件,所以在传递的时候需要注意,组件本身监听的事件命名不要和传递到子组件内部的命名相同,避免冲突;
2、组件显式定义接收的 props ,也可以通过this.$props获取,通过v-bind="$props"向下传递

16、v-show && v-if

v-if

DOM 不会生成,没有插入文档,等条件成立时才动态生成 DOM 并插入到页面!

v-show

DOM 在组件渲染的时候同时渲染了,只是单纯设置了 css 隐藏 ,等条件成立时再设置为显示

使用场景

  • 频繁切换显隐的用v-show,通过条件判断渲染内容不怎么切换的用v-if
  • DOM 结构不怎么变化的用v-show, 数据需要改动很大或者布局改动的用v-if

17、自定义指令

除了 Vue 默认内置的指令(如上面的v-ifv-show等),我们也可以注册自定义指令。

自定义的指令通常用来处理对 DOM 元素进行操作的可复用逻辑。

示例

1、控制元素在指定场景下不可点击

如:在一个搭建的展示页面,组件既有可能在预览页面显示,又有可能在正式页面显示,希望在预览页面显示时,页面部分按钮不可以点击。

这时我们就可以编写一个自定义指令处理这个逻辑,判断当前组件如果是在预览页面内部,则指令绑定的元素添加指定类名,再通过 css 样式设置不可点击。代码如下:

// 自定义指令
Vue.directive('preiview', {
  inserted(el, binding, vnode) {
    // 通过路由中 meta 属性判断当前是否预览页
    const isPreiview = vnode.context.$route.meta.preiview
    if (isPreiview) {
      el.classList.add('preiview-disabled')
    }
  }
})
// 元素绑定指令
<div v-preiview @click="doSomeThing">
  // ...
</div>

// css
.preiview-disabled {
   pointer-events: none
}

2、按钮权限的控制

现在很多系统都可以配置菜单、按钮级别的权限。如常规的做法:在后台配置权限后,前端项目初始化时,根据账号信息请求接口获取对应的权限信息,通过路由的动态注册,注册有权限的菜单,再将对应的按钮权限列表添加到对应路由的 meta 中(或者直接保存整个项目的按钮权限列表)。在页面中再根据按钮权限列表,进行判断哪些按钮显示、隐藏。

而判断指定按钮,是否配置了权限,如果没有权限则隐藏,这个逻辑就可以通过自定义指令来完成。代码如下:

// 自定义指令
Vue.directive('permission', {
  inserted(el, binding, vnode) {
    // 获取账号角色
    const role = vnode.context.$route.meta?.role;
    // 获取当前按钮权限列表
    const authList = vnode.context.$route.meta?.buttonAuth || [];
    // 管理员角色不做处理
    if(role === "admin") return;
    // 指定的按钮不在权限列表中,则移除按钮
    if(!authList.includes(binding.arg)) {
      el.parentNode && el.parentNode.removeChild(el);
    }
  }
})
// 元素绑定指令,当前菜单的按钮权限列表中如果没有 'add',则该元素会被移除
<button v-permission:add @click="doSomeThing">
  // ...
</button>

结语: 这里更多的是向大家展示,自定义指令可能使用的场景,希望能够有所启发。我们可以根据实际的情况来判断哪些地方应该使用,一个基本的判断原则时:如果有对 DOM 元素的可复用逻辑处理,就可以考虑使用自定义指令了。

更多关于自定义指令的详细使用,可以参考:《自定义指令》

18、过滤器

过滤器主要用于文本格式化。创建一个指定规则的过滤器,在文本显示时可以直接基于过滤器的规则进行显示,而不用修改源数据。

本质上就是一个函数,接受要格式化的文本为参数,也可以自定义其他参数,返回处理后的内容。可以定义全局过滤器,也可以定义 Vue 实例的局部过滤器。

使用起来也比较简单,如数字的格式化:

// 过滤器:数字的格式化处理
Vue.filter('formatNum', function (num, type = 'fix2') {
  num = +num || 0;
  // 保留2位小数
  if(type === 'fix2') {
    return num.toFixed(2)
  }
  // 整数
  if(type === 'integer') {
    return parseInt(num)
  }
})
// 使用
{{ value | formatNum }} // 88 => 88.00
{{ value | formatNum('integer') }} // 88.88 => 88

19、自动注册全局组件、指令、过滤器等

传统的注册全局组件方式(指令和过滤器基本也是这样):

// 依次引入全局组件,再依次进行注册
import baseButton from '@/globalComponents/baseButton.vue'
import baseDialog from '@/globalComponents/baseDialog.vue'
Vue.component('baseButton', baseButton)
Vue.component('baseDialog', baseDialog)

在这种方式下,如果我们注册的内容比较多的话,这里的代码就会非常长,后续要新添加全局组件,也需要再次修改注册的代码。

基于 webpackrequire.context() 来实现自动引入组件并注册。

例:创建一个globalComponents文件夹,将想要注册到全局的组件都放在这个文件夹里。在入口文件main.js中引入如下:

const requireComponent = require.context(
    './globalComponents', false, /\.vue$/
)

requireComponent.keys().forEach(fileName => {
    const componentConfig = requireComponent(fileName)
    const componentName = fileName.replace(/^\.\//, '').replace(/\.\w+$/, '')
    Vue.component(componentName, componentConfig.default)
})

后续再添加组件到globalComponents目录中,就会自动进行注册了,指令、过滤器等注册方法同理。

更详细的关于require.context的使用说明,可以参考另外一篇文章:# Vue中怎么自动注册组件、过滤器?

20、hook 生命周期监听

部分场景下使用 hook 监听生命周期更简单、合理

常用场景

一、组件销毁时销毁全局事件或销毁定时器。

// 传统的事件注册、销毁。有2点弊端
// 1.需要在实例中保存要销毁的事件或定时器
// 2.在不同的option中维护,option中内容较多时,维护起来较麻烦,并且容易忘记
// bad 😭
mounted() {
  window.addEventListener('resize', this.debounceHeight)
},
beforeDestroy() {
  window.removeEventListener('resize', this.debounceHeight)
}
// 使用hook销毁,不需要在实例中绑定事件,代码一处维护
// good 😊
mounted() {
  window.addEventListener('resize', _debounceHeight)
  // 也可以使用this.$on
  this.$once('hook:beforeDestroy', function() {
    window.removeEventListener('resize', _debounceHeight)
  })
},

二、监听子组件生命周期。

// 传统方式
// bad 😭
// 子组件指定生命周期抛出事件
mounted() {
  this.$emit("mounted")
}
// 父组件监听事件
<comp @mounted="handleEvent"/>
// hook方式
// good 😊
<comp @hook:mounted="handleEvent"/>

21、动态组件

Vue 内置的 <component> 组件,可根据传入的 is 参数,来决定哪个组件被渲染。

这在我们做一些组件动态渲染的时候往往非常有用。

比如,在之前开发的一个搭建项目中,在后台通过组件将页面配置好后。通过接口获取页面的JSON数据,渲染时再根据数据来展示页面,其中就涉及到组件的循环渲染。数据结构如下:

pageList: [
  {
    id: "1-1",
    type: "banner",
    data: {}
  },
  {
    id: "1-2",
    type: "nav",
    data: {}
  },
  ...
]

其中的 type 字段,就是组件类型,组件顺序是可以任意配置的,假设我们这个页面可能有10个组件。那我们渲染时应该怎么处理?

常规写法:

<div v-for="item in pageList">
    <banner
      v-if="item.type === 'banner'"
      :key="item.id"
      :parmes="item.data"
    />
    <nav
      v-if="item.type === 'nav'"
      :key="item.id"
      :parmes="item.data"
    />
    // 更多其他组件 ...
</div>

是不是感觉有大量的重复代码,而且不易维护,并且每次有增删组件都需要再修改代码。

我们再来看看通过 <component> 组件是怎么实现的:

<div v-for="currentComponent in pageList">
    // :is 属性值需要和组件定义名称对应上
    <component
      :is="currentComponent.type"
      :key="currentComponent.id"
      :parmes="currentComponent.data"
    />
</div>

是不是非常的简洁,只需要我们的type值和组件名对应上,就可以了。

再结合上面提到的组件自动注册,将我们的所有搭建组件放在一个目录中自动引入并注册,这样我们组件有增删时,只要目录中对应的文件变动就可以了,基本实现了一个小的自动化。

22、异步组件

组件的懒加载,只有在这个组件需要被渲染的时候才会加载组件资源,与路由的懒加载同样的原理。

当一个页面中,部分组件内容过多,过大,并且该组件并不是一进入页面就使用,这时我们就可以把这部分组件设置为异步组件。

如:有一个列表页,点击页面查看按钮后弹出查看组件,这个查看组件的内容非常大,并且不一定会被使用,这个查看组件就可以设置异步组件。这样进入页面只会加载其他内容的资源文件,点击查看按钮时才会加载查看组件的资源。

异步组件的使用方式:

// 一、简单使用
components: {
    // 异步引入查看组件
    ViewPage: () => import("./view")
}

// 二、处理加载状态
components: {
    // 异步引入查看组件
    ViewPage: () => ({
        // 需要加载的组件,该属性必填,其他都是非必填
        component: import("./view"),
        // 异步组件加载时使用的组件:loading组件
        loading: LoadingComponent,
        // 加载失败时使用的组件
        error: ErrorComponent,
        // 展示加载时组件(loading组件)的延时时间。默认值是 200 (毫秒)
        // 这个时间内组件加载完成则不展示 loading组件
        delay: 200,
        // 超时时间:如果提供了超时时间且组件加载超时,
        // 则使用加载失败时使用的组件。默认值是:`Infinity`
        timeout: 3000
    })
}

通常是在对页面有性能要求,希望能够加快页面访问速度时使用。

23、递归组件

组件在自己的模板中调用自身

递归组件通常用来开发一些具体有未知层级关系的功能。比如:联级选择器、树形控件、多级菜单

实例

比如我们现在要实现的一个不确定层级的多级菜单:

// SliderBar组件
<div class="menu-list">
  <SliderItem
    v-for="route in menuData"
    :key="route.name"
    :menu="route" 
  />
</div>

// 引入并注册 SliderItem 组件

// 菜单数据
menuData: [
    {
      name: "菜单1",
    },
    {
      name: "菜单2",
      children: [
        {
          name: "菜单2-1",
        },
        {
          name: "菜单2-2",
          children: [
            {
              name: "菜单2-2-1",
            },
            {
              name: "菜单2-2-2",
            }
          ]
        }
      ]
    },
    {
      name: "菜单3"
    }
]
// SliderItem 组件内容
<div class="item">
    <span>{{menu.name}}</span>
    <template v-if="menu.children">
      <i class="el-icon-arrow-down"></i>
      <!-- 这里递归引入自身(SliderItem)-->
      <Item
        v-for="child in menu.children"
        :key="child.name"
        :menu="child"
      />
    </template>
</div>

<script>
export default {
    // 必须定义name,组件内部才能递归调用
    name: 'Item',
    props: {
        menu: Object
    }
}
</script>

实现效果:

image.png

注意事项

  • 不需要 import 引入自身
  • 需要设置组件的 name 属性:在模板中调用自身时,组件名就是定义的 name
  • 需有结束的阙值,递归调用是条件性的(使用一个最终会得到 falsev-if),否则就是死循环了

基于递归组件的特性,我们的组件name名不能和该组件中使用的其他子组件名相同,否则会把子组件当成递归组件来渲染,造成死循环max stack size exceeded

24、组件之间的循环引用

和递归组件类似,在开发一些具体有未知层级关系的功能时,有时会由于结构等原因,出现2个组件互相引用的场景。如A组件引入了BB组件也引入了A

在模块解析时,发现它们互相依赖,变成了一个循环,不知道如何不经过其中一个组件而完全解析出另一个组件,导致解析时出错。

解决方案: 有以下4种解决方案,可根据实际情况任选其一

  1. A中动态引入B组件(异步组件)
    components: {
      BComponentName: () => import('./B.vue') 
    }
    
  2. AbeforeCreate生命周期中注册子组件
    beforeCreate: function () {
      this.$options.components.BComponentName = require('./B.vue').default
    }
    
  3. AB组件都使用全局注册(只有当这2个组件本来就是全局使用时才可使用该方案)
  4. 使用递归组件(部分组件循环引用的场景也可以通过递归组件来实现)

25、Vue.observable

让一个普通对象变为响应式数据。 Vue 内部就是用它来处理 data 函数返回的对象,让data中数据成为响应式数据。

通过 Vue.observable() 返回的对象可以直接用于渲染函数和计算属性内,并且会在发生改变时触发相应的更新。

在做一些小型的状态处理时,比较有用,比如实现一个小型的跨组件状态存储器,类似于一个简易的 Vuex

实例

// 文件路径 - /store/store.js
import Vue from 'vue'

export const store = Vue.observable({ count: 0 })
export const mutations = {
  setCount (count) {
    store.count = count
  }
}
//使用,每次数据变更时,视图就会自动更新
<template>
    <div>
        <label for="bookNum">数 量</label>
        <button @click="setCount(count+1)">+</button>
        <span>{{count}}</span>
        <button @click="setCount(count-1)">-</button>
    </div>
</template>

<script>
import { store, mutations } from '../store/store'

export default {
    name: 'Add',
    computed: {
        count () {
            return store.count
        }
    },
    methods: {
        setCount: mutations.setCount
    }
}
</script>

26、keep-alive

缓存页面、组件

组件销毁时,进行暂存而不是直接移除,下次激活时,再复用上次状态直接渲染。

使用场景

  • 保持页面状态(记录当前页面数据、滚动位置、翻页页数等),结合路由一起使用;

    就像文章最开始提到的问题中:"从列表页跳转到详情页,再返回列表,每次页面都刷新了,就不能保留之前的数据停留在之前的位置吗?"。通过 keep-alive 就可以完美解决这个问题。

  • 避免重复渲染(提高性能),在部分组件高频切换的场景使用

示例

// 作用于路由
<keep-alive :include="['a', 'b']" :max="10">
  <router-view></router-view>
</keep-alive>

// 作用于组件
<keep-alive :include="['a', 'b']">
  <component :is="view"></component>
</keep-alive>

注意事项

  • 提供 includeexclude 属性,两者都支持字符串、正则表达式、数组,include 表示名称匹配的组件会被缓存,exclude 表示名称匹配的组件不会被缓存 ,其中 exclude 的优先级更高(名称默认按照组件的 name 属性匹配);
  • 被缓存的组件,在生命周期中多2个钩子函数 activateddeactivated 。当组件被激活时,触发钩子函数 activated,当组件被移除时,触发钩子函数 deactivated
  • 不能无限制缓存,只缓存需要缓存的,不需要的缓存及时释放。否则占用内存太多可能影响性能。

    就像在缓存路由时,只有当路由需要缓存时,才添加到 include 列表中,而不是所有路由全部缓存。如:列表页跳转到详情页时,将列表页添加到 include 中,从详情页返回列表页时,再在 include 移除列表页。如果不得不全部缓存时,可以设置 max 属性,限制最多可缓存的实例数。

27、作用域插槽

让插槽的内容能够访问子组件的数据

常用于组件多处使用时,插槽需要基于子组件数据做自定义处理。

实例

一个商品列表组件,有公共的图片、名称、价格模块;

  • 在店铺页面使用时,需要再展示店铺相关信息;
  • 在搜索页面使用时,需要再展示销量相关页面;
  • 更多其他场景...
// 组件 product-list
<ul>
    <li v-for="item in list" :key="item.id">
        <div>
            <!--公共的图片、名称、价格模块-->
            ...
        </div>
        <!--父组件自定义展示内容-->
        <slot name="info" :product="item">
            <!--有默认内容也可以在这添加-->
        </slot>
    </li>
</ul>
 
props: {
  // 父组件传入的商品列表数据
  list: {
    type: Array,
    default() {
      return []
    }
  }
}
// 父组件

// 1、店铺页面
<product-list :list="list">
    //slotProps 可以随意命名
    //slotProps 接取的是子组件标签slot上属性数据的集合
    <template #info="slotProps" >
        <!--展示店铺信息-->
        {{slotProps.product.store}}
    </template>
</product-list>

// 2、搜索页面
<product-list :list="list">
    <!--也可以直接解构-->
    <template #info="{ product }" >
        <!--展示销量信息-->
        {{product.saleNumber}}
    </template>
</product-list>

结论: 基于这个,我们可以将插槽转换为可复用的模板,这些模板可以基于输入的 prop 渲染出不同的内容。这在设计封装数据逻辑的同时,允许父级组件自定义部分布局的可复用组件时是很有用的。在组件库中也经常会遇到这种用法。

element-ui的表格数据自定义。只是真正到了我们自己可能需要使用时,不容易想起来,所以这里我用了一个自定义的实例来介绍。

28、生产环境的检查、调试

有的时候在生产环境出现的问题,由于数据或环境原因,在开发或测试环境并没有复现,这个时候我们往往希望直接在生产环境进行检查、调试。比如页面数据为何没有更新?data中的内容和值是什么?怎么修改实例对象的属性,以达到我们的部分调试目的?

也就是:在生产环境如何查看Vue实例,获取、修改实例对象的属性?

调试方法

一、首先,找到我们组件跟节点对应的 DOM 元素(2种方式)

  • 在控制台中通过 DOM 查找方法(如querySelectorgetElementById等)选择元素。
  • 使用 Chrome 浏览器的控制台 Chrome devtools 的 elements 面板,选中组件的根元素,然后在控制台中输入$0$0表示最后选择的元素。$1是前一次选择的元素,依此类推.它记得最后五个元素$0$4.

二、通过 DOM 元素的__vue__属性,获取 Vue 实例的详细信息

Vue 实例的信息会通过__vue__属性绑定在组件的根节点上

// 方式1
document.querySelector('[data-v-1f10e70e]').__vue__

// 方式2
$0.__vue__

// 查看组件属性
$0.__vue__.msg

// 更改组件属性
$0.__vue__.msg = "获取到组件实例了"

通过__vue__属性,我们可以拿到实例对象,进而查看data中的值,或者修改值进行调试。实例对象如下:

image.png

29、移动端滚动穿透

在移动端开发时,往往会遇到各种各样的弹出框,而在弹出框中上下滑动时会触发页面的滚动,这个就是常见的移动端的滚动穿透。

要解决这一问题,可以给遮罩层增加以下方法 @touchmove.stop.prevent,阻止拖动的默认事件。如果是在自定义组件上绑定则需要添加 .native 修饰符。.native,表示是绑定的原生事件,而不是绑定组件内部 emit 传递的事件。

30、强制更新

正常情况下是不需要强制更新的,如果用到了强制更新,首先考虑是否代码哪里出了问题。

如上面提到的 set失败的场景非响应式数据的'伪视图'更新,我们首先应该考虑哪里导致的视图未更新,然后解决这个问题,而不是直接使用强制更新,不然下次又有可能因为相同的原因导致其他的问题,不停的补坑。所以其实非常不建议使用这个 api,除非在你不得不使用时。

this.$forceUpdate();

性能优化

1、webpack-bundle-analyzer: 构建结果输出分析

  • "怎么打包后的文件这么大,项目内容也不多啊?"
  • "打包的文件里面都有哪些内容啊?"

在我们项目开发完成,打包上线的时候,是不是经常有类似的疑问,特别是对性能有一定要求或我们自己要做相关分析的时候。如果单纯的靠看代码或页面的模块依赖,是很难并且不直观的。

webpack-bundle-analyzer 插件就可以解决这一问题。它可以更简单、直观地分析输出结果,在Vue-cli中默认集成了该插件。其他场景需要的话也可以自定义引入。

如在Vue-cli的项目中 执行 npm run build --report 后生成分析报告:可以查看我们各个包的大小。引入了哪些资源,从而进一步分析可优化项。如下:

image (1).png

我们可以查看整个项目,所有打包后的文件有哪些,及每个文件内部包含哪些内容;进而进一步分析:

  • 如发现vendor.js中引入了echarts整个包,非常大,而我们只使用了其中部分模块,就可以进行按需引入,再次查看可以发现引入的echarts体积会小非常多;
  • 发现我们有比较多的公共组件多次使用,但是每次都是直接打包到页面中了,此时可以配置Code Splitting:组件引用次数超过指定次数时,打包到一个新文件中,这种就不会重复打包,减少整体包的体积
  • 根据构建结果做更多的其他分析和优化

2、Code Splitting

SplitChunks 是 Webpack 中一个提取或分离代码的插件,主要作用是提取公共代码,防止代码被重复打包、拆分过大的 js 文件、合并零散的 js 文件等。

基于这个特性,我们可以针对项目进行打包的优化配置,比如:

  • 当我们的入口 js 文件过大时,可以将部分模块进行拆分打包,将 1 个特大文件拆分成多个小文件,利用 http 并行加载的特性提升页面加载速度;
  • 有些组件或插件在项目中会被多次使用,但是默认的打包机制,会将我们页面中引入的组件或插件打包到页面文件中,这样相同的模块就会被重复打包,增加项目的整体打包体积;此时我们就可以通过该方法将重复打包的文件提取到一个公共文件中,只打包一次,减少整体的资源体积,并利用浏览器文件缓存的特性,增加访问速度;
  • 还有其他的代码合并拆分策略;可以根据上面提到的 webpack-bundle-analyzer 对项目打包文件进行具体分析,查看可优化项。

实例

配置前

在一个基于 Vue-cli 创建的简单可视化大屏项目中,有几个组件 PageTitleTurnBtn,还有外部的第三方插件 vue-count-to,有好几个页面都会引入;默认打包后的部分文件如下:

image.png

我们可以看到,红色框中的几个相同组件、黄色框的几个相同插件,在每个页面都被分别打包到了页面文件中,但是它们的内容是一样的,这样就会造成重复打包,增加体积;

配置后

此时我们就可以配置,如:引入超过 3 次的组件和插件,我们就打包到公共文件中;配置后打包的文件如下:

image.png

我们可以看到,几个多次引入的组件(红色框)和多次引入的插件(黄色框)我们分别打包到了1个单独的 js 文件中,而页面中的打包文件中就不再包含这些内容。

前后整体包的对比

基于上面的优化,我们再把公共模块中的 echarts 插件提取到一个单独的文件中,减少公共文件的体积。

再来对比我们拆分前后的整体包:

拆分前:

拆分后:

可以发现,echarts 单独打包到一个文件,公共文件体积减少;新增了2个公共组件和插件的包,单个页面的包体积变小了;整体包的体积也变小了。由于这个项目本身并不大,所以看起来包体积可能变化并不大,如果是更大一点的项目,这个效果会更加明显。

代码实现

我们来看看,在代码中是怎么实现的:

// vue.config.js 文件
module.exports = {
  // 其他配置
  // ...
  
  // splitChunks 插件配置
  chainWebpack(config) {
    config.optimization.splitChunks({
      // 这里可以配置插件的公共配置选项
      // ...
      
      // 重点,配置提取模块的方案,每一项代表一个模块提取的方案
      cacheGroups: {
        // 提取echarts插件到一个单独的文件,减少公共js文件体积
        echarts: {
          // 提取哪些模块 async: 异步加载的; initial: 同步的; all: 所有
          chunks: 'all',
          // 打包后的文件名
          name: 'chunk-echarts',
          // 优先级,越大优先级越高
          priority: 10,
          // 匹配模块的正则
          test: /[\\/]_?echarts(.*)/
        },
        // 提取被引入3次以上的插件,到chunk-libs文件中
        libs: {
          name: 'chunk-libs',
          test: /[\\/]node_modules[\\/]/,
          // 至少被引几次后才能被提取
          minChunks: 3,
          priority: 2,
          // 如果当前要提取的模块,已经被打包,是否复用,不重新打包
          reuseExistingChunk: true
        },
        // 提取被引入3次以上的组件,到chunk-components中
        components: {
          name: 'chunk-components',
          test: /[\\/]src\/components[\\/]/,
          minChunks: 3,
          priority: 5
        }
      }
    })
  }
}

结语:我们通常会基于 webpack-bundle-analyzer 对项目进行整体的分析,根据分析结果再通过 Code Splitting 的方式优化我们的项目代码,做到有的放矢,更容易产生看得见的效果。

3、Object.freeze

大数据优化。数据量特别大的时候,使用容易卡顿,如果这些数据并不需要响应式变化,就可以冻结对象,禁止响应式,以减少开销、提高渲染效率。

就像上面 vue.set 使用中提到的,Vue 实例中的 data 数据,会使用 Object.defineProperty 把所有属性全部 getter/setter 化,Vue 对这些数据进行依赖收集,在数据变更时再进行派发更新。

而使用了 Object.freeze 之后,则不会对数据进行 getter/setter 转化,不仅可以减少转换过程的开销,也不会对这部分数据更新进行监听,还能减少不少内存开销,提高页面渲染的效率。

使用方式:this.obj = Object.freeze(Object.assign({}, obj))

注意:冻结只是冻结对象里面的属性,对象本身还是可以更改

new Vue({
    data: {
        // 这样vue不会对list里的对象属性做getter、setter绑定
        list: Object.freeze([
            { value: 1 },
            { value: 2 }
        ])
    },
    mounted () {
        // 界面不会有响应,因为单个属性被冻结
        this.list[0].value = 100;

        // 重新赋值,数据重新变成响应式
        this.list = [
            { value: 100 },
            { value: 200 }
        ];
    }
})

4、v-for && v-if

v-for 遍历必须为 item 添加 key,且避免同时使用 v-if。在 Vue2.x 中,同时使用这2个指令时,依旧会把所有数据循环一遍,再控制是否显示(相当于先执行的v-for,再执行的v-if)。在 Vue3 中针对这一点做了优化。

5、无限列表性能

如果应用存在非常长或者无限滚动的列表,那么需要采用窗口化的技术来优化性能,只需要渲染少部分区域的内容,减少重新渲染组件和创建 DOM 节点的时间。 可以参考开源项目 vue-virtual-scroll-listvue-virtual-scroller 等来优化这种无限列表的场景。

6、防抖节流

涉及到对性能有影响的高频操作,可以使用防抖节流控制频率,提高性能。

比如我之前做的可视化搭建系统,为了实现配置的所见即所得,每次后台配置组件的展示属性时,都会通过postmessage实时向组件展示区域(另外一个h5系统)发送配置数据,特别是涉及到slider滑块、input 输入框类的配置时,这种高频的传输是比较消耗性能的,此时使用防抖或节流就是很好的选择。

7、组件库的按需加载

对于大型组件库的使用,特别是只用到其中一部分的内容,只引入需要的部分(一般大点的组件库都支持按需引入)

如:echarts

// 方式1: 引入整个包 😭
import echarts from 'echarts'
// 方式2: 按需引入 😊

// 引入 ECharts 主模块
var echarts = require('echarts/lib/echarts');
// 按需引入用到的模块
require("echarts/lib/chart/line");
require("echarts/lib/chart/pie");
require("echarts/lib/component/grid");
require("echarts/lib/component/legend");

对比上面2种引入方式。可以发现,最终打包的代码,按需引入的echarts代码包的体积,比全部引入明显小很多。当然如果我们组件库中大部分内容都会被使用,那还是可以整个引入,可以根据实际情况判断。

Vue3中使用注意事项

以上所有内容都是基于 Vue2.x 版本,由于在 Vue3 中对部分 api 做了改造、移除等处理,包括 Vue 内部处理的改动。所以有些内容并不适用于 Vue3。 如下:

  • 数据响应式的处理由原来的Object.defineProperty更改为使用Proxy,所以对于数据的响应式不再是getter、setter化处理,Proxy 代理的是对象,性能更好,新增属性也不需要做特殊处理。
  • Vue.observable,改为用 Vue.reactive 替换
  • $on$off$once 实例方法已被移除
  • Vue3 中 v-model 合并了原来的v-model & .sync, 一个组件也定义多个 v-model、并指定不同的属性名
  • 过滤器(Filters)被移除,推荐使用计算属性代替
  • 异步组件新的定义方法:defineAsyncComponent
  • v-ifv-for 作用在同一元素上的优先级修改。2.x 中v-for会优先作用,3.x 中v-if 会优先作用
  • 生命周期的重命名。destroyed重命名为unmountedbeforeDestroy重命名为beforeUnmount

包括还有其他更多的改动,这里只列举出了本文档中涉及到的内容。

结语

后续如果还有其他内容,也会考虑持续更新;如果大家也有其他的想法或者建议,欢迎在评论区留言。😄

最后,熬夜爆肝,写了这么多,实在不容易,点个赞呗!👍👍👍