vue2 组件库开发记录-开发技巧
前言
本文主要是记录我在开发组件库时的一些开发技巧。并且会讲解一些比较特殊的组件。
install
我们在使用element-ui
的时候,可以通过Vue.use(Button)
注册组件。use
函数内部实际上就是调用了传入的对象的install
函数,同时install
函数会接受到一个vue
参数。
import Alert from "./src/alert.vue";
Alert.install = Vue => Vue.component(Alert.name, Alert);
export default Alert;
props 属性
props 是用来做父子组件之间的通信,并且 props 的写法有很多种,相信做 vue 开发的同学应该都知道的。所以我这里主要说 2 点
- 当默认值是一个数组或者是对象时,必须从一个工厂函数中返回,否则所有组件实例都会共用一个值
export default {
props: {
obj: {
type: Object,
// 简写的时候需要注意返回的{}外层需要套一个(),否则就是一个空函数,没有返回值
default: () => ({})
}
}
};
- 自定义校验。这个在项目开发中可能会很少会用到。但是在组件开发中却经常用到。比如一个
Button
组件的type
属性是String
类型,但是只接受default
,primary
,success
,开发者可能传入的字符串不符合我们的预期,所以需要用到自定义校验,给开发者一个适当的提示。
export default {
props: {
type: {
type: String,
default: 'default',
validator: function (value) {
// 这个值必须匹配下列字符串中的一个
return ['default', 'primary', 'success'].indexOf(value) !== -1;
}
}
}
};
注意:组件的 this,在default
和validator
字段中是不可用的。
provide 和 inject 属性
这两个属性我相信很多同学都是没有使用过的,甚至有的人可能都没听过。这两个属性可以用来做跨组件层级通讯,实现父子或者祖孙组件之间的通信。可能会有人说,我使用props
属性将数据一层一层的传递下去不也可以实现 provide
和 inject
的效果吗,这个说的也没错,但是有些场景却实现不了。我们看一下checkbox
组件的使用方式
<checkbox-group v-model="value">
<checkbox label="抽烟"></checkbox>
<checkbox label="喝酒"></checkbox>
<checkbox label="探头"></checkbox>
</checkbox-group>
上面可以看见checkbox-group
组件无法通过props
属性给checkbox
组件传递数据,因为checkbox
组件并不是直接写在checkbox-group
组件内部的,而是通过slot
插槽放置到checkbox-group
组件中的,从而实现了父子关系的组件。此时,provide
和 inject
属性就可以解决这种组合组件的写法之间的通讯问题。provide
和 inject
的特点如下:
provide
可以是一个对象或者是返回一个对象的函数(推荐使用这种写法)。inject
可以是一个数组或者是一个对象(推荐这种写法)。- 如果
provide
传入的是一个响应式的对象(组件开发中一般直接传入this
),那么inject
接收的值也是个响应式数据
代码示例:
// checkbox-group组件
export default {
props: {
// 绑定值
value: {
type: Array
},
},
provide() {
return {
// 直接把组件实例传递给所有子孙组件
CheckboxGroup: this
};
}
};
// checkbox组件
export default {
inject: {
// 接收checkbox-group组件通过provide传递过来的数据
CheckboxGroup: {
default: ""
}
},
mounted(){
// checkbox-group组件的value值
console.log(this.CheckboxGroup?.value)
}
};
provide
和 inject
的使用场景有两个,分别如下:
- 具有组合关系的组件。比方说上面的
checkbox-group
组件和checkbox
组件,他们就是组合关系。checkbox
组件可以单个进行使用,也可以和checkbox-group
组件在一起组合使用。checkbox
组件通过判断this.CheckboxGroup
是否存在来判断开发者是单个使用还是结合checkbox-group
组件一起使用,从而实现不同的逻辑。 - 跨层级组件传递数据。当你的组件层级很深的时候,比如
A->B->C->D
。如果A
想要跟D
进行通讯,就必须通过B
和C
的props
属性一层一层的传递下去,这样会造成数据的混乱的,而且如果传递的数据非常多,写起来也很麻烦。所以这个时候可以使用provide
和inject
来进行通信,数据的流向就不用经过B
和C
,你只需要专注于A
和D
之间的数据处理即可。
$children 和 $parent
$children
是用来获取当前组件的所有子组件,$parent
是用来获取当前组件的父组件。组件的子组件可能会有多个,但是父组件只能有一个(根组件没有父组件)。所以我们可以通过递归的方式获取该组件的子孙组件和父级组件,并实现广播和派发的功能,实现具有上下级组件关系的通讯。
注意:一般查找父级组件或者子孙组件都是通过组件的name
字段进行查找的,所以每个组件内部最好有一个name
字段,这样才能有效过滤出你想要查找的组件。
代码示例:
// 查找所有子孙组件
function findChildren(context, componentName) {
return context.$children.reduce((components, child) => {
if (child.$options.name === componentName) {
components.push(child);
}
const children = findChildren(child, componentName);
return components.concat(children);
}, []);
}
// 查找所有父级组件
function findParents(context, componentName) {
const parents = [];
const parent = context.$parent;
if (parent) {
if (parent.$options.name === componentName) {
parents.push(parent);
}
return parents.concat(findParents(parent, componentName));
} else {
return [];
}
}
派发与广播
// 向下通知
function broadcast(options) {
const { eventName, params, componentName } = options;
// 获取当前组件下的所有的孩子
const broad = (children) => {
children.forEach((child) => {
if (componentName) {
if (child.$options.name === componentName) {
child.$emit(eventName, params);
}
} else {
child.$emit(eventName, params);
}
if (child.$children) {
broad(child.$children);
}
});
};
broad(this.$children);
}
// 向上通知
function dispatch(options) {
const { eventName, params, componentName } = options;
let parent = this.$parent || this.$root;
let name = parent.$options.name;
while (parent) {
if (componentName) {
if (name === componentName) {
parent.$emit(eventName, params);
}
} else {
parent.$emit(eventName, params);
}
parent = parent.$parent;
name = parent?.$options.name;
}
}
广播通信例子
const parent = {
name:'parent'
template:`
<div>
<div @click='onBroadcast'>broadcast</div>
<child/>
</div>
`,
methods:{
onBroadcast(){
broadcast.call(this,{
name:'custom',
params:'hello world',
componentName:'child'
})
}
}
}
const child = {
name:'child',
created(){
this.$on('custom',event=>{
console.log(event) // hello world
})
}
}
在上面例子中,child 组件需要在 parent 组件进行广播前使用$on
注册事件,否则是接收不到 parent 组件的广播的
广播与派发的应用场景可用于Form
和FormItem
组件的表单校验:
input
,checkbox
等表单组件值发生变化的时候,通过dispatch
向上通知FormItem
组件进行校验Form
组件需要校验整个表单的时候,通过findChildren
查找到所有FormItem
组件,调用FormItem
组件内部的方法进行校验,获得校验结果,从而反馈给用户
EventBus 事件总线
EventBus 可实现任意组件之间的通信,借助 EventBus 的$emit
派发事件,$on
监听事件就可以实现任意组件之间的通信。 EventBus 实际上是通过发布/订阅方法来实现的,通过导出一个new Vue()
实例(单例),所有组件都是用该实例进行事件的派发和监听。
代码示例:
// event-bus.js
import Vue from 'vue';
const EventBus = new Vue();
// 发送事件
EventBus.$emit('custom', { age: 1 });
// 接收事件
EventBus.$on('custom', (event) => {});
// 监听一次事件
EventBus.$once('custom', (event) => {});
// 移除事件
EventBus.$off('custom');
EventBus 在组件库开发中使用的场景比较少,但也是一种跨组件的通信方式,对比于props
,provide 和 inject
,$children 和 $parent
这些通信方式(只能在具有上下级关系的组件中进行通信,不能再兄弟组件之间进行通信),优点在于可以在任意组件之间进行通信,缺点就是一旦事件多了,就变得很难管理。
$attrs
$attrs
包含哪些没有在组件的props
字段中声明的属性(class 和 style 除外)。
例子
const A = {
props:['name']
}
<A name='张三' age='17' sex='男' />
从上面的例子中,我们可以看见props
属性中只声明了name
字段,所以age
和sex
字段包含在了$attrs
中,name
并不在$attrs
中
常用于那些有许多原生属性的组件中,比如input
组件,原生的input
标签包含了很多字段,如果我们将input
标签的所有原生属性都在props
中都声明一边,那就会非常麻烦。我们一般会将一些非原生属性或者需要在组件内部使用到的原生属性声明在props
中,其余的字段通过v-bind="$attrs"
挂在到input
标签上面
代码示例:
const LinInput = {
template:'<input :disabled="disabled" :value="value" v-bind="$attrs" />',
props:['disabled','value']
}
<lin-input disabled value='1' name='age' placeholder='请输入' />
$scopedSlots 作用域插槽
$scopedSlots
作用域插槽。这个东西我相信很多同学都没接触过。我也是在开发Table
组件时第一次使用到它。不得不说这个东西真的很强大很巧妙。文字说明可能不够透彻,所以下面我们通过Table
组件的代码来讲解。
首先看一下使用方式:
<template>
<lin-table value-key="id" :dataSource="tableData">
<lin-table-column prop="date" label="日期">
<template slot-scope="scope">
<div>{{ scope.row.date }}</div>
</template>
</lin-table-column>
<lin-table-column prop="name" label="姓名"></lin-table-column>
<lin-table-column prop="address" label="地址"></lin-table-column>
</lin-table>
</template>
export default {
data() {
return {
tableData: [
{
id: 1,
date: "2016-05-02",
name: "王小虎",
address: "上海市普陀区金沙江路 1518 弄",
}
],
};
},
};
lin-table-column
组件只负责收集数据,并不会渲染任何东西。比如prop
,label
这些数据,然后将这些数据存放在table
组件中。然后通过table
组件来渲染这些东西。
lin-table-column.jsx
let columnId = 0;
export default {
name: 'LinTableColumn',
props: {
prop: String,
label: String
},
inject: {
// table组件的实例
table: {
default: null
}
},
watch: {
prop(val) {
this.column.prop = val;
},
label(val) {
this.column.label = val;
}
},
created() {
// 把该组件的props属性都存储起来
const column = {
...this.$props,
id: `col-${columnId++}`
};
// 默认提供一个渲染单元格的render函数,核心内容
// h是渲染函数,rowData是每一行的数据
column.renderCell = (h, rowData) => {
let render = (h, data) => {
return data.row[column.prop];
};
// 判断是不是使用了插槽
if (this.$scopedSlots.default) {
// 通过this.$scopedSlots.default获取默认插槽的VNode,也就是这个东西
// <template slot-scope="scope">
// <div>{{ scope.row.date }}</div>
// </template>
render = (h, data) => this.$scopedSlots.default(data);
}
return render(h, rowData);
};
this.column = column;
},
mounted() {
if (this.table) {
// 把该组件收集到的数据存储在table组件中。
this.table.columns.push(this.column);
}
},
destroyed() {
if (this.table) {
// 销毁的时候需要把对应的列移除掉
const index = this.table.columns.findIndex(
(column) => column.id === this.column.id
);
if (index > -1) {
this.table.columns.splice(index, 1);
}
}
},
render() {
// 不做实际的渲染
return null;
}
};
table.vue
<template>
<div>
<div class="lin-table-slot">
<!-- lin-table-column组件 -->
<slot></slot>
</div>
<table class="lin-table">
<!-- 渲染头部相关的东西 -->
<lin-table-header ref="linTableHeaderComp"></lin-table-header>
<!-- 渲染表格内容 -->
<lin-table-body ref="linTableBodyComp"></lin-table-body>
</table>
</div>
</template>
<script>
import LinTableHeader from './TableHeader.jsx';
import LinTableBody from './TableBody.jsx';
export default {
name: 'LinTable',
components: {
LinTableHeader,
LinTableBody
},
props: {
// 数据源
dataSource: {
type: Array,
default: () => [],
require: true
},
// 每一行数据的唯一标识key
valueKey: {
type: String,
require: true
}
},
provide() {
return {
// 往子组件中注入table实例,以便子组件可以访问到table组件的数据
table: this
};
},
data() {
return {
// 存储lin-table-column组件收集到的信息
columns: []
};
}
};
</script>
TableHeader 组件的内容还是很简单的,就是根据使用v-for
将columns
字段的 label 字段渲染出来。所以这里不讲解 TableHeader 组件,直接讲解TableBody
组件
TableBody.jsx
export default {
name: 'LinTableBody',
computed: {
// 数据源
dataSource() {
if (this.table) {
return this.table.dataSource;
}
return [];
},
// 列数组
columns() {
if (this.table) {
return this.table.columns;
}
return [];
},
// 每一行数据的唯一标识 key
valueKey() {
if (this.table) {
return this.table.valueKey;
}
return '';
}
},
inject: {
table: {
default: null
}
},
render(h) {
const { dataSource, columns, valueKey } = this;
return (
<tbody class="lin-table-tbody">
{dataSource.map((row, rowIndex) => {
const rowKey = row[valueKey] || rowIndex;
return (
<tr key={rowKey}>
{columns.map((column, idx) => (
<td key={`${rowKey}-${idx}`}>
// lin-table-column中的renderCell渲染函数
{column.renderCell(h, {
row,
column,
rowIndex
})}
</td>
))}
</tr>
);
})}
</tbody>
);
}
};
从上面可以看出TableBody
组件的核心就是调用renderCell
函数,而renderCell
这个函数就是在lin-table-column
组件收集到的每个单元格的渲染函数。
Vue.extends 实现 js 调用组件
像element-ui
的message
,message-box
等组件都通过 js 的方式进行调用。在实际项目开发中有时候也需要根据需求实现一个 js 调用的组件,比如,我点击一个按钮,用户没有权限的时候需要弹框显示暂无权限,游客则需要弹出登录框。所以,下面以message
组件为例,讲解一下怎么通过 Vue.extends 实现 js 调用组件。
首先新建一个message.vue
,并实现你需要的功能
<template>
<transition name="message" @after-leave="afterLeave">
<div :class="`lin-message-${type}`" v-show="show">
<p class="lin-message-content">{{ message }}</p>
</div>
</transition>
</template>
<script>
export default {
name: 'LinMessage',
props: {
// 类型主题
type: {
type: String,
default: 'info'
},
// 消息文字
message: {
type: String
}
},
data() {
return {
// 控制是否显示
show: false
};
},
methods: {
// vue动画结束后回调函数
afterLeave() {
this.$emit('closed');
}
}
};
</script>
使用 Vue.extends 继承一个 vue 组件
import Vue from 'vue';
import Message from './message.vue';
// 创建一个子类构造器
const MessageConstruct = Vue.extend(Message);
class LinMessage {
// 参数
options = null;
// message实例对象
instance = null;
// message组件参数
propsData = {};
// 自动关闭定时器
timer = null;
constructor(options) {
this.options = options || {};
this.initProps(options);
this.init();
}
// 初始化message组件参数
initProps() {
const props = ['type', 'message'];
const propsData = {};
props.forEach((prop) => {
if (prop in this.options) {
propsData[prop] = this.options[prop];
}
});
this.propsData = propsData;
}
// 初始化
init() {
// 创建一个vue实例,实际上跟new Vue()差不多
this.instance = new MessageConstruct({
// propsData会跟组件中的props进行合并
propsData: {
...this.propsData
}
// 这个选项会直接覆盖掉组件的props,所以一般这个是不会使用的
// props:{}
// 这个选项会跟组件中的data进行合并,写法上可以是一个对象,也可以是返回一个对象的函数
// data:{}
});
// 渲染
this.instance.$mount();
// 将渲染出来的dom挂在到body上面
document.body.appendChild(this.instance.$el);
// 显示出来,this.instance相当于在组件中的this
this.instance.show = true;
// 设置定时器,用于定时关掉message组件
this.setTimer();
// 监听事件
this.instance.$once('closed', () => {
// 销毁组件
this.destory();
});
}
setTimer() {
const { duration } = this.options;
// 等于0不会自动消失
if (duration !== 0) {
this.timer = setTimeout(() => {
this.close();
}, duration || 3000);
}
}
// 隐藏message组件
close() {
if (this.instance && this.instance.show) {
this.instance.show = false;
}
}
// 销毁message组件
destory() {
if (this.instance) {
document.body.removeChild(this.instance.$el);
this.instance.$destroy();
}
if (this.timer) {
clearTimeout(this.timer);
}
}
}
// 创建实例,options可传入字符串或者一个对象
function createInstance(options) {
const toString = Object.prototype.toString;
if (toString.call(options).includes('Object')) {
return new LinMessage(options);
}
return new LinMessage({
message: options.toString()
});
}
// 创建不同类型type的message组件
function createInstanceByType(options, type) {
const toString = Object.prototype.toString;
if (toString.call(options).includes('Object')) {
return new LinMessage({
...options,
type
});
}
return new LinMessage({
message: options.toString(),
type
});
}
createInstance.success = function success(options) {
return createInstanceByType(options, 'success');
};
createInstance.info = function info(options) {
return createInstanceByType(options, 'info');
};
export default createInstance;
指令
指令在组件库或者在实际项目开发中使用到的场景比较少。虽然比较少,但是还是有必要讲一下。比如现在有个需求需要根据用户的权限去显示或者隐藏某个按钮,你可能会在页面上先判断用户是否有权限,然后再通过v-show
去显示或者隐藏。这样做是可以的,但是如果页面上需要控制的按钮比较多,那样就会显得很麻烦了。这个时候我们可以使用指令。用法如下:
<button v-permission="['p1','p2]"></button>
当用户拥有p1
和p2
权限的时候,就会显示按钮。
指令中也提供了 5 个钩子函数,我们可以再不同的钩子函数中处理不同的事情:
- bind:只调用一次。在这里可进行初始化
- inserted:元素插入到父节点
- update:指令所在的组件发生更新调用
- componentUpdated:指令所在的组件和
其子组件
全部更新完成后调用 - unbind:调用一次。指令更元素解绑,在这里可进行一些销毁工作
常用到的钩子函数有三个,分别是:bind
,update
,unbind
,其余的我基本上没用过。
指令的结构如下:
Vue.directive('permission', {
bind(el, binding, vnode) {
// el是指令绑定的元素,你可以在el上面绑定一些信息,用于在其他钩子函数中使用,比如 el.message='你好'
// binding是包含了指令的相关信息,比如 v-permission:foo="['p1','p2]" ,foo和['p1','p2]都可以在binding中拿到,详情可以打印出来看一下
// vnode这个比较有意思了。他可以拿到指令所在的组件的上下文实例,你可以拿着这个上下文实例去访问组件中的方法和属性
},
update(el, binding, vnode) {},
unbind(el, binding, vnode) {}
});
v-permission
实现
Vue.directive('permission', function (el, binding, vnode) {
const permissionList = vnode.context.$store.state.permissionList;
const value = binding.value;
if (!permissionList.includes(value)) {
el.style.display = 'none';
}
});
上面的写法等同于
Vue.directive('permission', {
bind(el, binding, vnode) {
const permissionList = vnode.context.$store.state.permissionList;
const value = binding.value;
if (!permissionList.includes(value)) {
el.style.display = 'none';
}
},
update(el, binding, vnode) {
const permissionList = vnode.context.$store.state.permissionList;
const value = binding.value;
if (!permissionList.includes(value)) {
el.style.display = 'none';
}
}
});
mixin
通常我们会将一些具有相同逻辑功能的东西封装成一个mixin
,方面其他组件或者页面使用。比如,select
这个组件需要在用户点击组件外的地方时,把下拉框隐藏起来,而且其他组件也需要用到这个功能。所以我们可以把用户点击组件外的地方的逻辑抽离出来,封装成一个mixin
。下面讲解一下meta-info
这个mixin
,主要功能就是根据页面中的metaInfo
字段修改网页的 meta 信息。
使用方式如下:
export default {
metaInfo: {
title: "metaInfo", 设置title
meta: [
{
// 设置meta
name: "keyWords",
content: "metaInfo",
},
],
link: [
{
// 设置 link
rel: "asstes",
href: "https://github.com/c10342/lin-view-ui",
},
],
},
};
代码示例:
import { VUE_META_KEY_NAME } from './src/common/constants.js';
import updateMetaInfo from './src/metaOperate/updateMetaInfo.js';
import { isUndefined, isFunction } from '@lin-view-ui/utils';
const VueMetaInfo = {};
VueMetaInfo.install = function install(Vue) {
Vue.mixin({
beforeCreate() {
// 获取页面中的 metaInfo 字段信息
const metaInfo = this.$options[VUE_META_KEY_NAME];
// metaInfo 存在
if (!isUndefined(metaInfo)) {
// 标记该页面存在 metaInfo 字段信息
this._hasMetaInfo = true;
// 判断组件内是否存在computed对象
if (isUndefined(this.$options.computed)) {
// 没有需要先初始化一下
this.$options.computed = {};
}
// 为组件添加computed对象并返回meta信息
// metaInfo 写法上可以是一个对象,也可以是一个返回一个对象的函数
if (isFunction(metaInfo)) {
this.$options.computed.$metaInfo = metaInfo;
} else {
// 如果是一个对象,则需要改写成函数的形式
this.$options.computed.$metaInfo = () => metaInfo;
}
}
},
beforeMount() {
// 在页面挂在到dom之前更新meta信息
if (this._hasMetaInfo) {
updateMetaInfo(this.$metaInfo);
}
},
mounted() {
// dom挂载之后,继续监听meta信息。如果发生变化,继续更新
if (this._hasMetaInfo) {
this.$watch('$metaInfo', () => {
updateMetaInfo(this.$metaInfo);
});
}
},
activated() {
if (this._hasMetaInfo) {
// keep-alive组件激活时调用
updateMetaInfo(this.$metaInfo);
}
},
deactivated() {
if (this._hasMetaInfo) {
// keep-alive 组件停用时调用。
updateMetaInfo(this.$metaInfo);
}
}
});
};
export default VueMetaInfo;
总结
在组件库开发中,会遇见很多平时实际项目中用不到的东西,比如$attrs
,provide
,injected
,$scopedSlots
这些东西。在开发的过程中可以多参考一下其他组件库的源码,然后在对比一下自己的实现思路,你会看见很多你所不知道的知识点。最后,如果这篇文章对你有帮助的话,希望可以帮我点个赞。去点赞