Skip to content

应用技巧

props 校验

​ 父子传参的props校验(底层由 flow.js 实现,vue3 使用 ts 实现)是很有必要的,props校验支持定义typedefaultrequiredvalidator

关注点:

  • type:除了支持常见数据格式,还支持 Promise 和 Symbol,传入数组表示支持多类型;
  • default:对象或数组的默认值必须从一个工厂函数返回;
  • validator:校验函数,返回值为falsy则报错;

v-bind、v-on

​ 在进行组件数据、事件绑定的时候,v-bind、v-on 支持直接传递对象或表达式,如下:

<template>
	<child v-bind="attrs" v-on="events ? {click: () => {}} : ''">
</template>

<script>
	export default = {
        data() {
            return {
                attrs: {
                    name: '小花',
                    age: '18.8',
                },
                events: true,
            }
        }
    }
</script>

提示:

在封装通用组件的时候,搭配$attrs、$listeners、$props 起来使用,可根据不同页面需求实现动态注册 props、event;

关于默认值,组件 attrs 的优先级高于 v-bind、v-on,原因是模板编译的时候会特殊处理 v-bind、v-on,通过遍历 v-bind 读取 key,如组件 attrs 上存在 key 则不处理,不存在则添加到组件 attrs 上,所以组件 attrs 的优先级高于 v-bind、v-on。

// 此时attrs上的name属性会被xxx覆盖
<child name="xxx" v-bind="attrs" v-on="events ? {click: () => {}} : ''" />;

// 伪代码
for (let key in vm.vBind) {
  if (!vm._attrs[key]) {
    vm._attrs[key] = vm.vBind[key];
  }
}

所以动态注册 props、event 时,组件需要可覆盖的默认值的话,那就得在 v-bind、v-on 中设置默认值(使用扩展运算符,如:<child v-bind="{name: 'children', ...attrs}" />);

$attrs、$listeners

​ 官网中介绍到这两个 API 在封装高级别的组件时非常有用,就是二次封装第三方组件的时候使用上这两个 API,搭配inheritAttrs: false,将组件父作用域中的事件监听和非 props 的属性透传给下级组件,

​ 如封装 elementUI 的头像组件,该组件支持 hover 出现下拉菜单,代码如下:

<template>
  <!-- 用法:<Avatar :size="60" src="http://cube.elemecdn.com/
  3/7c/3ea6beec64369c2642b92c6726f1epng.png" /> -->
  <el-dropdown>
    <el-avatar v-bind="$attrs" v-on="$listeners"></el-avatar>
    <el-dropdown-menu slot="dropdown">
      <el-dropdown-item>个人中心</el-dropdown-item>
      <el-dropdown-item>退出登录</el-dropdown-item>
    </el-dropdown-menu>
  </el-dropdown>
</template>

<script>
export default {
  name: 'Avartar'
  inheritAttrs: false
}
</script>

TIP

  组件的内的$attrs是指父作用域中不作为 prop 被识别 (且获取) 的 attribute 绑定(排除 class、style),与之相对的是$props

  在data工厂函数中,直接使用工厂函数的第一形参vm,或者直接访问this,都可以访问到$props$attrs之类的属性,通过在工厂函数中计算这个属性的值,可以动态返回data的值,如:

// 类似于created钩子函数中设置默认值,非必要请勿滥用
data() {
  return { reply: this.$props.msg === 'How are you' ? 'I am fine' : 'Thank you' }
  }

$options

$options当前组件的初始选项,获取组件的初始值(读取组件初始信息),常用来处理“数据重置”,如表单的"重置"功能;

Object.assign(this.$data, this.$options.data());

Object.freeze

​ 针对某些需要初次渲染后就不再响应式变化的变量,vue2 没有像 vue3 那样特别定义出 Api,在 vue2 中可以在赋值的时候使用Object.freeze来阻止 vue 写入追踪响应式变化(会有那么点性能提升);(vue 官网描述)

<template>
  <div v-for="item in name" :key="item">{{ "姓名:" + item }}</div>
</template>

<script>
export default = {
       data() {
           return {
               name: Object.freeze(['小明', '小红', '小刚'])
           }
       }
   }
</script>

.sync 修饰符

​ 受限于单向数据流,vue 的父子组件传参是通过 props 和$emit来实现,很多时候,父子组件实现props的双向绑定是使用官网建议的自定义组件的 v-model,另外的,官网也介绍了.sync修饰符的使用,父组件传递props的时候加上.sync修饰符,子组件需要更新props时,使用$emit('update:props', payload)的形式来变更,父组件那边就可以省略一个自定义事件监听(实际上.sync修饰符、自定义组件中使用 v-model的实现,还是在借助props$emit,只是一种语法糖,并未破坏vue的单向数据流);

// 例如element-ui的dialog组件就使用了这种语法糖
<el-dialog :visible.sync="showDialog" />

// dialog组件内 => this.$emit('update:visible', false)

$watch

$watch允许动态添加数据侦听,它返回一个unwatch函数,取消侦听的时候调用一下即可,常用于一次性侦听(只侦听一次或特定值变化时取消侦听,有点类似于setTimeIntervalclearInterval,某些情景下十分有用)

// 监听游戏是否胜利,胜利则结束游戏,并祝贺玩家,取消监听
created() {
    const unWatch = this.$watch('win', (cur, old) => {
      this.gameOver = true
      console.log('Congratulration!')
      unWatch()
    })
},

TIP

watch方法中的immediatedeep两个配置项可写成对象形式以$watch的第三参数填写进去;

比较有意思的是,$watch实现的动态监听,其监听条件,官网文档给出的类型是expOrFn,即表达式/函数(string / function),据此可以实现一个简单的waitUntil,如下:

import Vue from "vue";
/**
 * @example
 * ```js
 * waitUntil(() => this.num > 1, () => console.log('num > 1'))
 * waitUntil(() => this.num > 1).then(() => console.log('num > 1'))
 * ```
 */
function waitUntil(conditions, cb) {
  if (typeof conditions !== "function") {
    throw new Error("Watcher only accepts function");
  }
  let promise;
  if (!cb) {
    promise = new Promise((resolve) => {
      cb = () => {
        resolve();
      };
    });
  }

  const vm = new Vue();
  const unWatch = vm.$watch(conditions, function (val) {
    if (val) {
      cb();
      unWatch();
      vm.$destroy();
    }
  });

  if (promise) {
    return promise;
  }
}

多参数 filter

​ 过滤器filter是支持多参数和嵌套调用的,例如:

Vue.filter("stringJoin", (val, val1) => val + val1);
/*
 * 使用:例如将Joy . DC合成Joy.DC
 * 写法:{{ 'DC' | stringJoin('.') | stringJoin('Joy') }}
 */

嵌套调用的方式类似链式调用,可以组合相当多的功能,实现格式转换的一定程度上的自由度。

$event

$event,自定义事件的事件对象($emit 提交的参数)或原生事件对象,支持在template中显式书写出来,如:

<template>
	<div>
    	<child @click="handle('子组件1', $event)">
        <child @click="handle('子组件2', $event)">
    </div>
</template>

<script>
	export default = {
        methods: {
            handle(name, ev) {
                console.log(`这是${name}接受到的自定义事件参数${ev}`)
            }
        }
    }
</script>

TIP

  在组件中$emit的参数就是父作用域中自定义事件回调的$event,如果自定义事件回调只有$event一个参数的话,可以将@click="handleClick($event)"简写为@click="handleClick"$event以回调函数第一参数的形式隐式传递下去;

  子组件$emit多参数时,不适合使用$event占位,建议使用箭头函数传递自定义参数参数,如@click="(val, val1) => handleClick(val, val1, 自定义参数)"(PS:最烦的是想偷懒,但是 vetur/volar 会报参数的 ts 类型未指定,只好把隐私 any 搞起来了……);

事件总线 EventBus

​ 跨级组件通讯或未知层级组件通讯可以使用EventBus,其原理是 Vue 的$on$emit这两个 API 实现单实例内自定义事件的监听和触发;使用方法是实例化一个空的 Vue 实例,然后在需求组件中调用这个实例上的$on来监听自定义事件或调用$emit来触发自定义事件并传参,如:

// main.js
Vue.prototype.$eventBus = = new Vue()

// component A
created() {
this.$eventBus.$on('sayName', (name) => console.log(name))
this.$once('hook:beforeDestroy', () => { this.$eventBus.$off('sayName') }) } //
component B
<button
  @click="$eventBus.$emit('sayName', 'Jane')"
>my name is hanMeiMei</button>

TIP

​  使用事件总线监听事件要记得搭配$off及时取消监听,慎用this.$off(),这相当于直接清空所有监听事件,建议指定需要取消的监听事件;

HookEvent

定时器、DOM 事件监听、自定义事件监听之类消耗资源的行为应该在组件或页面切换的时候及时销毁,Vue 官网针对这类情况,提出了程序化的事件侦听器,就是使用$once搭配hook的写法来实现资源销毁的简写;

mounted: function () {
  document.addEventListener('scroll', this.handleScroll)
  const timer = setInterval(() => {
    this.time++
  }, 1000)
  this.$once('hook:beforeDestroy', () => {
    document.removeEventListener('scroll', this.handleScroll)
    // 销毁定时器
    clearInterval(this.timer)
  })
}

​ 除了上述的情况使用到hook之外,另外的用法是父组件内监听子组件的生命周期,常用于监听mountedupdatedbeforeDestroy之类的生命周期钩子来拓展某些功能(加 loading 效果、重新绑定事件或者销毁某些资源之类的操作);

<child @hook:updated="viewsChange" />

transition组件的事件钩子(elementUI 的 dialog 组件就是用 transition 组件的事件钩子来实现 open、opened、close 事件回调),也算是我们封装组件可供选择的另一种监听方案;

无论是 eventBus,还是说 HookEvent,它背后的原理都离不开Vue原型上实现的发布/订阅相关的 API($on、$once、$off、$emit),又由于所有的组件都继承于Vue对象,所以所有组件都存在这些 API,具体的分析可以查看《组件内的发布/订阅模式》;

神奇的 key

​ 业务中总免不了要强制刷新组件,记得在使用element-ui的 dialog 组件嵌套表单组件的时候,重新打开 dialog 组件,其内的表单组件并没有被重置,每次打开 dialog 组件,表单组件都保存着上一次的状态,研究发现,这个 dialog 组件的显示/隐藏就是v-show一样的操作 css 属性来实现,实际上相关的组件并没有被重新渲染;

​ 要解决刚提到的组件不刷新(重新渲染)问题,这时候,key的存在就十分的合理了,key是虚拟 DOM 节点的唯一标识,diff算法也会对其进行判断,从而决定是否复用这个节点,组件不刷新,那我们就告诉diff算法这个组件不是从前的组件了(key 不同了),别复用这个组件节点,重新渲染组件;

​ 注意:以下示例结合了element-ui的 dialog 组件、表单组件

// iInput.vue
<template>
  <div>
    <el-input v-model="inpt"></el-input>
  </div>
</template>

// parent.vue
<template>
  <div>
    <el-dialog :visible.sync="visible">
      <i-input :key="inptKey" />
      <div slot="footer">
        <el-button @click="visible = false">取 消</el-button>
        <el-button type="primary" @click="visible = false">确 定</el-button>
      </div>
    </el-dialog>
    <button @click="handleClick">显示/隐藏</button>
  </div>
</template>

<script>
import IInput from "./iInput.vue";
export default {
  data() {
    return { visible: false, inptKey: 0 };
  },
  methods: {
    handleClick() {
      this.visible = !this.visible;
      if (this.visible) {
        // 每次打开dialog都重置一下form组件
        this.inptKey = new Date().getTime();
      }
    },
  },
};
</script>

TIP

​ 除了上面提到v-show情况,诸如强制刷新keepAlive组件动态路由组件刷新都可以为需要刷新的组件加上一个不同的 key 值,使得组件重新渲染,相关数据初始化;

Provide/Inject

来自官方文档的提示:

provide 和 inject 绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的 property 还是可响应的(在element-ui中常见这种注入的操作,也算是一种状态管理的方式)。

组件实例 API

​ Vue 提供$root$parent$children$refs这几种特殊 API 来获取指定组件的实例,某些场景下拿到 Vue 的组件实例是十分有用的,如调用组件实例的methods、修改$data的值,又或者是获取到组件的$el等操作,实际上这类的操作也就相当于组件通讯了,获取到组件实例之后,在调用其methods时,完全可以传递参数给该组件方法,不建议滥用,任意的调用或修改其它组件的方法/属性,会影响组件的稳定性;

应用场景:

  • 类似于element-ui的表单校验所调用的resetFieldsvalidate等方法;
  • 通过$root的属性/方法注入,以达到全部组件可读写的目的,就是实现类似于vuex的状态管理(比较少用),或添加路由组件的reload方法之类的;
  • 通过$parent$children$refs获取到组件实例,然后读取$el,进行一些 DOM 上的操作(例如 echarts 挂载节点的获取,建议少用);

Vue.extend

Vue.extend这个 API 支持组件实例,最常用的场景是实现动态挂载(未知组件挂载位置),或借助extend继承甚至于扩展组件能力(如 elementUI 的 message、msgBox 之类的),官方文档说到extend类似于mixins,实际上比 mixins 强大很多,mixins 是没办法做到<template>模板的复用和拓展,而extend可以;

​ 示例:

<!-- suffixMsg.vue -->
<template>
  <span>{{ text }}</span>
</template>

<script>
export default {
  name: "suffixMsg",
  data() {
    return {
      text: "",
    };
  },
};
</script>
<!-- 点击具体按钮,在按钮后面追加提示信息,若原有追加信息则先删除再追加 -->
<template>
  <div v-for="i in 3" :key="i" :ref="'btn' + i">
    <button @click="appendMsg(i)">按钮{{ i }}</button>
  </div>
</template>

<script>
import Vue from "vue";
import suffixMsg from "@/views/home/suffixMsg";
export default {
  data() {
    return {
      instance: null,
      nodeRecord: null,
    };
  },
  methods: {
    appendMsg(i) {
      // 原有追加信息,删除追加信息
      if (this.instance && this.nodeRecord) {
        this.instance.$destroy();
        this.$refs[this.nodeRecord][0].removeChild(this.instance.$el);
      }
      // 扩展组件并挂载DOM
      const SuffixMsg = Vue.extend(suffixMsg);
      this.instance = new SuffixMsg({
        data: { text: "这里是按钮" + i },
      });
      this.instance.$mount();
      this.nodeRecord = "btn" + i;
      this.$refs[this.nodeRecord][0].appendChild(this.instance.$el);
    },
  },
};
</script>

TIP

​ 除了使用appendChild的方式来实现组件实例挂载,还可以使用new SuffixMsg({ el: 'css选择器' })或者new SuffixMsg().$mount('css选择器')的形式实现实例挂载;

想在 SFC 组件上使用 extend?只需要在组件中使用extends属性即可:

<script>
export default {
  extends: require("./suffixMsg.vue").default,
  data() {
    return {
      text: "hello world",
    };
  },
};
</script>

需要注意的是,无论是Vue.extend,还是SFC的extends,都只支持一个组件的继承,如果需要多个组件的继承,可以使用Vue.mixin,实现继承的时候需要注意 render 函数的覆盖,如果想复用组件的逻辑,但修改模板,就需要修改 render 函数(SFC 中是template模板),render 会直接覆盖原组件的 render;

Vue class component中实现继承,直接使用 ES6 的extends语法糖即可,记得在constructor中调用super

需要留意一下的是,引入的SFC组件默认是一个构造函数,就是得使用Vue.extend来实例化为Vue组件实例(这过程是SFC将模板转成渲染函数的过程);

$set、$delete

总所周知,Vue2 实现响应式的原理是借用 Object.defineProperty 来实现对象属性的劫持,其本质上是定义对象属性,Vue 在组件初始化的时候会对 data 中的数据进行属性遍历定义响应式,在初始化结束之后,如果需要对 data 中的数据进行新增或者删除,Vue 是无法监听到的,这时候就需要使用$set或者$delete来实现响应式;

export default {
  data() {
    return {
      list: [1, 2, 3],
    };
  },
  methods: {
    add() {
      // error
      // this.list[3] = 4;

      // correctly
      this.list.push(4);
      // or
      this.$set(this.list, this.list.length, 5);
    },
  },
};

关于数组常用的元素操作方法,Vue 自身是在数组原型上进行了这类 API 的monkey patch(源码位置:src/core/observer/array.js),所以在使用这些方法的时候,Vue 是可以监听到的;

$scopedSlots

在 Vue 中,使用slot来实现组件占位,在子组件初始化的时候,会读取父组件传递过来的slot来进行渲染,本质上slotsprops做的都是参数传递,并无差别(用 props 传 VNode 也不是不行),业务上,element-ui$message组件支持传递VNode,本质上就是通过在组件实例化之前使用vm.$slots.default = VNode来实现对VNode传递的支持(挺多人以为支持VNode就是得传一个渲染函数或 JSX,实际上各种类型的组件都是可以的,SFC、Functional、JSX、渲染函数、Vue.component,只要是返回组件构造器的,都可以);

// Child.vue
<template>
  <div>
    <!-- props传递VNode -->
    <component :is="propsComponent" />
    <slot></slot>
  </div>
</template>

<script>
export default {
  props: {
    propsComponent: {
      type: Object,
      default: () => {
        render: (h) => h("div", "props传递的VNode");
      },
    },
  },
};
</script>

我们都知道,在模板中,或者 render 函数中,可以显式的使用插槽(v-slot、$slots)来占位,在官网中是这么介绍$scopedSlots 的:所有的 $slots 现在都会作为函数暴露在 $scopedSlots 中。如果你在使用渲染函数,不论当前插槽是否带有作用域,我们都推荐始终通过 $scopedSlots 访问它们。这不仅仅使得在未来添加作用域变得简单,也可以让你最终轻松迁移到所有插槽都是函数的 Vue 3。

其中的意味就是,那怕你没显式声明插槽,只要父组件上给组件传了插槽,那么组件内部就可以通过$scopedSlots来访问到这个插槽,可以借此实现动态插槽,这在组件封装上是是十分实用的(例如基于组件库二封的 JSON schema 时支持动态派发插槽组件,实现更灵活的组件表现);

// Child.vue
<template>
  <div>
    <div v-for="(slotName, index) in Object.keys($scopedSlots)" :key="index">
      <slot :name="slotName"></slot>
    </div>
  </div>
</template>

<script>
export default {
  data(vm) {
    console.log(vm.$scopedSlots);

    return {};
  },
};
</script>

在讲解$attrs$listeners的时候,我们提到二次封装组件的透传属性、事件的方法,利用$scopedSlots就可以实现插槽的透传的了(其实就是上面的Child.vue改造一下就可以了);

<template>
  <grandson>
    <!-- 这里的key就可以不用传了,主要是得用上v-slot并指定名字和读取作用域插槽的数据,
      并通过v-bind传递给上一级 -->
    <template v-for="slotName in Object.keys($scopedSlots)" #[slotName]="scoped">
      <slot :name="slotName" v-bind="scoped"></slot>
    </template>
  </grandson>
</template>

参考文档

Vue 官方教程

一个合格的中级前端工程师应该掌握的 20 个 Vue 技巧