高阶组件实现组件配置化

650 阅读3分钟

前言

react一直尽量绕开mixin而推荐使用高阶组件,对于vue来说高阶组件不太常用,这和react与vue的实现有关联。react函数即组件,虽然vue组件的最终表现也是函数,但是在vue组件未注册时其实还是对象。本期我们将利用vue中不常用的高阶组件,来实现简单的组件配置化。

一、vue高阶组件

vue中高阶组件的概念与高阶函数类似,即输入一个或多个组件,输出另一个组件。vue在组件没有注册时,是没有构造函数的,本质上就是一个对象,那么上面的高阶组件就简化为输入一个或多个对象,输出另一个对象。简单结构如下:

function(comA, comB) {
    return {
        props: {},
        computed: {},
        data: {},
        methods: {},
        render(h) {
            h('div', null, [
                h(comA, {
                    props:{},
                    attrs:{},
                    on: {},
                    style: {},
                    scopedSlots: {}
                }, slots),
                h(comB, {
                    props:{},
                    attrs:{},
                    on: {},
                    style: {},
                    scopedSlots: {}
                }, slots)
            ])
        }
    }
}

二、实现

下面我们就以此为基础实现组件的配置化

高阶组件

注意的点

在封装高阶组件时,需要注意以下几点:

1.透传事件监听

input和change事件我们需要自定义,因为需要修改绑定的值,之后再手动emit出去即可

2.插槽的处理

如果针对单个组件,我们直接通过this.$slots透传即可,但本次实现是涉及多个组件,我们需要区分不同的slots分别传递给不同组件

我们可以通过传入插槽时,添加额外的属性加以判断即可(注意:额外的元素需加在实体元素上,不能放在template标签上),没有添加额外属性的slots会被判断为给最外层div使用的插槽,用于设置标题等。

另外要特别注意的点是:在传递插槽给相应的组件时,需要注意插槽的上下文,这是因为vue源码是强判断 this.$vnode.context === this.$vnode.componentOptions.children[0].context,如果父组件slot的上下文与当前高阶组件不一致,就会导致渲染不出来,这里通过将插槽的context处理为当前高阶组件实例即可,所以下面加了slot.context = this._self的逻辑。

作用域插槽相对于具名插槽就更简单了,我们将组件名称与插槽名称组合作为传入的作用域插槽名称,通过是否包含当前组件名称,获取当前组件的作用域插槽。

我们在处理渲染的节点时,将props传入的config配置内容作为不同组件的属性透传,绑定值都通过传入的model以及config中的prop确定,从而通过change和input修改;on透传处理后的listeners,scopedSlots透传处理后的scopedSlots,slots也透传上述规则处理后的slots,最后返回渲染渲染函数包裹的节点。

export default function getHOC(comMap) {
    const ignoreArr = ['change', 'input']
    return {
        computed: {
            // 过滤掉input和change事件监听
            listeners() {
                const res = {}
                for(let key in this.$listeners) {
                    if(!ignoreArr.includes(key)) {
                        res[key] = this.$listeners[key]
                    }
                }
                return res
            },
            // 处理插槽
            slotsMap() {
                const tempSlotsMap = Object.keys(this.$slots)
                .reduce((arr, cc) => {
                    const slot = this.$slots[cc][0]
                    const type = slot.data.attrs.comType || 'default'
                    if(!arr[type]) {
                        arr[type] = []
                    }
                    slot.context = this._self
                    arr[type].push(slot)
                    return arr
                }, {})
                return tempSlotsMap
            }
        },
        methods: {
            // 处理scopedSlot插槽
            handleScopeSlots(slots, name) {
                let slotsMap = {}
                for(let key in slots) {
                    const res = key.split('_')
                    if(res[0]===name) {
                        slotsMap[res[1]]=slots[key]
                    }
                }
                return slotsMap
            },
            // 处理渲染dom
            handleNodes(h) {
                const { config, model } = this.$attrs || {}
                let hArr = []
                for(let key in comMap) {
                    const slots = this.slotsMap[key]
                    const scopedSlots = this.handleScopeSlots(this.$scopedSlots, key)
                    hArr = [...hArr, h(comMap[key], {
                        props: {
                            value: model ? model[config?.[key]?.prop] : '',
                            ...(this.$props?.config?.[key] || {}),
                            ...config[key]
                        },
                        attrs: {
                            key: `${key}-${config[key].prop}`,
                            ...config[key],
                        },
                        on: {
                            change: (v) => {
                                model[config?.[key]?.prop] = v
                                this.$emit('change', {prop: config[key].prop, v})
                            },
                            input: (v) => {
                                model[config?.[key]?.prop] = v
                                this.$emit('input', {prop: config[key].prop, v})
                            },
                            ...this.listeners
                        },
                        style: {
                            marginBottom: '10px',
                            maxWidth: '300px',
                            ...(config?.[key]?.style || {})
                        },
                        scopedSlots: scopedSlots
                    }, slots || null)]
                }
                return hArr
            }
        },
        render(h) {
            const nodes = this.handleNodes(h)
            return h('div', null, [
                h('div', null, this.slotsMap.default),
                ...nodes
            ])
        }
    }
}

高阶组件的使用

这里配置化通过config实现,但也没有完全配置,插槽还是在组件内使用的。不过这里没有放到配置文件中的事件其实是可以配置化的,通过额外的在config中添加event字段,用于将事件方法传递给相应的组件即可,小伙伴可以尝试下哦~

这里给input输入框传入了prefix插槽,通过comType额外的属性确定是哪个组件使用;autocomplete传入了作用域插槽;高阶组件中的div标签传入了header插槽,用于设置标题;其次,高阶组件使用时,我们还用了自定义的MySelect组件。

<template>
  <div>
    <Hoc
        :model="data"
        :config="config"
        @input="input"
        @change="change"
        @blur="blur"
        @select="handleSelect"
    >
        <template>
            <h2 slot="header" style="padding-bottom: 20px;">我是高阶组件</h2>
        </template>
        <template>
            <i slot="prefix" class="el-icon-date" style="margin-top: 13px;" comType="input"></i>
        </template>
        <template slot="autocomplete_default" slot-scope="{ item }">
            <div class="name">{{ item.value }}</div>
            <span class="addr">{{ item.address }}</span>
        </template>
    </Hoc>
    <div>{{data}}</div>
  </div>
</template>

<script>
import getHOC from '../components/hoc'
import MySelect from '../components/select.vue'
import { Input, Switch, rate, Autocomplete } from 'element-ui'

const Hoc = getHOC({input: Input, mySelect: MySelect, switch: Switch, rate, autocomplete: Autocomplete})
export default {
    components: {
        Hoc,
    },
    data() {
        return {
            data: {
                inputValue: '',
                autocompleteValue: '',
                selectValue: '',
                switchValue: false,
                rateValue: 0
            },
            config: {
                input: {
                    prop: 'inputValue',
                    placeholder:'我是输入框',
                    style: {},
                },
                autocomplete: {
                    prop: 'autocompleteValue',
                    placeholder: '请输入内容',
                    fetchSuggestions: this.querySearch
                },
                mySelect: {
                    prop: 'selectValue',
                    placeholder: '请选择',
                    list: [{
                        value: '选项1',
                        label: '黄金糕'
                        }, {
                        value: '选项2',
                        label: '双皮奶'
                    }]
                },
                switch: {
                    prop: 'switchValue',
                    activeColor: "#13ce66",
                    inactiveColor: "#ff4949"
                },
                rate: {
                    prop: 'rateValue'
                },
                restaurants: []
            }
        }
    },
    mounted() {
      this.restaurants = this.loadAll();
    },
    methods: {
        change(v) {
            console.log('v: ', v);
            
        },
        input(v) {
            console.log('v222: ', v)

        },
        blur(e) {
            console.log('event: ', e)
        },
        handleSelect(item) {
            console.log('item: ', item);
        },
        querySearch(queryString, cb) {
            const restaurants = this.restaurants;
            const results = queryString ? restaurants.filter(this.createFilter(queryString)) : restaurants;
            // 调用 callback 返回建议列表的数据
            cb(results);
        },
        createFilter(queryString) {
            return (restaurant) => {
            return (restaurant.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0);
            };
        },
        loadAll() {
            return [
                    { "value": "三全鲜食(北新泾店)", "address": "长宁区新渔路144号" },
                    { "value": "Hot honey 首尔炸鸡(仙霞路)", "address": "上海市长宁区淞虹路661号" },
                    { "value": "新旺角茶餐厅", "address": "上海市普陀区真北路988号创邑金沙谷6号楼113" },
                ]
        }
    }
}
</script>

效果

image.png

三、总结

以上我们就简单利用高阶组件实现了组件的配置化。

其实react与vue在高阶组件上的使用区别主要是体现在 React 中写组件就是在写函数,函数拥有的功能组件都有。而 Vue 更像是高度封装的函数,在更高的层面 Vue 能够让你轻松的完成一些事情,但与高度的封装相对的就是损失一定的灵活,你需要按照一定规则才能使系统更好地运行。

以上组件配置化如果在外面包一层表单的话,实用性就更强一些了,可以实现表单的配置化,这样写表单就更加方便写。当然在高阶组件中也可以用jsx的语法来写,但是需要是将传入的组件注册一下才能使用;h函数也可以按标签的字符串名称渲染,这同样需要全局注册一次组件,否则它并不知道你要渲染的是什么组件,而这里,我们直接按传入组件对象即可,h函数帮我们渲染相应的节点。

下期预告

下一期,我们可以用react来实现一次高阶组件,与vue对比学习,可以加深我们对它们设计理念不同的理解~