业务组件如何优雅的包装(yang)第三方(Vue)组件--《前端那些事》

场景

回想一下,我们写过的那些业务代码,有遇到过以下场景吗?

  • 业务代码中重复出现类似的使用第三方组件的代码块
  • 业务代码中很多组件其实只是简单的包装或者组合了第三方组件
  • 期望封装第三方组件作为业务组件方便重用。
  • 期望封装了第三方组件的业务组件能够完全兼备第三方组件的特性,而非需要一个一个地搬运。

例如查询列表中的筛选项,相信业务中很多地方都存在着类似的代码片段。

<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,不要做过多的封装,减少学习成本。
  • 事件也以类似的方式来处理,例如:itemOnitemNativeOn
  • 当组合了多个第三方组件时,建议通过前缀来区分各组件的插槽,例如: item-field-,便于兼备第三方组件的所有插槽。
  • 通过深度作用选择器(/deep/)来直接覆盖第三方组件的样式。
  • 渲染函数中没有与 v-model 的直接对应——你必须自己实现相应的逻辑。
  • 对比 templaterender模版机制的局限性是 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>

行动

现在大家知道应该如何让全天下的组件为我所用了吧,快点在你的项目中试一试吧。(๑•̀ㅂ•́)و✧

参考

上一篇:网页设计师和网页前端开发我该选择哪一个


下一篇:前端代码是怎样智能生成的-业务模块识别篇