场景
回想一下,我们写过的那些业务代码,有遇到过以下场景吗?
- 业务代码中重复出现类似的使用第三方组件的代码块。
- 业务代码中很多组件其实只是简单的包装或者组合了第三方组件。
- 期望封装第三方组件作为业务组件方便重用。
- 期望封装了第三方组件的业务组件能够完全兼备第三方组件的特性,而非需要一个一个地搬运。
例如查询列表中的筛选项,相信业务中很多地方都存在着类似的代码片段。
<el-form-item label="手机号码" prop="mobile">
<el-input v-model="form.mobile" size="mini" clearable maxlength="20" />
</el-form-item>
那么我们应该如何将这些重复的代码片段封装起来,成为一个好用的业务组件呢?
关键
在经历了很多项目实践以后,我们发现,要想将第三方组件封装为好用的业务组件,关键是需要通过以下方式来进行封装:
- 通过渲染函数(
render
)来创建第三方组件。 -
透传
数据对象
来搬运第三方组件的特性。- 属性(
props
) - 事件(
on
) - 插槽(
slot
)
- 属性(
- 使用作用域样式(
scoped
)来覆盖第三方组件的样式。
示例
下面以 Element UI
作为第三方组件,封装一个表单筛选项的业务组件作为示例,方便大家理解。建议后续业务组件我们都可以按照类似的思路来进行封装。
-
封装业务组件:
lib/components/form-item/mobile.vue
<script> import Vue from 'vue'; /** * 获取前缀为 `prefix` 的 slot 值并创建 slot 元素 * * @param {string} prefix * @param {*} slots * @param {*} h createElement * @returns */ function createSlotElements(prefix, slots, h) { return getValues(prefix, slots).map(function(item) { return h('template', { slot: item.name }, [item.value]); }); } /** * 获取前缀为 `item-` 的 slot 值并创建 slot 元素 * * @param {*} slots * @param {*} h createElement * @returns */ function createItemSlotElements(slots, h) { return createSlotElements('item-', slots, h); } /** * 获取前缀为 `field-` 的 slot 值并创建 slot 元素 * * @param {*} slots * @param {*} h createElement * @returns */ function createFieldSlotElements(slots, h) { return createSlotElements('field-', slots, h); } /** * 获取前缀为 `prefix` 的属性值 * * @param {string} prefix * @param {*} object * @returns [{name, value, originName, prefix}] */ function getValues(prefix, object) { return Object.entries(object).map(function([name, value]) { return { name: name.replace(prefix, ''), value, originName: name, prefix }; }); } /** * 表单项: 手机号码 */ export default Vue.extend({ props: { /** * 表单元素 v-model 绑定的对象 */ model: { type: Object, required: true }, /** * el-form-item 的 prop */ prop: { type: String, default: 'mobile' }, /** * 透传 el-form-item 的属性 */ itemProps: { type: Object }, /** * 透传 el-form-item 的事件 */ itemOn: { type: Object }, /** * 透传 el-form-item 的 native 事件 */ itemNativeOn: { type: Object }, /** * 透传表单元素的属性 */ fieldProps: { type: Object }, /** * 透传表单元素的事件 */ fieldOn: { type: Object }, /** * 透传表单元素的 native 事件 */ fieldNativeOn: { type: Object }, }, methods: { /** * 创建 el-form-item 元素 * * @param {*} fieldElement 表单元素 * @param {*} h createElement * @returns */ createItemElement(fieldElement, h) { return h('el-form-item', { props: { label: '手机号码', prop: this.prop, ...this.itemProps }, on: { ...this.itemOn }, nativeOn: { ...this.itemNativeOn } }, [ ...createItemSlotElements(this.$slots, h), fieldElement ]); }, /** * 创建表单元素 * * @param {*} h createElement * @returns */ createFieldElement(h) { const self = this; return h('el-input', { attrs: { maxlength: 20, placeholder: '请输入' }, props: { size: 'mini', clearable: true, value: self.model[self.prop], ...self.fieldProps }, on: { input(value) { self.model[self.prop] = value; }, ...self.fieldOn }, nativeOn: { ...self.fieldNativeOn } }, [ ...createFieldSlotElements(self.$slots, h) ]); } }, render(h) { const fieldElement = this.createFieldElement(h); return this.createItemElement(fieldElement, h); } }); </script> <style scoped> /deep/.el-form-item__label { color: red; } </style>
-
使用业务组件
<template> <div> <el-form :model="form"> <Mobile :model="form" :itemNativeOn="{ click: testClick }" :fieldOn="{ focus: testFocus }" > <!-- 通过 item- 前缀来区分插槽, 例如 item-label, 即设置 el-form-item 的 label 插槽 --> <template #item-label> <span>el-form-item label slot</span> </template> <!-- 通过 field- 前缀来区分插槽, 例如 field-prepend, 即设置 el-input 的 prepend 插槽 --> <template #field-prepend> <span>el-input prepend slot</span> </template> </Mobile> </el-form> </div> </template> <script> import Mobile from '@/lib/components/form-item/mobile.vue'; export default { components: { Mobile, }, data() { return { form: { mobile: '' } }; }, methods: { testClick(event) { console.log('click', event); }, testFocus(event) { console.log('focus', event); } } }; </script> <style scoped> </style>
细节
针对上述示例,再结合我们实际的项目经验,建议在封装的过程中注意以下细节:
- 建议将第三方组件的属性放置在一个对外的
props
上,例如:itemProps
,不要做过多的封装,减少学习成本。 - 事件也以类似的方式来处理,例如:
itemOn
,itemNativeOn
。 - 当组合了多个第三方组件时,建议通过前缀来区分各组件的插槽,例如:
item-
和field-
,便于兼备第三方组件的所有插槽。 - 通过深度作用选择器(
/deep/
)来直接覆盖第三方组件的样式。 - 渲染函数中没有与
v-model
的直接对应——你必须自己实现相应的逻辑。 -
对比
template
和render
,模版机制的局限性是v-on
无法透传原生事件,只能一个一个地搬运。<template> <el-form-item v-bind="{ label: '手机号码', prop: prop, ...itemProps }" v-on="itemOn" > <!-- 遍历 el-form-item 的 slot --> <template v-for="item in itemSlots" v-slot:[item.name]> <slot :name="item.originName"></slot> </template> <!-- 表单元素 --> <el-input v-model="model[prop]" v-bind="{ maxlength: '20', placeholder: '请输入', size: 'mini', clearable: true, ...fieldProps }" v-on="fieldOn" > <!-- 遍历表单元素的 slot --> <template v-for="field in fieldSlots" v-slot:[field.name]> <slot :name="field.originName"></slot> </template> </el-input> </el-form-item> </template> <script> import Vue from 'vue'; /** * 获取前缀为 `prefix` 的属性值 * * @param {string} prefix * @param {*} object * @returns [{name, value, originName, prefix}] */ function getValues(prefix, object) { return Object.entries(object).map(function([name, value]) { return { name: name.replace(prefix, ''), value, originName: name, prefix }; }); } /** * 表单项: 手机号码 */ export default Vue.extend({ props: { /** * 表单元素 v-model 绑定的对象 */ model: { type: Object, required: true }, /** * el-form-item 的 prop */ prop: { type: String, default: 'mobile' }, /** * 透传 el-form-item 的属性 */ itemProps: { type: Object }, /** * 透传 el-form-item 的事件 */ itemOn: { type: Object }, /** * 透传表单元素的属性 */ fieldProps: { type: Object }, /** * 透传表单元素的事件 */ fieldOn: { type: Object }, }, computed: { itemSlots() { return getValues('item-', this.$slots); }, fieldSlots() { return getValues('field-', this.$slots); } } }); </script> <style scoped> /deep/.el-form-item__label { color: red; } </style>
行动
现在大家知道应该如何让全天下的组件为我所用了吧,快点在你的项目中试一试吧。(๑•̀ㅂ•́)و✧