使用vue3给所有ui组件披上外衣

1,859 阅读3分钟

为什么会将所有的组件都在套上一层外壳呢?

在实际项目中曾经有过这么一个需求,需要把所有组件的size都设置为小号。当时项目时间短,只能使用编辑器全局替换,现在正巧有时间,于是但是用vue3的语法把ant-design-vue(2.x)提供的所有的组件全部封装一遍,这样就可以一次性修改组件的默认值。

提取组件名

ant-design-vue这个ui库里包含的组件是有很多的,如果只是复制粘贴是很费时间的,所以使用正则表达式从node_modules\ant-design-vue\lib\components.js文件里提取所有的组件名

Affix,Anchor,AnchorLink,AutoComplete,AutoCompleteOptGroup,AutoCompleteOption,Alert,Avatar,AvatarGroup,BackTop,Badge,BadgeRibbon,Breadcrumb,BreadcrumbItem,BreadcrumbSeparator,Button,ButtonGroup,Calendar,Card,CardGrid,CardMeta,Collapse,CollapsePanel,Carousel,Cascader,Checkbox,CheckboxGroup,Col,Comment,ConfigProvider,DatePicker,RangePicker,MonthPicker,WeekPicker,Descriptions,DescriptionsItem,Divider,Dropdown,DropdownButton,Drawer,Empty,Form,FormItem,Grid,Input,InputGroup,InputPassword,InputSearch,Textarea,Image,ImagePreviewGroup,InputNumber,Layout,LayoutHeader,LayoutSider,LayoutFooter,LayoutContent,List,ListItem,ListItemMeta,message,Menu,MenuDivider,MenuItem,MenuItemGroup,SubMenu,Mentions,MentionsOption,Modal,Statistic,StatisticCountdown,notification,PageHeader,Pagination,Popconfirm,Popover,Progress,Radio,RadioButton,RadioGroup,Rate,Result,Row,Select,SelectOptGroup,SelectOption,Skeleton,SkeletonButton,SkeletonAvatar,SkeletonInput,SkeletonImage,Slider,Space,Spin,Steps,Step,Switch,Table,TableColumn,TableColumnGroup,Transfer,Tree,TreeNode,DirectoryTree,TreeSelect,TreeSelectNode,Tabs,TabPane,TabContent,Tag,CheckableTag,TimePicker,Timeline,TimelineItem,Tooltip,Typography,TypographyLink,TypographyParagraph,TypographyText,TypographyTitle,Upload,UploadDragger,LocaleProvider

组件封装

我的思路

考虑到组件的数量共计123个,如果使用.vue文件的方式来封装组件,那么工作量也是很大的,且会有很多重复工作。

这里我选择在一个ts文件里使用defineComponent的方式一个个定义并导出所有的组件(完成一个模板,批量替换完成),在setup函数里构建参数,在render函数里渲染模板。

这里你可能会问,为什么不直接在setup函数里返回一个render函数?

我起初做的第一版是这样做的,组件封装是看似没有问题,但是这种方案不能通过ref的方式来调用组件内部的函数,在某些情况下(例如手动调用focus)没法满足需求。

参数透传

先上代码吧

interface RawProps {
    [key: string]: any
}
​
interface SetupArgs {
    props: RawProps
    slots: any
}
/**
 * 获取组件渲染需要的参数
 * @param props
 * @param attrs
 * @param slots
 * @param extraArgs
 */
function useSetup(props: RawProps, {
    attrs,
    slots,
}: SetupContext<any>, extraArgs: RawProps = {}): SetupArgs {
    let attrAndProps = computed(() => ({...unref(attrs), ...props, ...extraArgs}));
    let renderSlots = extendSlots(slots);
    return {
        props: attrAndProps,
        slots: renderSlots,
    };
}
import {Slots} from "vue";
import {isFunction} from "@/utils/is";
​
/**
 * 插槽传递
 * @param slots
 * @param excludeKeys
 */
export function extendSlots(slots: Slots, excludeKeys: string[] = []) {
    const _getSlot = (slots: Slots, slot = "default", data?: any) => {
        if (!slots || !Reflect.has(slots, slot)) {
            return null;
        }
        if (!isFunction(slots[slot])) {
            console.error(`${slot} is not a function!`);
            return null;
        }
        const slotFn = slots[slot];
        if (!slotFn) return null;
        return slotFn(data);
    };
    const slotKeys = Object.keys(slots);
    const ret: any = {};
    slotKeys.forEach((key) => {
        if (!excludeKeys.includes(key)) {
            ret[key] = () => _getSlot(slots, key);
        }
    });
    return ret;
}
​

组件封装主要处理了这两个点:

  1. 属性(事件)传递

    因为vue3$attrs$listeners进行合并,所以只处理attrs即可。(在开发过程中,我发现组件上的key和ref的值是不会在attrs里传递)

  2. 插槽传递(参考Vue-Vben-Admin)

    遍历vue3提供的context里的slots,过滤掉需要排除的插槽。

组件渲染

前面提到过提取了所有的组件名,这里把所有的组件存到Map里。实际代码很长,这里只贴上了关键代码

import {Button} from "ant-design-vue";
export type ComponentType ="Button"
export const componentMap = new Map<ComponentType, Component>();
componentMap.set("Button", Button);
​
export function get(compName: ComponentType) {
    return componentMap.get(compName) as (typeof defineComponent);
}

准备好了所有的组件,那么就可以开始渲染了

/**
 * 使用render进行渲染,参数在setup里进行暴露
 * @param componentType
 * @param props
 * @param slots
 */
function componentRender(componentType: ComponentType, {props, slots}: SetupArgs) {
    let component = get(componentType);
    return h(component, {...unref(props)}, slots);
}

组件定义

这里以封装Button为例

import VueTypes from "vue-types";
​
export const XButton = defineComponent({
    name: "XButton",
    props: {
        size: VueTypes.oneOf(["small", "middle", "large"]).def("small"),
        onClick:VueTypes.func //这里处理事件修饰符返回数组的问题
    },
    setup(props, context) {
        return useSetup(props, context);
    },
    render(setupArgs: SetupArgs) {
        return componentRender("Button", setupArgs);
    },
});

原本官方的Button组件的size属性的默认值是middle,这里封装之后变为了small,而且组件的用法和官方文档的是一样的。

 <x-button type="primary" @click="handleAdd" class="margin-left-10">
     <template #icon>
        <ImportOutlined/>
     </template>
     导入
 </x-button>

再举个栗子吧,在我之前文章里分享过封装一个能够过滤空格的input组件,那么在vue3用这种方式该怎么实现呢?

来,上代码。

type Nullable<T> = T | null;
export const XInput = defineComponent({
    name: "XInput",
    emits: ["update:value"],
    props: {
        //默认是会将值trim掉的,如果不需要则val=>val即可
        filterHandler: VueTypes.func.def(val => val.trim()),
    },
    setup(props, context) {
        let inputRef = ref<Nullable<HTMLElement>>(null);
        let handleFilterValue = (e: any) => {
            context.emit("update:value", props.filterHandler?.(e.target.value));
        };
        const focus = () => inputRef?.value?.focus();
        let setupArgs = useSetup(props, context, {ref: inputRef, onChange: handleFilterValue});
        return {
            inputRef,
            ...setupArgs,
            focus,
        };
    },
    render(setupArgs: SetupArgs) {
        return componentRender("Input", setupArgs);
    },
});

特殊处理

目前为止我发现有这么一个组件并不能通过以上的方案来处理,这个组件就是TableTable组件的插槽并不是固定的,设置columnsslots属性,会动态生成插槽。现在的解决方案是通过在插槽里定义新的插槽来解决插槽传递的问题

<template>
  <Table
      v-bind="attrAndProps"
  >
    <!--      这里的用法待研究,在插槽里定义插槽-->
    <template #[name]="data" v-for="(_,name) in $slots" :key="name">
      <slot :name="name" v-bind="data"></slot>
    </template>
  </Table>
</template>
​
<script lang="ts">
import {computed, defineComponent, unref} from "vue";
import {Table} from "ant-design-vue";
​
export default defineComponent({
  name: "XTable",
  components: {
    Table,
  },
  setup(props, {attrs}) {
    let attrAndProps = computed(() => ({...unref(attrs), ...props}));
    return {
      attrAndProps,
    };
  },
});
</script>
​

效果截图

template截图

image-20210803122734465.png

效果截图

image-20210803122845739.png

总结

使用上述的方案可以很容易搭建一套完全可控的组件库,以后遇到默认值的修改、ui框架的切换也能达到尽可能少的代码量的改动。在摸索这个方案的过程中我也发现目前存在的一些问题:

  1. 组件传值过程中,key属性接收不到值,猜测是vue渲染组件时内部有处理。在一些需要key属性的组件中(例如menu-item)只能用别名代替。-

    // 目前所有的key值需要用keyName替换
    export const XMenuItem = defineComponent({
        name: "XMenuItem",
        props: {
            keyName: VueTypes.string,
        },
        setup(props, context) {
            return useSetup(props, context, {key: props.keyName});
        },
        render(setupArgs: SetupArgs) {
            return componentRender("MenuItem", setupArgs);
        },
    });
    
  2. Table组件目前还不能采用统一的方式进行处理。

  3. 原组件提供的方法,需要参考focus的方式,绑定ref来进行对外暴露,麻烦了一点点。

参考资料

Vue-Vben-Admin

jeecg-boot